commit d36c0b3705475b51b4193d956c6f0d9c2bd030d8 Author: Kashyab Ambarani Date: Sun Aug 16 20:29:56 2026 -0700 Initial pi-devin package: Devin Local models inside Pi. Auth and catalog come from the local Devin CLI. Pi stays the harness. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bb8d1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +package-lock.json +.DS_Store +*.log +.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c4698c3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md — pi-devin + +Pi package that registers the `devin` provider. Auth and the model catalog come from the local Devin CLI. Pi remains the harness. + +## Layout + +``` +extensions/index.ts # registerProvider("devin"), /login, /devin-status, /devin-refresh +src/cli.ts # locate + spawn `devin` +src/credentials.ts # ~/.local/share/devin/credentials.toml +src/models.ts # `devin models list --format json` → ProviderModelConfig[] +src/stream.ts # streamSimple via GetChatMessage (Connect/protobuf) +src/jwt.ts # GetUserJwt cache +src/metadata.ts # Metadata proto (ide_name=devin) +src/wire.ts # protobuf + Connect framing +src/context-map.ts # Pi Context → Cognition chat history +``` + +## Contract + +- `/login devin` must call `devin auth login` when credentials are missing, not a custom Windsurf paste flow. +- Model IDs must come from `devin models list`, not a hardcoded cloud allowlist. +- Do not spawn `devin acp` as the session owner. Pi keeps tools, permissions, and the session tree. +- Package must stay installable as a Pi package: `keywords: ["pi-package"]` and `pi.extensions`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0f1488c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kashyab Ambarani + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e53edc8 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# pi-devin + +A [Pi](https://pi.dev) package that uses **Devin Local** models inside Pi. + +Pi stays the harness. The local [Devin CLI](https://docs.devin.ai/cli) owns login and the live model catalog (`devin auth`, `devin models list`). This package does **not** wrap Devin as an ACP agent and does **not** use the old `pi-devin-auth` cloud-only shim. + +## Why this exists + +`pi-devin-auth` treated Devin as Cascade cloud chat. Models like Sol High, Opus 5, and Fable 5 then failed with: + +```text +This model is only in Devin Local. +``` + +Zed works because it launches `devin acp` — Devin Local. This package uses that same local CLI for auth + catalog, then streams completions into Pi so Pi's tools, sessions, and UI stay in charge. + +## Requirements + +- Pi Coding Agent 0.80+ +- A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`) +- Node 18+ + +The CLI binary is resolved in this order: + +1. `$DEVIN_CLI` +2. `~/.local/bin/devin`, Homebrew, `/usr/local/bin/devin` +3. Devin.app +4. Zed's ACP registry install (`~/Library/Application Support/Zed/external_agents/registry/devin/.../bin/devin`) +5. `which devin` + +## Install + +Local (this repo): + +```bash +pi install /Users/kashyab/pi-devin +``` + +After you publish to npm: + +```bash +pi install npm:pi-devin +``` + +From git: + +```bash +pi install git:github.com//pi-devin +``` + +Restart Pi or run `/reload`. + +## Usage + +```text +/login devin +/model devin/claude-opus-5-high +/model devin/claude-5-fable-high +/model devin/gpt-5-6-sol-high +``` + +`/login devin` runs `devin auth login` if `~/.local/share/devin/credentials.toml` is missing. If you already signed in through Devin Desktop, the CLI, or Zed, that file is reused. + +Commands: + +- `/devin-status` — CLI path, version, auth +- `/devin-refresh` — reload `devin models list --format json` + +## What this is / is not + +| This package | Not this package | +|---|---| +| Pi is the agent | Devin ACP taking over the session | +| Devin CLI for auth + catalog | Fake Windsurf OAuth paste flow | +| Live CLI families (Opus 5, Fable 5, Sol, …) | Hardcoded 11-model cloud allowlist | +| Completions streamed into Pi tools | `devin acp` as the editor host | + +## Publish + +This is a standard Pi package (`keywords: ["pi-package"]` + `pi.extensions`). After you push to npm with that keyword, it can show up on [pi.dev/packages](https://pi.dev/packages). + +## License + +MIT. Unofficial. Not affiliated with Cognition. diff --git a/extensions/index.ts b/extensions/index.ts new file mode 100644 index 0000000..6796c1f --- /dev/null +++ b/extensions/index.ts @@ -0,0 +1,117 @@ +import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; +import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai"; +import { authStatus, loginWithCli, readCredentials } from "../src/credentials.js"; +import { whichDevin, devinVersion } from "../src/cli.js"; +import { FALLBACK_MODELS, loadCliCatalog, modelsFromCatalog } from "../src/models.js"; +import { streamDevin } from "../src/stream.js"; + +const PROVIDER_ID = "devin"; +const PLACEHOLDER_BASE_URL = "https://server.codeium.com"; + +let _pi: ExtensionAPI | null = null; + +function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void { + pi.registerProvider(PROVIDER_ID, { + name: "Devin Local", + api: "devin-local", + baseUrl: PLACEHOLDER_BASE_URL, + models, + oauth: { + name: "Devin CLI", + async login(_callbacks: OAuthLoginCallbacks): Promise { + const creds = await loginWithCli(); + if (_pi) { + try { + const catalog = await loadCliCatalog(); + registerDevinProvider(_pi, modelsFromCatalog(catalog)); + } catch { + // keep current models + } + } + return { + refresh: "", + access: creds.apiKey, + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + }; + }, + async refreshToken(credentials: OAuthCredentials): Promise { + const creds = readCredentials(); + if (!creds) return credentials; + return { + refresh: "", + access: creds.apiKey, + expires: Date.now() + 365 * 24 * 60 * 60 * 1000, + }; + }, + getApiKey(credentials: OAuthCredentials): string { + return readCredentials()?.apiKey || credentials.access; + }, + modifyModels(models: Model[], _credentials: OAuthCredentials): Model[] { + return models; + }, + }, + streamSimple: streamDevin, + }); +} + +export default async function (pi: ExtensionAPI): Promise { + _pi = pi; + registerDevinProvider(pi, FALLBACK_MODELS); + + try { + if (readCredentials()) { + const catalog = await loadCliCatalog(); + registerDevinProvider(pi, modelsFromCatalog(catalog)); + } + } catch { + // fallback models already registered + } + + pi.on("session_start", async () => { + try { + if (!_pi || !readCredentials()) return; + const catalog = await loadCliCatalog(); + registerDevinProvider(_pi, modelsFromCatalog(catalog)); + } catch { + // keep current models + } + }); + + pi.registerCommand("devin-status", { + description: "Show Devin CLI auth + binary status", + handler: async (_args, ctx) => { + const bin = await whichDevin(); + const version = await devinVersion(); + const status = await authStatus(); + ctx.ui.notify( + [ + bin ? `CLI: ${bin}` : "CLI: not found", + version ? `Version: ${version}` : "Version: unknown", + status.loggedIn ? "Auth: signed in via Devin CLI" : "Auth: not signed in. Run /login devin or `devin auth login`", + ].join("\n"), + status.loggedIn && bin ? "info" : "warning", + ); + }, + }); + + pi.registerCommand("devin-refresh", { + description: "Refresh Devin Local model catalog from `devin models list`", + handler: async (_args, ctx) => { + try { + const catalog = await loadCliCatalog(); + const models = modelsFromCatalog(catalog); + registerDevinProvider(pi, models); + ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info"); + } catch (error) { + ctx.ui.notify( + `Devin refresh failed: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } + }, + }); + + pi.on("session_shutdown", async () => { + _pi = null; + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6555202 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "pi-devin", + "version": "0.1.0", + "description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.", + "type": "module", + "keywords": [ + "pi-package", + "pi-extension", + "devin", + "cognition", + "custom-provider" + ], + "author": "Kashyab Ambarani", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/kashyab/pi-devin.git" + }, + "pi": { + "extensions": [ + "./extensions/index.ts" + ] + }, + "files": [ + "extensions/", + "src/", + "README.md", + "AGENTS.md", + "LICENSE" + ], + "scripts": { + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..2eb0e95 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,127 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const ZED_DEVIN = + join( + homedir(), + "Library/Application Support/Zed/external_agents/registry/devin", + ); + +const KNOWN_BINS = [ + process.env.DEVIN_CLI, + join(homedir(), ".local/bin/devin"), + join(homedir(), ".devin/bin/devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", +].filter((p): p is string => Boolean(p)); + +let cachedBin: string | null | undefined; + +function newestZedDevin(): string | null { + try { + if (!existsSync(ZED_DEVIN)) return null; + const versions = readdirSync(ZED_DEVIN) + .map((name) => { + const bin = join(ZED_DEVIN, name, "bin/devin"); + try { + return { bin, mtime: statSync(bin).mtimeMs }; + } catch { + return null; + } + }) + .filter((row): row is { bin: string; mtime: number } => Boolean(row)) + .sort((a, b) => b.mtime - a.mtime); + return versions[0]?.bin ?? null; + } catch { + return null; + } +} + +export function findDevinBin(): string | null { + if (cachedBin !== undefined) return cachedBin; + for (const bin of KNOWN_BINS) { + if (existsSync(bin)) { + cachedBin = bin; + return bin; + } + } + const zed = newestZedDevin(); + if (zed) { + cachedBin = zed; + return zed; + } + cachedBin = null; + return null; +} + +export function clearDevinBinCache(): void { + cachedBin = undefined; +} + +export async function whichDevin(): Promise { + const known = findDevinBin(); + if (known) return known; + try { + const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 }); + const path = stdout.trim(); + if (path && existsSync(path)) { + cachedBin = path; + return path; + } + } catch { + // not on PATH + } + return null; +} + +export async function runDevin( + args: string[], + opts: { timeoutMs?: number; inheritStdio?: boolean } = {}, +): Promise<{ stdout: string; stderr: string; code: number }> { + const bin = await whichDevin(); + if (!bin) { + throw new Error( + "Devin CLI not found. Install Devin, or set DEVIN_CLI to the `devin` binary (Zed ACP registry and Devin.app are also checked).", + ); + } + + if (opts.inheritStdio) { + return await new Promise((resolve, reject) => { + const child = spawn(bin, args, { stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ stdout: "", stderr: "", code: code ?? 1 }); + }); + }); + } + + try { + const { stdout, stderr } = await execFileAsync(bin, args, { + timeout: opts.timeoutMs ?? 30_000, + maxBuffer: 8 * 1024 * 1024, + }); + return { stdout, stderr, code: 0 }; + } catch (error) { + const err = error as { stdout?: string; stderr?: string; code?: number; message?: string }; + if (typeof err.code === "number") { + return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.code }; + } + throw error; + } +} + +export async function devinVersion(): Promise { + try { + const { stdout, code } = await runDevin(["version"], { timeoutMs: 8_000 }); + if (code !== 0) return null; + return stdout.trim() || null; + } catch { + return null; + } +} diff --git a/src/context-map.ts b/src/context-map.ts new file mode 100644 index 0000000..3721b4e --- /dev/null +++ b/src/context-map.ts @@ -0,0 +1,96 @@ +import type { Context, Message, Tool } from "@earendil-works/pi-ai"; + +export interface ContentPart { + type: "text" | "image"; + text?: string; + mimeType?: string; + base64Data?: string; +} + +export interface ChatHistoryItem { + role: "user" | "assistant" | "system" | "tool"; + content: string | ContentPart[]; + tool_call_id?: string; + tool_calls?: Array<{ id: string; name: string; arguments: string }>; +} + +export interface ToolDef { + name: string; + description: string; + parameters: unknown; +} + +export interface MappedChat { + messages: ChatHistoryItem[]; + tools: ToolDef[]; +} + +function userContent(content: Message["content"]): string | ContentPart[] { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: ContentPart[] = []; + for (const part of content) { + if (!part || typeof part !== "object") continue; + if (part.type === "text") parts.push({ type: "text", text: part.text }); + if (part.type === "image") { + parts.push({ type: "image", mimeType: part.mimeType, base64Data: part.data }); + } + } + return parts; +} + +export function mapContextToChat(context: Context): MappedChat { + const messages: ChatHistoryItem[] = []; + if (context.systemPrompt) { + messages.push({ role: "system", content: context.systemPrompt }); + } + + for (const message of context.messages) { + if (message.role === "user") { + messages.push({ role: "user", content: userContent(message.content) }); + continue; + } + if (message.role === "assistant") { + const texts: string[] = []; + const toolCalls: Array<{ id: string; name: string; arguments: string }> = []; + for (const part of message.content) { + if (part.type === "text") texts.push(part.text); + if (part.type === "toolCall") { + toolCalls.push({ + id: part.id, + name: part.name, + arguments: JSON.stringify(part.arguments ?? {}), + }); + } + } + messages.push({ + role: "assistant", + content: texts.join("\n"), + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); + continue; + } + if (message.role === "toolResult") { + const text = + typeof message.content === "string" + ? message.content + : message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + messages.push({ + role: "tool", + content: text, + tool_call_id: message.toolCallId, + }); + } + } + + const tools: ToolDef[] = (context.tools ?? []).map((tool: Tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + })); + + return { messages, tools }; +} diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 0000000..1e2d1fd --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,77 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { runDevin } from "./cli.js"; + +export interface DevinCredentials { + apiKey: string; + apiServerUrl: string; + webappHost: string; + apiUrl: string; + path: string; +} + +const CREDENTIALS_PATH = join(homedir(), ".local/share/devin/credentials.toml"); + +function parseTomlStrings(text: string): Record { + const out: Record = {}; + for (const line of text.split("\n")) { + const match = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/); + if (match) out[match[1]] = match[2]; + } + return out; +} + +export function credentialsPath(): string { + return CREDENTIALS_PATH; +} + +export function readCredentials(): DevinCredentials | null { + if (!existsSync(CREDENTIALS_PATH)) return null; + const raw = parseTomlStrings(readFileSync(CREDENTIALS_PATH, "utf8")); + const apiKey = raw.windsurf_api_key || raw.api_key; + if (!apiKey) return null; + return { + apiKey, + apiServerUrl: (raw.api_server_url || "https://server.codeium.com").replace(/\/$/, ""), + webappHost: raw.devin_webapp_host || "app.devin.ai", + apiUrl: raw.devin_api_url || "https://api.devin.ai", + path: CREDENTIALS_PATH, + }; +} + +export async function authStatus(): Promise<{ + loggedIn: boolean; + summary: string; +}> { + const creds = readCredentials(); + try { + const { stdout, stderr, code } = await runDevin(["auth", "status"], { timeoutMs: 15_000 }); + const text = `${stdout}\n${stderr}`.trim(); + const loggedIn = code === 0 && /logged in/i.test(text); + return { loggedIn: loggedIn || Boolean(creds), summary: text || (creds ? "credentials.toml present" : "not signed in") }; + } catch (error) { + if (creds) { + return { loggedIn: true, summary: `Devin credentials present at ${CREDENTIALS_PATH}` }; + } + return { + loggedIn: false, + summary: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function loginWithCli(): Promise { + const already = readCredentials(); + const status = await authStatus(); + if (already && status.loggedIn) return already; + + const { code } = await runDevin(["auth", "login"], { inheritStdio: true }); + const creds = readCredentials(); + if (!creds) { + throw new Error( + `\`devin auth login\` ${code === 0 ? "finished" : `exited ${code}`} but ${CREDENTIALS_PATH} is missing. Run \`devin auth login\` yourself, then /login devin again.`, + ); + } + return creds; +} diff --git a/src/jwt.ts b/src/jwt.ts new file mode 100644 index 0000000..4d8f621 --- /dev/null +++ b/src/jwt.ts @@ -0,0 +1,103 @@ +import { randomUUID } from "node:crypto"; +import { buildMetadata } from "./metadata.js"; +import { encodeMessage, iterFields } from "./wire.js"; + +export interface MintedUserJwt { + jwt: string; + expiresAt: number; +} + +function anySignal(signals: AbortSignal[]): AbortSignal { + const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any; + if (typeof builtin === "function") return builtin(signals); + const controller = new AbortController(); + const onAbort = (reason: unknown): void => { + if (!controller.signal.aborted) controller.abort(reason); + }; + for (const signal of signals) { + if (signal.aborted) { + onAbort(signal.reason); + break; + } + signal.addEventListener("abort", () => onAbort(signal.reason), { once: true }); + } + return controller.signal; +} + +export async function mintUserJwt( + apiKey: string, + host: string, + signal?: AbortSignal, +): Promise { + const metadata = buildMetadata({ + apiKey, + sessionId: randomUUID(), + requestId: BigInt(Date.now()), + triggerId: randomUUID(), + }); + const timeout = AbortSignal.timeout(30_000); + const combined = signal ? anySignal([signal, timeout]) : timeout; + const resp = await fetch(`${host.replace(/\/$/, "")}/exa.auth_pb.AuthService/GetUserJwt`, { + method: "POST", + headers: { + "Content-Type": "application/proto", + "Connect-Protocol-Version": "1", + }, + body: new Uint8Array(encodeMessage(1, metadata)), + signal: combined, + }); + const buf = Buffer.from(await resp.arrayBuffer()); + if (!resp.ok) { + throw new Error(`GetUserJwt HTTP ${resp.status}: ${buf.toString("utf8").slice(0, 240)}`); + } + + let jwt: string | null = null; + for (const field of iterFields(buf)) { + if (field.num === 1 && field.wire === 2 && Buffer.isBuffer(field.value)) { + const value = field.value.toString("utf8"); + if (value.startsWith("eyJ")) { + jwt = value; + break; + } + } + } + if (!jwt) throw new Error("GetUserJwt returned no JWT"); + + let expiresAt = Math.floor(Date.now() / 1000) + 600; + try { + const payload = jwt.split(".")[1] ?? ""; + const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4); + const parsed = JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); + if (typeof parsed.exp === "number") expiresAt = parsed.exp; + } catch { + // keep fallback + } + return { jwt, expiresAt }; +} + +let cache: { jwt: string; expiresAt: number; apiKey: string; host: string } | null = null; +const inFlight = new Map>(); + +export async function getCachedUserJwt(apiKey: string, host: string, signal?: AbortSignal): Promise { + const now = Math.floor(Date.now() / 1000); + if (cache && cache.apiKey === apiKey && cache.host === host && cache.expiresAt > now + 60) { + return cache.jwt; + } + const key = `${host}\x1f${apiKey}`; + const existing = inFlight.get(key); + if (existing) return (await existing).jwt; + const promise = mintUserJwt(apiKey, host, signal); + inFlight.set(key, promise); + try { + const minted = await promise; + cache = { jwt: minted.jwt, expiresAt: minted.expiresAt, apiKey, host }; + return minted.jwt; + } finally { + inFlight.delete(key); + } +} + +export function clearCachedUserJwt(): void { + cache = null; + inFlight.clear(); +} diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 0000000..5a27b51 --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,44 @@ +import { + encodeMessage, + encodeString, + encodeTimestampBody, + encodeVarintField, +} from "./wire.js"; + +const DEFAULT_VERSION = "3000.4.25"; + +export interface MetadataInput { + apiKey: string; + userJwt?: string; + sessionId: string; + requestId: bigint; + triggerId: string; + version?: string; +} + +export function buildMetadata(input: MetadataInput): Buffer { + const version = input.version ?? DEFAULT_VERSION; + const os = + process.platform === "darwin" + ? "darwin" + : process.platform === "win32" + ? "windows" + : "linux"; + const parts: Buffer[] = [ + encodeString(1, "devin"), + encodeString(2, version), + encodeString(3, input.apiKey), + encodeString(4, "en"), + encodeString(5, os), + encodeString(7, version), + encodeVarintField(9, input.requestId), + encodeString(10, input.sessionId), + encodeString(12, "devin"), + encodeMessage(16, encodeTimestampBody()), + encodeString(25, input.triggerId), + encodeString(26, "Unset"), + encodeString(28, "devin"), + ]; + if (input.userJwt) parts.push(encodeString(21, input.userJwt)); + return Buffer.concat(parts); +} diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..e141b97 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,223 @@ +import type { ThinkingLevelMap } from "@earendil-works/pi-ai"; +import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent"; +import { runDevin } from "./cli.js"; + +export interface DevinVariant { + model_uid: string; + label: string; + max_context_tokens?: number; + max_output_tokens?: number; + cost_tier?: string; + cost_summary?: string; + is_new?: boolean; + is_beta?: boolean; +} + +export interface DevinFamily { + family_label: string; + family_uid: string; + slug: string; + aliases?: string[]; + variants: DevinVariant[]; +} + +export interface DevinCatalog { + families: DevinFamily[]; +} + +const THINKING_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + +function parseCost(summary?: string): ProviderModelConfig["cost"] { + const empty = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + if (!summary) return empty; + const input = summary.match(/\$([0-9.]+)\s*\/\s*MTok In/i); + const output = summary.match(/\$([0-9.]+)\s*\/\s*MTok Out/i); + const inCost = input ? Number(input[1]) : 0; + const outCost = output ? Number(output[1]) : 0; + return { + input: inCost, + output: outCost, + cacheRead: Number((inCost * 0.1).toFixed(4)), + cacheWrite: Number((inCost * 1.25).toFixed(4)), + }; +} + +function variantKey(uid: string): string | null { + const suffixes = [ + "none-priority", + "low-priority", + "medium-priority", + "high-priority", + "xhigh-priority", + "max-priority", + "low-fast", + "medium-fast", + "high-fast", + "xhigh-fast", + "max-fast", + "thinking-1m", + "thinking", + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "minimal", + ]; + for (const suffix of suffixes) { + if (uid === suffix || uid.endsWith(`-${suffix}`)) return suffix; + } + return null; +} + +function thinkingFromSuffix(suffix: string | null): keyof ThinkingLevelMap | null { + if (!suffix) return "high"; + if (suffix === "none" || suffix === "none-priority") return "off"; + if (suffix === "minimal") return "minimal"; + if (suffix.startsWith("low")) return "low"; + if (suffix.startsWith("medium")) return "medium"; + if (suffix.startsWith("high") && !suffix.startsWith("xhigh")) return "high"; + if (suffix.startsWith("xhigh")) return "xhigh"; + if (suffix.startsWith("max")) return "max"; + if (suffix.includes("thinking")) return "high"; + return null; +} + +function preferredDefault(map: ThinkingLevelMap): string | undefined { + for (const level of ["high", "medium", "max", "xhigh", "low", "minimal", "off"] as const) { + const value = map[level]; + if (typeof value === "string") return value; + } + return undefined; +} + +function familyToModels(family: DevinFamily): ProviderModelConfig[] { + const usable = family.variants.filter((variant) => { + const key = variantKey(variant.model_uid); + return !key || (!key.includes("priority") && !key.includes("fast") && key !== "thinking-1m"); + }); + const source = usable.length > 0 ? usable : family.variants; + const thinkingLevelMap: ThinkingLevelMap = {}; + for (const variant of source) { + const level = thinkingFromSuffix(variantKey(variant.model_uid)); + if (level && thinkingLevelMap[level] === undefined) { + thinkingLevelMap[level] = variant.model_uid; + } + } + + const defaultUid = preferredDefault(thinkingLevelMap) ?? source[0]?.model_uid ?? family.family_uid; + const sample = source.find((variant) => variant.model_uid === defaultUid) ?? source[0]; + if (!sample) return []; + + const mappedLevels = THINKING_ORDER.filter((level) => typeof thinkingLevelMap[level] === "string"); + const reasoning = mappedLevels.length > 1; + + return [ + { + id: defaultUid, + name: family.family_label || family.slug || defaultUid, + reasoning, + thinkingLevelMap: reasoning ? thinkingLevelMap : undefined, + input: ["text", "image"], + cost: parseCost(sample.cost_summary), + contextWindow: sample.max_context_tokens ?? 256_000, + maxTokens: sample.max_output_tokens ?? 128_000, + }, + ]; +} + +export const FALLBACK_MODELS: ProviderModelConfig[] = [ + { + id: "claude-opus-5-high", + name: "Claude Opus 5", + reasoning: true, + thinkingLevelMap: { + low: "claude-opus-5-low", + medium: "claude-opus-5-medium", + high: "claude-opus-5-high", + xhigh: "claude-opus-5-xhigh", + max: "claude-opus-5-max", + }, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + { + id: "claude-5-fable-high", + name: "Claude Fable 5", + reasoning: true, + thinkingLevelMap: { + low: "claude-5-fable-low", + medium: "claude-5-fable-medium", + high: "claude-5-fable-high", + xhigh: "claude-5-fable-xhigh", + max: "claude-5-fable-max", + }, + input: ["text", "image"], + cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + { + id: "gpt-5-6-sol-high", + name: "GPT-5.6 Sol", + reasoning: true, + thinkingLevelMap: { + off: "gpt-5-6-sol-none", + low: "gpt-5-6-sol-low", + medium: "gpt-5-6-sol-medium", + high: "gpt-5-6-sol-high", + xhigh: "gpt-5-6-sol-xhigh", + max: "gpt-5-6-sol-max", + }, + input: ["text", "image"], + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + { + id: "swe-1-7", + name: "SWE-1.7", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262_000, + maxTokens: 128_000, + }, +]; + +export function modelsFromCatalog(catalog: DevinCatalog | null): ProviderModelConfig[] { + if (!catalog?.families?.length) return FALLBACK_MODELS; + const models = catalog.families.flatMap(familyToModels); + return models.length > 0 ? models : FALLBACK_MODELS; +} + +export async function loadCliCatalog(): Promise { + const { stdout, code, stderr } = await runDevin(["models", "list", "--format", "json"], { + timeoutMs: 20_000, + }); + if (code !== 0) { + throw new Error(stderr.trim() || `devin models list exited ${code}`); + } + const parsed = JSON.parse(stdout) as DevinCatalog; + if (!parsed?.families) return null; + return parsed; +} + +export function resolveModelUid( + modelId: string, + thinkingLevelMap: ThinkingLevelMap | undefined, + reasoning?: string, +): string { + if (reasoning && thinkingLevelMap) { + const mapped = thinkingLevelMap[reasoning as keyof ThinkingLevelMap]; + if (typeof mapped === "string") return mapped; + } + if (thinkingLevelMap) { + const fallback = preferredDefault(thinkingLevelMap); + if (fallback) return fallback; + } + return modelId; +} diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 0000000..3cc3eb4 --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,568 @@ +import { randomUUID } from "node:crypto"; +import * as zlib from "node:zlib"; +import { + type Api, + type AssistantMessage, + type AssistantMessageEventStream, + type Context, + type Model, + type SimpleStreamOptions, + calculateCost, + createAssistantMessageEventStream, +} from "@earendil-works/pi-ai"; +import { mapContextToChat, type ChatHistoryItem, type ContentPart, type ToolDef } from "./context-map.js"; +import { getCachedUserJwt } from "./jwt.js"; +import { buildMetadata } from "./metadata.js"; +import { resolveModelUid } from "./models.js"; +import { + encodeFixed64Field, + encodeMessage, + encodeString, + encodeVarintField, + frameConnectStream, + iterFields, +} from "./wire.js"; + +const SOURCE_BY_ROLE: Record = { + user: 1, + assistant: 2, + system: 1, + tool: 4, +}; + +export type CloudChatEvent = + | { kind: "text"; text: string } + | { kind: "reasoning"; text: string } + | { kind: "tool_call_start"; id: string; name: string } + | { kind: "tool_call_args"; argsDelta: string; id?: string } + | { kind: "finish"; reason: "stop" | "tool_calls" | "length" | "content_filter" } + | { + kind: "usage"; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + cachedInputTokens?: number; + cacheCreationInputTokens?: number; + }; + +function normalizeContent(content: string | ContentPart[]): ContentPart[] { + if (typeof content === "string") return [{ type: "text", text: content }]; + return content; +} + +function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] { + const out: ChatHistoryItem[] = []; + let pending: string[] = []; + const textOf = (content: string | ContentPart[]) => + normalizeContent(content) + .filter((part) => part.type === "text" && part.text) + .map((part) => part.text as string) + .join("\n"); + + for (const message of messages) { + if (message.role === "system") { + const text = textOf(message.content); + if (text) pending.push(text); + continue; + } + if (message.role === "user" && pending.length > 0) { + const userParts = normalizeContent(message.content); + const userText = textOf(userParts); + const images = userParts.filter((part) => part.type === "image"); + out.push({ + role: "user", + content: [{ type: "text", text: `\n${pending.join("\n\n")}\n\n${userText}` }, ...images], + }); + pending = []; + continue; + } + out.push(message); + } + if (pending.length > 0) { + out.push({ role: "user", content: `\n${pending.join("\n\n")}\n` }); + } + return out; +} + +function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer { + return Buffer.concat([ + encodeString(1, img.base64Data ?? ""), + encodeString(2, img.mimeType ?? "image/png"), + ]); +} + +function encodeChatToolCall(tc: { id: string; name: string; arguments: string }): Buffer { + return Buffer.concat([encodeString(1, tc.id), encodeString(2, tc.name), encodeString(3, tc.arguments)]); +} + +function encodeChatMessagePrompt( + content: ContentPart[], + source: number, + opts?: { toolCallId?: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> }, +): Buffer { + const text = content + .filter((part) => part.type === "text") + .map((part) => part.text ?? "") + .join("\n"); + const parts: Buffer[] = [ + encodeVarintField(2, source), + encodeString(3, text), + encodeVarintField(4, Math.max(1, Math.floor(text.length / 4))), + encodeVarintField(5, 1), + ]; + if (opts?.toolCallId) parts.push(encodeString(7, opts.toolCallId)); + for (const tc of opts?.toolCalls ?? []) parts.push(encodeMessage(6, encodeChatToolCall(tc))); + for (const img of content.filter((part) => part.type === "image")) { + parts.push(encodeMessage(10, encodeImageData(img))); + } + return Buffer.concat(parts); +} + +function encodeCompletionConfiguration(maxOutputTokens?: number): Buffer { + return Buffer.concat([ + encodeVarintField(1, 1), + encodeVarintField(2, 64_000), + encodeVarintField(3, maxOutputTokens ?? 128_000), + encodeFixed64Field(5, 0.7), + encodeFixed64Field(6, 0.95), + encodeVarintField(7, 50), + encodeFixed64Field(8, 1.0), + encodeFixed64Field(11, 1.0), + ]); +} + +function encodeToolDef(tool: ToolDef): Buffer { + const description = tool.description.length > 6_998 ? `${tool.description.slice(0, 6_995)}...` : tool.description; + return Buffer.concat([ + encodeString(1, tool.name), + encodeString(2, description), + encodeString(3, JSON.stringify(tool.parameters ?? {})), + ]); +} + +function buildGetChatMessageRequest(args: { + apiKey: string; + userJwt: string; + modelUid: string; + messages: ChatHistoryItem[]; + tools?: ToolDef[]; + cascadeId: string; + promptId: string; + sessionId: string; + requestId: bigint; + triggerId: string; + maxOutputTokens?: number; +}): Buffer { + const metadata = buildMetadata({ + apiKey: args.apiKey, + userJwt: args.userJwt, + sessionId: args.sessionId, + requestId: args.requestId, + triggerId: args.triggerId, + }); + const prompts = collapseSystemIntoUser(args.messages).map((message) => + encodeMessage( + 3, + encodeChatMessagePrompt(normalizeContent(message.content), SOURCE_BY_ROLE[message.role] ?? 1, { + toolCallId: message.role === "tool" ? message.tool_call_id : undefined, + toolCalls: message.role === "assistant" ? message.tool_calls : undefined, + }), + ), + ); + return Buffer.concat([ + encodeMessage(1, metadata), + ...prompts, + encodeVarintField(7, 5), + encodeMessage(8, encodeCompletionConfiguration(args.maxOutputTokens)), + ...(args.tools ?? []).map((tool) => encodeMessage(10, encodeToolDef(tool))), + encodeString(16, args.cascadeId), + encodeString(21, args.modelUid), + encodeString(22, args.promptId), + ]); +} + +function* decodeChatFrame(proto: Buffer): Generator { + for (const field of iterFields(proto)) { + if (field.num === 3 && field.wire === 2 && Buffer.isBuffer(field.value)) { + const text = field.value.toString("utf8"); + if (text) yield { kind: "text", text }; + } else if (field.num === 9 && field.wire === 2 && Buffer.isBuffer(field.value)) { + const text = field.value.toString("utf8"); + if (text) yield { kind: "reasoning", text }; + } else if (field.num === 6 && field.wire === 2 && Buffer.isBuffer(field.value)) { + let id: string | undefined; + let name: string | undefined; + let argsDelta: string | undefined; + for (const inner of iterFields(field.value)) { + if (inner.wire === 2 && Buffer.isBuffer(inner.value)) { + const text = inner.value.toString("utf8"); + if (inner.num === 1) id = text; + else if (inner.num === 2) name = text; + else if (inner.num === 3) argsDelta = text; + } + } + if (id !== undefined && name !== undefined) yield { kind: "tool_call_start", id, name }; + if (argsDelta !== undefined) yield { kind: "tool_call_args", argsDelta, ...(id ? { id } : {}) }; + } else if (field.num === 5 && field.wire === 0) { + const value = Number(field.value); + let reason: Extract['reason'] = "stop"; + if (value === 10) reason = "tool_calls"; + else if (value === 11) reason = "content_filter"; + else if (value === 1 || value === 3) reason = "length"; + yield { kind: "finish", reason }; + } else if (field.num === 28 && field.wire === 2 && Buffer.isBuffer(field.value)) { + const usage = decodeUsage(field.value); + if (usage) yield usage; + } + } +} + +function decodeUsage(buf: Buffer): CloudChatEvent | null { + let promptTokens: number | undefined; + let completionTokens: number | undefined; + let cachedInputTokens: number | undefined; + let cacheCreationInputTokens: number | undefined; + for (const field of iterFields(buf)) { + if (field.num !== 2 || field.wire !== 2 || !Buffer.isBuffer(field.value)) continue; + let metric: string | undefined; + let value: number | undefined; + for (const inner of iterFields(field.value)) { + if (inner.num === 5 && inner.wire === 2 && Buffer.isBuffer(inner.value)) { + metric = inner.value.toString("utf8"); + } else if (inner.num === 4 && inner.wire === 2 && Buffer.isBuffer(inner.value)) { + for (const dim of iterFields(inner.value)) { + if (dim.num === 2 && dim.wire === 5 && Buffer.isBuffer(dim.value)) { + value = dim.value.readFloatLE(0); + } + } + } + } + if (!metric || value === undefined || !Number.isFinite(value)) continue; + const n = Math.round(value); + if (metric === "input_tokens") promptTokens = n; + else if (metric === "output_tokens") completionTokens = n; + else if (metric.includes("cached") || metric.includes("cache_read")) cachedInputTokens = n; + else if (metric.includes("cache_creation")) cacheCreationInputTokens = n; + } + if (promptTokens === undefined && completionTokens === undefined) return null; + return { + kind: "usage", + promptTokens, + completionTokens, + totalTokens: (promptTokens ?? 0) + (completionTokens ?? 0), + cachedInputTokens, + cacheCreationInputTokens, + }; +} + +const sessionCache = new Map(); + +function sessionIds(apiKey: string, host: string) { + const key = `${host}\x1f${apiKey}`; + let ids = sessionCache.get(key); + if (!ids) { + ids = { sessionId: randomUUID(), cascadeId: randomUUID() }; + sessionCache.set(key, ids); + } + return ids; +} + +async function* streamChatEvents(args: { + apiKey: string; + host: string; + modelUid: string; + messages: ChatHistoryItem[]; + tools?: ToolDef[]; + maxOutputTokens?: number; + signal?: AbortSignal; +}): AsyncGenerator { + const host = args.host.replace(/\/$/, ""); + const userJwt = await getCachedUserJwt(args.apiKey, host, args.signal); + const ids = sessionIds(args.apiKey, host); + const proto = buildGetChatMessageRequest({ + apiKey: args.apiKey, + userJwt, + modelUid: args.modelUid, + messages: args.messages, + tools: args.tools, + cascadeId: ids.cascadeId, + promptId: randomUUID(), + sessionId: ids.sessionId, + requestId: BigInt(Date.now()), + triggerId: randomUUID(), + maxOutputTokens: args.maxOutputTokens, + }); + + const resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`, { + 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 (!resp.ok) { + throw new Error(`GetChatMessage HTTP ${resp.status}: ${(await resp.text()).slice(0, 300)}`); + } + if (!resp.body) throw new Error("GetChatMessage returned an empty body"); + + const reader = resp.body.getReader(); + const queue: Buffer[] = []; + let queued = 0; + let sawEos = false; + let trailerError: string | null = null; + + const peek = (n: number): Buffer | null => { + if (queued < n) return null; + if (queue.length === 1 && queue[0].length >= n) return queue[0].subarray(0, n); + const parts: Buffer[] = []; + let remaining = n; + for (const chunk of queue) { + if (remaining <= 0) break; + if (chunk.length <= remaining) { + parts.push(chunk); + remaining -= chunk.length; + } else { + parts.push(chunk.subarray(0, remaining)); + remaining = 0; + } + } + return Buffer.concat(parts, n); + }; + + const drop = (n: number): void => { + queued -= n; + let remaining = n; + while (remaining > 0 && queue.length > 0) { + const head = queue[0]; + if (head.length <= remaining) { + queue.shift(); + remaining -= head.length; + } else { + queue[0] = head.subarray(remaining); + remaining = 0; + } + } + }; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + queue.push(Buffer.from(value)); + queued += value.length; + } + while (queued >= 5) { + const header = peek(5); + if (!header) break; + const flags = header[0]; + const len = header.readUInt32BE(1); + if (queued < 5 + len) break; + drop(5); + const raw = peek(len) ?? Buffer.alloc(0); + drop(len); + let payload = raw; + if (flags & 0x01) payload = zlib.gunzipSync(raw); + if (flags & 0x02) { + sawEos = true; + const text = payload.toString("utf8"); + if (text.includes('"error"')) { + try { + const parsed = JSON.parse(text) as { error?: { message?: string } }; + trailerError = parsed.error?.message ?? text; + } catch { + trailerError = text; + } + } + continue; + } + yield* decodeChatFrame(payload); + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + try { + void resp.body?.cancel(); + } catch { + // ignore + } + } + + if (trailerError) throw new Error(trailerError); + if (!sawEos) throw new Error("Devin stream ended without an EOS trailer"); +} + +export function streamDevin( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream { + const stream = createAssistantMessageEventStream(); + + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + let textOpen = false; + let thinkingOpen = false; + let toolIndex = -1; + let partialJson = ""; + let toolId = ""; + let toolName = ""; + + const closeText = () => { + if (!textOpen) return; + const idx = output.content.length - 1; + const block = output.content[idx]; + if (block.type === "text") { + stream.push({ type: "text_end", contentIndex: idx, content: block.text, partial: output }); + } + textOpen = false; + }; + const closeThinking = () => { + if (!thinkingOpen) return; + const idx = output.content.length - 1; + const block = output.content[idx]; + if (block.type === "thinking") { + stream.push({ type: "thinking_end", contentIndex: idx, content: block.thinking, partial: output }); + } + thinkingOpen = false; + }; + const closeTool = () => { + if (toolIndex < 0) return; + const block = output.content[toolIndex]; + if (block.type === "toolCall") { + try { + block.arguments = JSON.parse(partialJson); + } catch { + // keep last parsed object + } + stream.push({ + type: "toolcall_end", + contentIndex: toolIndex, + toolCall: { type: "toolCall", id: toolId, name: toolName, arguments: block.arguments }, + partial: output, + }); + } + toolIndex = -1; + }; + + try { + const apiKey = options?.apiKey; + if (!apiKey) throw new Error("No Devin credentials. Run /login devin (uses the local Devin CLI)."); + const host = (options?.env?.DEVIN_API_SERVER_URL || "https://server.codeium.com").replace(/\/$/, ""); + const modelUid = resolveModelUid(model.id, model.thinkingLevelMap, options?.reasoning); + const mapped = mapContextToChat(context); + stream.push({ type: "start", partial: output }); + + for await (const event of streamChatEvents({ + apiKey, + host, + modelUid, + messages: mapped.messages, + tools: mapped.tools.length > 0 ? mapped.tools : undefined, + maxOutputTokens: options?.maxTokens, + signal: options?.signal, + })) { + if (event.kind === "text") { + closeThinking(); + if (!textOpen) { + output.content.push({ type: "text", text: "" }); + stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output }); + textOpen = true; + } + const idx = output.content.length - 1; + const block = output.content[idx]; + if (block.type === "text") { + block.text += event.text; + stream.push({ type: "text_delta", contentIndex: idx, delta: event.text, partial: output }); + } + } else if (event.kind === "reasoning") { + closeText(); + if (!thinkingOpen) { + output.content.push({ type: "thinking", thinking: "" }); + stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); + thinkingOpen = true; + } + const idx = output.content.length - 1; + const block = output.content[idx]; + if (block.type === "thinking") { + block.thinking += event.text; + stream.push({ type: "thinking_delta", contentIndex: idx, delta: event.text, partial: output }); + } + } else if (event.kind === "tool_call_start") { + closeText(); + closeThinking(); + closeTool(); + toolId = event.id; + toolName = event.name; + partialJson = ""; + output.content.push({ type: "toolCall", id: event.id, name: event.name, arguments: {} }); + toolIndex = output.content.length - 1; + stream.push({ type: "toolcall_start", contentIndex: toolIndex, partial: output }); + } else if (event.kind === "tool_call_args") { + if (toolIndex < 0) continue; + partialJson += event.argsDelta; + const block = output.content[toolIndex]; + if (block.type === "toolCall") { + try { + block.arguments = JSON.parse(partialJson); + } catch { + // incomplete json + } + } + stream.push({ type: "toolcall_delta", contentIndex: toolIndex, delta: event.argsDelta, partial: output }); + } else if (event.kind === "finish") { + closeText(); + closeThinking(); + closeTool(); + output.stopReason = + event.reason === "tool_calls" ? "toolUse" : event.reason === "length" ? "length" : "stop"; + } else if (event.kind === "usage") { + output.usage.input = event.promptTokens ?? 0; + output.usage.output = event.completionTokens ?? 0; + output.usage.cacheRead = event.cachedInputTokens ?? 0; + output.usage.cacheWrite = event.cacheCreationInputTokens ?? 0; + output.usage.totalTokens = event.totalTokens ?? output.usage.input + output.usage.output; + calculateCost(model, output.usage); + } + } + + closeText(); + closeThinking(); + closeTool(); + stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output }); + stream.end(); + } catch (error) { + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : String(error); + stream.push({ type: "error", reason: output.stopReason as "aborted" | "error", error: output }); + stream.end(); + } + })(); + + return stream; +} diff --git a/src/wire.ts b/src/wire.ts new file mode 100644 index 0000000..1dea112 --- /dev/null +++ b/src/wire.ts @@ -0,0 +1,111 @@ +import * as zlib from "node:zlib"; + +export function encodeVarint(value: number | bigint): Buffer { + const bytes: number[] = []; + let v = BigInt(value); + if (v < 0n) throw new RangeError(`encodeVarint: negative input (${value})`); + while (v > 127n) { + bytes.push(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + bytes.push(Number(v)); + return Buffer.from(bytes); +} + +export function encodeTag(fieldNum: number, wire: number): Buffer { + return encodeVarint((fieldNum << 3) | wire); +} + +export function encodeString(fieldNum: number, s: string): Buffer { + const buf = Buffer.from(s, "utf8"); + return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(buf.length), buf]); +} + +export function encodeMessage(fieldNum: number, body: Buffer): Buffer { + return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(body.length), body]); +} + +export function encodeVarintField(fieldNum: number, v: number | bigint): Buffer { + return Buffer.concat([encodeTag(fieldNum, 0), encodeVarint(v)]); +} + +export function encodeFixed64Field(fieldNum: number, v: number): Buffer { + const b = Buffer.alloc(8); + b.writeDoubleLE(v, 0); + return Buffer.concat([encodeTag(fieldNum, 1), b]); +} + +export function encodeTimestampBody(): Buffer { + const now = Date.now(); + const seconds = Math.floor(now / 1000); + const nanos = (now % 1000) * 1_000_000; + return Buffer.concat([ + encodeVarintField(1, seconds), + nanos > 0 ? encodeVarintField(2, nanos) : Buffer.alloc(0), + ]); +} + +export function decodeVarint(buf: Buffer, offset: number): [bigint, number] { + let res = 0n; + let shift = 0n; + let i = offset; + while (i < buf.length) { + const b = buf[i++]; + res |= BigInt(b & 0x7f) << shift; + if (!(b & 0x80)) return [res, i]; + shift += 7n; + } + throw new Error("truncated varint"); +} + +export interface ProtoField { + num: number; + wire: number; + value: bigint | Buffer; +} + +export function* iterFields(buf: Buffer): Generator { + let i = 0; + while (i < buf.length) { + const [tagBig, next] = decodeVarint(buf, i); + i = next; + const tag = Number(tagBig); + const num = tag >> 3; + const wire = tag & 0x7; + if (wire === 0) { + const [v, after] = decodeVarint(buf, i); + i = after; + yield { num, wire, value: v }; + } else if (wire === 1) { + if (i + 8 > buf.length) return; + yield { num, wire, value: buf.subarray(i, i + 8) }; + i += 8; + } else if (wire === 2) { + const [n, after] = decodeVarint(buf, i); + i = after; + const len = Number(n); + if (len < 0 || i + len > buf.length) return; + yield { num, wire, value: buf.subarray(i, i + len) }; + i += len; + } else if (wire === 5) { + if (i + 4 > buf.length) return; + yield { num, wire, value: buf.subarray(i, i + 4) }; + i += 4; + } else { + return; + } + } +} + +export function frameConnectStream(body: Buffer, compress = true): Buffer { + let payload = body; + let flags = 0; + if (compress) { + payload = zlib.gzipSync(body); + flags |= 0x01; + } + const header = Buffer.alloc(5); + header[0] = flags; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a70c37a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["extensions/**/*.ts", "src/**/*.ts"] +}