15 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
15 changed files with 809 additions and 526 deletions
-7
View File
@@ -1,7 +0,0 @@
node_modules/
dist/
package-lock.json
.DS_Store
*.log
.env
.reference/
+7 -7
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.86+
- 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 22.19+ (required by Pi 0.86)
- Node 22.19+
The CLI binary is resolved in this order:
@@ -35,16 +35,12 @@ From git:
pi install git:github.com/kashyab12/pi-devin
```
From npm:
After npm publish:
```bash
pi install npm:pi-devin
```
Version 0.2.0 requires Pi 0.86+ and Node 22.19+. It restores system instructions and tools on the current Pi transcript format and sends system instructions through Devin's dedicated prompt field. Version 0.1.2 predates Pi 0.86 support.
`pi-devin-local` is a separate npm package maintained in the [mizorewww/pi-devin fork](https://github.com/mizorewww/pi-devin). Changes merged here do not update that package. Install only one: both packages register the `devin` provider, so their registrations can overwrite each other.
Local checkout:
```bash
@@ -85,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.
+61 -20
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 {
if (readCredentials()) {
const catalog = await loadCliCatalog();
registerDevinProvider(pi, modelsFromCatalog(catalog));
}
} catch {
// fallback models already registered
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);
pi.on("session_start", async () => {
if (!isOffline()) {
try {
if (!_pi || !readCredentials()) return;
const catalog = await loadCliCatalog();
registerDevinProvider(_pi, modelsFromCatalog(catalog));
if (readCredentials()) {
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 {
// keep current models
// 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;
});
}
+1 -5
View File
@@ -1,6 +1,6 @@
{
"name": "pi-devin",
"version": "0.2.0",
"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,10 +28,6 @@
"AGENTS.md",
"LICENSE"
],
"scripts": {
"test": "tsx --test test/*.test.mjs",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@earendil-works/pi-ai": ">=0.86.0",
"@earendil-works/pi-coding-agent": ">=0.86.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;
}
+69 -21
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 = [
process.env.DEVIN_CLI,
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));
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));
}
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,15 +65,24 @@ export function clearDevinBinCache(): void {
export async function whichDevin(): Promise<string | null> {
const known = findDevinBin();
if (known) return known;
try {
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 });
const path = stdout.trim();
if (path && existsSync(path)) {
cachedBin = path;
return path;
// 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(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
}
} 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));
}
+46 -29
View File
@@ -1,11 +1,6 @@
import {
collapseSystemMessages,
getCurrentSystemPrompt,
getCurrentTools,
withoutInitialSystemMessage,
type Message,
type Tool,
type TranscriptContext,
collapseSystemMessages, getCurrentSystemPrompt, getCurrentTools, withoutInitialSystemMessage,
type Api, type TranscriptContext, type Message, type Model, type Tool,
} from "@earendil-works/pi-ai";
export interface ContentPart {
@@ -17,9 +12,14 @@ export interface ContentPart {
export interface ChatHistoryItem {
role: "user" | "assistant" | "tool";
content: string | ContentPart[];
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 {
@@ -29,15 +29,14 @@ export interface ToolDef {
}
export interface MappedChat {
/** Sent in GetChatMessageRequest.prompt, separately from conversation history. */
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;
@@ -49,22 +48,39 @@ function userContent(content: Message["content"]): string | ContentPart[] {
return parts;
}
export function mapContextToChat(context: TranscriptContext): MappedChat {
// Fold later system updates into the single prompt sent separately to Devin.
export function mapContextToChat(context: TranscriptContext, model: Model<Api>): MappedChat {
const transcript = collapseSystemMessages(context);
const systemPrompt = getCurrentSystemPrompt(transcript.messages);
const messages: ChatHistoryItem[] = [];
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,
@@ -72,25 +88,24 @@ export function mapContextToChat(context: TranscriptContext): 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,
});
}
}
@@ -101,5 +116,7 @@ export function mapContextToChat(context: TranscriptContext): MappedChat {
parameters: tool.parameters,
}));
return { systemPrompt: systemPrompt || undefined, 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();
+3 -1
View File
@@ -11,7 +11,9 @@ import {
* 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 Thinking" for gpt-5-6-sol-high, 2026-08-29).
* (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 =
+100 -33
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 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] == null) {
thinkingLevelMap[level] = variant.model_uid;
}
}
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 {
id: 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;
const thinkingLevelMap: ThinkingLevelMap = {};
// 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 level = thinkingFromSuffix(variantKey(variant.model_uid));
if (level && thinkingLevelMap[level] === undefined) {
thinkingLevelMap[level] = variant.model_uid;
}
const context = variant.max_context_tokens ?? 0;
const group = groups.get(context) ?? [];
group.push(variant);
groups.set(context, group);
}
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 mappedLevels = THINKING_ORDER.filter((level) => typeof thinkingLevelMap[level] === "string");
const reasoning = mappedLevels.length > 1;
return [
{
id: defaultUid,
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,
},
];
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;
}
+299 -173
View File
@@ -4,14 +4,15 @@ import {
type Api,
type AssistantMessage,
type AssistantMessageEventStream,
type TranscriptContext,
type Model,
type SimpleStreamOptions,
type TranscriptContext,
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 {
@@ -23,32 +24,40 @@ import {
iterFields,
} from "./wire.js";
const SOURCE_BY_ROLE: Record<ChatHistoryItem["role"], number> = {
const SOURCE_BY_ROLE: Record<string, number> = {
user: 1,
assistant: 2,
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 encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer {
return Buffer.concat([
encodeString(1, img.base64Data ?? ""),
@@ -60,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),
]);
}
@@ -105,7 +116,7 @@ function encodeToolDef(tool: ToolDef): Buffer {
]);
}
function buildGetChatMessageRequest(args: {
export interface WireRequestArgs {
apiKey: string;
userJwt: string;
modelUid: string;
@@ -117,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,
@@ -126,36 +142,46 @@ function buildGetChatMessageRequest(args: {
requestId: args.requestId,
triggerId: args.triggerId,
});
const prompts = args.messages.map((message) =>
encodeMessage(
3,
encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role], {
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),
...(args.systemPrompt ? [encodeString(2, args.systemPrompt)] : []),
...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;
@@ -172,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 (!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",
promptTokens,
completionTokens,
totalTokens: (promptTokens ?? 0) + (completionTokens ?? 0),
cachedInputTokens,
cacheCreationInputTokens,
};
if (input === undefined && output === undefined && cacheRead === undefined && cacheWrite === 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 {
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) {
@@ -234,45 +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;
systemPrompt?: 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,
systemPrompt: args.systemPrompt,
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",
headers: {
"Content-Type": "application/connect+proto",
"Connect-Protocol-Version": "1",
"Connect-Content-Encoding": "gzip",
"Connect-Accept-Encoding": "gzip",
const resp = await (args.fetchFn ?? fetch)(
`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`,
{
method: "POST",
headers: mergeHeaders(args.headers),
body: new Uint8Array(frameConnectStream(args.request.body, false)),
signal: args.signal,
},
body: new Uint8Array(frameConnectStream(proto, true)),
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)}`);
}
@@ -282,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;
@@ -330,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);
@@ -338,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 {
@@ -359,13 +400,30 @@ 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");
}
@@ -397,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;
@@ -420,42 +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);
stream.push({ type: "start", partial: output });
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);
for await (const event of streamChatEvents({
const sampling =
model.samplingParams || options?.samplingParams
? { ...model.samplingParams, ...options?.samplingParams }
: {};
const wireArgs = {
apiKey,
host,
userJwt,
modelUid,
systemPrompt: mapped.systemPrompt,
messages: mapped.messages,
tools: mapped.tools.length > 0 ? mapped.tools : undefined,
maxOutputTokens: options?.maxTokens,
signal: options?.signal,
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({
host,
request,
headers: options?.headers,
signal,
fetchFn: options?.fetch,
onResponse: options?.onResponse
? (response) => options?.onResponse?.(response, model)
: undefined,
})) {
if (event.kind === "text") {
closeThinking();
@@ -483,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) {
-53
View File
@@ -1,53 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeContext } from "@earendil-works/pi-ai";
import { mapContextToChat } from "../src/context-map.ts";
const tool = (name) => ({
name,
description: name,
parameters: { type: "object", properties: {} },
});
// Regression for #7: Pi 0.86 moves prompt and tools into system messages.
test("preserves the prompt and tools from a normalized transcript", () => {
const read = tool("read");
const context = normalizeContext({
systemPrompt: "Inspect the repository.",
tools: [read],
messages: [{ role: "user", content: "Read README", timestamp: 1 }],
});
assert.deepEqual(mapContextToChat(context), {
systemPrompt: "Inspect the repository.",
messages: [
{ role: "user", content: "Read README" },
],
tools: [read],
});
});
test("applies later instruction, section, and tool changes", () => {
const bash = tool("bash");
const context = {
messages: [
{ role: "system", content: "Inspect the repository.", sections: { policy: "Old policy", obsolete: "Remove me" }, toolsAdded: [tool("read")], timestamp: 0 },
{ role: "user", content: "Continue", timestamp: 1 },
{ role: "system", content: "Use bash.", sections: { policy: "Do not edit.", obsolete: null }, toolsRemoved: [{ name: "read" }], toolsAdded: [bash], timestamp: 2 },
],
};
assert.deepEqual(mapContextToChat(context), {
systemPrompt: "Inspect the repository.\n\nUse bash.\n\nDo not edit.",
messages: [
{ role: "user", content: "Continue" },
],
tools: [bash],
});
});
test("accepts an empty transcript", () => {
assert.deepEqual(mapContextToChat(normalizeContext({ messages: [] })), {
systemPrompt: undefined,
messages: [],
tools: [],
});
});
-164
View File
@@ -1,164 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { gunzipSync } from "node:zlib";
import { normalizeContext } from "@earendil-works/pi-ai";
import { clearCachedUserJwt } from "../src/jwt.ts";
import { streamDevin } from "../src/stream.ts";
import { encodeMessage, encodeString, encodeVarintField, frameConnectStream, iterFields } from "../src/wire.ts";
const model = {
id: "test-model",
name: "Test model",
api: "devin-local",
provider: "devin",
baseUrl: "https://devin.invalid",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262_000,
maxTokens: 1_000,
};
const readTool = {
name: "read",
description: "Read a file",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
};
const user = (content) => ({ role: "user", content, timestamp: 1 });
const fields = (buffer) => [...iterFields(buffer)];
const messages = (request) => request.filter((field) => field.num === 3).map((field) => fields(field.value));
const stringField = (fields, number) => fields.find((field) => field.num === number)?.value.toString("utf8");
function response(body) {
// Connect's end-of-stream frame contains the JSON trailer {}.
return new Response(Buffer.concat([frameConnectStream(body, false), Buffer.from([2, 0, 0, 0, 2, 123, 125])]));
}
function mockDevin(t, replies = [Buffer.concat([encodeString(3, "OK"), encodeVarintField(5, 0)])]) {
const requests = [];
clearCachedUserJwt();
t.after(clearCachedUserJwt);
t.mock.method(globalThis, "fetch", async (url, options) => {
if (url === "https://devin.invalid/exa.auth_pb.AuthService/GetUserJwt") {
return new Response(encodeMessage(1, Buffer.from("eyJtest.jwt")));
}
assert.equal(url, "https://devin.invalid/exa.api_server_pb.ApiServerService/GetChatMessage");
const frame = Buffer.from(options.body);
assert.equal(frame[0], 1, "request uses Connect gzip compression");
assert.equal(frame.readUInt32BE(1), frame.length - 5);
requests.push(fields(gunzipSync(frame.subarray(5))));
const reply = replies[requests.length - 1];
assert.ok(reply, "unexpected extra chat request");
return response(reply);
});
return requests;
}
async function complete(context) {
const stream = streamDevin(model, context, {
apiKey: "synthetic-test-key",
env: { DEVIN_API_SERVER_URL: "https://devin.invalid" },
});
for await (const event of stream) {
assert.notEqual(event.type, "error", event.error?.errorMessage);
}
return stream.result();
}
// #7: verify the normalized transcript survives the complete request-encoding path.
test("encodes the current system prompt once in field 2, with unchanged user text and images", async (t) => {
const requests = mockDevin(t);
const text = " Inspect this: <system>literal user text</system>\nこんにちは ";
const image = { type: "image", mimeType: "image/png", data: "aW1hZ2UtZml4dHVyZQ==" };
const context = normalizeContext({
systemPrompt: "Initial instructions.",
tools: [readTool],
messages: [user([{ type: "text", text }, image])],
});
const tools = Array.from({ length: 90 }, (_, i) => ({ ...readTool, name: `read_${i}` }));
context.messages.push({
role: "system",
content: "Updated instructions.",
sections: { policy: "Do not edit." },
toolsRemoved: [{ name: "read" }],
toolsAdded: tools,
timestamp: 2,
});
await complete(context);
assert.equal(requests.length, 1);
const request = requests[0];
const promptFields = request.filter((field) => field.num === 2);
assert.equal(promptFields.length, 1);
assert.equal(promptFields[0].wire, 2);
assert.equal(promptFields[0].value.toString(), "Initial instructions.\n\nUpdated instructions.\n\nDo not edit.");
const history = messages(request);
assert.equal(history.length, 1);
assert.equal(history[0].find((field) => field.num === 2).value, 1n);
assert.equal(stringField(history[0], 3), text);
const images = history[0].filter((field) => field.num === 10);
assert.equal(images.length, 1);
assert.equal(stringField(fields(images[0].value), 1), image.data);
assert.equal(stringField(fields(images[0].value), 2), image.mimeType);
assert.deepEqual(request.filter((field) => field.num === 10).map((field) => {
const definition = fields(field.value);
return { name: stringField(definition, 1), description: stringField(definition, 2), parameters: JSON.parse(stringField(definition, 3)) };
}), tools);
});
test("omits an empty system prompt while preserving tools and user content", async (t) => {
const requests = mockDevin(t);
await complete(normalizeContext({ systemPrompt: "", tools: [readTool], messages: [user("Read probe.txt")] }));
assert.equal(requests[0].filter((field) => field.num === 2).length, 0);
assert.equal(requests[0].filter((field) => field.num === 10).length, 1);
assert.deepEqual(messages(requests[0]).map((message) => stringField(message, 3)), ["Read probe.txt"]);
});
test("sends a system-only transcript without inventing a user message", async (t) => {
const requests = mockDevin(t);
await complete(normalizeContext({ systemPrompt: "System-only instructions.", messages: [] }));
assert.equal(stringField(requests[0], 2), "System-only instructions.");
assert.deepEqual(messages(requests[0]), []);
});
test("encodes an empty transcript without a prompt or history", async (t) => {
const requests = mockDevin(t);
await complete(normalizeContext({ messages: [] }));
assert.equal(stringField(requests[0], 2), undefined);
assert.deepEqual(messages(requests[0]), []);
});
test("retains the system prompt and replays assistant tool calls and results on the next request", async (t) => {
const callId = "call-read";
const args = { path: "probe.txt" };
const requests = mockDevin(t, [
Buffer.concat([
encodeMessage(6, Buffer.concat([encodeString(1, callId), encodeString(2, "read"), encodeString(3, JSON.stringify(args))])),
encodeVarintField(5, 10),
]),
Buffer.concat([encodeString(3, "file-value"), encodeVarintField(5, 0)]),
]);
const context = normalizeContext({ systemPrompt: "Use tools; never guess.", tools: [readTool], messages: [user("Read probe.txt")] });
const first = await complete(context);
assert.equal(first.stopReason, "toolUse");
assert.deepEqual(first.content, [{ type: "toolCall", id: callId, name: "read", arguments: args }]);
context.messages.push(first, {
role: "toolResult", toolCallId: callId, toolName: "read",
content: [{ type: "text", text: "file-value" }], isError: false, timestamp: 3,
});
const second = await complete(context);
assert.equal(second.stopReason, "stop");
assert.equal(second.content[0].text, "file-value");
assert.equal(requests.length, 2);
for (const request of requests) {
assert.equal(request.filter((field) => field.num === 2).length, 1);
assert.equal(stringField(request, 2), "Use tools; never guess.");
}
const history = messages(requests[1]);
assert.deepEqual(history.map((message) => message.find((field) => field.num === 2).value), [1n, 2n, 4n]);
assert.equal(stringField(history[0], 3), "Read probe.txt");
const call = fields(history[1].find((field) => field.num === 6).value);
assert.equal(stringField(call, 1), callId);
assert.equal(stringField(call, 2), "read");
assert.deepEqual(JSON.parse(stringField(call, 3)), args);
assert.equal(stringField(history[2], 7), callId);
assert.equal(stringField(history[2], 3), "file-value");
});
-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"]
}