22 Commits
Author SHA1 Message Date
Shujakuinkuraudo decb944e42 fix(pi-devin): parse Devin CLI 1M cost_summary correctly
Devin catalog emits '$4 / 1M Input · $0.2 / 1M Cached input · $20 / 1M Output'
but the vendored parseCost only matched '/MTok In/Out', leaving output and
cached prices at 0 so session cost totals for devin models were wrong.
Accept 1M Input/Output/Cached input labels and use the real cached-input
price for cacheRead instead of the 10% input heuristic.
2026-10-06 22:12:28 -04:00
Shujakuinkuraudo 9e64132d91 feat(pi): port the managed stack to Pi 0.87.1 and Pi Web 0.9.3 2026-09-28 17:27:05 -04:00
Shujakuinkuraudo 624065c4f1 fix(pi-devin): observe errored stream cancel rejection in reader teardown 2026-09-17 22:53:54 +08:00
Shujakuinkuraudo 654221119d refactor(pi-devin): switch from npm+patch to vendored local fork
- Vendor the patched pi-devin@0.1.2 tree at apps/pi-devin/upstream/
  (third-party archive -> upstream_fork); git history replaces the patch
  layer as the change archive. Includes the devin-desktop identity fix
  and normalized catalog variant mapping from the previous patch.
- build-pi-package.sh now verifies source.sha256 against
  host-local-packages.json and npm-packs the vendored tree; drop the
  npm fetch + patch stage.
- install-pi-package.sh strips the npm-pack package/ prefix to match
  the playbook's unarchive behavior.
- Deployed to a100 via deploy_pi_devin_package (--limit a100, R2 then
  R3); remote verified to carry the fork source.
2026-09-15 12:08:30 -04:00
Kashyab Ambarani 851c361a84 chore: release pi-devin 0.2.1
Tests / test (22, macos-latest) (push) Canceled after 0s
Tests / test (22, ubuntu-latest) (push) Canceled after 0s
Tests / test (22, windows-latest) (push) Canceled after 0s
Tests / test (24, macos-latest) (push) Canceled after 0s
Tests / test (24, ubuntu-latest) (push) Canceled after 0s
Tests / test (24, windows-latest) (push) Canceled after 0s
2026-09-25 03:53:22 -07:00
Kashyab Ambarani 0cabd02d47 test: compare resolved Windows paths after locator expansion 2026-09-25 03:49:10 -07:00
Kashyab Ambarani 52c87e3050 docs: describe Windows CLI discovery 2026-09-25 03:48:36 -07:00
Kashyab Ambarani 418c00604a test: preserve Windows PATH casing and exercise real fetch aborts 2026-09-25 03:47:32 -07:00
Kashyab Ambarani ce6ba534f7 test: report Windows locator failures 2026-09-25 03:45:42 -07:00
Kashyab Ambarani bbefd49833 Merge pull request #11 with Windows wrapper execution and platform tests 2026-09-25 03:44:07 -07:00
Kashyab Ambaranianddualface e6b5247cc0 perf: cache the CLI catalog with validated startup fallback
Ports #6 with optional-field validation, conversion before cache writes, an upstream-specific cache directory, and startup regression tests.

Co-authored-by: dualface <dualface@gmail.com>
2026-09-25 03:41:59 -07:00
5d6090ae46 fix: preserve streaming tool arguments and complete usage totals
Consolidates #2, #3 and #4 with split-frame regression tests. Uses Pi's streaming JSON parser and preserves usage across empty trailers.

Co-authored-by: Max19970 <maxshubert@yandex.ru>
Co-authored-by: SannaMarcoDev <sannamarco.dev@gmail.com>
Co-authored-by: Jeremias Volker <jeremias.volker@uniklinik-freiburg.de>
2026-09-25 03:38:47 -07:00
Kashyab Ambarani b9e5ae098c Merge pull request #9: contain aborted stream cleanup rejection
Fixes #8
2026-09-25 03:36:53 -07:00
ZhaoJY 3a95791f61 fix: locate the Devin CLI on Windows
KNOWN_BINS only listed Unix/macOS paths, and whichDevin() always shelled
out to /usr/bin/which. On Windows neither resolves, so the provider could
not find `devin.exe` even when it was installed and on PATH. The model
catalog therefore never loaded and the provider silently fell back to the
four hard-coded FALLBACK_MODELS entries.

- Probe the documented Windows install locations (%LOCALAPPDATA%\devin\
  cli\bin\devin.exe, the Devin Desktop bundled CLI for both per-user and
  machine-wide installs, plus two fallbacks).
- Build the candidate list lazily so a DEVIN_CLI value set after module
  load is still honoured.
- Use `where` instead of /usr/bin/which on Windows, and accept multiple
  result lines, since `where` prints one path per line.

All existing Unix/macOS paths are preserved.

Tested on Windows 11, Node 24.5.0, pi 0.87.1, Devin CLI 3000.11.3:
`pi --list-models devin` goes from 4 models to 52 with DEVIN_CLI unset.
`tsc --noEmit` and the existing test suite both pass.
2026-09-25 18:02:57 +08:00
denispolandDevin 463f29e76a fix: contain AbortError from aborted response body cancellation
`void resp.body?.cancel()` in streamChatEvents' finally floated a
rejected promise: an aborted fetch errors the body with the abort
reason, and cancel() on an errored stream rejects with it, escaping
as unhandledRejection -> uncaughtException and killing pi on Escape.

Await the cancel inside the existing try/catch so the rejection is
actually swallowed. Adds a regression test that aborts a mocked
in-flight stream and asserts no unhandled rejection is raised.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 23:49:59 +02:00
Kashyab Ambarani dc815fc15f chore: prepare pi-devin 0.2.0 release 2026-09-22 15:43:06 -07:00
Kashyab Ambarani 3b40b4020d fix: send system instructions in Devin's dedicated prompt field 2026-09-22 15:38:37 -07:00
Kashyab Ambarani f42b776b8e fix: require Pi 0.86 and test transcript context mapping 2026-09-22 15:05:44 -07:00
Kashyab Ambarani 2f94b72ee1 Merge pull request #5 from dualface/fix/transcript-context-tools
fix: read prompt and tools from the normalized transcript (pi 0.86 breaks all tool calls)
2026-09-22 15:04:45 -07:00
dualfaceandClaude Opus 5 8cf221e13f fix: read prompt and tools from the normalized transcript
pi 0.86.0 hands custom providers a TranscriptContext: `systemPrompt` and
`tools` are gone, and tool declarations live in the transcript's system
messages. Reading `context.tools` therefore sent zero tools to Devin, so
every Devin model answered that it cannot run commands, and the system
prompt was dropped with it.

Collapse the system messages first, then take the prompt and the current
tool set from the replayed transcript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:08:30 +08:00
Kashyab Ambarani e4d4ec3aae Merge pull request #1 from 1am2syman/fix/gpt-5-6-devin-desktop-identity
Fix GPT-5.6 'This model is only in Devin Local': identify as devin-desktop
2026-08-29 22:28:00 -07:00
local-patch 275cd5a493 Fix GPT-5.6 gate: identify as devin-desktop, not windsurf
With ide=windsurf, GetChatMessage rejects every GPT-5.6 variant with
'This model is only in Devin Local.' Switching the client identity to
devin-desktop makes the server serve them; response-header wire echo
confirms the exact model (gpt-5-6-sol-high -> 'GPT-5.6 Sol High Thinking').
Unknown UIDs still hard-error, so there is no silent fallback.
2026-08-29 19:09:06 +00:00
13 changed files with 826 additions and 339 deletions
-6
View File
@@ -1,6 +0,0 @@
node_modules/
dist/
package-lock.json
.DS_Store
*.log
.env
+6 -2
View File
@@ -16,9 +16,9 @@ Those models are available through the local Devin CLI. This package uses that C
## Requirements ## 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`) - 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: 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 ## License
MIT. Unofficial. Not affiliated with Cognition. 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
View File
@@ -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 type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
import { authStatus, loginWithCli, readCredentials } from "../src/credentials.js"; import { authStatus, loginWithCli, readCredentials } from "../src/credentials.js";
import { whichDevin, devinVersion } from "../src/cli.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 { FALLBACK_MODELS, loadCliCatalog, modelsFromCatalog } from "../src/models.js";
import { CLIENT_IDE, CLIENT_VERSION } from "../src/metadata.js"; import { CLIENT_IDE, CLIENT_VERSION } from "../src/metadata.js";
import { streamDevin } from "../src/stream.js"; import { streamDevin } from "../src/stream.js";
@@ -10,6 +17,37 @@ const PROVIDER_ID = "devin";
const PLACEHOLDER_BASE_URL = "https://server.codeium.com"; const PLACEHOLDER_BASE_URL = "https://server.codeium.com";
let _pi: ExtensionAPI | null = null; 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 { function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void {
pi.registerProvider(PROVIDER_ID, { pi.registerProvider(PROVIDER_ID, {
@@ -23,8 +61,7 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
const creds = await loginWithCli(); const creds = await loginWithCli();
if (_pi) { if (_pi) {
try { try {
const catalog = await loadCliCatalog(); await refreshCatalog(_pi);
registerDevinProvider(_pi, modelsFromCatalog(catalog));
} catch { } catch {
// keep current models // keep current models
} }
@@ -57,26 +94,32 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
export default async function (pi: ExtensionAPI): Promise<void> { export default async function (pi: ExtensionAPI): Promise<void> {
_pi = pi; _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 { try {
if (readCredentials()) { if (readCredentials()) {
const catalog = await loadCliCatalog(); if (!cached) {
registerDevinProvider(pi, modelsFromCatalog(catalog)); await refreshCatalog(pi);
} } else if (!isCatalogCacheFresh(cached)) {
} catch { void refreshCatalog(pi).catch((error) => {
// fallback models already registered console.warn(`Devin: background model catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
}
pi.on("session_start", async () => {
try {
if (!_pi || !readCredentials()) return;
const catalog = await loadCliCatalog();
registerDevinProvider(_pi, modelsFromCatalog(catalog));
} catch {
// keep current models
}
}); });
}
}
} catch {
// Cached or fallback models stay registered when credential discovery or a cold refresh fails.
}
}
pi.registerCommand("devin-status", { pi.registerCommand("devin-status", {
description: "Show Devin CLI auth + binary 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`", description: "Refresh Devin Local model catalog from `devin models list`",
handler: async (_args, ctx) => { handler: async (_args, ctx) => {
try { try {
const catalog = await loadCliCatalog(); const models = await refreshCatalog(pi);
const models = modelsFromCatalog(catalog);
registerDevinProvider(pi, models);
ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info"); ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info");
} catch (error) { } catch (error) {
ctx.ui.notify( ctx.ui.notify(
@@ -114,6 +155,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
}); });
pi.on("session_shutdown", async () => { pi.on("session_shutdown", async () => {
_pi = null; if (_pi === pi) _pi = null;
}); });
} }
+5 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "pi-devin", "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.", "description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.",
"type": "module", "type": "module",
"keywords": [ "keywords": [
@@ -28,18 +28,16 @@
"AGENTS.md", "AGENTS.md",
"LICENSE" "LICENSE"
], ],
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": "*", "@earendil-works/pi-ai": ">=0.86.0",
"@earendil-works/pi-coding-agent": "*" "@earendil-works/pi-coding-agent": ">=0.86.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"tsx": "^4.23.15",
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=22.19.0"
} }
} }
+104
View File
@@ -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;
}
+57 -9
View File
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs"; import { statSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { execFile, spawn } from "node:child_process"; import { execFile, spawn } from "node:child_process";
@@ -6,21 +6,50 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile); 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.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(), ".local/bin/devin"),
join(homedir(), ".devin/bin/devin"), join(homedir(), ".devin/bin/devin"),
"/opt/homebrew/bin/devin", "/opt/homebrew/bin/devin",
"/usr/local/bin/devin", "/usr/local/bin/devin",
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/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; let cachedBin: string | null | undefined;
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
export function findDevinBin(): string | null { 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; if (cachedBin !== undefined) return cachedBin;
for (const bin of KNOWN_BINS) { for (const bin of candidateBins()) {
if (existsSync(bin)) { if (isFile(bin)) {
cachedBin = bin; cachedBin = bin;
return bin; return bin;
} }
@@ -36,16 +65,25 @@ export function clearDevinBinCache(): void {
export async function whichDevin(): Promise<string | null> { export async function whichDevin(): Promise<string | null> {
const known = findDevinBin(); const known = findDevinBin();
if (known) return known; 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 { try {
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 }); const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 });
const path = stdout.trim(); // `where` may return several lines; take the first existing one.
if (path && existsSync(path)) { for (const line of stdout.split(/\r?\n/)) {
const path = line.trim();
if (path && isFile(path)) {
cachedBin = path; cachedBin = path;
return path; return path;
} }
}
} catch { } catch {
// not on PATH // not on PATH
} }
}
return null; 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) { if (opts.inheritStdio) {
return await new Promise((resolve, reject) => { 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("error", reject);
child.on("close", (code) => { child.on("close", (code) => {
resolve({ stdout: "", stderr: "", code: code ?? 1 }); resolve({ stdout: "", stderr: "", code: code ?? 1 });
@@ -71,9 +118,10 @@ export async function runDevin(
} }
try { try {
const { stdout, stderr } = await execFileAsync(bin, args, { const { stdout, stderr } = await execFileAsync(executable, commandArgs, {
timeout: opts.timeoutMs ?? 30_000, timeout: opts.timeoutMs ?? 30_000,
maxBuffer: 8 * 1024 * 1024, maxBuffer: 8 * 1024 * 1024,
windowsVerbatimArguments: wrapper,
}); });
return { stdout, stderr, code: 0 }; return { stdout, stderr, code: 0 };
} catch (error) { } catch (error) {
+118
View File
@@ -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
View File
@@ -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 { export interface ContentPart {
type: "text" | "image"; type: "text" | "image";
@@ -8,10 +11,15 @@ export interface ContentPart {
} }
export interface ChatHistoryItem { export interface ChatHistoryItem {
role: "user" | "assistant" | "system" | "tool"; role: "user" | "assistant" | "tool";
content: string | ContentPart[]; content: ContentPart[];
tool_call_id?: string; tool_call_id?: string;
tool_calls?: Array<{ id: string; name: string; arguments: 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 { export interface ToolDef {
@@ -21,13 +29,14 @@ export interface ToolDef {
} }
export interface MappedChat { export interface MappedChat {
systemPrompt?: string;
messages: ChatHistoryItem[]; messages: ChatHistoryItem[];
tools: ToolDef[]; tools: ToolDef[];
} }
function userContent(content: Message["content"]): string | ContentPart[] { function contentParts(content: Message["content"] | string): ContentPart[] {
if (typeof content === "string") return content; if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
if (!Array.isArray(content)) return ""; if (!Array.isArray(content)) return [];
const parts: ContentPart[] = []; const parts: ContentPart[] = [];
for (const part of content) { for (const part of content) {
if (!part || typeof part !== "object") continue; if (!part || typeof part !== "object") continue;
@@ -39,23 +48,39 @@ function userContent(content: Message["content"]): string | ContentPart[] {
return parts; return parts;
} }
export function mapContextToChat(context: Context): MappedChat { export function mapContextToChat(context: TranscriptContext, model: Model<Api>): MappedChat {
const transcript = collapseSystemMessages(context);
const messages: ChatHistoryItem[] = []; 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") { 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; continue;
} }
if (message.role === "assistant") { if (message.role === "assistant") {
const texts: string[] = []; const content: ContentPart[] = [];
const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; 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) { for (const part of message.content) {
if (part.type === "text") texts.push(part.text); if (part.type === "text") {
if (part.type === "toolCall") { 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({ toolCalls.push({
id: part.id, id: part.id,
name: part.name, 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({ messages.push({
role: "assistant", role: "assistant",
content: texts.join("\n"), content,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined, tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
thinking: thinking || undefined,
signature,
thinking_redacted: thinkingRedacted || undefined,
message_id: native ? message.responseId : undefined,
}); });
continue; continue;
} }
if (message.role === "toolResult") { 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({ messages.push({
role: "tool", role: "tool",
content: text, content: contentParts(message.content),
tool_call_id: message.toolCallId, 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, name: tool.name,
description: tool.description, description: tool.description,
parameters: tool.parameters, parameters: tool.parameters,
})); }));
return { messages, tools }; const systemPrompt = getCurrentSystemPrompt(transcript.messages) || undefined;
return { systemPrompt, messages, tools };
} }
+1 -1
View File
@@ -7,7 +7,7 @@ export interface MintedUserJwt {
expiresAt: number; expiresAt: number;
} }
function anySignal(signals: AbortSignal[]): AbortSignal { export function anySignal(signals: AbortSignal[]): AbortSignal {
const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any; const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
if (typeof builtin === "function") return builtin(signals); if (typeof builtin === "function") return builtin(signals);
const controller = new AbortController(); const controller = new AbortController();
+8 -5
View File
@@ -7,10 +7,13 @@ import {
} from "./wire.js"; } from "./wire.js";
/** /**
* Cognition version-gates GetChatMessage against a known Windsurf/Devin Desktop * Cognition gates Devin Local-only models (every GPT-5.6 variant: Sol, Terra,
* release. The Devin CLI build id (`3000.4.25`) is not that string and the * Luna) by client ide name. With ide="windsurf" GetChatMessage rejects them
* server answers: "Your Windsurf version is out of date." * with "This model is only in Devin Local."; with ide="devin-desktop" the
* Prefer the installed Devin.app product.json, then a current desktop release. * 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 FALLBACK_WINDSURF_VERSION = "3.6.27";
const PRODUCT_JSON = const PRODUCT_JSON =
@@ -29,7 +32,7 @@ function desktopWindsurfVersion(): string {
} }
export const CLIENT_VERSION = desktopWindsurfVersion(); export const CLIENT_VERSION = desktopWindsurfVersion();
export const CLIENT_IDE = "windsurf"; export const CLIENT_IDE = "devin-desktop";
export interface MetadataInput { export interface MetadataInput {
apiKey: string; apiKey: string;
+92 -25
View File
@@ -30,14 +30,18 @@ const THINKING_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max
function parseCost(summary?: string): ProviderModelConfig["cost"] { function parseCost(summary?: string): ProviderModelConfig["cost"] {
const empty = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const empty = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
if (!summary) return empty; if (!summary) return empty;
const input = summary.match(/\$([0-9.]+)\s*\/\s*MTok In/i); // Devin CLI emits e.g. "$10 / 1M Input · $0.25 / 1M Cached input · $50 / 1M Output";
const output = summary.match(/\$([0-9.]+)\s*\/\s*MTok Out/i); // 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 inCost = input ? Number(input[1]) : 0;
const outCost = output ? Number(output[1]) : 0; const outCost = output ? Number(output[1]) : 0;
const cacheRead = cached ? Number(cached[1]) : Number((inCost * 0.1).toFixed(4));
return { return {
input: inCost, input: inCost,
output: outCost, output: outCost,
cacheRead: Number((inCost * 0.1).toFixed(4)), cacheRead,
cacheWrite: Number((inCost * 1.25).toFixed(4)), cacheWrite: Number((inCost * 1.25).toFixed(4)),
}; };
} }
@@ -65,8 +69,13 @@ function variantKey(uid: string): string | null {
"max", "max",
"minimal", "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) { for (const suffix of suffixes) {
if (uid === suffix || uid.endsWith(`-${suffix}`)) return suffix; if (normalized === suffix || normalized.endsWith(`-${suffix}`)) return suffix;
} }
return null; return null;
} }
@@ -92,39 +101,82 @@ function preferredDefault(map: ThinkingLevelMap): string | undefined {
return undefined; return undefined;
} }
function familyToModels(family: DevinFamily): ProviderModelConfig[] { function contextLabel(tokens: number | undefined): string {
const usable = family.variants.filter((variant) => { if (!tokens || tokens <= 0) return "";
const key = variantKey(variant.model_uid); if (tokens % 1_000_000 === 0) return `${tokens / 1_000_000}M`;
return !key || (!key.includes("priority") && !key.includes("fast") && key !== "thinking-1m"); return `${Math.round(tokens / 1_000)}K`;
}); }
const source = usable.length > 0 ? usable : family.variants;
const thinkingLevelMap: ThinkingLevelMap = {}; function variantsToModel(
for (const variant of source) { 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)); const level = thinkingFromSuffix(variantKey(variant.model_uid));
if (level && thinkingLevelMap[level] === undefined) { if (level && thinkingLevelMap[level] == null) {
thinkingLevelMap[level] = variant.model_uid; thinkingLevelMap[level] = variant.model_uid;
} }
} }
const defaultUid = preferredDefault(thinkingLevelMap) ?? source[0]?.model_uid ?? family.family_uid; const defaultUid = preferredDefault(thinkingLevelMap) ?? variants[0]?.model_uid ?? family.family_uid;
const sample = source.find((variant) => variant.model_uid === defaultUid) ?? source[0]; const sample = variants.find((variant) => variant.model_uid === defaultUid) ?? variants[0];
if (!sample) return []; if (!sample) return null;
const mappedLevels = THINKING_ORDER.filter((level) => typeof thinkingLevelMap[level] === "string"); const mappedLevels = THINKING_ORDER.filter((level) => typeof thinkingLevelMap[level] === "string");
const reasoning = mappedLevels.length > 1; const reasoning = mappedLevels.length > 1;
return [ return {
{
id: defaultUid, id: defaultUid,
name: family.family_label || family.slug || defaultUid, name: name ?? (family.family_label || family.slug || defaultUid),
reasoning, reasoning,
thinkingLevelMap: reasoning ? thinkingLevelMap : undefined, thinkingLevelMap: reasoning ? thinkingLevelMap : undefined,
input: ["text", "image"], input: ["text", "image"],
cost: parseCost(sample.cost_summary), cost: parseCost(sample.cost_summary),
contextWindow: sample.max_context_tokens ?? 256_000, contextWindow: sample.max_context_tokens ?? 256_000,
maxTokens: sample.max_output_tokens ?? 128_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[] = [ export const FALLBACK_MODELS: ProviderModelConfig[] = [
@@ -133,6 +185,8 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
name: "Claude Opus 5", name: "Claude Opus 5",
reasoning: true, reasoning: true,
thinkingLevelMap: { thinkingLevelMap: {
off: null,
minimal: null,
low: "claude-opus-5-low", low: "claude-opus-5-low",
medium: "claude-opus-5-medium", medium: "claude-opus-5-medium",
high: "claude-opus-5-high", high: "claude-opus-5-high",
@@ -149,6 +203,8 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
name: "Claude Fable 5", name: "Claude Fable 5",
reasoning: true, reasoning: true,
thinkingLevelMap: { thinkingLevelMap: {
off: null,
minimal: null,
low: "claude-5-fable-low", low: "claude-5-fable-low",
medium: "claude-5-fable-medium", medium: "claude-5-fable-medium",
high: "claude-5-fable-high", high: "claude-5-fable-high",
@@ -166,6 +222,7 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
reasoning: true, reasoning: true,
thinkingLevelMap: { thinkingLevelMap: {
off: "gpt-5-6-sol-none", off: "gpt-5-6-sol-none",
minimal: null,
low: "gpt-5-6-sol-low", low: "gpt-5-6-sol-low",
medium: "gpt-5-6-sol-medium", medium: "gpt-5-6-sol-medium",
high: "gpt-5-6-sol-high", high: "gpt-5-6-sol-high",
@@ -181,6 +238,15 @@ export const FALLBACK_MODELS: ProviderModelConfig[] = [
id: "swe-1-7", id: "swe-1-7",
name: "SWE-1.7", name: "SWE-1.7",
reasoning: true, reasoning: true,
thinkingLevelMap: {
off: null,
minimal: null,
low: null,
medium: "swe-1-7",
high: "swe-1-7",
xhigh: null,
max: null,
},
input: ["text", "image"], input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262_000, contextWindow: 262_000,
@@ -211,11 +277,12 @@ export function resolveModelUid(
thinkingLevelMap: ThinkingLevelMap | undefined, thinkingLevelMap: ThinkingLevelMap | undefined,
reasoning?: string, reasoning?: string,
): string { ): string {
if (reasoning && thinkingLevelMap) {
const mapped = thinkingLevelMap[reasoning as keyof ThinkingLevelMap];
if (typeof mapped === "string") return mapped;
}
if (thinkingLevelMap) { 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); const fallback = preferredDefault(thinkingLevelMap);
if (fallback) return fallback; if (fallback) return fallback;
} }
+296 -200
View File
@@ -4,14 +4,15 @@ import {
type Api, type Api,
type AssistantMessage, type AssistantMessage,
type AssistantMessageEventStream, type AssistantMessageEventStream,
type Context, type TranscriptContext,
type Model, type Model,
type SimpleStreamOptions, type SimpleStreamOptions,
calculateCost, calculateCost,
createAssistantMessageEventStream, createAssistantMessageEventStream,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { mapContextToChat, type ChatHistoryItem, type ContentPart, type ToolDef } from "./context-map.js"; import { clampMaxTokensToContext } from "./context-estimate.js";
import { getCachedUserJwt } from "./jwt.js"; import { mapContextToChat, type ChatHistoryItem, type ToolDef } from "./context-map.js";
import { anySignal, getCachedUserJwt } from "./jwt.js";
import { buildMetadata } from "./metadata.js"; import { buildMetadata } from "./metadata.js";
import { resolveModelUid } from "./models.js"; import { resolveModelUid } from "./models.js";
import { import {
@@ -26,64 +27,37 @@ import {
const SOURCE_BY_ROLE: Record<string, number> = { const SOURCE_BY_ROLE: Record<string, number> = {
user: 1, user: 1,
assistant: 2, assistant: 2,
system: 1,
tool: 4, 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 = export type CloudChatEvent =
| { kind: "text"; text: string } | { kind: "text"; text: string }
| { kind: "reasoning"; 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_start"; id: string; name: string }
| { kind: "tool_call_args"; argsDelta: string; id?: 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"; kind: "usage";
promptTokens?: number; input?: number;
completionTokens?: number; output?: number;
totalTokens?: number; cacheRead?: number;
cachedInputTokens?: number; cacheWrite?: number;
cacheCreationInputTokens?: 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 { function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer {
return Buffer.concat([ return Buffer.concat([
encodeString(1, img.base64Data ?? ""), 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)]); return Buffer.concat([encodeString(1, tc.id), encodeString(2, tc.name), encodeString(3, tc.arguments)]);
} }
function encodeChatMessagePrompt( export function encodeChatMessagePrompt(item: ChatHistoryItem, messageId: string): Buffer {
content: ContentPart[], const text = item.content
source: number,
opts?: { toolCallId?: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> },
): Buffer {
const text = content
.filter((part) => part.type === "text") .filter((part) => part.type === "text")
.map((part) => part.text ?? "") .map((part) => part.text ?? "")
.join("\n"); .join("\n");
const parts: Buffer[] = [ const parts: Buffer[] = [
encodeVarintField(2, source), encodeString(1, messageId),
encodeVarintField(2, SOURCE_BY_ROLE[item.role] ?? 1),
encodeString(3, text), 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 item.tool_calls ?? []) parts.push(encodeMessage(6, encodeChatToolCall(tc)));
for (const tc of opts?.toolCalls ?? []) parts.push(encodeMessage(6, encodeChatToolCall(tc))); if (item.tool_call_id) parts.push(encodeString(7, item.tool_call_id));
for (const img of content.filter((part) => part.type === "image")) { 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))); 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); return Buffer.concat(parts);
} }
function encodeCompletionConfiguration(maxOutputTokens?: number): Buffer { function encodeCompletionConfiguration(args: {
maxTokens: number;
temperature: number;
topP: number;
topK: number;
}): Buffer {
return Buffer.concat([ return Buffer.concat([
encodeVarintField(1, 1), encodeVarintField(1, 1),
encodeVarintField(2, 64_000), encodeVarintField(2, args.maxTokens),
encodeVarintField(3, maxOutputTokens ?? 128_000), encodeVarintField(3, 400),
encodeFixed64Field(5, 0.7), encodeFixed64Field(5, args.temperature),
encodeFixed64Field(6, 0.95), encodeVarintField(7, args.topK),
encodeVarintField(7, 50), encodeFixed64Field(8, args.topP),
encodeFixed64Field(8, 1.0),
encodeFixed64Field(11, 1.0),
]); ]);
} }
@@ -140,10 +116,11 @@ function encodeToolDef(tool: ToolDef): Buffer {
]); ]);
} }
function buildGetChatMessageRequest(args: { export interface WireRequestArgs {
apiKey: string; apiKey: string;
userJwt: string; userJwt: string;
modelUid: string; modelUid: string;
systemPrompt?: string;
messages: ChatHistoryItem[]; messages: ChatHistoryItem[];
tools?: ToolDef[]; tools?: ToolDef[];
cascadeId: string; cascadeId: string;
@@ -151,8 +128,13 @@ function buildGetChatMessageRequest(args: {
sessionId: string; sessionId: string;
requestId: bigint; requestId: bigint;
triggerId: string; triggerId: string;
maxOutputTokens?: number; maxTokens: number;
}): Buffer { temperature: number;
topP: number;
topK: number;
}
export function buildGetChatMessageRequest(args: WireRequestArgs): { body: Buffer; historyBytes: number } {
const metadata = buildMetadata({ const metadata = buildMetadata({
apiKey: args.apiKey, apiKey: args.apiKey,
userJwt: args.userJwt, userJwt: args.userJwt,
@@ -160,35 +142,46 @@ function buildGetChatMessageRequest(args: {
requestId: args.requestId, requestId: args.requestId,
triggerId: args.triggerId, triggerId: args.triggerId,
}); });
const prompts = collapseSystemIntoUser(args.messages).map((message) => const promptFields: Buffer[] = [];
encodeMessage( args.messages.forEach((message, index) => {
3, const messageId = message.message_id ?? `${args.cascadeId}-${index}`;
encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role] ?? 1, { promptFields.push(encodeMessage(3, encodeChatMessagePrompt(message, messageId)));
toolCallId: message.role === "tool" ? message.tool_call_id : undefined, });
toolCalls: message.role === "assistant" ? message.tool_calls : undefined, const history = Buffer.concat(promptFields);
}), const body = Buffer.concat([
),
);
return Buffer.concat([
encodeMessage(1, metadata), encodeMessage(1, metadata),
...prompts, args.systemPrompt ? encodeString(2, args.systemPrompt) : Buffer.alloc(0),
history,
encodeVarintField(7, 5), encodeVarintField(7, 5),
encodeMessage(8, encodeCompletionConfiguration(args.maxOutputTokens)), encodeMessage(8, encodeCompletionConfiguration(args)),
...(args.tools ?? []).map((tool) => encodeMessage(10, encodeToolDef(tool))), ...(args.tools ?? []).map((tool) => encodeMessage(10, encodeToolDef(tool))),
encodeString(16, args.cascadeId), encodeString(16, args.cascadeId),
encodeString(17, args.promptId),
encodeVarintField(20, 1),
encodeString(21, args.modelUid), 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)) { 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"); const text = field.value.toString("utf8");
if (text) yield { kind: "text", text }; if (text) yield { kind: "text", text };
} else if (field.num === 9 && field.wire === 2 && Buffer.isBuffer(field.value)) { } else if (field.num === 9 && field.wire === 2 && Buffer.isBuffer(field.value)) {
const text = field.value.toString("utf8"); const text = field.value.toString("utf8");
if (text) yield { kind: "reasoning", text }; 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)) { } else if (field.num === 6 && field.wire === 2 && Buffer.isBuffer(field.value)) {
let id: string | undefined; let id: string | undefined;
let name: 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 } : {}) }; if (argsDelta !== undefined) yield { kind: "tool_call_args", argsDelta, ...(id ? { id } : {}) };
} else if (field.num === 5 && field.wire === 0) { } else if (field.num === 5 && field.wire === 0) {
const value = Number(field.value); 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"; 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 === 11) reason = "content_filter";
else if (value === 1 || value === 3) reason = "length"; else if (value === 12 || value === 13) reason = "error";
yield { kind: "finish", reason }; yield { kind: "finish", reason };
} else if (field.num === 28 && field.wire === 2 && Buffer.isBuffer(field.value)) { } else if (field.num === 7 && field.wire === 2 && Buffer.isBuffer(field.value)) {
const usage = decodeUsage(field.value); const usage = decodeUsageStats(field.value);
if (usage) yield usage; if (usage) yield usage;
} }
} }
} }
function decodeUsage(buf: Buffer): CloudChatEvent | null { function decodeUsageStats(buf: Buffer): CloudChatEvent | null {
let promptTokens: number | undefined; let input: number | undefined;
let completionTokens: number | undefined; let output: number | undefined;
let cachedInputTokens: number | undefined; let cacheWrite: number | undefined;
let cacheCreationInputTokens: number | undefined; let cacheRead: number | undefined;
let modelUid: string | undefined;
for (const field of iterFields(buf)) { for (const field of iterFields(buf)) {
if (field.num !== 2 || field.wire !== 2 || !Buffer.isBuffer(field.value)) continue; if (field.wire === 0) {
let metric: string | undefined; const n = Number(field.value);
let value: number | undefined; if (field.num === 2) input = n;
for (const inner of iterFields(field.value)) { else if (field.num === 3) output = n;
if (inner.num === 5 && inner.wire === 2 && Buffer.isBuffer(inner.value)) { else if (field.num === 4) cacheWrite = n;
metric = inner.value.toString("utf8"); else if (field.num === 5) cacheRead = n;
} else if (inner.num === 4 && inner.wire === 2 && Buffer.isBuffer(inner.value)) { } else if (field.num === 9 && field.wire === 2 && Buffer.isBuffer(field.value)) {
for (const dim of iterFields(inner.value)) { modelUid = field.value.toString("utf8");
if (dim.num === 2 && dim.wire === 5 && Buffer.isBuffer(dim.value)) {
value = dim.value.readFloatLE(0);
} }
} }
if (input === undefined && output === undefined && cacheRead === undefined && cacheWrite === undefined) {
return null;
} }
return { kind: "usage", input, output, cacheRead, cacheWrite, modelUid };
} }
if (!metric || value === undefined || !Number.isFinite(value)) continue;
const n = Math.round(value); export interface TrailerError {
if (metric === "input_tokens") promptTokens = n; code: string;
else if (metric === "output_tokens") completionTokens = n; message: string;
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;
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 { return {
kind: "usage", code: typeof parsed.error.code === "string" ? parsed.error.code : "",
promptTokens, message: typeof parsed.error.message === "string" ? parsed.error.message : "",
completionTokens,
totalTokens: (promptTokens ?? 0) + (completionTokens ?? 0),
cachedInputTokens,
cacheCreationInputTokens,
}; };
} catch {
return { code: "", message: text.slice(0, 512) };
}
} }
const sessionCache = new Map<string, { sessionId: string; cascadeId: string }>(); 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}`; const key = `${host}\x1f${apiKey}`;
let ids = sessionCache.get(key); let ids = sessionCache.get(key);
if (!ids) { if (!ids) {
@@ -267,43 +278,41 @@ function sessionIds(apiKey: string, host: string) {
return ids; 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: { async function* streamChatEvents(args: {
apiKey: string;
host: string; host: string;
modelUid: string; request: { body: Buffer; historyBytes: number };
messages: ChatHistoryItem[]; headers?: Record<string, string | null>;
tools?: ToolDef[];
maxOutputTokens?: number;
signal?: AbortSignal; signal?: AbortSignal;
fetchFn?: typeof fetch;
onResponse?: (response: { status: number; headers: Record<string, string> }) => void | Promise<void>;
}): AsyncGenerator<CloudChatEvent> { }): AsyncGenerator<CloudChatEvent> {
const host = args.host.replace(/\/$/, ""); const host = args.host.replace(/\/$/, "");
const userJwt = await getCachedUserJwt(args.apiKey, host, args.signal); const resp = await (args.fetchFn ?? fetch)(
const ids = sessionIds(args.apiKey, host); `${host}/exa.api_server_pb.ApiServerService/GetChatMessage`,
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`, {
method: "POST", method: "POST",
headers: { headers: mergeHeaders(args.headers),
"Content-Type": "application/connect+proto", body: new Uint8Array(frameConnectStream(args.request.body, false)),
"Connect-Protocol-Version": "1",
"Connect-Content-Encoding": "gzip",
"Connect-Accept-Encoding": "gzip",
},
body: new Uint8Array(frameConnectStream(proto, true)),
signal: args.signal, signal: args.signal,
}); },
);
if (args.onResponse) {
await args.onResponse({ status: resp.status, headers: Object.fromEntries(resp.headers.entries()) });
}
if (!resp.ok) { if (!resp.ok) {
throw new Error(`GetChatMessage HTTP ${resp.status}: ${(await resp.text()).slice(0, 300)}`); throw new Error(`GetChatMessage HTTP ${resp.status}: ${(await resp.text()).slice(0, 300)}`);
} }
@@ -313,7 +322,8 @@ async function* streamChatEvents(args: {
const queue: Buffer[] = []; const queue: Buffer[] = [];
let queued = 0; let queued = 0;
let sawEos = false; let sawEos = false;
let trailerError: string | null = null; let producedOutput = false;
let trailerError: TrailerError | null = null;
const peek = (n: number): Buffer | null => { const peek = (n: number): Buffer | null => {
if (queued < n) return null; if (queued < n) return null;
@@ -361,6 +371,9 @@ async function* streamChatEvents(args: {
if (!header) break; if (!header) break;
const flags = header[0]; const flags = header[0];
const len = header.readUInt32BE(1); 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; if (queued < 5 + len) break;
drop(5); drop(5);
const raw = peek(len) ?? Buffer.alloc(0); const raw = peek(len) ?? Buffer.alloc(0);
@@ -369,18 +382,15 @@ async function* streamChatEvents(args: {
if (flags & 0x01) payload = zlib.gunzipSync(raw); if (flags & 0x01) payload = zlib.gunzipSync(raw);
if (flags & 0x02) { if (flags & 0x02) {
sawEos = true; sawEos = true;
const text = payload.toString("utf8"); trailerError = readTrailerError(payload.toString("utf8").trim());
if (text.includes('"error"')) {
try {
const parsed = JSON.parse(text) as { error?: { message?: string } };
trailerError = parsed.error?.message ?? text;
} catch {
trailerError = text;
}
}
continue; 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 { } finally {
@@ -390,19 +400,36 @@ async function* streamChatEvents(args: {
// ignore // ignore
} }
try { try {
void resp.body?.cancel(); // Await teardown so a rejection from an errored stream is observed.
await resp.body?.cancel();
} catch { } catch {
// ignore // 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"); if (!sawEos) throw new Error("Devin stream ended without an EOS trailer");
} }
export function streamDevin( export function streamDevin(
model: Model<Api>, model: Model<Api>,
context: Context, context: TranscriptContext,
options?: SimpleStreamOptions, options?: SimpleStreamOptions,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream(); const stream = createAssistantMessageEventStream();
@@ -428,10 +455,9 @@ export function streamDevin(
let textOpen = false; let textOpen = false;
let thinkingOpen = false; let thinkingOpen = false;
let toolIndex = -1; const openTools = new Map<string, number>();
let partialJson = ""; const toolJson = new Map<string, string>();
let toolId = ""; let lastToolId: string | undefined;
let toolName = "";
const closeText = () => { const closeText = () => {
if (!textOpen) return; if (!textOpen) return;
@@ -451,41 +477,92 @@ export function streamDevin(
} }
thinkingOpen = false; thinkingOpen = false;
}; };
const closeTool = () => { const closeTool = (id: string) => {
if (toolIndex < 0) return; const idx = openTools.get(id);
const block = output.content[toolIndex]; if (idx === undefined) return;
openTools.delete(id);
const block = output.content[idx];
if (block.type === "toolCall") { if (block.type === "toolCall") {
const json = toolJson.get(id) ?? "";
try { try {
block.arguments = JSON.parse(partialJson); block.arguments = JSON.parse(json);
} catch { } catch {
// keep last parsed object // keep last parsed object
} }
stream.push({ stream.push({
type: "toolcall_end", type: "toolcall_end",
contentIndex: toolIndex, contentIndex: idx,
toolCall: { type: "toolCall", id: toolId, name: toolName, arguments: block.arguments }, toolCall: { type: "toolCall", id, name: block.name, arguments: block.arguments },
partial: output, 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 { try {
const apiKey = options?.apiKey; const apiKey = options?.apiKey;
if (!apiKey) throw new Error("No Devin credentials. Run /login devin (uses the local Devin CLI)."); 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 host = (options?.env?.DEVIN_API_SERVER_URL || "https://server.codeium.com").replace(/\/$/, "");
const modelUid = resolveModelUid(model.id, model.thinkingLevelMap, options?.reasoning); 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 }); stream.push({ type: "start", partial: output });
for await (const event of streamChatEvents({ for await (const event of streamChatEvents({
apiKey,
host, host,
modelUid, request,
messages: mapped.messages, headers: options?.headers,
tools: mapped.tools.length > 0 ? mapped.tools : undefined, signal,
maxOutputTokens: options?.maxTokens, fetchFn: options?.fetch,
signal: options?.signal, onResponse: options?.onResponse
? (response) => options?.onResponse?.(response, model)
: undefined,
})) { })) {
if (event.kind === "text") { if (event.kind === "text") {
closeThinking(); closeThinking();
@@ -513,47 +590,66 @@ export function streamDevin(
block.thinking += event.text; block.thinking += event.text;
stream.push({ type: "thinking_delta", contentIndex: idx, delta: event.text, partial: output }); 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") { } else if (event.kind === "tool_call_start") {
closeText(); closeText();
closeThinking(); closeThinking();
closeTool();
toolId = event.id;
toolName = event.name;
partialJson = "";
output.content.push({ type: "toolCall", id: event.id, name: event.name, arguments: {} }); output.content.push({ type: "toolCall", id: event.id, name: event.name, arguments: {} });
toolIndex = output.content.length - 1; openTools.set(event.id, output.content.length - 1);
stream.push({ type: "toolcall_start", contentIndex: toolIndex, partial: output }); 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") { } else if (event.kind === "tool_call_args") {
if (toolIndex < 0) continue; const id = event.id ?? lastToolId;
partialJson += event.argsDelta; const idx = id === undefined ? undefined : openTools.get(id);
const block = output.content[toolIndex]; 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") { if (block.type === "toolCall") {
try { try {
block.arguments = JSON.parse(partialJson); block.arguments = JSON.parse(json);
} catch { } catch {
// incomplete json // 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") { } else if (event.kind === "finish") {
closeText(); closeText();
closeThinking(); 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 = output.stopReason =
event.reason === "tool_calls" ? "toolUse" : event.reason === "length" ? "length" : "stop"; event.reason === "tool_calls" ? "toolUse" : event.reason === "length" ? "length" : "stop";
} else if (event.kind === "usage") { } else if (event.kind === "usage") {
output.usage.input = event.promptTokens ?? 0; output.usage.input = event.input ?? 0;
output.usage.output = event.completionTokens ?? 0; output.usage.output = event.output ?? 0;
output.usage.cacheRead = event.cachedInputTokens ?? 0; output.usage.cacheRead = event.cacheRead ?? 0;
output.usage.cacheWrite = event.cacheCreationInputTokens ?? 0; output.usage.cacheWrite = event.cacheWrite ?? 0;
output.usage.totalTokens = event.totalTokens ?? output.usage.input + output.usage.output; 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); calculateCost(model, output.usage);
} }
} }
closeText(); closeText();
closeThinking(); closeThinking();
closeTool(); closeAllTools();
stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output }); stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
stream.end(); stream.end();
} catch (error) { } catch (error) {
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["extensions/**/*.ts", "src/**/*.ts"]
}