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
- 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
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 { 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
View File
@@ -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"
}
}
+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;
}
+58 -10
View File
@@ -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) {
+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 {
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
-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"]
}