Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
decb944e42 | ||
|
|
9e64132d91 | ||
|
|
624065c4f1 | ||
|
|
654221119d | ||
|
|
851c361a84 | ||
|
|
0cabd02d47 | ||
|
|
52c87e3050 | ||
|
|
418c00604a | ||
|
|
ce6ba534f7 | ||
|
|
bbefd49833 | ||
|
|
e6b5247cc0 | ||
|
|
5d6090ae46 | ||
|
|
b9e5ae098c | ||
|
|
3a95791f61 | ||
|
|
463f29e76a | ||
|
|
dc815fc15f | ||
|
|
3b40b4020d | ||
|
|
f42b776b8e | ||
|
|
2f94b72ee1 | ||
|
|
8cf221e13f | ||
|
|
e4d4ec3aae | ||
|
|
275cd5a493 |
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
@@ -16,9 +16,9 @@ Those models are available through the local Devin CLI. This package uses that C
|
||||
|
||||
## Requirements
|
||||
|
||||
- Pi Coding Agent 0.80+
|
||||
- Pi Coding Agent 0.86+ (managed fork validated against 0.87.1)
|
||||
- A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`)
|
||||
- Node 18+
|
||||
- Node 22.19+
|
||||
|
||||
The CLI binary is resolved in this order:
|
||||
|
||||
@@ -81,3 +81,7 @@ This is a standard Pi package (`keywords: ["pi-package"]` + `pi.extensions`). Af
|
||||
## License
|
||||
|
||||
MIT. Unofficial. Not affiliated with Cognition.
|
||||
|
||||
## Managed fork
|
||||
|
||||
Based on npm pi-devin 0.2.1. Retains native signature/response replay, corrected wire fields, bounded frames, context clamping, multi-window catalog variants and abort cleanup. Uses the upstream six-hour XDG catalog cache. Tests and typecheck are maintained in the parent app tests directory.
|
||||
|
||||
+63
-22
@@ -2,6 +2,13 @@ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-codin
|
||||
import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
|
||||
import { authStatus, loginWithCli, readCredentials } from "../src/credentials.js";
|
||||
import { whichDevin, devinVersion } from "../src/cli.js";
|
||||
import {
|
||||
type CachedDevinCatalog,
|
||||
isUsableCatalog,
|
||||
isCatalogCacheFresh,
|
||||
readCatalogCache,
|
||||
writeCatalogCache,
|
||||
} from "../src/catalog-cache.js";
|
||||
import { FALLBACK_MODELS, loadCliCatalog, modelsFromCatalog } from "../src/models.js";
|
||||
import { CLIENT_IDE, CLIENT_VERSION } from "../src/metadata.js";
|
||||
import { streamDevin } from "../src/stream.js";
|
||||
@@ -10,6 +17,37 @@ const PROVIDER_ID = "devin";
|
||||
const PLACEHOLDER_BASE_URL = "https://server.codeium.com";
|
||||
|
||||
let _pi: ExtensionAPI | null = null;
|
||||
let catalogRequest: Promise<ProviderModelConfig[]> | null = null;
|
||||
|
||||
function isOffline(): boolean {
|
||||
const value = process.env.PI_OFFLINE?.toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes";
|
||||
}
|
||||
|
||||
function refreshCatalog(pi: ExtensionAPI): Promise<ProviderModelConfig[]> {
|
||||
if (!catalogRequest) {
|
||||
const pending = loadCliCatalog().then((catalog) => {
|
||||
if (!isUsableCatalog(catalog)) {
|
||||
throw new Error("Devin CLI returned no usable model families");
|
||||
}
|
||||
const models = modelsFromCatalog(catalog);
|
||||
try {
|
||||
writeCatalogCache(catalog);
|
||||
} catch (error) {
|
||||
console.warn(`Devin: failed to cache model catalog: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
return models;
|
||||
});
|
||||
catalogRequest = pending;
|
||||
void pending.finally(() => {
|
||||
if (catalogRequest === pending) catalogRequest = null;
|
||||
}).catch(() => {});
|
||||
}
|
||||
return catalogRequest.then((models) => {
|
||||
if (_pi === pi) registerDevinProvider(pi, models);
|
||||
return models;
|
||||
});
|
||||
}
|
||||
|
||||
function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void {
|
||||
pi.registerProvider(PROVIDER_ID, {
|
||||
@@ -23,8 +61,7 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
|
||||
const creds = await loginWithCli();
|
||||
if (_pi) {
|
||||
try {
|
||||
const catalog = await loadCliCatalog();
|
||||
registerDevinProvider(_pi, modelsFromCatalog(catalog));
|
||||
await refreshCatalog(_pi);
|
||||
} catch {
|
||||
// keep current models
|
||||
}
|
||||
@@ -57,26 +94,32 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
|
||||
|
||||
export default async function (pi: ExtensionAPI): Promise<void> {
|
||||
_pi = pi;
|
||||
registerDevinProvider(pi, FALLBACK_MODELS);
|
||||
let cached: CachedDevinCatalog | null | undefined;
|
||||
let initialModels = FALLBACK_MODELS;
|
||||
try {
|
||||
cached = readCatalogCache();
|
||||
if (cached) initialModels = modelsFromCatalog(cached.catalog);
|
||||
} catch (error) {
|
||||
cached = null;
|
||||
console.warn(`Devin: failed to read model catalog cache: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
registerDevinProvider(pi, initialModels);
|
||||
|
||||
if (!isOffline()) {
|
||||
try {
|
||||
if (readCredentials()) {
|
||||
const catalog = await loadCliCatalog();
|
||||
registerDevinProvider(pi, modelsFromCatalog(catalog));
|
||||
}
|
||||
} catch {
|
||||
// fallback models already registered
|
||||
}
|
||||
|
||||
pi.on("session_start", async () => {
|
||||
try {
|
||||
if (!_pi || !readCredentials()) return;
|
||||
const catalog = await loadCliCatalog();
|
||||
registerDevinProvider(_pi, modelsFromCatalog(catalog));
|
||||
} catch {
|
||||
// keep current models
|
||||
}
|
||||
if (!cached) {
|
||||
await refreshCatalog(pi);
|
||||
} else if (!isCatalogCacheFresh(cached)) {
|
||||
void refreshCatalog(pi).catch((error) => {
|
||||
console.warn(`Devin: background model catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cached or fallback models stay registered when credential discovery or a cold refresh fails.
|
||||
}
|
||||
}
|
||||
|
||||
pi.registerCommand("devin-status", {
|
||||
description: "Show Devin CLI auth + binary status",
|
||||
@@ -100,9 +143,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
||||
description: "Refresh Devin Local model catalog from `devin models list`",
|
||||
handler: async (_args, ctx) => {
|
||||
try {
|
||||
const catalog = await loadCliCatalog();
|
||||
const models = modelsFromCatalog(catalog);
|
||||
registerDevinProvider(pi, models);
|
||||
const models = await refreshCatalog(pi);
|
||||
ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info");
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
@@ -114,6 +155,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
_pi = null;
|
||||
if (_pi === pi) _pi = null;
|
||||
});
|
||||
}
|
||||
|
||||
+5
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pi-devin",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.1",
|
||||
"description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
@@ -28,18 +28,16 @@
|
||||
"AGENTS.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "*",
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
"@earendil-works/pi-ai": ">=0.86.0",
|
||||
"@earendil-works/pi-coding-agent": ">=0.86.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { DevinCatalog } from "./models.js";
|
||||
|
||||
const CACHE_VERSION = 1;
|
||||
|
||||
export const CATALOG_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export interface CachedDevinCatalog {
|
||||
catalog: DevinCatalog;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface CatalogCacheFile extends CachedDevinCatalog {
|
||||
version: typeof CACHE_VERSION;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): boolean {
|
||||
return value === undefined || typeof value === "string";
|
||||
}
|
||||
|
||||
function optionalTokenLimit(value: unknown): boolean {
|
||||
return value === undefined || (typeof value === "number" && Number.isSafeInteger(value) && value > 0);
|
||||
}
|
||||
|
||||
export function isUsableCatalog(value: unknown): value is DevinCatalog {
|
||||
if (!isRecord(value) || !Array.isArray(value.families) || value.families.length === 0) return false;
|
||||
return value.families.some((family) => isRecord(family) && Array.isArray(family.variants) && family.variants.length > 0)
|
||||
&& value.families.every((family) =>
|
||||
isRecord(family)
|
||||
&& typeof family.family_label === "string"
|
||||
&& typeof family.family_uid === "string"
|
||||
&& typeof family.slug === "string"
|
||||
&& (family.aliases === undefined || (Array.isArray(family.aliases) && family.aliases.every((alias) => typeof alias === "string")))
|
||||
&& Array.isArray(family.variants)
|
||||
&& family.variants.every((variant) =>
|
||||
isRecord(variant)
|
||||
&& typeof variant.model_uid === "string"
|
||||
&& variant.model_uid.trim().length > 0
|
||||
&& typeof variant.label === "string"
|
||||
&& optionalString(variant.cost_summary)
|
||||
&& optionalString(variant.cost_tier)
|
||||
&& optionalTokenLimit(variant.max_context_tokens)
|
||||
&& optionalTokenLimit(variant.max_output_tokens)
|
||||
&& (variant.is_new === undefined || typeof variant.is_new === "boolean")
|
||||
&& (variant.is_beta === undefined || typeof variant.is_beta === "boolean")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function catalogCachePath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
home: string = homedir(),
|
||||
): string {
|
||||
const cacheRoot = env.XDG_CACHE_HOME || join(home, ".cache");
|
||||
return join(cacheRoot, "pi-devin", "models.json");
|
||||
}
|
||||
|
||||
export function readCatalogCache(path: string = catalogCachePath()): CachedDevinCatalog | null {
|
||||
if (!existsSync(path)) return null;
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (
|
||||
!isRecord(parsed)
|
||||
|| parsed.version !== CACHE_VERSION
|
||||
|| typeof parsed.fetchedAt !== "number"
|
||||
|| !Number.isFinite(parsed.fetchedAt)
|
||||
|| !isUsableCatalog(parsed.catalog)
|
||||
) {
|
||||
throw new Error(`Invalid Devin model catalog cache: ${path}`);
|
||||
}
|
||||
return { catalog: parsed.catalog, fetchedAt: parsed.fetchedAt };
|
||||
}
|
||||
|
||||
export function writeCatalogCache(
|
||||
catalog: DevinCatalog,
|
||||
path: string = catalogCachePath(),
|
||||
fetchedAt: number = Date.now(),
|
||||
): void {
|
||||
if (!isUsableCatalog(catalog)) throw new Error("Refusing to cache an invalid Devin model catalog");
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
||||
const body: CatalogCacheFile = { version: CACHE_VERSION, fetchedAt, catalog };
|
||||
try {
|
||||
writeFileSync(temporaryPath, `${JSON.stringify(body)}\n`, { mode: 0o600 });
|
||||
renameSync(temporaryPath, path);
|
||||
} finally {
|
||||
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function isCatalogCacheFresh(
|
||||
cached: CachedDevinCatalog,
|
||||
now: number = Date.now(),
|
||||
maxAgeMs: number = CATALOG_CACHE_MAX_AGE_MS,
|
||||
): boolean {
|
||||
const age = now - cached.fetchedAt;
|
||||
return age >= 0 && age <= maxAgeMs;
|
||||
}
|
||||
+58
-10
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
@@ -6,21 +6,50 @@ import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const KNOWN_BINS = [
|
||||
function candidateBins(): string[] {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData/Local");
|
||||
return [
|
||||
// Explicit override always wins. Read lazily so a value set after module
|
||||
// load (e.g. by the host process) is still honoured.
|
||||
process.env.DEVIN_CLI,
|
||||
...(process.platform === "win32" ? [
|
||||
// Official installer location (%LOCALAPPDATA%\devin\cli\bin\devin.exe)
|
||||
join(localAppData, "devin/cli/bin/devin.exe"),
|
||||
// Devin Desktop (Windsurf-based) bundled CLI, per-user install
|
||||
join(localAppData, "Programs/Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"),
|
||||
// Devin Desktop bundled CLI, machine-wide install
|
||||
process.env.ProgramFiles &&
|
||||
join(process.env.ProgramFiles, "Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"),
|
||||
// Set by the official Windows installer when it completes normally.
|
||||
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "devin/bin/devin.exe"),
|
||||
// Manual fallback for installs that could not finish while Devin was running.
|
||||
join(homedir(), ".devin-cli/devin.exe"),
|
||||
] : [
|
||||
join(homedir(), ".local/bin/devin"),
|
||||
join(homedir(), ".devin/bin/devin"),
|
||||
"/opt/homebrew/bin/devin",
|
||||
"/usr/local/bin/devin",
|
||||
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin",
|
||||
].filter((p): p is string => Boolean(p));
|
||||
]),
|
||||
].filter((p): p is string => Boolean(p));
|
||||
}
|
||||
|
||||
let cachedBin: string | null | undefined;
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findDevinBin(): string | null {
|
||||
// An explicit override must take effect even after a successful discovery.
|
||||
if (process.env.DEVIN_CLI && isFile(process.env.DEVIN_CLI)) return process.env.DEVIN_CLI;
|
||||
if (cachedBin !== undefined) return cachedBin;
|
||||
for (const bin of KNOWN_BINS) {
|
||||
if (existsSync(bin)) {
|
||||
for (const bin of candidateBins()) {
|
||||
if (isFile(bin)) {
|
||||
cachedBin = bin;
|
||||
return bin;
|
||||
}
|
||||
@@ -36,16 +65,25 @@ export function clearDevinBinCache(): void {
|
||||
export async function whichDevin(): Promise<string | null> {
|
||||
const known = findDevinBin();
|
||||
if (known) return known;
|
||||
// Windows has no `which`; use `where` and probe the returned paths directly.
|
||||
const isWindows = process.platform === "win32";
|
||||
const locator = isWindows ? "where.exe" : "/usr/bin/which";
|
||||
const candidates = isWindows ? ["devin.exe", "devin.cmd", "devin"] : ["devin"];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 });
|
||||
const path = stdout.trim();
|
||||
if (path && existsSync(path)) {
|
||||
const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 });
|
||||
// `where` may return several lines; take the first existing one.
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const path = line.trim();
|
||||
if (path && isFile(path)) {
|
||||
cachedBin = path;
|
||||
return path;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not on PATH
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -60,9 +98,18 @@ export async function runDevin(
|
||||
);
|
||||
}
|
||||
|
||||
// Batch wrappers need cmd.exe. Quote paths with spaces, and reject shell
|
||||
// expansion characters rather than interpreting them as part of a command.
|
||||
const wrapper = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
|
||||
if (wrapper && [bin, ...args].some((part) => /["%!\r\n]/.test(part))) {
|
||||
throw new Error("Unsupported shell characters in Devin wrapper path or arguments. Set DEVIN_CLI to devin.exe.");
|
||||
}
|
||||
const executable = wrapper ? process.env.ComSpec || "cmd.exe" : bin;
|
||||
const commandArgs = wrapper ? ["/d", "/s", "/c", `"${[bin, ...args].map((part) => `"${part}"`).join(" ")}"`] : args;
|
||||
|
||||
if (opts.inheritStdio) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { stdio: "inherit" });
|
||||
const child = spawn(executable, commandArgs, { stdio: "inherit", windowsVerbatimArguments: wrapper });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
resolve({ stdout: "", stderr: "", code: code ?? 1 });
|
||||
@@ -71,9 +118,10 @@ export async function runDevin(
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(bin, args, {
|
||||
const { stdout, stderr } = await execFileAsync(executable, commandArgs, {
|
||||
timeout: opts.timeoutMs ?? 30_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
windowsVerbatimArguments: wrapper,
|
||||
});
|
||||
return { stdout, stderr, code: 0 };
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getSystemMessageText, type Api, type TranscriptContext, type Message, type Model, type Usage } from "@earendil-works/pi-ai";
|
||||
|
||||
/**
|
||||
* Local port of pi-ai 0.87.1's `utils/estimate.js` + `clampMaxTokensToContext`.
|
||||
* Extensions can only resolve the pi-ai compat entry, so the upstream helpers
|
||||
* are reproduced here to keep the maxTokens context clamp identical.
|
||||
*/
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
const ESTIMATED_IMAGE_CHARS = 4800;
|
||||
const CONTEXT_SAFETY_TOKENS = 4096;
|
||||
const MIN_MAX_TOKENS = 1;
|
||||
|
||||
function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function estimateTextAndImageContentChars(content: Message["content"]): number {
|
||||
if (typeof content === "string") return content.length;
|
||||
let chars = 0;
|
||||
for (const block of content) chars += block.type === "text" ? block.text.length : ESTIMATED_IMAGE_CHARS;
|
||||
return chars;
|
||||
}
|
||||
|
||||
function estimateTextTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
function estimateTextAndImageContentTokens(content: Message["content"]): number {
|
||||
return Math.ceil(estimateTextAndImageContentChars(content) / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
function estimateMessageTokens(message: Message): number {
|
||||
if (message.role === "system") {
|
||||
return estimateTextTokens(getSystemMessageText(message)) +
|
||||
estimateToolsTokens(message.toolsAdded) + estimateToolsTokens(message.toolsRemoved);
|
||||
}
|
||||
if (message.role === "user" || message.role === "toolResult") {
|
||||
return estimateTextAndImageContentTokens(message.content);
|
||||
}
|
||||
let chars = 0;
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") chars += block.text.length;
|
||||
else if (block.type === "thinking") chars += block.thinking.length;
|
||||
else chars += block.name.length + safeJsonStringify(block.arguments).length;
|
||||
}
|
||||
return Math.ceil(chars / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
interface UsageInfo {
|
||||
usage: Usage;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function getLastAssistantUsageInfo(messages: Message[]): UsageInfo | undefined {
|
||||
let latestPrefixTimestamp = Number.NEGATIVE_INFINITY;
|
||||
let usageInfo: UsageInfo | undefined;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (message.role === "assistant") {
|
||||
const usageAppliesToPrefix = message.timestamp >= latestPrefixTimestamp;
|
||||
if (
|
||||
usageAppliesToPrefix &&
|
||||
message.stopReason !== "aborted" &&
|
||||
message.stopReason !== "error" &&
|
||||
calculateContextTokens(message.usage) > 0
|
||||
) {
|
||||
usageInfo = { usage: message.usage, index: i };
|
||||
}
|
||||
}
|
||||
latestPrefixTimestamp = Math.max(latestPrefixTimestamp, message.timestamp);
|
||||
}
|
||||
return usageInfo;
|
||||
}
|
||||
|
||||
interface ContextEstimate {
|
||||
tokens: number;
|
||||
usageTokens: number;
|
||||
trailingTokens: number;
|
||||
lastUsageIndex: number | null;
|
||||
}
|
||||
|
||||
function estimateMessages(messages: Message[]): ContextEstimate {
|
||||
const usageInfo = getLastAssistantUsageInfo(messages);
|
||||
if (usageInfo) {
|
||||
const usageTokens = calculateContextTokens(usageInfo.usage);
|
||||
let trailingTokens = 0;
|
||||
for (let i = usageInfo.index + 1; i < messages.length; i++) {
|
||||
trailingTokens += estimateMessageTokens(messages[i]);
|
||||
}
|
||||
return { tokens: usageTokens + trailingTokens, usageTokens, trailingTokens, lastUsageIndex: usageInfo.index };
|
||||
}
|
||||
let tokens = 0;
|
||||
for (const message of messages) tokens += estimateMessageTokens(message);
|
||||
return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null };
|
||||
}
|
||||
|
||||
function estimateToolsTokens(tools: readonly unknown[] | undefined): number {
|
||||
if (!tools || tools.length === 0) return 0;
|
||||
return estimateTextTokens(safeJsonStringify(tools));
|
||||
}
|
||||
|
||||
export function estimateContextTokens(context: TranscriptContext): ContextEstimate {
|
||||
return estimateMessages(context.messages);
|
||||
}
|
||||
|
||||
export function clampMaxTokensToContext(model: Model<Api>, context: TranscriptContext, maxTokens: number): number {
|
||||
if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens);
|
||||
const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
|
||||
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
|
||||
}
|
||||
+52
-26
@@ -1,4 +1,7 @@
|
||||
import type { Context, Message, Tool } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
collapseSystemMessages, getCurrentSystemPrompt, getCurrentTools, withoutInitialSystemMessage,
|
||||
type Api, type TranscriptContext, type Message, type Model, type Tool,
|
||||
} from "@earendil-works/pi-ai";
|
||||
|
||||
export interface ContentPart {
|
||||
type: "text" | "image";
|
||||
@@ -8,10 +11,15 @@ export interface ContentPart {
|
||||
}
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string | ContentPart[];
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: ContentPart[];
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
||||
tool_result_is_error?: boolean;
|
||||
thinking?: string;
|
||||
signature?: string;
|
||||
thinking_redacted?: boolean;
|
||||
message_id?: string;
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
@@ -21,13 +29,14 @@ export interface ToolDef {
|
||||
}
|
||||
|
||||
export interface MappedChat {
|
||||
systemPrompt?: string;
|
||||
messages: ChatHistoryItem[];
|
||||
tools: ToolDef[];
|
||||
}
|
||||
|
||||
function userContent(content: Message["content"]): string | ContentPart[] {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
function contentParts(content: Message["content"] | string): ContentPart[] {
|
||||
if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
|
||||
if (!Array.isArray(content)) return [];
|
||||
const parts: ContentPart[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
@@ -39,23 +48,39 @@ function userContent(content: Message["content"]): string | ContentPart[] {
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function mapContextToChat(context: Context): MappedChat {
|
||||
export function mapContextToChat(context: TranscriptContext, model: Model<Api>): MappedChat {
|
||||
const transcript = collapseSystemMessages(context);
|
||||
const messages: ChatHistoryItem[] = [];
|
||||
if (context.systemPrompt) {
|
||||
messages.push({ role: "system", content: context.systemPrompt });
|
||||
}
|
||||
|
||||
for (const message of context.messages) {
|
||||
for (const message of withoutInitialSystemMessage(transcript.messages)) {
|
||||
if (message.role === "user") {
|
||||
messages.push({ role: "user", content: userContent(message.content) });
|
||||
const content = contentParts(message.content);
|
||||
if (content.length === 0) continue;
|
||||
messages.push({ role: "user", content });
|
||||
continue;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const texts: string[] = [];
|
||||
const content: ContentPart[] = [];
|
||||
const toolCalls: Array<{ id: string; name: string; arguments: string }> = [];
|
||||
let thinking = "";
|
||||
let signature: string | undefined;
|
||||
let thinkingRedacted = false;
|
||||
// Only messages produced by this provider/model carry wire-replayable
|
||||
// thinking signatures and response ids.
|
||||
const native =
|
||||
message.api === model.api &&
|
||||
message.provider === model.provider &&
|
||||
message.model === model.id;
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") texts.push(part.text);
|
||||
if (part.type === "toolCall") {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "thinking") {
|
||||
thinking += part.thinking;
|
||||
if (part.redacted) thinkingRedacted = true;
|
||||
if (native && !signature && part.thinkingSignature) {
|
||||
signature = part.thinkingSignature;
|
||||
}
|
||||
} else if (part.type === "toolCall") {
|
||||
toolCalls.push({
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
@@ -63,34 +88,35 @@ export function mapContextToChat(context: Context): MappedChat {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (content.length === 0 && !thinking && !signature && toolCalls.length === 0) continue;
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: texts.join("\n"),
|
||||
content,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
thinking: thinking || undefined,
|
||||
signature,
|
||||
thinking_redacted: thinkingRedacted || undefined,
|
||||
message_id: native ? message.responseId : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const text =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: text,
|
||||
content: contentParts(message.content),
|
||||
tool_call_id: message.toolCallId,
|
||||
tool_result_is_error: message.isError || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tools: ToolDef[] = (context.tools ?? []).map((tool: Tool) => ({
|
||||
const tools: ToolDef[] = getCurrentTools(transcript.messages).map((tool: Tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
}));
|
||||
|
||||
return { messages, tools };
|
||||
const systemPrompt = getCurrentSystemPrompt(transcript.messages) || undefined;
|
||||
|
||||
return { systemPrompt, messages, tools };
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export interface MintedUserJwt {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function anySignal(signals: AbortSignal[]): AbortSignal {
|
||||
export function anySignal(signals: AbortSignal[]): AbortSignal {
|
||||
const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
|
||||
if (typeof builtin === "function") return builtin(signals);
|
||||
const controller = new AbortController();
|
||||
|
||||
+8
-5
@@ -7,10 +7,13 @@ import {
|
||||
} from "./wire.js";
|
||||
|
||||
/**
|
||||
* Cognition version-gates GetChatMessage against a known Windsurf/Devin Desktop
|
||||
* release. The Devin CLI build id (`3000.4.25`) is not that string and the
|
||||
* server answers: "Your Windsurf version is out of date."
|
||||
* Prefer the installed Devin.app product.json, then a current desktop release.
|
||||
* Cognition gates Devin Local-only models (every GPT-5.6 variant: Sol, Terra,
|
||||
* Luna) by client ide name. With ide="windsurf" GetChatMessage rejects them
|
||||
* with "This model is only in Devin Local."; with ide="devin-desktop" the
|
||||
* server serves them and the response header echoes the exact model
|
||||
* (verified: gpt-5-6-sol-high, 2026-09-15). The version string is still
|
||||
* gated against a known Windsurf/Devin Desktop release: prefer the installed
|
||||
* Devin.app product.json, then a current desktop release.
|
||||
*/
|
||||
const FALLBACK_WINDSURF_VERSION = "3.6.27";
|
||||
const PRODUCT_JSON =
|
||||
@@ -29,7 +32,7 @@ function desktopWindsurfVersion(): string {
|
||||
}
|
||||
|
||||
export const CLIENT_VERSION = desktopWindsurfVersion();
|
||||
export const CLIENT_IDE = "windsurf";
|
||||
export const CLIENT_IDE = "devin-desktop";
|
||||
|
||||
export interface MetadataInput {
|
||||
apiKey: string;
|
||||
|
||||
+92
-25
@@ -30,14 +30,18 @@ const THINKING_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max
|
||||
function parseCost(summary?: string): ProviderModelConfig["cost"] {
|
||||
const empty = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
if (!summary) return empty;
|
||||
const input = summary.match(/\$([0-9.]+)\s*\/\s*MTok In/i);
|
||||
const output = summary.match(/\$([0-9.]+)\s*\/\s*MTok Out/i);
|
||||
// Devin CLI emits e.g. "$10 / 1M Input · $0.25 / 1M Cached input · $50 / 1M Output";
|
||||
// older catalogs used "/MTok In" style labels. Match the unit token loosely.
|
||||
const input = summary.match(/\$([0-9.]+)\s*\/\s*1?M(?:Tok)?\s+In(?:put)?/i);
|
||||
const output = summary.match(/\$([0-9.]+)\s*\/\s*1?M(?:Tok)?\s+Out(?:put)?/i);
|
||||
const cached = summary.match(/\$([0-9.]+)\s*\/\s*1?M(?:Tok)?\s+Cached\s+in(?:put)?/i);
|
||||
const inCost = input ? Number(input[1]) : 0;
|
||||
const outCost = output ? Number(output[1]) : 0;
|
||||
const cacheRead = cached ? Number(cached[1]) : Number((inCost * 0.1).toFixed(4));
|
||||
return {
|
||||
input: inCost,
|
||||
output: outCost,
|
||||
cacheRead: Number((inCost * 0.1).toFixed(4)),
|
||||
cacheRead,
|
||||
cacheWrite: Number((inCost * 1.25).toFixed(4)),
|
||||
};
|
||||
}
|
||||
@@ -65,8 +69,13 @@ function variantKey(uid: string): string | null {
|
||||
"max",
|
||||
"minimal",
|
||||
];
|
||||
// Catalog uids are not uniform: some families use `MODEL_GPT_5_2_HIGH`-style
|
||||
// uppercase/underscore ids and long-context twins append `-1m` after the
|
||||
// thinking suffix. Normalize case, separators, and the context suffix before
|
||||
// matching so every variant lands on its real thinking level.
|
||||
const normalized = uid.toLowerCase().replace(/_/g, "-").replace(/-1m$/, "");
|
||||
for (const suffix of suffixes) {
|
||||
if (uid === suffix || uid.endsWith(`-${suffix}`)) return suffix;
|
||||
if (normalized === suffix || normalized.endsWith(`-${suffix}`)) return suffix;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -92,39 +101,82 @@ function preferredDefault(map: ThinkingLevelMap): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function familyToModels(family: DevinFamily): ProviderModelConfig[] {
|
||||
const usable = family.variants.filter((variant) => {
|
||||
const key = variantKey(variant.model_uid);
|
||||
return !key || (!key.includes("priority") && !key.includes("fast") && key !== "thinking-1m");
|
||||
});
|
||||
const source = usable.length > 0 ? usable : family.variants;
|
||||
const thinkingLevelMap: ThinkingLevelMap = {};
|
||||
for (const variant of source) {
|
||||
function contextLabel(tokens: number | undefined): string {
|
||||
if (!tokens || tokens <= 0) return "";
|
||||
if (tokens % 1_000_000 === 0) return `${tokens / 1_000_000}M`;
|
||||
return `${Math.round(tokens / 1_000)}K`;
|
||||
}
|
||||
|
||||
function variantsToModel(
|
||||
family: DevinFamily,
|
||||
variants: DevinVariant[],
|
||||
name?: string,
|
||||
): ProviderModelConfig | null {
|
||||
// Seed every level with null so Pi treats levels the catalog lacks as
|
||||
// explicitly unsupported instead of selectable-but-silently-remapped.
|
||||
const thinkingLevelMap: ThinkingLevelMap = {
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: null,
|
||||
xhigh: null,
|
||||
max: null,
|
||||
};
|
||||
for (const variant of variants) {
|
||||
const level = thinkingFromSuffix(variantKey(variant.model_uid));
|
||||
if (level && thinkingLevelMap[level] === undefined) {
|
||||
if (level && thinkingLevelMap[level] == null) {
|
||||
thinkingLevelMap[level] = variant.model_uid;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultUid = preferredDefault(thinkingLevelMap) ?? source[0]?.model_uid ?? family.family_uid;
|
||||
const sample = source.find((variant) => variant.model_uid === defaultUid) ?? source[0];
|
||||
if (!sample) return [];
|
||||
const defaultUid = preferredDefault(thinkingLevelMap) ?? variants[0]?.model_uid ?? family.family_uid;
|
||||
const sample = variants.find((variant) => variant.model_uid === defaultUid) ?? variants[0];
|
||||
if (!sample) return null;
|
||||
|
||||
const mappedLevels = THINKING_ORDER.filter((level) => typeof thinkingLevelMap[level] === "string");
|
||||
const reasoning = mappedLevels.length > 1;
|
||||
|
||||
return [
|
||||
{
|
||||
return {
|
||||
id: defaultUid,
|
||||
name: family.family_label || family.slug || defaultUid,
|
||||
name: name ?? (family.family_label || family.slug || defaultUid),
|
||||
reasoning,
|
||||
thinkingLevelMap: reasoning ? thinkingLevelMap : undefined,
|
||||
input: ["text", "image"],
|
||||
cost: parseCost(sample.cost_summary),
|
||||
contextWindow: sample.max_context_tokens ?? 256_000,
|
||||
maxTokens: sample.max_output_tokens ?? 128_000,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
function familyToModels(family: DevinFamily): ProviderModelConfig[] {
|
||||
const usable = family.variants.filter((variant) => {
|
||||
const key = variantKey(variant.model_uid);
|
||||
return !key || (!key.includes("priority") && !key.includes("fast") && key !== "thinking-1m");
|
||||
});
|
||||
const source = usable.length > 0 ? usable : family.variants;
|
||||
// Group by advertised context window so long-context twins (`*-1m`) remain
|
||||
// selectable instead of collapsing into the primary variant group. The
|
||||
// smallest-context group keeps the plain family label for stable model ids.
|
||||
const groups = new Map<number, DevinVariant[]>();
|
||||
for (const variant of source) {
|
||||
const context = variant.max_context_tokens ?? 0;
|
||||
const group = groups.get(context) ?? [];
|
||||
group.push(variant);
|
||||
groups.set(context, group);
|
||||
}
|
||||
const ordered = [...groups.values()].sort(
|
||||
(a, b) => (a[0]?.max_context_tokens ?? 0) - (b[0]?.max_context_tokens ?? 0),
|
||||
);
|
||||
const models: ProviderModelConfig[] = [];
|
||||
ordered.forEach((variants, index) => {
|
||||
const context = variants[0]?.max_context_tokens;
|
||||
const name =
|
||||
index === 0 ? undefined : `${family.family_label || family.slug} (${contextLabel(context)})`;
|
||||
const model = variantsToModel(family, variants, name);
|
||||
if (model) models.push(model);
|
||||
});
|
||||
return models;
|
||||
}
|
||||
|
||||
export const FALLBACK_MODELS: ProviderModelConfig[] = [
|
||||
@@ -133,6 +185,8 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
|
||||
name: "Claude Opus 5",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: "claude-opus-5-low",
|
||||
medium: "claude-opus-5-medium",
|
||||
high: "claude-opus-5-high",
|
||||
@@ -149,6 +203,8 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
|
||||
name: "Claude Fable 5",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: "claude-5-fable-low",
|
||||
medium: "claude-5-fable-medium",
|
||||
high: "claude-5-fable-high",
|
||||
@@ -166,6 +222,7 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
off: "gpt-5-6-sol-none",
|
||||
minimal: null,
|
||||
low: "gpt-5-6-sol-low",
|
||||
medium: "gpt-5-6-sol-medium",
|
||||
high: "gpt-5-6-sol-high",
|
||||
@@ -181,6 +238,15 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
|
||||
id: "swe-1-7",
|
||||
name: "SWE-1.7",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: "swe-1-7",
|
||||
high: "swe-1-7",
|
||||
xhigh: null,
|
||||
max: null,
|
||||
},
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262_000,
|
||||
@@ -211,11 +277,12 @@ export function resolveModelUid(
|
||||
thinkingLevelMap: ThinkingLevelMap | undefined,
|
||||
reasoning?: string,
|
||||
): string {
|
||||
if (reasoning && thinkingLevelMap) {
|
||||
const mapped = thinkingLevelMap[reasoning as keyof ThinkingLevelMap];
|
||||
if (typeof mapped === "string") return mapped;
|
||||
}
|
||||
if (thinkingLevelMap) {
|
||||
// Pi encodes thinkingLevel "off" as an absent reasoning option; resolve it
|
||||
// against the explicit "off" mapping (the catalog's *-none variant) before
|
||||
// falling back to the family's preferred default.
|
||||
const mapped = thinkingLevelMap[(reasoning ?? "off") as keyof ThinkingLevelMap];
|
||||
if (typeof mapped === "string") return mapped;
|
||||
const fallback = preferredDefault(thinkingLevelMap);
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
|
||||
+298
-202
@@ -4,14 +4,15 @@ import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEventStream,
|
||||
type Context,
|
||||
type TranscriptContext,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
calculateCost,
|
||||
createAssistantMessageEventStream,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { mapContextToChat, type ChatHistoryItem, type ContentPart, type ToolDef } from "./context-map.js";
|
||||
import { getCachedUserJwt } from "./jwt.js";
|
||||
import { clampMaxTokensToContext } from "./context-estimate.js";
|
||||
import { mapContextToChat, type ChatHistoryItem, type ToolDef } from "./context-map.js";
|
||||
import { anySignal, getCachedUserJwt } from "./jwt.js";
|
||||
import { buildMetadata } from "./metadata.js";
|
||||
import { resolveModelUid } from "./models.js";
|
||||
import {
|
||||
@@ -26,64 +27,37 @@ import {
|
||||
const SOURCE_BY_ROLE: Record<string, number> = {
|
||||
user: 1,
|
||||
assistant: 2,
|
||||
system: 1,
|
||||
tool: 4,
|
||||
};
|
||||
|
||||
/** Connect frame payloads larger than this indicate a corrupt length prefix. */
|
||||
const MAX_CONNECT_FRAME_PAYLOAD = 16 * 1024 * 1024;
|
||||
/**
|
||||
* Recovery heuristic for opaque Devin `invalid_argument`/`internal error`
|
||||
* trailers: compactable message history this large is likely the cause, so the
|
||||
* failure is reported in a shape Pi recognizes as context overflow.
|
||||
*/
|
||||
const LARGE_HISTORY_RECOVERY_BYTES = 512 * 1024;
|
||||
|
||||
export type CloudChatEvent =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "reasoning"; text: string }
|
||||
| { kind: "signature"; text: string }
|
||||
| { kind: "thinking_redacted" }
|
||||
| { kind: "message_id"; id: string }
|
||||
| { kind: "actual_model"; uid: string }
|
||||
| { kind: "tool_call_start"; id: string; name: string }
|
||||
| { kind: "tool_call_args"; argsDelta: string; id?: string }
|
||||
| { kind: "finish"; reason: "stop" | "tool_calls" | "length" | "content_filter" }
|
||||
| { kind: "finish"; reason: "stop" | "tool_calls" | "length" | "content_filter" | "error" }
|
||||
| {
|
||||
kind: "usage";
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
cacheCreationInputTokens?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
modelUid?: string;
|
||||
};
|
||||
|
||||
function normalizeContent(content: string | ContentPart[]): ContentPart[] {
|
||||
if (typeof content === "string") return [{ type: "text", text: content }];
|
||||
return content;
|
||||
}
|
||||
|
||||
function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] {
|
||||
const out: ChatHistoryItem[] = [];
|
||||
let pending: string[] = [];
|
||||
const textOf = (content: string | ContentPart[]) =>
|
||||
normalizeContent(content)
|
||||
.filter((part) => part.type === "text" && part.text)
|
||||
.map((part) => part.text as string)
|
||||
.join("\n");
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "system") {
|
||||
const text = textOf(message.content);
|
||||
if (text) pending.push(text);
|
||||
continue;
|
||||
}
|
||||
if (message.role === "user" && pending.length > 0) {
|
||||
const userParts = normalizeContent(message.content);
|
||||
const userText = textOf(userParts);
|
||||
const images = userParts.filter((part) => part.type === "image");
|
||||
out.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `<system>\n${pending.join("\n\n")}\n</system>\n${userText}` }, ...images],
|
||||
});
|
||||
pending = [];
|
||||
continue;
|
||||
}
|
||||
out.push(message);
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
out.push({ role: "user", content: `<system>\n${pending.join("\n\n")}\n</system>` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer {
|
||||
return Buffer.concat([
|
||||
encodeString(1, img.base64Data ?? ""),
|
||||
@@ -95,39 +69,41 @@ function encodeChatToolCall(tc: { id: string; name: string; arguments: string })
|
||||
return Buffer.concat([encodeString(1, tc.id), encodeString(2, tc.name), encodeString(3, tc.arguments)]);
|
||||
}
|
||||
|
||||
function encodeChatMessagePrompt(
|
||||
content: ContentPart[],
|
||||
source: number,
|
||||
opts?: { toolCallId?: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> },
|
||||
): Buffer {
|
||||
const text = content
|
||||
export function encodeChatMessagePrompt(item: ChatHistoryItem, messageId: string): Buffer {
|
||||
const text = item.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text ?? "")
|
||||
.join("\n");
|
||||
const parts: Buffer[] = [
|
||||
encodeVarintField(2, source),
|
||||
encodeString(1, messageId),
|
||||
encodeVarintField(2, SOURCE_BY_ROLE[item.role] ?? 1),
|
||||
encodeString(3, text),
|
||||
encodeVarintField(4, Math.max(1, Math.floor(text.length / 4))),
|
||||
encodeVarintField(5, 1),
|
||||
];
|
||||
if (opts?.toolCallId) parts.push(encodeString(7, opts.toolCallId));
|
||||
for (const tc of opts?.toolCalls ?? []) parts.push(encodeMessage(6, encodeChatToolCall(tc)));
|
||||
for (const img of content.filter((part) => part.type === "image")) {
|
||||
for (const tc of item.tool_calls ?? []) parts.push(encodeMessage(6, encodeChatToolCall(tc)));
|
||||
if (item.tool_call_id) parts.push(encodeString(7, item.tool_call_id));
|
||||
if (item.tool_result_is_error) parts.push(encodeVarintField(9, 1));
|
||||
for (const img of item.content.filter((part) => part.type === "image")) {
|
||||
parts.push(encodeMessage(10, encodeImageData(img)));
|
||||
}
|
||||
if (item.thinking) parts.push(encodeString(11, item.thinking));
|
||||
if (item.signature) parts.push(encodeString(12, item.signature));
|
||||
if (item.thinking_redacted) parts.push(encodeVarintField(13, 1));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function encodeCompletionConfiguration(maxOutputTokens?: number): Buffer {
|
||||
function encodeCompletionConfiguration(args: {
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
topP: number;
|
||||
topK: number;
|
||||
}): Buffer {
|
||||
return Buffer.concat([
|
||||
encodeVarintField(1, 1),
|
||||
encodeVarintField(2, 64_000),
|
||||
encodeVarintField(3, maxOutputTokens ?? 128_000),
|
||||
encodeFixed64Field(5, 0.7),
|
||||
encodeFixed64Field(6, 0.95),
|
||||
encodeVarintField(7, 50),
|
||||
encodeFixed64Field(8, 1.0),
|
||||
encodeFixed64Field(11, 1.0),
|
||||
encodeVarintField(2, args.maxTokens),
|
||||
encodeVarintField(3, 400),
|
||||
encodeFixed64Field(5, args.temperature),
|
||||
encodeVarintField(7, args.topK),
|
||||
encodeFixed64Field(8, args.topP),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -140,10 +116,11 @@ function encodeToolDef(tool: ToolDef): Buffer {
|
||||
]);
|
||||
}
|
||||
|
||||
function buildGetChatMessageRequest(args: {
|
||||
export interface WireRequestArgs {
|
||||
apiKey: string;
|
||||
userJwt: string;
|
||||
modelUid: string;
|
||||
systemPrompt?: string;
|
||||
messages: ChatHistoryItem[];
|
||||
tools?: ToolDef[];
|
||||
cascadeId: string;
|
||||
@@ -151,8 +128,13 @@ function buildGetChatMessageRequest(args: {
|
||||
sessionId: string;
|
||||
requestId: bigint;
|
||||
triggerId: string;
|
||||
maxOutputTokens?: number;
|
||||
}): Buffer {
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
topP: number;
|
||||
topK: number;
|
||||
}
|
||||
|
||||
export function buildGetChatMessageRequest(args: WireRequestArgs): { body: Buffer; historyBytes: number } {
|
||||
const metadata = buildMetadata({
|
||||
apiKey: args.apiKey,
|
||||
userJwt: args.userJwt,
|
||||
@@ -160,35 +142,46 @@ function buildGetChatMessageRequest(args: {
|
||||
requestId: args.requestId,
|
||||
triggerId: args.triggerId,
|
||||
});
|
||||
const prompts = collapseSystemIntoUser(args.messages).map((message) =>
|
||||
encodeMessage(
|
||||
3,
|
||||
encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role] ?? 1, {
|
||||
toolCallId: message.role === "tool" ? message.tool_call_id : undefined,
|
||||
toolCalls: message.role === "assistant" ? message.tool_calls : undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return Buffer.concat([
|
||||
const promptFields: Buffer[] = [];
|
||||
args.messages.forEach((message, index) => {
|
||||
const messageId = message.message_id ?? `${args.cascadeId}-${index}`;
|
||||
promptFields.push(encodeMessage(3, encodeChatMessagePrompt(message, messageId)));
|
||||
});
|
||||
const history = Buffer.concat(promptFields);
|
||||
const body = Buffer.concat([
|
||||
encodeMessage(1, metadata),
|
||||
...prompts,
|
||||
args.systemPrompt ? encodeString(2, args.systemPrompt) : Buffer.alloc(0),
|
||||
history,
|
||||
encodeVarintField(7, 5),
|
||||
encodeMessage(8, encodeCompletionConfiguration(args.maxOutputTokens)),
|
||||
encodeMessage(8, encodeCompletionConfiguration(args)),
|
||||
...(args.tools ?? []).map((tool) => encodeMessage(10, encodeToolDef(tool))),
|
||||
encodeString(16, args.cascadeId),
|
||||
encodeString(17, args.promptId),
|
||||
encodeVarintField(20, 1),
|
||||
encodeString(21, args.modelUid),
|
||||
encodeString(22, args.promptId),
|
||||
]);
|
||||
return { body, historyBytes: history.length };
|
||||
}
|
||||
|
||||
function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
|
||||
export function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
|
||||
for (const field of iterFields(proto)) {
|
||||
if (field.num === 3 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
if (field.num === 1 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
const id = field.value.toString("utf8");
|
||||
if (id) yield { kind: "message_id", id };
|
||||
} else if (field.num === 3 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
const text = field.value.toString("utf8");
|
||||
if (text) yield { kind: "text", text };
|
||||
} else if (field.num === 9 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
const text = field.value.toString("utf8");
|
||||
if (text) yield { kind: "reasoning", text };
|
||||
} else if (field.num === 10 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
const signature = field.value.toString("utf8");
|
||||
if (signature) yield { kind: "signature", text: signature };
|
||||
} else if (field.num === 11 && field.wire === 0 && field.value) {
|
||||
yield { kind: "thinking_redacted" };
|
||||
} else if (field.num === 23 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
const uid = field.value.toString("utf8");
|
||||
if (uid) yield { kind: "actual_model", uid };
|
||||
} else if (field.num === 6 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
let id: string | undefined;
|
||||
let name: string | undefined;
|
||||
@@ -205,59 +198,77 @@ function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
|
||||
if (argsDelta !== undefined) yield { kind: "tool_call_args", argsDelta, ...(id ? { id } : {}) };
|
||||
} else if (field.num === 5 && field.wire === 0) {
|
||||
const value = Number(field.value);
|
||||
let reason: Extract<CloudChatEvent, { kind: "finish" }>['reason'] = "stop";
|
||||
let reason: Extract<CloudChatEvent, { kind: "finish" }>["reason"] = "stop";
|
||||
if (value === 10) reason = "tool_calls";
|
||||
else if (value === 1 || value === 3 || value === 5) reason = "length";
|
||||
else if (value === 11) reason = "content_filter";
|
||||
else if (value === 1 || value === 3) reason = "length";
|
||||
else if (value === 12 || value === 13) reason = "error";
|
||||
yield { kind: "finish", reason };
|
||||
} else if (field.num === 28 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
const usage = decodeUsage(field.value);
|
||||
} else if (field.num === 7 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
const usage = decodeUsageStats(field.value);
|
||||
if (usage) yield usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUsage(buf: Buffer): CloudChatEvent | null {
|
||||
let promptTokens: number | undefined;
|
||||
let completionTokens: number | undefined;
|
||||
let cachedInputTokens: number | undefined;
|
||||
let cacheCreationInputTokens: number | undefined;
|
||||
function decodeUsageStats(buf: Buffer): CloudChatEvent | null {
|
||||
let input: number | undefined;
|
||||
let output: number | undefined;
|
||||
let cacheWrite: number | undefined;
|
||||
let cacheRead: number | undefined;
|
||||
let modelUid: string | undefined;
|
||||
for (const field of iterFields(buf)) {
|
||||
if (field.num !== 2 || field.wire !== 2 || !Buffer.isBuffer(field.value)) continue;
|
||||
let metric: string | undefined;
|
||||
let value: number | undefined;
|
||||
for (const inner of iterFields(field.value)) {
|
||||
if (inner.num === 5 && inner.wire === 2 && Buffer.isBuffer(inner.value)) {
|
||||
metric = inner.value.toString("utf8");
|
||||
} else if (inner.num === 4 && inner.wire === 2 && Buffer.isBuffer(inner.value)) {
|
||||
for (const dim of iterFields(inner.value)) {
|
||||
if (dim.num === 2 && dim.wire === 5 && Buffer.isBuffer(dim.value)) {
|
||||
value = dim.value.readFloatLE(0);
|
||||
if (field.wire === 0) {
|
||||
const n = Number(field.value);
|
||||
if (field.num === 2) input = n;
|
||||
else if (field.num === 3) output = n;
|
||||
else if (field.num === 4) cacheWrite = n;
|
||||
else if (field.num === 5) cacheRead = n;
|
||||
} else if (field.num === 9 && field.wire === 2 && Buffer.isBuffer(field.value)) {
|
||||
modelUid = field.value.toString("utf8");
|
||||
}
|
||||
}
|
||||
if (input === undefined && output === undefined && cacheRead === undefined && cacheWrite === undefined) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!metric || value === undefined || !Number.isFinite(value)) continue;
|
||||
const n = Math.round(value);
|
||||
if (metric === "input_tokens") promptTokens = n;
|
||||
else if (metric === "output_tokens") completionTokens = n;
|
||||
else if (metric.includes("cached") || metric.includes("cache_read")) cachedInputTokens = n;
|
||||
else if (metric.includes("cache_creation")) cacheCreationInputTokens = n;
|
||||
}
|
||||
if (promptTokens === undefined && completionTokens === undefined) return null;
|
||||
return { kind: "usage", input, output, cacheRead, cacheWrite, modelUid };
|
||||
}
|
||||
|
||||
export interface TrailerError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function readTrailerError(text: string): TrailerError | null {
|
||||
if (!text.includes('"error"')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: { code?: unknown; message?: unknown } };
|
||||
if (!parsed.error || typeof parsed.error !== "object") return null;
|
||||
return {
|
||||
kind: "usage",
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens: (promptTokens ?? 0) + (completionTokens ?? 0),
|
||||
cachedInputTokens,
|
||||
cacheCreationInputTokens,
|
||||
code: typeof parsed.error.code === "string" ? parsed.error.code : "",
|
||||
message: typeof parsed.error.message === "string" ? parsed.error.message : "",
|
||||
};
|
||||
} catch {
|
||||
return { code: "", message: text.slice(0, 512) };
|
||||
}
|
||||
}
|
||||
|
||||
const sessionCache = new Map<string, { sessionId: string; cascadeId: string }>();
|
||||
|
||||
function sessionIds(apiKey: string, host: string) {
|
||||
export function sessionIds(
|
||||
apiKey: string,
|
||||
host: string,
|
||||
options?: { sessionId?: string; cacheRetention?: string },
|
||||
): { sessionId: string; cascadeId: string } {
|
||||
// Compaction and other no-cache callers must not share or reuse a cascade.
|
||||
if (options?.cacheRetention === "none") {
|
||||
return { sessionId: randomUUID(), cascadeId: randomUUID() };
|
||||
}
|
||||
// Pi threads its session id through so each session tree keeps its own
|
||||
// Devin cascade instead of sharing one process-wide cascade.
|
||||
if (options?.sessionId) {
|
||||
return { sessionId: options.sessionId, cascadeId: options.sessionId };
|
||||
}
|
||||
const key = `${host}\x1f${apiKey}`;
|
||||
let ids = sessionCache.get(key);
|
||||
if (!ids) {
|
||||
@@ -267,43 +278,41 @@ function sessionIds(apiKey: string, host: string) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function mergeHeaders(extra: Record<string, string | null> | undefined): Record<string, string> {
|
||||
const headers = new Map<string, string>([
|
||||
["content-type", "application/connect+proto"],
|
||||
["connect-protocol-version", "1"],
|
||||
["connect-accept-encoding", "gzip"],
|
||||
["user-agent", "connect-go/1.18.1 (go1.26.3)"],
|
||||
]);
|
||||
for (const [name, value] of Object.entries(extra ?? {})) {
|
||||
if (value == null) headers.delete(name.toLowerCase());
|
||||
else headers.set(name.toLowerCase(), value);
|
||||
}
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
|
||||
async function* streamChatEvents(args: {
|
||||
apiKey: string;
|
||||
host: string;
|
||||
modelUid: string;
|
||||
messages: ChatHistoryItem[];
|
||||
tools?: ToolDef[];
|
||||
maxOutputTokens?: number;
|
||||
request: { body: Buffer; historyBytes: number };
|
||||
headers?: Record<string, string | null>;
|
||||
signal?: AbortSignal;
|
||||
fetchFn?: typeof fetch;
|
||||
onResponse?: (response: { status: number; headers: Record<string, string> }) => void | Promise<void>;
|
||||
}): AsyncGenerator<CloudChatEvent> {
|
||||
const host = args.host.replace(/\/$/, "");
|
||||
const userJwt = await getCachedUserJwt(args.apiKey, host, args.signal);
|
||||
const ids = sessionIds(args.apiKey, host);
|
||||
const proto = buildGetChatMessageRequest({
|
||||
apiKey: args.apiKey,
|
||||
userJwt,
|
||||
modelUid: args.modelUid,
|
||||
messages: args.messages,
|
||||
tools: args.tools,
|
||||
cascadeId: ids.cascadeId,
|
||||
promptId: randomUUID(),
|
||||
sessionId: ids.sessionId,
|
||||
requestId: BigInt(Date.now()),
|
||||
triggerId: randomUUID(),
|
||||
maxOutputTokens: args.maxOutputTokens,
|
||||
});
|
||||
|
||||
const resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`, {
|
||||
const resp = await (args.fetchFn ?? fetch)(
|
||||
`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/connect+proto",
|
||||
"Connect-Protocol-Version": "1",
|
||||
"Connect-Content-Encoding": "gzip",
|
||||
"Connect-Accept-Encoding": "gzip",
|
||||
},
|
||||
body: new Uint8Array(frameConnectStream(proto, true)),
|
||||
headers: mergeHeaders(args.headers),
|
||||
body: new Uint8Array(frameConnectStream(args.request.body, false)),
|
||||
signal: args.signal,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (args.onResponse) {
|
||||
await args.onResponse({ status: resp.status, headers: Object.fromEntries(resp.headers.entries()) });
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GetChatMessage HTTP ${resp.status}: ${(await resp.text()).slice(0, 300)}`);
|
||||
}
|
||||
@@ -313,7 +322,8 @@ async function* streamChatEvents(args: {
|
||||
const queue: Buffer[] = [];
|
||||
let queued = 0;
|
||||
let sawEos = false;
|
||||
let trailerError: string | null = null;
|
||||
let producedOutput = false;
|
||||
let trailerError: TrailerError | null = null;
|
||||
|
||||
const peek = (n: number): Buffer | null => {
|
||||
if (queued < n) return null;
|
||||
@@ -361,6 +371,9 @@ async function* streamChatEvents(args: {
|
||||
if (!header) break;
|
||||
const flags = header[0];
|
||||
const len = header.readUInt32BE(1);
|
||||
if (len > MAX_CONNECT_FRAME_PAYLOAD) {
|
||||
throw new Error(`Devin stream frame too large: ${len} bytes`);
|
||||
}
|
||||
if (queued < 5 + len) break;
|
||||
drop(5);
|
||||
const raw = peek(len) ?? Buffer.alloc(0);
|
||||
@@ -369,18 +382,15 @@ async function* streamChatEvents(args: {
|
||||
if (flags & 0x01) payload = zlib.gunzipSync(raw);
|
||||
if (flags & 0x02) {
|
||||
sawEos = true;
|
||||
const text = payload.toString("utf8");
|
||||
if (text.includes('"error"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: { message?: string } };
|
||||
trailerError = parsed.error?.message ?? text;
|
||||
} catch {
|
||||
trailerError = text;
|
||||
}
|
||||
}
|
||||
trailerError = readTrailerError(payload.toString("utf8").trim());
|
||||
continue;
|
||||
}
|
||||
yield* decodeChatFrame(payload);
|
||||
for (const event of decodeChatFrame(payload)) {
|
||||
if (event.kind === "text" || event.kind === "reasoning" || event.kind === "tool_call_start") {
|
||||
producedOutput = true;
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -390,19 +400,36 @@ async function* streamChatEvents(args: {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
void resp.body?.cancel();
|
||||
// Await teardown so a rejection from an errored stream is observed.
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (trailerError) throw new Error(trailerError);
|
||||
if (trailerError) {
|
||||
if (
|
||||
!producedOutput &&
|
||||
trailerError.code.toLowerCase() === "invalid_argument" &&
|
||||
/\binternal error\b/i.test(trailerError.message) &&
|
||||
args.request.historyBytes >= LARGE_HISTORY_RECOVERY_BYTES
|
||||
) {
|
||||
throw new Error(
|
||||
`Devin stream rejected: ${trailerError.message} (message history of ${args.request.historyBytes} bytes likely exceeds the context window)`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
trailerError.code
|
||||
? `Devin stream rejected (${trailerError.code}): ${trailerError.message}`
|
||||
: `Devin stream rejected: ${trailerError.message}`,
|
||||
);
|
||||
}
|
||||
if (!sawEos) throw new Error("Devin stream ended without an EOS trailer");
|
||||
}
|
||||
|
||||
export function streamDevin(
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
@@ -428,10 +455,9 @@ export function streamDevin(
|
||||
|
||||
let textOpen = false;
|
||||
let thinkingOpen = false;
|
||||
let toolIndex = -1;
|
||||
let partialJson = "";
|
||||
let toolId = "";
|
||||
let toolName = "";
|
||||
const openTools = new Map<string, number>();
|
||||
const toolJson = new Map<string, string>();
|
||||
let lastToolId: string | undefined;
|
||||
|
||||
const closeText = () => {
|
||||
if (!textOpen) return;
|
||||
@@ -451,41 +477,92 @@ export function streamDevin(
|
||||
}
|
||||
thinkingOpen = false;
|
||||
};
|
||||
const closeTool = () => {
|
||||
if (toolIndex < 0) return;
|
||||
const block = output.content[toolIndex];
|
||||
const closeTool = (id: string) => {
|
||||
const idx = openTools.get(id);
|
||||
if (idx === undefined) return;
|
||||
openTools.delete(id);
|
||||
const block = output.content[idx];
|
||||
if (block.type === "toolCall") {
|
||||
const json = toolJson.get(id) ?? "";
|
||||
try {
|
||||
block.arguments = JSON.parse(partialJson);
|
||||
block.arguments = JSON.parse(json);
|
||||
} catch {
|
||||
// keep last parsed object
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: toolIndex,
|
||||
toolCall: { type: "toolCall", id: toolId, name: toolName, arguments: block.arguments },
|
||||
contentIndex: idx,
|
||||
toolCall: { type: "toolCall", id, name: block.name, arguments: block.arguments },
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
toolIndex = -1;
|
||||
toolJson.delete(id);
|
||||
if (lastToolId === id) lastToolId = undefined;
|
||||
};
|
||||
const closeAllTools = () => {
|
||||
for (const id of [...openTools.keys()]) closeTool(id);
|
||||
};
|
||||
const currentThinkingBlock = () => {
|
||||
const block = output.content[output.content.length - 1];
|
||||
return block?.type === "thinking" ? block : undefined;
|
||||
};
|
||||
|
||||
try {
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) throw new Error("No Devin credentials. Run /login devin (uses the local Devin CLI).");
|
||||
if (options?.deferred) throw new Error("The devin provider does not support deferred responses");
|
||||
const host = (options?.env?.DEVIN_API_SERVER_URL || "https://server.codeium.com").replace(/\/$/, "");
|
||||
const modelUid = resolveModelUid(model.id, model.thinkingLevelMap, options?.reasoning);
|
||||
const mapped = mapContextToChat(context);
|
||||
output.providerThinkingLevel = modelUid;
|
||||
output.responseModel = modelUid;
|
||||
const mapped = mapContextToChat(context, model);
|
||||
const timeoutMs = options?.timeoutMs;
|
||||
const signal =
|
||||
timeoutMs && timeoutMs > 0
|
||||
? anySignal(
|
||||
[options?.signal, AbortSignal.timeout(timeoutMs)].filter((s): s is AbortSignal => Boolean(s)),
|
||||
)
|
||||
: options?.signal;
|
||||
const ids = sessionIds(apiKey, host, options);
|
||||
const userJwt = await getCachedUserJwt(apiKey, host, signal);
|
||||
|
||||
const sampling =
|
||||
model.samplingParams || options?.samplingParams
|
||||
? { ...model.samplingParams, ...options?.samplingParams }
|
||||
: {};
|
||||
const wireArgs = {
|
||||
apiKey,
|
||||
userJwt,
|
||||
modelUid,
|
||||
systemPrompt: mapped.systemPrompt,
|
||||
messages: mapped.messages,
|
||||
tools: mapped.tools.length > 0 ? mapped.tools : undefined,
|
||||
cascadeId: ids.cascadeId,
|
||||
promptId: randomUUID(),
|
||||
sessionId: ids.sessionId,
|
||||
requestId: BigInt(Date.now()),
|
||||
triggerId: randomUUID(),
|
||||
maxTokens: clampMaxTokensToContext(model, context, options?.maxTokens ?? model.maxTokens),
|
||||
temperature: options?.temperature ?? 1.0,
|
||||
topP: typeof sampling.topP === "number" ? sampling.topP : 0.95,
|
||||
topK: typeof sampling.topK === "number" ? sampling.topK : 40,
|
||||
};
|
||||
const overridden = await options?.onPayload?.(wireArgs, model);
|
||||
const finalArgs =
|
||||
overridden && typeof overridden === "object" ? { ...wireArgs, ...overridden } : wireArgs;
|
||||
const request = buildGetChatMessageRequest(finalArgs);
|
||||
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
for await (const event of streamChatEvents({
|
||||
apiKey,
|
||||
host,
|
||||
modelUid,
|
||||
messages: mapped.messages,
|
||||
tools: mapped.tools.length > 0 ? mapped.tools : undefined,
|
||||
maxOutputTokens: options?.maxTokens,
|
||||
signal: options?.signal,
|
||||
request,
|
||||
headers: options?.headers,
|
||||
signal,
|
||||
fetchFn: options?.fetch,
|
||||
onResponse: options?.onResponse
|
||||
? (response) => options?.onResponse?.(response, model)
|
||||
: undefined,
|
||||
})) {
|
||||
if (event.kind === "text") {
|
||||
closeThinking();
|
||||
@@ -513,47 +590,66 @@ export function streamDevin(
|
||||
block.thinking += event.text;
|
||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: event.text, partial: output });
|
||||
}
|
||||
} else if (event.kind === "signature") {
|
||||
const block = currentThinkingBlock();
|
||||
if (block) block.thinkingSignature = event.text;
|
||||
} else if (event.kind === "thinking_redacted") {
|
||||
const block = currentThinkingBlock();
|
||||
if (block) block.redacted = true;
|
||||
} else if (event.kind === "message_id") {
|
||||
if (!output.responseId) output.responseId = event.id;
|
||||
} else if (event.kind === "actual_model") {
|
||||
output.responseModel = event.uid;
|
||||
} else if (event.kind === "tool_call_start") {
|
||||
closeText();
|
||||
closeThinking();
|
||||
closeTool();
|
||||
toolId = event.id;
|
||||
toolName = event.name;
|
||||
partialJson = "";
|
||||
output.content.push({ type: "toolCall", id: event.id, name: event.name, arguments: {} });
|
||||
toolIndex = output.content.length - 1;
|
||||
stream.push({ type: "toolcall_start", contentIndex: toolIndex, partial: output });
|
||||
openTools.set(event.id, output.content.length - 1);
|
||||
toolJson.set(event.id, "");
|
||||
lastToolId = event.id;
|
||||
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
|
||||
} else if (event.kind === "tool_call_args") {
|
||||
if (toolIndex < 0) continue;
|
||||
partialJson += event.argsDelta;
|
||||
const block = output.content[toolIndex];
|
||||
const id = event.id ?? lastToolId;
|
||||
const idx = id === undefined ? undefined : openTools.get(id);
|
||||
if (id === undefined || idx === undefined) continue;
|
||||
const json = (toolJson.get(id) ?? "") + event.argsDelta;
|
||||
toolJson.set(id, json);
|
||||
const block = output.content[idx];
|
||||
if (block.type === "toolCall") {
|
||||
try {
|
||||
block.arguments = JSON.parse(partialJson);
|
||||
block.arguments = JSON.parse(json);
|
||||
} catch {
|
||||
// incomplete json
|
||||
}
|
||||
}
|
||||
stream.push({ type: "toolcall_delta", contentIndex: toolIndex, delta: event.argsDelta, partial: output });
|
||||
stream.push({ type: "toolcall_delta", contentIndex: idx, delta: event.argsDelta, partial: output });
|
||||
} else if (event.kind === "finish") {
|
||||
closeText();
|
||||
closeThinking();
|
||||
closeTool();
|
||||
closeAllTools();
|
||||
if (event.reason === "content_filter") {
|
||||
throw new Error("Devin stop_reason: content_filter");
|
||||
}
|
||||
if (event.reason === "error") {
|
||||
throw new Error("Devin stop_reason: error");
|
||||
}
|
||||
output.stopReason =
|
||||
event.reason === "tool_calls" ? "toolUse" : event.reason === "length" ? "length" : "stop";
|
||||
} else if (event.kind === "usage") {
|
||||
output.usage.input = event.promptTokens ?? 0;
|
||||
output.usage.output = event.completionTokens ?? 0;
|
||||
output.usage.cacheRead = event.cachedInputTokens ?? 0;
|
||||
output.usage.cacheWrite = event.cacheCreationInputTokens ?? 0;
|
||||
output.usage.totalTokens = event.totalTokens ?? output.usage.input + output.usage.output;
|
||||
output.usage.input = event.input ?? 0;
|
||||
output.usage.output = event.output ?? 0;
|
||||
output.usage.cacheRead = event.cacheRead ?? 0;
|
||||
output.usage.cacheWrite = event.cacheWrite ?? 0;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
if (!output.responseModel && event.modelUid) output.responseModel = event.modelUid;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
closeText();
|
||||
closeThinking();
|
||||
closeTool();
|
||||
closeAllTools();
|
||||
stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["extensions/**/*.ts", "src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user