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