Initial pi-devin package: Devin Local models inside Pi.
Auth and catalog come from the local Devin CLI. Pi stays the harness.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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/<you>/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.
|
||||
@@ -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<OAuthCredentials> {
|
||||
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<OAuthCredentials> {
|
||||
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<Api>[], _credentials: OAuthCredentials): Model<Api>[] {
|
||||
return models;
|
||||
},
|
||||
},
|
||||
streamSimple: streamDevin,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function (pi: ExtensionAPI): Promise<void> {
|
||||
_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;
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+127
@@ -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<string | null> {
|
||||
const known = findDevinBin();
|
||||
if (known) return known;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 });
|
||||
const path = stdout.trim();
|
||||
if (path && existsSync(path)) {
|
||||
cachedBin = path;
|
||||
return path;
|
||||
}
|
||||
} 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<string | null> {
|
||||
try {
|
||||
const { stdout, code } = await runDevin(["version"], { timeoutMs: 8_000 });
|
||||
if (code !== 0) return null;
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<DevinCredentials> {
|
||||
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;
|
||||
}
|
||||
+103
@@ -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<MintedUserJwt> {
|
||||
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<string, Promise<MintedUserJwt>>();
|
||||
|
||||
export async function getCachedUserJwt(apiKey: string, host: string, signal?: AbortSignal): Promise<string> {
|
||||
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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+223
@@ -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<DevinCatalog | null> {
|
||||
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;
|
||||
}
|
||||
+568
@@ -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<string, number> = {
|
||||
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: `<system>\n${pending.join("\n\n")}\n</system>\n${userText}` }, ...images],
|
||||
});
|
||||
pending = [];
|
||||
continue;
|
||||
}
|
||||
out.push(message);
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
out.push({ role: "user", content: `<system>\n${pending.join("\n\n")}\n</system>` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeImageData(img: { mimeType?: string; base64Data?: string }): Buffer {
|
||||
return Buffer.concat([
|
||||
encodeString(1, img.base64Data ?? ""),
|
||||
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<CloudChatEvent> {
|
||||
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<CloudChatEvent, { kind: "finish" }>['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<string, { sessionId: string; cascadeId: string }>();
|
||||
|
||||
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<CloudChatEvent> {
|
||||
const host = args.host.replace(/\/$/, "");
|
||||
const userJwt = await getCachedUserJwt(args.apiKey, host, args.signal);
|
||||
const ids = sessionIds(args.apiKey, host);
|
||||
const proto = buildGetChatMessageRequest({
|
||||
apiKey: args.apiKey,
|
||||
userJwt,
|
||||
modelUid: args.modelUid,
|
||||
messages: args.messages,
|
||||
tools: args.tools,
|
||||
cascadeId: ids.cascadeId,
|
||||
promptId: randomUUID(),
|
||||
sessionId: ids.sessionId,
|
||||
requestId: BigInt(Date.now()),
|
||||
triggerId: randomUUID(),
|
||||
maxOutputTokens: args.maxOutputTokens,
|
||||
});
|
||||
|
||||
const resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`, {
|
||||
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<Api>,
|
||||
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;
|
||||
}
|
||||
+111
@@ -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<ProtoField> {
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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