feat(sse): add Devin CLI + Windsurf providers

Adds two providers for Windsurf/Devin CLI access:

**`devin-cli`** — official path via ACP JSON-RPC over stdio
- Spawns `devin acp --agent-type summarizer` as a subprocess
- Full streaming via session/update notifications
- Auth: WINDSURF_API_KEY env var or `devin auth login` stored creds
- Binary auto-discovered: %LOCALAPPDATA%\devin\cli\bin\devin.exe (Win),
  ~/.local/share/devin/bin/devin (Linux), PATH fallback
- Model IDs verified from model_configs_v2.bin in Devin CLI binary
  (swe-1.6-fast, claude-opus-4.7-max, gpt-5.5-high, gemini-3.1-pro-high, etc.)

**`windsurf`** — direct gRPC-web fallback (no binary needed)
- Calls server.self-serve.windsurf.com directly via gRPC-web+proto
- Auth: token from windsurf.com/show-auth-token
- MODEL_ALIAS_MAP dot-to-dash normalisation for all 100+ catalog models

Supporting changes:
- cliRuntime.ts: add `devin` to CLI_TOOLS with Windows/Linux binary paths
- tokenRefresh.ts: Firebase STS refresh for devin-cli + windsurf
- oauth providers: windsurf + devin-cli (token import / device-code)
- providerRegistry: full model list from CLI binary (GPT-5.5, GPT-5.4,
  GPT-5.3-Codex, Claude Opus 4.7, SWE-1.6, DeepSeek V4, Kimi K2.6, etc.)

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Zhaba1337228
2026-05-11 20:21:12 +03:00
parent 67af6c303f
commit 5925f313f9
10 changed files with 1679 additions and 0 deletions

View File

@@ -1066,6 +1066,227 @@ export const REGISTRY: Record<string, RegistryEntry> = {
passthroughModels: true,
},
windsurf: {
id: "windsurf",
alias: "ws",
format: "windsurf",
executor: "windsurf",
// gRPC-web endpoint — handled entirely inside WindsurfExecutor.
// Model IDs are the canonical Windsurf catalog names (with dots), auto-synced
// from the Windsurf cloud via GetCascadeModelConfigs. Source: guanxiaol/WindsurfPoolAPI.
baseUrl: "https://server.self-serve.windsurf.com",
authType: "oauth",
authHeader: "Authorization",
authPrefix: "Bearer ",
defaultContextLength: 200000,
// Model IDs verified against model_configs_v2.bin from Devin CLI binary (2026.5.x).
// dot-notation = OmniRoute ID; executor MODEL_ALIAS_MAP maps it to Windsurf modelUid.
models: [
// ── Cognition / SWE ──────────────────────────────────────────────────
{ id: "swe-1.6-fast", name: "SWE-1.6 Fast" },
{ id: "swe-1.6", name: "SWE-1.6" },
{ id: "swe-1.5-fast", name: "SWE-1.5 Fast" },
{ id: "swe-1.5", name: "SWE-1.5" },
{ id: "swe-check", name: "SWE Check" },
// ── Claude Opus 4.7 — effort-tiered ─────────────────────────────────
{ id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 },
{ id: "claude-opus-4.7-xhigh", name: "Claude Opus 4.7 XHigh", contextLength: 200000 },
{ id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 },
{ id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 },
{ id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 },
{ id: "claude-opus-4.7-review", name: "Claude Opus 4.7 Review", contextLength: 200000 },
// ── Claude Sonnet/Opus 4.6 ──────────────────────────────────────────
{
id: "claude-sonnet-4.6-thinking-1m",
name: "Claude Sonnet 4.6 Thinking 1M",
contextLength: 1000000,
},
{ id: "claude-sonnet-4.6-1m", name: "Claude Sonnet 4.6 1M", contextLength: 1000000 },
{
id: "claude-sonnet-4.6-thinking",
name: "Claude Sonnet 4.6 Thinking",
contextLength: 200000,
},
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 },
{ id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 },
// ── Claude 4.5 ──────────────────────────────────────────────────────
{ id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 Thinking", contextLength: 200000 },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5", contextLength: 200000 },
{
id: "claude-sonnet-4.5-thinking",
name: "Claude Sonnet 4.5 Thinking",
contextLength: 200000,
},
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 },
// ── Claude 4.1 / 4 ──────────────────────────────────────────────────
{ id: "claude-4.1-opus-thinking", name: "Claude 4.1 Opus Thinking", contextLength: 200000 },
{ id: "claude-4.1-opus", name: "Claude 4.1 Opus", contextLength: 200000 },
{ id: "claude-4-opus-thinking", name: "Claude 4 Opus Thinking", contextLength: 200000 },
{ id: "claude-4-opus", name: "Claude 4 Opus", contextLength: 200000 },
{ id: "claude-4-sonnet-thinking", name: "Claude 4 Sonnet Thinking", contextLength: 200000 },
{ id: "claude-4-sonnet", name: "Claude 4 Sonnet", contextLength: 200000 },
// ── Claude 3.x ──────────────────────────────────────────────────────
{
id: "claude-3.7-sonnet-thinking",
name: "Claude 3.7 Sonnet Thinking",
contextLength: 200000,
},
{ id: "claude-3.7-sonnet", name: "Claude 3.7 Sonnet", contextLength: 200000 },
{ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet", contextLength: 200000 },
// ── GPT-5.5 — effort-tiered (+ fast/priority variants) ──────────────
{ id: "gpt-5.5-xhigh-fast", name: "GPT-5.5 XHigh Fast", contextLength: 200000 },
{ id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 },
{ id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast", contextLength: 200000 },
{ id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 },
{ id: "gpt-5.5-medium-fast", name: "GPT-5.5 Medium Fast", contextLength: 200000 },
{ id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 },
{ id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast", contextLength: 200000 },
{ id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 },
{ id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast", contextLength: 200000 },
{ id: "gpt-5.5-none", name: "GPT-5.5 None", contextLength: 200000 },
{ id: "gpt-5.5-review", name: "GPT-5.5 Review", contextLength: 200000 },
// ── GPT-5.4 — effort-tiered (+ mini + fast variants) ────────────────
{ id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 XHigh Fast", contextLength: 200000 },
{ id: "gpt-5.4-xhigh", name: "GPT-5.4 XHigh", contextLength: 200000 },
{ id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast", contextLength: 200000 },
{ id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 },
{ id: "gpt-5.4-medium-fast", name: "GPT-5.4 Medium Fast", contextLength: 200000 },
{ id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 },
{ id: "gpt-5.4-low-fast", name: "GPT-5.4 Low Fast", contextLength: 200000 },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 },
{ id: "gpt-5.4-none-fast", name: "GPT-5.4 None Fast", contextLength: 200000 },
{ id: "gpt-5.4-none", name: "GPT-5.4 None", contextLength: 200000 },
{ id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini XHigh", contextLength: 128000 },
{ id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High", contextLength: 128000 },
{ id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini Medium", contextLength: 128000 },
{ id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low", contextLength: 128000 },
// ── GPT-5.3 Codex — effort-tiered (+ fast variants) ─────────────────
{ id: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex XHigh Fast", contextLength: 200000 },
{ id: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex XHigh", contextLength: 200000 },
{ id: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast", contextLength: 200000 },
{ id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 },
{ id: "gpt-5.3-codex-medium-fast", name: "GPT-5.3 Codex Medium Fast", contextLength: 200000 },
{ id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 },
{ id: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast", contextLength: 200000 },
{ id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 },
// ── GPT-5.2 ─────────────────────────────────────────────────────────
{ id: "gpt-5.2-xhigh", name: "GPT-5.2 XHigh", contextLength: 200000 },
{ id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 },
{ id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 },
{ id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 },
{ id: "gpt-5.2-none", name: "GPT-5.2 None", contextLength: 200000 },
// ── GPT-5 ────────────────────────────────────────────────────────────
{ id: "gpt-5-codex", name: "GPT-5 Codex", contextLength: 200000 },
{ id: "gpt-5", name: "GPT-5", contextLength: 200000 },
// ── GPT-4.1 / 4o ────────────────────────────────────────────────────
{ id: "gpt-4.1", name: "GPT-4.1", contextLength: 200000 },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextLength: 128000 },
{ id: "gpt-4.1-nano", name: "GPT-4.1 Nano", contextLength: 32000 },
{ id: "gpt-4o", name: "GPT-4o", contextLength: 128000 },
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextLength: 128000 },
// ── Gemini ───────────────────────────────────────────────────────────
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1000000 },
{ id: "gemini-3.0-pro", name: "Gemini 3.0 Pro", contextLength: 1000000 },
{ id: "gemini-3.0-flash-high", name: "Gemini 3.0 Flash High", contextLength: 1000000 },
{ id: "gemini-3.0-flash-medium", name: "Gemini 3.0 Flash Medium", contextLength: 1000000 },
{ id: "gemini-3.0-flash-low", name: "Gemini 3.0 Flash Low", contextLength: 1000000 },
{ id: "gemini-3.0-flash-minimal", name: "Gemini 3.0 Flash Minimal", contextLength: 1000000 },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", contextLength: 1000000 },
// ── Others ───────────────────────────────────────────────────────────
{ id: "deepseek-v4", name: "DeepSeek V4", contextLength: 64000 },
{ id: "deepseek-r1", name: "DeepSeek R1", contextLength: 64000 },
{ id: "deepseek-v3-2", name: "DeepSeek V3-2", contextLength: 64000 },
{ id: "deepseek-v3", name: "DeepSeek V3", contextLength: 64000 },
{ id: "grok-3-mini-thinking", name: "Grok 3 Mini Thinking", contextLength: 131000 },
{ id: "grok-3-mini", name: "Grok 3 Mini", contextLength: 131000 },
{ id: "grok-3", name: "Grok 3", contextLength: 131000 },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1", contextLength: 131000 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 131000 },
{ id: "kimi-k2.5", name: "Kimi K2.5", contextLength: 131000 },
{ id: "kimi-k2", name: "Kimi K2", contextLength: 131000 },
{ id: "glm-5.1", name: "GLM-5.1", contextLength: 128000 },
{ id: "glm-5", name: "GLM-5", contextLength: 128000 },
{ id: "glm-4.7", name: "GLM-4.7", contextLength: 128000 },
],
},
// ── Devin CLI (Official — ACP JSON-RPC over stdio) ──────────────────────────
// Uses the official `devin` binary via `devin acp --agent-type summarizer`.
// Requires devin CLI installed (https://cli.devin.ai) and authenticated
// via `devin auth login` or WINDSURF_API_KEY env var.
// Model IDs are passed directly to the ACP session/new `model` param.
"devin-cli": {
id: "devin-cli",
alias: "dv",
format: "openai",
executor: "devin-cli",
baseUrl: "devin://acp/stdio",
authType: "oauth",
authHeader: "Authorization",
authPrefix: "Bearer ",
defaultContextLength: 200000,
models: [
// Cognition / SWE — default model family recommended for coding tasks
{ id: "swe-1.6-fast", name: "SWE-1.6 Fast" },
{ id: "swe-1.6", name: "SWE-1.6" },
{ id: "swe-1.5-fast", name: "SWE-1.5 Fast" },
{ id: "swe-1.5", name: "SWE-1.5" },
{ id: "swe-check", name: "SWE Check" },
// Claude Opus 4.7
{ id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 },
{ id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 },
{ id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 },
{ id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 },
// Claude Sonnet/Opus 4.6
{
id: "claude-sonnet-4.6-thinking-1m",
name: "Claude Sonnet 4.6 Thinking 1M",
contextLength: 1000000,
},
{
id: "claude-sonnet-4.6-thinking",
name: "Claude Sonnet 4.6 Thinking",
contextLength: 200000,
},
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 },
{ id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 },
// Claude 4.5
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 },
// GPT-5.5
{ id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 },
{ id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 },
{ id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 },
{ id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 },
// GPT-5.4
{ id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 },
{ id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 },
// GPT-5.3 Codex
{ id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 },
{ id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 },
{ id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 },
// GPT-5.2
{ id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 },
{ id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 },
{ id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 },
// Gemini
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 },
{ id: "gemini-3.0-pro", name: "Gemini 3.0 Pro", contextLength: 1000000 },
{ id: "gemini-3.0-flash-high", name: "Gemini 3.0 Flash High", contextLength: 1000000 },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 },
// Others
{ id: "deepseek-v4", name: "DeepSeek V4", contextLength: 64000 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 131000 },
{ id: "glm-5.1", name: "GLM-5.1", contextLength: 128000 },
],
},
minimax: {
id: "minimax",
alias: "minimax",

View File

@@ -0,0 +1,470 @@
/**
* DevinCliExecutor — routes completions through the official Devin CLI binary
* via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio.
*
* Protocol flow:
* 1. Spawn `devin acp --agent-type summarizer` as a subprocess
* (summarizer = no file-system tools → pure text replies, safe for proxy use)
* 2. Send: initialize → session/new (with model + cwd) → session/prompt
* 3. Receive: session/update notifications (streaming text deltas)
* 4. Emit deltas as OpenAI-compatible SSE chunks
* 5. Kill subprocess on [DONE] or error
*
* Authentication:
* credentials.apiKey / accessToken → passed as WINDSURF_API_KEY env var to devin.
* If not set, devin falls back to credentials stored by `devin auth login`.
*
* Binary discovery:
* 1. CLI_DEVIN_BIN env var (absolute path override)
* 2. PATH lookup ("devin" / "devin.exe")
* 3. %LOCALAPPDATA%\devin\cli\bin\devin.exe (Windows installer)
* 4. ~/.local/share/devin/bin/devin (Linux installer)
*
* Model selection:
* Passed directly to ACP session/new as `model` param (e.g. "swe-1.6-fast",
* "claude-sonnet-4.6", "gpt-5.5-high"). Devin CLI resolves them against its
* model_configs_v2.bin catalog on startup.
*/
import { spawn } from "node:child_process";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
// ─── Binary discovery ────────────────────────────────────────────────────────
function resolveDevinBin(): string {
// 1. Explicit override
const envBin = process.env.CLI_DEVIN_BIN?.trim();
if (envBin) return envBin;
// 2. Common name (PATH lookup handled by spawn shell option)
const isWin = process.platform === "win32";
// 3. Windows installer default: %LOCALAPPDATA%\devin\cli\bin\devin.exe
if (isWin) {
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const winPath = path.join(localAppData, "devin", "cli", "bin", "devin.exe");
if (fs.existsSync(winPath)) return winPath;
}
// 4. Linux/macOS installer paths
const home = os.homedir();
for (const candidate of [
path.join(home, ".local", "share", "devin", "bin", "devin"),
path.join(home, ".devin", "bin", "devin"),
]) {
if (fs.existsSync(candidate)) return candidate;
}
// Fallback — rely on PATH
return isWin ? "devin.exe" : "devin";
}
// ─── ACP JSON-RPC helpers ────────────────────────────────────────────────────
type AcpMessage = {
jsonrpc: "2.0";
method?: string;
result?: unknown;
error?: { code: number; message: string };
params?: unknown;
id?: number | null;
};
function rpc(method: string, params: unknown, id?: number): string {
const msg: AcpMessage = { jsonrpc: "2.0", method, params };
if (id !== undefined) msg.id = id;
return JSON.stringify(msg) + "\n";
}
// ─── Multi-turn message → single prompt builder ───────────────────────────────
type OpenAIMsg = { role?: string; content?: unknown };
function buildPromptText(messages: OpenAIMsg[]): string {
// Devin CLI (summarizer mode) receives a single text prompt.
// We inline the whole conversation so the model has full context.
const lines: string[] = [];
for (const m of messages) {
const role = String(m.role || "user");
let text = "";
if (typeof m.content === "string") {
text = m.content;
} else if (Array.isArray(m.content)) {
for (const p of m.content) {
if (p && typeof p === "object" && (p as Record<string, unknown>).type === "text") {
text += String((p as Record<string, unknown>).text || "");
}
}
}
if (!text.trim()) continue;
if (role === "system") {
lines.push(`[System]\n${text}`);
} else if (role === "assistant") {
lines.push(`[Assistant]\n${text}`);
} else {
lines.push(`[User]\n${text}`);
}
}
return lines.join("\n\n") || "(empty)";
}
// ─── DevinCliExecutor ─────────────────────────────────────────────────────────
export class DevinCliExecutor extends BaseExecutor {
constructor() {
super("devin-cli", { id: "devin-cli", baseUrl: "" });
}
buildUrl(): string {
return "devin://acp/stdio";
}
buildHeaders(): Record<string, string> {
return {};
}
transformRequest(): unknown {
return null;
}
async execute({ model, body, stream: _stream, credentials, signal, log }: ExecuteInput): Promise<{
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
}> {
const b = (body ?? {}) as Record<string, unknown>;
const messages: OpenAIMsg[] = Array.isArray(b.messages) ? (b.messages as OpenAIMsg[]) : [];
const promptText = buildPromptText(messages);
const apiKey =
credentials.apiKey || credentials.accessToken || process.env.WINDSURF_API_KEY || "";
const devinBin = resolveDevinBin();
log?.info?.("DEVIN", `devin acp → model=${model}, bin=${devinBin}`);
const sseStream = new ReadableStream<Uint8Array>({
start(controller) {
const enc = new TextEncoder();
const emit = (data: string) => controller.enqueue(enc.encode(data));
const env: NodeJS.ProcessEnv = { ...process.env };
if (apiKey) env.WINDSURF_API_KEY = apiKey;
const child = spawn(devinBin, ["acp", "--agent-type", "summarizer"], {
env,
stdio: ["pipe", "pipe", "pipe"],
// On Windows, devin.exe may need shell resolution
shell: process.platform === "win32",
});
let spawnError: Error | null = null;
let stdinClosed = false;
child.on("error", (err) => {
spawnError = err;
const msg =
err.message.includes("ENOENT") || err.message.includes("not found")
? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.`
: `Devin CLI spawn error: ${err.message}`;
emit(
`data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n`
);
emit("data: [DONE]\n\n");
controller.close();
});
if (signal) {
signal.addEventListener("abort", () => {
if (!child.killed) child.kill("SIGTERM");
});
}
// ── JSON-RPC state machine ──────────────────────────────────────────
let idCounter = 1;
let sessionId: string | null = null;
let initDone = false;
let sessionCreated = false;
let promptSent = false;
let responseId = `chatcmpl-devin-${Date.now()}`;
let created = Math.floor(Date.now() / 1000);
let roleEmitted = false;
let totalText = "";
let finished = false;
const sendRpc = (method: string, params: unknown) => {
if (stdinClosed || child.stdin.destroyed) return;
const id = idCounter++;
try {
child.stdin.write(rpc(method, params, id));
} catch {
/* ignore write errors after close */
}
return id;
};
const finish = (error?: string) => {
if (finished) return;
finished = true;
if (error) {
emit(
`data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n`
);
} else {
// Emit finish chunk
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: Math.ceil(promptText.length / 4),
completion_tokens: Math.ceil(totalText.length / 4),
total_tokens: Math.ceil((promptText.length + totalText.length) / 4),
estimated: true,
},
})}\n\n`
);
}
emit("data: [DONE]\n\n");
// Gracefully close stdin → devin will exit
try {
if (!stdinClosed) {
stdinClosed = true;
child.stdin.end();
}
} catch {
/* ignore */
}
// Give it 2s to exit cleanly, then SIGKILL
const killTimer = setTimeout(() => {
if (!child.killed) child.kill("SIGKILL");
}, 2000);
killTimer.unref?.();
controller.close();
};
// ── stdout reader (NDJSON) ──────────────────────────────────────────
let buffer = "";
child.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf8");
let nl: number;
// Each ACP message is a newline-terminated JSON line
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
let msg: AcpMessage;
try {
msg = JSON.parse(line);
} catch {
continue; // ignore non-JSON lines (banner text, etc.)
}
// ── Initialize response ───────────────────────────────────────
if (!initDone && msg.result !== undefined && !msg.method) {
initDone = true;
// Create session: send session/new with model and a temp cwd
sendRpc("session/new", {
cwd: process.cwd(),
model: model || undefined,
});
continue;
}
// ── session/new response → get sessionId ──────────────────────
if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) {
const res = msg.result as Record<string, unknown>;
sessionId = (res?.sessionId as string) || null;
if (!sessionId) {
finish("Devin ACP: session/new returned no sessionId");
return;
}
sessionCreated = true;
// Send the prompt
promptSent = true;
sendRpc("session/prompt", {
sessionId,
content: [{ type: "text", text: promptText }],
});
continue;
}
// ── session/prompt response (ack) ─────────────────────────────
if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) {
// Acknowledged — streaming notifications will follow
continue;
}
// ── Streaming notifications (session/update) ──────────────────
if (msg.method === "session/update" || msg.method === "$/update") {
const params = msg.params as Record<string, unknown> | undefined;
if (!params) continue;
const type = params.type as string | undefined;
if (type === "message_delta" || type === "text_delta" || type === "content_delta") {
const delta =
(params.content as string) ||
(params.delta as string) ||
(params.text as string) ||
"";
if (delta) {
if (!roleEmitted) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null,
},
],
})}\n\n`
);
roleEmitted = true;
}
totalText += delta;
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
})}\n\n`
);
}
} else if (type === "message_stop" || type === "stop" || type === "done") {
finish();
return;
} else if (type === "error") {
finish(String(params.message || params.error || "Devin ACP error"));
return;
}
continue;
}
// ── session/prompt final result (non-streaming path) ──────────
if (promptSent && msg.result !== undefined && !msg.method && !finished) {
const res = msg.result as Record<string, unknown> | undefined;
// Extract text from result if we haven't streamed anything yet
if (!roleEmitted && res) {
const content = extractResultText(res);
if (content) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null,
},
],
})}\n\n`
);
totalText = content;
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content }, finish_reason: null }],
})}\n\n`
);
}
}
const stopReason = (res?.stopReason as string) || "";
if (stopReason && stopReason !== "cancelled") {
finish();
}
}
// ── Error responses ───────────────────────────────────────────
if (msg.error) {
finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`);
return;
}
}
});
child.stderr.on("data", (chunk: Buffer) => {
log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`);
});
child.on("close", (code) => {
if (!finished) {
if (code !== 0 && !spawnError) {
finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`);
} else {
finish();
}
}
});
// ── Send initialize ───────────────────────────────────────────────
sendRpc("initialize", {
protocolVersion: "0.3",
clientInfo: { name: "omniroute", version: "1.0" },
capabilities: {},
});
},
});
return {
response: new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: "devin://acp/stdio",
headers: {},
transformedBody: { model, promptLength: (body as Record<string, unknown>)?.messages },
};
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Try to extract text from a final ACP session/prompt result object. */
function extractResultText(result: Record<string, unknown>): string {
// Common result shapes:
// { message: { content: "..." } }
// { messages: [{ content: "..." }] }
// { content: "..." }
// { text: "..." }
if (typeof result.content === "string") return result.content;
if (typeof result.text === "string") return result.text;
const msg = result.message as Record<string, unknown> | undefined;
if (msg && typeof msg.content === "string") return msg.content;
const msgs = result.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(msgs)) {
return msgs
.filter((m) => m.role === "assistant")
.map((m) => String(m.content || ""))
.join("\n");
}
return "";
}

View File

@@ -22,6 +22,8 @@ import { AzureOpenAIExecutor } from "./azure-openai.ts";
import { GitlabExecutor } from "./gitlab.ts";
import { NlpCloudExecutor } from "./nlpcloud.ts";
import { PetalsExecutor } from "./petals.ts";
import { WindsurfExecutor } from "./windsurf.ts";
import { DevinCliExecutor } from "./devin-cli.ts";
const executors = {
antigravity: new AntigravityExecutor(),
@@ -62,6 +64,10 @@ const executors = {
"bb-web": new BlackboxWebExecutor(), // Alias
"muse-spark-web": new MuseSparkWebExecutor(),
"ms-web": new MuseSparkWebExecutor(), // Alias
windsurf: new WindsurfExecutor(),
ws: new WindsurfExecutor(), // Alias
"devin-cli": new DevinCliExecutor(),
devin: new DevinCliExecutor(), // Alias
};
const defaultCache = new Map();
@@ -102,3 +108,5 @@ export { AzureOpenAIExecutor } from "./azure-openai.ts";
export { GitlabExecutor } from "./gitlab.ts";
export { NlpCloudExecutor } from "./nlpcloud.ts";
export { PetalsExecutor } from "./petals.ts";
export { WindsurfExecutor } from "./windsurf.ts";
export { DevinCliExecutor } from "./devin-cli.ts";

View File

@@ -0,0 +1,754 @@
/**
* WindsurfExecutor — routes requests to Windsurf (Devin CLI / Codeium) backend.
*
* Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto).
* Service: exa.language_server_pb.LanguageServerService
* Method: GetChatMessage (unary → streamed as SSE)
*
* Authentication:
* credentials.accessToken = Codeium API key from windsurf.com/show-auth-token
* — placed in Metadata.api_key protobuf field of every request.
*
* Model IDs accepted by this executor (snake_case sent to Windsurf wire):
* Cognition SWE: swe-1, swe-1-5, swe-1-6, swe-1-6-fast, swe-1-lite
* Claude: claude-4-5-sonnet, claude-4-5-opus, claude-4-sonnet, claude-4-opus,
* claude-3-7-sonnet, claude-3-7-sonnet-thinking
* Gemini: gemini-2-5-pro, gemini-2-5-flash, gemini-3-0-pro, gemini-3-0-flash
* OpenAI: gpt-4-1, gpt-4-5, o1, o1-mini
*
* OmniRoute → Windsurf model-ID mapping lives in MODEL_ID_MAP below.
*/
import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { randomUUID } from "node:crypto";
// ─── Windsurf API constants ──────────────────────────────────────────────────
const WS_BASE_URL = "https://server.self-serve.windsurf.com";
const WS_SERVICE = "exa.language_server_pb.LanguageServerService";
const WS_METHOD_CHAT = "GetChatMessage";
const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`;
const WS_IDE_NAME = "windsurf";
const WS_IDE_VERSION = "3.14.0";
const WS_EXT_VERSION = "3.14.0";
const WS_LOCALE = "en-US";
// ─── Model alias normalizer ──────────────────────────────────────────────────
//
// Model names are passed directly to the Windsurf API as ModelOrAlias strings.
// The API accepts the catalog names as-is (e.g. "claude-4.5-sonnet", "swe-1.6-fast").
//
// This table handles only OmniRoute-style backwards-compat aliases where users
// might type dashes instead of dots (e.g. "swe-1-6-fast" → "swe-1.6-fast").
// Model IDs — source: model_configs_v2.bin extracted from Devin CLI binary.
// OmniRoute uses dot-notation user IDs (e.g. "gpt-5.5-high").
// Windsurf API accepts dash-notation modelUids (e.g. "gpt-5-5-high").
// This map normalises dot→dash for newer models and handles legacy aliases.
const MODEL_ALIAS_MAP: Record<string, string> = {
// ── SWE ─────────────────────────────────────────────────────────────────
"swe-1.6-fast": "swe-1-6-fast",
"swe-1.6": "swe-1-6",
"swe-1.5-fast": "swe-1p5", // fast variant
"swe-1.5": "swe-1p5",
"swe-check": "swe-check",
// ── Claude Opus 4.7 ──────────────────────────────────────────────────────
"claude-opus-4.7-max": "claude-opus-4-7-max",
"claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh",
"claude-opus-4.7-high": "claude-opus-4-7-high",
"claude-opus-4.7-medium": "claude-opus-4-7-medium",
"claude-opus-4.7-low": "claude-opus-4-7-low",
"claude-opus-4.7-review": "opus-4-7-review",
// ── Claude Opus/Sonnet 4.6 ───────────────────────────────────────────────
"claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m",
"claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m",
"claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking",
"claude-sonnet-4.6": "claude-sonnet-4-6",
"claude-opus-4.6-thinking": "claude-opus-4-6-thinking",
"claude-opus-4.6": "claude-opus-4-6",
// ── Claude 4.5 ───────────────────────────────────────────────────────────
"claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING",
"claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS",
"claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3",
"claude-sonnet-4.5": "MODEL_PRIVATE_2",
"claude-haiku-4.5": "MODEL_PRIVATE_11",
// backward-compat flat names
"claude-4.5-opus-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING",
"claude-4.5-opus": "MODEL_CLAUDE_4_5_OPUS",
"claude-4.5-sonnet-thinking": "MODEL_PRIVATE_3",
"claude-4.5-sonnet": "MODEL_PRIVATE_2",
"claude-4.5-haiku": "MODEL_PRIVATE_11",
// ── Claude 4 ─────────────────────────────────────────────────────────────
"claude-4-opus-thinking": "MODEL_CLAUDE_4_OPUS_THINKING",
"claude-4-opus": "MODEL_CLAUDE_4_OPUS",
"claude-4-sonnet-thinking": "MODEL_CLAUDE_4_SONNET_THINKING",
"claude-4-sonnet": "MODEL_CLAUDE_4_SONNET",
"claude-4.1-opus-thinking": "MODEL_CLAUDE_4_1_OPUS_THINKING",
"claude-4.1-opus": "MODEL_CLAUDE_4_1_OPUS",
// ── Claude 3.x ───────────────────────────────────────────────────────────
"claude-3.7-sonnet-thinking": "CLAUDE_3_7_SONNET_20250219_THINKING",
"claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219",
"claude-3.5-sonnet": "CLAUDE_3_5_SONNET_20241022",
// ── GPT-5.5 ──────────────────────────────────────────────────────────────
"gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority",
"gpt-5.5-high-fast": "gpt-5-5-high-priority",
"gpt-5.5-medium-fast": "gpt-5-5-medium-priority",
"gpt-5.5-low-fast": "gpt-5-5-low-priority",
"gpt-5.5-none-fast": "gpt-5-5-none-priority",
"gpt-5.5-xhigh": "gpt-5-5-xhigh",
"gpt-5.5-high": "gpt-5-5-high",
"gpt-5.5-medium": "gpt-5-5-medium",
"gpt-5.5-low": "gpt-5-5-low",
"gpt-5.5-none": "gpt-5-5-none",
"gpt-5.5-review": "gpt-5-5-review",
"gpt-5.5": "gpt-5-5-medium", // default effort level
// ── GPT-5.4 ──────────────────────────────────────────────────────────────
"gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority",
"gpt-5.4-high-fast": "gpt-5-4-high-priority",
"gpt-5.4-medium-fast": "gpt-5-4-medium-priority",
"gpt-5.4-low-fast": "gpt-5-4-low-priority",
"gpt-5.4-none-fast": "gpt-5-4-none-priority",
"gpt-5.4-xhigh": "gpt-5-4-xhigh",
"gpt-5.4-high": "gpt-5-4-high",
"gpt-5.4-medium": "gpt-5-4-medium",
"gpt-5.4-low": "gpt-5-4-low",
"gpt-5.4-none": "gpt-5-4-none",
"gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh",
"gpt-5.4-mini-high": "gpt-5-4-mini-high",
"gpt-5.4-mini-medium": "gpt-5-4-mini-medium",
"gpt-5.4-mini-low": "gpt-5-4-mini-low",
"gpt-5.4": "gpt-5-4-medium", // default effort level
// ── GPT-5.3-Codex ────────────────────────────────────────────────────────
"gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority",
"gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority",
"gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority",
"gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority",
"gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh",
"gpt-5.3-codex-high": "gpt-5-3-codex-high",
"gpt-5.3-codex-medium": "gpt-5-3-codex-medium",
"gpt-5.3-codex-low": "gpt-5-3-codex-low",
"gpt-5.3-codex": "gpt-5-3-codex-medium",
// ── GPT-5.2 ──────────────────────────────────────────────────────────────
"gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH",
"gpt-5.2-high": "MODEL_GPT_5_2_HIGH",
"gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM",
"gpt-5.2-low": "MODEL_GPT_5_2_LOW",
"gpt-5.2-none": "MODEL_GPT_5_2_NONE",
"gpt-5.2": "MODEL_GPT_5_2_MEDIUM",
// ── GPT-5 ────────────────────────────────────────────────────────────────
"gpt-5-codex": "gpt-5-codex",
"gpt-5": "gpt-5",
// ── GPT-4.1 / 4o ─────────────────────────────────────────────────────────
"gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14",
"gpt-4.1-mini": "gpt-4.1-mini",
"gpt-4.1-nano": "gpt-4.1-nano",
"gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06",
"gpt-4o-mini": "gpt-4o-mini",
// ── Gemini ────────────────────────────────────────────────────────────────
"gemini-3.1-pro-high": "gemini-3-1-pro-high",
"gemini-3.1-pro-low": "gemini-3-1-pro-low",
"gemini-3.1-pro": "gemini-3-1-pro-high",
"gemini-3.0-pro": "gemini-3-pro",
"gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM",
"gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW",
"gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL",
"gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO",
"gemini-2.5-flash": "gemini-2.5-flash",
// ── Others ───────────────────────────────────────────────────────────────
"deepseek-v4": "deepseek-v4",
"deepseek-r1": "deepseek-r1",
"deepseek-v3-2": "deepseek-v3-2",
"deepseek-v3": "deepseek-v3",
"grok-3-mini-thinking": "MODEL_XAI_GROK_3_MINI_REASONING",
"grok-3-mini": "grok-3-mini",
"grok-3": "MODEL_XAI_GROK_3",
"grok-code-fast-1": "grok-code-fast-1",
"kimi-k2.6": "kimi-k2-6",
"kimi-k2.5": "kimi-k2-5",
"kimi-k2": "MODEL_KIMI_K2",
"glm-5.1": "glm-5-1",
"glm-5": "glm-5",
"glm-4.7": "MODEL_GLM_4_7",
};
function resolveWsModelId(model: string): string {
return MODEL_ALIAS_MAP[model] ?? model;
}
// ─── Minimal protobuf encoder ────────────────────────────────────────────────
//
// Implements only what is needed for GetChatMessageRequest.
// Wire types: 0 = varint, 2 = length-delimited.
function encodeVarint(value: number): Uint8Array {
const bytes: number[] = [];
let v = value >>> 0;
while (v > 0x7f) {
bytes.push((v & 0x7f) | 0x80);
v >>>= 7;
}
bytes.push(v & 0x7f);
return new Uint8Array(bytes);
}
function concatBytes(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrays) {
out.set(a, off);
off += a.length;
}
return out;
}
const TEXT_ENC = new TextEncoder();
const TEXT_DEC = new TextDecoder();
/** Encode a length-delimited field (strings and nested messages share wire type 2). */
function encodeField(fieldNum: number, payload: Uint8Array): Uint8Array {
const tag = encodeVarint((fieldNum << 3) | 2);
const len = encodeVarint(payload.length);
return concatBytes([tag, len, payload]);
}
/** Encode a UTF-8 string field. */
function encodeString(fieldNum: number, value: string): Uint8Array {
return encodeField(fieldNum, TEXT_ENC.encode(value));
}
/** Encode a nested message field. */
function encodeMessage(fieldNum: number, msg: Uint8Array): Uint8Array {
return encodeField(fieldNum, msg);
}
// ─── Protobuf message builders ───────────────────────────────────────────────
function buildMetadata(apiKey: string, sessionId: string): Uint8Array {
return concatBytes([
encodeString(1, apiKey),
encodeString(2, WS_IDE_NAME),
encodeString(3, WS_IDE_VERSION),
encodeString(4, WS_EXT_VERSION),
encodeString(5, sessionId),
encodeString(6, WS_LOCALE),
]);
}
function buildModelOrAlias(model: string): Uint8Array {
// ModelOrAlias wraps the model identifier in field 1
return encodeString(1, model);
}
type WsChatMessage = { role: string; content: string; toolCallId?: string };
function buildChatMessage(msg: WsChatMessage): Uint8Array {
const parts: Uint8Array[] = [encodeString(1, msg.role), encodeString(2, msg.content)];
if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId));
return concatBytes(parts);
}
function buildGetChatMessageRequest(
apiKey: string,
model: string,
messages: WsChatMessage[]
): Uint8Array {
const sessionId = randomUUID();
const cascadeId = randomUUID();
const parts: Uint8Array[] = [
encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata
encodeString(2, cascadeId), // cascade_id
encodeMessage(3, buildModelOrAlias(model)), // model_or_alias
];
for (const msg of messages) {
parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages
}
return concatBytes(parts);
}
// ─── gRPC-web framing ────────────────────────────────────────────────────────
/** Wrap a protobuf message in a 5-byte gRPC-web data frame. */
function grpcWebFrame(payload: Uint8Array): Uint8Array {
const frame = new Uint8Array(5 + payload.length);
frame[0] = 0x00; // compression flag: no compression
const view = new DataView(frame.buffer);
view.setUint32(1, payload.length, false); // big-endian length
frame.set(payload, 5);
return frame;
}
// ─── Protobuf response decoder ───────────────────────────────────────────────
//
// CompletionChunk (oneof):
// field 1 (length-delimited) → ContentChunk { field 1: string text }
// field 2 (length-delimited) → ToolCallChunk (skipped for now)
// field 3 (length-delimited) → DoneChunk { field 1: UsageStats }
// field 4 (length-delimited) → ErrorChunk { field 1: string message }
//
// GetChatMessageResponse (unary fallback):
// field 1 (length-delimited) → content string (heuristic)
// field 2 (length-delimited) → nested message (heuristic)
type DecodedChunk =
| { kind: "content"; text: string }
| { kind: "done"; promptTokens: number; completionTokens: number }
| { kind: "error"; message: string }
| { kind: "unknown" };
/** Read a varint from buf starting at offset; returns [value, newOffset]. */
function readVarint(buf: Uint8Array, offset: number): [number, number] {
let result = 0;
let shift = 0;
while (offset < buf.length) {
const b = buf[offset++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7;
}
return [result >>> 0, offset];
}
/** Decode a single protobuf message payload as a CompletionChunk. */
function decodeCompletionChunk(buf: Uint8Array): DecodedChunk {
let offset = 0;
while (offset < buf.length) {
let tag: number;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
// length-delimited
let len: number;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === 1) {
// ContentChunk — field 1 inside = text string
const text = decodeContentChunk(payload);
if (text !== null) return { kind: "content", text };
} else if (fieldNum === 3) {
// DoneChunk — field 1 inside = UsageStats
const usage = decodeDoneChunk(payload);
return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] };
} else if (fieldNum === 4) {
// ErrorChunk — field 1 inside = error message string
const msg = decodeStringField(payload, 1);
return { kind: "error", message: msg ?? "unknown windsurf error" };
}
// field 2 = ToolCallChunk — not yet handled; skip
} else if (wireType === 0) {
let _v: number;
[_v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break; // unknown wire type — stop parsing
}
}
return { kind: "unknown" };
}
/** Extract text string from ContentChunk (field 1 = string). */
function decodeContentChunk(buf: Uint8Array): string | null {
return decodeStringField(buf, 1);
}
/** Extract prompt_tokens + completion_tokens from DoneChunk.UsageStats. */
function decodeDoneChunk(buf: Uint8Array): [number, number] {
// DoneChunk: field 1 = UsageStats (nested)
// UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint)
let offset = 0;
let usageBytes: Uint8Array | null = null;
while (offset < buf.length) {
let tag: number;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len: number;
[len, offset] = readVarint(buf, offset);
if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len);
offset += len;
} else if (wireType === 0) {
let _v: number;
[_v, offset] = readVarint(buf, offset);
} else {
break;
}
}
if (!usageBytes) return [0, 0];
let promptTokens = 0;
let completionTokens = 0;
offset = 0;
while (offset < usageBytes.length) {
let tag: number;
[tag, offset] = readVarint(usageBytes, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 0) {
let v: number;
[v, offset] = readVarint(usageBytes, offset);
if (fieldNum === 1) promptTokens = v;
else if (fieldNum === 2) completionTokens = v;
} else if (wireType === 2) {
let len: number;
[len, offset] = readVarint(usageBytes, offset);
offset += len;
} else {
break;
}
}
return [promptTokens, completionTokens];
}
/** Read a length-delimited string at a given field number from buf. */
function decodeStringField(buf: Uint8Array, targetField: number): string | null {
let offset = 0;
while (offset < buf.length) {
let tag: number;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len: number;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === targetField) return TEXT_DEC.decode(payload);
} else if (wireType === 0) {
let _v: number;
[_v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return null;
}
// ─── Convert OpenAI messages → Windsurf WsChatMessage[] ──────────────────────
type OpenAIMessage = {
role?: string;
content?: unknown;
tool_call_id?: string;
};
function openAIMessagesToWs(messages: OpenAIMessage[]): WsChatMessage[] {
const out: WsChatMessage[] = [];
for (const m of messages) {
const role = String(m.role || "user");
let content = "";
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
// Multi-part: concatenate text parts
for (const part of m.content) {
if (part && typeof part === "object" && (part as Record<string, unknown>).type === "text") {
content += String((part as Record<string, unknown>).text || "");
}
}
}
out.push({ role, content, toolCallId: m.tool_call_id });
}
return out;
}
// ─── gRPC-web response stream parser ─────────────────────────────────────────
//
// gRPC-web frame layout:
// byte 0: flag (0x00 = data, 0x80 = trailers)
// bytes 1-4: message length (big-endian uint32)
// bytes 5…: protobuf payload
//
// The response body is a concatenated sequence of these frames.
function* parseGrpcWebFrames(buf: Uint8Array): Generator<{ flag: number; payload: Uint8Array }> {
let offset = 0;
while (offset + 5 <= buf.length) {
const flag = buf[offset];
const len =
(buf[offset + 1] << 24) | (buf[offset + 2] << 16) | (buf[offset + 3] << 8) | buf[offset + 4];
offset += 5;
if (len < 0 || offset + len > buf.length) break;
yield { flag, payload: buf.slice(offset, offset + len) };
offset += len;
}
}
// ─── WindsurfExecutor ─────────────────────────────────────────────────────────
export class WindsurfExecutor extends BaseExecutor {
constructor() {
super("windsurf", PROVIDERS["windsurf"] || { id: "windsurf", baseUrl: WS_CHAT_URL });
}
buildUrl(): string {
return WS_CHAT_URL;
}
buildHeaders(credentials: { accessToken?: string; apiKey?: string }): Record<string, string> {
const token = credentials.accessToken || credentials.apiKey || "";
return {
"Content-Type": "application/grpc-web+proto",
Accept: "application/grpc-web+proto",
// Codeium API key also goes in Metadata.api_key (protobuf field) — see request body.
// Some endpoints also accept it as a Bearer token header.
...(token ? { Authorization: `Bearer ${token}` } : {}),
"User-Agent": `windsurf/${WS_IDE_VERSION}`,
"X-Grpc-Web": "1",
};
}
transformRequest(): unknown {
// Request body is built manually in execute() because it requires the model + messages
return null;
}
async execute({
model,
body,
stream,
credentials,
signal,
log,
upstreamExtraHeaders,
}: ExecuteInput): Promise<{
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
}> {
const apiKey = credentials.accessToken || credentials.apiKey || "";
const wsModel = resolveWsModelId(model);
// Parse OpenAI messages from request body
const b = (body ?? {}) as Record<string, unknown>;
const rawMessages = Array.isArray(b.messages) ? (b.messages as OpenAIMessage[]) : [];
const wsMessages = openAIMessagesToWs(rawMessages);
if (wsMessages.length === 0) {
wsMessages.push({ role: "user", content: "" });
}
// Build and frame the protobuf request
const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages);
const framedPayload = grpcWebFrame(protoPayload);
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
log?.info?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`);
const upstream = await fetch(url, {
method: "POST",
headers,
body: framedPayload,
signal: signal ?? undefined,
});
if (!upstream.ok && upstream.status !== 200) {
return { response: upstream, url, headers, transformedBody: protoPayload };
}
// Transform gRPC-web binary response → SSE stream
const sseResponse = this.transformToSSE(upstream, model, stream);
return { response: sseResponse, url, headers, transformedBody: protoPayload };
}
/** Convert a gRPC-web response body into an OpenAI-compatible SSE stream. */
private transformToSSE(upstream: Response, model: string, _stream: boolean): Response {
const responseId = `chatcmpl-ws-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const transformStream = new TransformStream<Uint8Array, Uint8Array>({
async transform(chunk, controller) {
// Accumulate — gRPC-web frames may arrive split across fetch chunks.
// For simplicity we buffer the entire message set in flush().
controller.enqueue(chunk);
},
});
// We need to buffer the full response to parse gRPC frames.
// Use a ReadableStream that:
// 1. reads the entire upstream body
// 2. parses gRPC-web frames
// 3. emits SSE events
const sseStream = new ReadableStream<Uint8Array>({
async start(controller) {
const enc = new TextEncoder();
let roleEmitted = false;
let totalText = "";
let promptTokens = 0;
let completionTokens = 0;
let hadError: string | null = null;
function emit(data: string) {
controller.enqueue(enc.encode(data));
}
try {
const bodyBytes = upstream.body ? await readStream(upstream.body) : new Uint8Array(0);
for (const { flag, payload } of parseGrpcWebFrames(bodyBytes)) {
if (flag === 0x80) {
// Trailer frame — contains grpc-status, grpc-message
const trailer = TEXT_DEC.decode(payload);
const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer);
if (statusMatch && statusMatch[1] !== "0") {
const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer);
hadError = msgMatch
? decodeURIComponent(msgMatch[1].trim())
: `gRPC status ${statusMatch[1]}`;
}
continue;
}
if (flag !== 0x00) continue; // skip unknown flags
const chunk = decodeCompletionChunk(payload);
if (chunk.kind === "content" && chunk.text) {
totalText += chunk.text;
if (!roleEmitted) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null },
],
})}\n\n`
);
roleEmitted = true;
}
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }],
})}\n\n`
);
} else if (chunk.kind === "done") {
promptTokens = chunk.promptTokens;
completionTokens = chunk.completionTokens;
} else if (chunk.kind === "error") {
hadError = chunk.message;
}
}
if (hadError) {
emit(
`data: ${JSON.stringify({
error: { message: hadError, type: "windsurf_error", code: "upstream_error" },
})}\n\n`
);
emit("data: [DONE]\n\n");
controller.close();
return;
}
// If nothing was streamed but we got a response, treat the decoded
// text as the full reply (unary response path).
if (!roleEmitted && totalText) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null },
],
})}\n\n`
);
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }],
})}\n\n`
);
}
// Finish chunk
const finishPayload: Record<string, unknown> = {
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
};
if (promptTokens > 0 || completionTokens > 0) {
finishPayload.usage = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
}
emit(`data: ${JSON.stringify(finishPayload)}\n\n`);
emit("data: [DONE]\n\n");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
emit(
`data: ${JSON.stringify({
error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" },
})}\n\n`
);
emit("data: [DONE]\n\n");
}
controller.close();
},
});
void transformStream; // unused — kept for reference
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
}
/** Read an entire ReadableStream<Uint8Array> into a single Uint8Array. */
async function readStream(readable: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
const reader = readable.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
} finally {
reader.releaseLock();
}
return concatBytes(chunks);
}

View File

@@ -116,6 +116,79 @@ export async function refreshAccessToken(
* Specialized refresh for Cline OAuth tokens.
* Cline refresh endpoint expects JSON body and returns camelCase fields.
*/
/**
* Refresh Windsurf (Devin CLI / Codeium) tokens.
*
* Windsurf uses Firebase Secure Token Service (STS) for token refresh.
* If the token is a long-lived Codeium API key (import flow), it never
* expires and refresh is a no-op returning the same token.
* If the token is a Firebase ID token (device-code flow), it expires after
* ~1 hour and can be refreshed with the stored Firebase refresh token.
*/
export async function refreshWindsurfToken(
refreshToken: string,
providerSpecificData: Record<string, unknown> | null | undefined,
log: RefreshLogger,
proxyConfig: unknown = null
) {
if (!refreshToken) {
log?.warn?.(
"TOKEN_REFRESH",
"No refresh token stored for Windsurf — token may be a long-lived API key"
);
return null;
}
const authMethod = (providerSpecificData?.authMethod as string) || "import";
// Long-lived Codeium API keys (import flow) have no expiry — nothing to refresh.
if (authMethod === "import") {
log?.debug?.("TOKEN_REFRESH", "Windsurf import token is long-lived — no refresh needed");
return null;
}
// Firebase STS refresh for device-code tokens
const firebaseApiKey =
process.env.WINDSURF_FIREBASE_API_KEY || "AIzaSyBpLTEGSt59AUPKxBb7lIWjSE2ZXQH7mgU";
const tokenUrl = `https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`;
try {
const response = await runWithProxyContext(proxyConfig, () =>
fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken }),
})
);
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Windsurf Firebase token", {
status: response.status,
error: errorText.slice(0, 200),
});
return null;
}
const data = await response.json();
const expiresIn = parseInt(data.expires_in ?? "3600", 10);
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Windsurf Firebase token", {
expiresIn,
hasNewIdToken: !!data.id_token,
});
return {
accessToken: data.id_token,
refreshToken: data.refresh_token || refreshToken,
expiresIn,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Windsurf token: ${error.message}`);
return null;
}
}
export async function refreshClineToken(refreshToken, log, proxyConfig: unknown = null) {
const endpoint = PROVIDERS.cline?.refreshUrl;
if (!endpoint) {
@@ -789,6 +862,15 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
case "kimi-coding":
return await refreshKimiCodingToken(credentials.refreshToken, log, proxyConfig);
case "windsurf":
case "devin-cli":
return await refreshWindsurfToken(
credentials.refreshToken,
credentials.providerSpecificData,
log,
proxyConfig
);
default:
// Fallback to generic OAuth refresh for unknown providers
return refreshAccessToken(provider, credentials.refreshToken, credentials, log, proxyConfig);
@@ -812,6 +894,8 @@ export function supportsTokenRefresh(provider) {
"amazon-q",
"cline",
"kimi-coding",
"windsurf",
"devin-cli",
]);
if (explicitlySupported.has(provider)) return true;
const config = PROVIDERS[provider];

View File

@@ -264,6 +264,29 @@ export const CURSOR_CONFIG = {
},
};
// Windsurf (Devin CLI / Codeium) Configuration
// Authentication supports two methods:
// 1. Token import: user visits windsurf.com/show-auth-token, copies Codeium API key
// 2. Device code flow: Codeium's own gRPC device flow (poll-based, no browser redirect needed)
export const WINDSURF_CONFIG = {
// API server for device-code flow and user registration
apiServerUrl: "https://server.codeium.com",
// Inference server URL (gRPC-web requests go here)
inferenceUrl: "https://server.self-serve.windsurf.com",
// User-friendly token page — the "link" integration
showAuthTokenUrl: "https://windsurf.com/show-auth-token",
// Token refresh via Firebase Secure Token Service
firebaseApiKey:
process.env.WINDSURF_FIREBASE_API_KEY || "AIzaSyBpLTEGSt59AUPKxBb7lIWjSE2ZXQH7mgU",
firebaseTokenUrl: "https://securetoken.googleapis.com/v1/token",
// Auth method flag
authMethods: ["import", "device-code"] as const,
// IDE identity sent with every gRPC request
ideName: "windsurf",
ideVersion: "3.14.0",
extensionVersion: "3.14.0",
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -284,4 +307,6 @@ export const PROVIDERS = {
CURSOR: "cursor",
KILOCODE: "kilocode",
CLINE: "cline",
WINDSURF: "windsurf",
DEVIN_CLI: "devin-cli",
};

View File

@@ -23,6 +23,7 @@ import { kiro } from "./kiro";
import { cursor } from "./cursor";
import { kilocode } from "./kilocode";
import { cline } from "./cline";
import { windsurf } from "./windsurf";
export const PROVIDERS = {
claude,
@@ -39,6 +40,9 @@ export const PROVIDERS = {
cursor,
kilocode,
cline,
windsurf,
// devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login)
"devin-cli": windsurf,
};
export default PROVIDERS;

View File

@@ -0,0 +1,58 @@
import { WINDSURF_CONFIG } from "../constants/oauth";
/**
* Windsurf (Devin CLI / Codeium) OAuth Provider
*
* Supports two authentication methods:
*
* 1. **Token Import** (recommended, "link" integration):
* User visits windsurf.com/show-auth-token after signing into Windsurf,
* copies their Codeium API token, and pastes it here. The token is stored
* directly as `accessToken` and sent in gRPC request Metadata.api_key.
*
* 2. **Device-Code Flow** (via Codeium's gRPC ExtensionServerService):
* The executor calls StartDeviceFlow → user visits a verification URL →
* poll GetDeviceFlowState until the Firebase ID token is returned. The
* Firebase token is then exchanged for a Codeium API key via RegisterUser.
* This mirrors how `devin auth login` opens a browser link.
*
* Token lifetime:
* - Import tokens (Codeium API keys) are long-lived and do not expire.
* - Device-code Firebase tokens expire after ~1 hour; the refresh token is
* persisted and exchanged against Firebase STS for a new ID token when
* the existing one is near expiry.
*/
export const windsurf = {
config: WINDSURF_CONFIG,
flowType: "import_token",
/**
* Map imported/exchanged token data to OmniRoute connection fields.
* Called after the user pastes their token or after a device-code exchange.
*/
mapTokens: (tokens: {
accessToken?: string;
apiKey?: string;
refreshToken?: string;
expiresIn?: number;
email?: string;
authMethod?: string;
firebaseToken?: string;
}) => {
// Accept either `accessToken` (UI import flow) or `apiKey` (device-code result)
const token = tokens.accessToken || tokens.apiKey || "";
const isFirebaseToken = tokens.authMethod === "device-code" || Boolean(tokens.firebaseToken);
return {
accessToken: token,
refreshToken: tokens.refreshToken || null,
expiresIn: tokens.expiresIn || (isFirebaseToken ? 3600 : 0),
email: tokens.email || null,
providerSpecificData: {
authMethod: tokens.authMethod || "import",
// If the original firebase ID token is separate from the API key, store it too
firebaseToken: tokens.firebaseToken || null,
},
};
},
};

View File

@@ -90,6 +90,28 @@ export const OAUTH_PROVIDERS = {
color: "#5B9BD5",
textIcon: "CL",
},
windsurf: {
id: "windsurf",
alias: "ws",
name: "Windsurf (Devin CLI)",
icon: "air",
color: "#00C5A0",
textIcon: "WS",
authHint:
"Sign in at windsurf.com to get your token. Visit windsurf.com/show-auth-token after logging in and paste it here, or use the device-code login flow.",
website: "https://windsurf.com",
},
"devin-cli": {
id: "devin-cli",
alias: "dv",
name: "Devin CLI (Official)",
icon: "terminal",
color: "#6366F1",
textIcon: "DV",
authHint:
"Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai",
website: "https://cli.devin.ai",
},
};
// Web / Cookie Providers

View File

@@ -67,6 +67,24 @@ const CLI_TOOLS: Record<string, any> = {
healthcheckTimeoutMs: 4000,
paths: {},
},
devin: {
defaultCommand: "devin",
envBinKey: "CLI_DEVIN_BIN",
requiresBinary: true,
// devin acp cold-start can take a few seconds on first run
healthcheckTimeoutMs: 12000,
paths: {
// %APPDATA%\devin\config.json (Windows)
// ~/.config/devin/config.json (Linux/macOS)
config: isWindows()
? path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
"devin",
"config.json"
)
: path.join(os.homedir(), ".config", "devin", "config.json"),
},
},
cline: {
defaultCommand: "cline",
envBinKey: "CLI_CLINE_BIN",
@@ -439,6 +457,10 @@ const getKnownToolPaths = (toolId: string): string[] => {
["qodercli.cmd", "qodercli"],
["qodercli.exe", "qodercli"],
],
devin: [
["devin.exe", "devin"],
["devin.cmd", "devin"],
],
};
const bins = toolBins[toolId] || [];
@@ -464,6 +486,11 @@ const getKnownToolPaths = (toolId: string): string[] => {
paths.push(path.join(home, "bin", "droid.exe"));
}
// Devin CLI installs to %LOCALAPPDATA%\devin\cli\bin\devin.exe
if (toolId === "devin" && localAppData) {
paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.exe"));
}
for (const [winName] of bins) {
if (npmPrefix) paths.push(path.join(npmPrefix, winName));
if (appData) {
@@ -501,6 +528,12 @@ const getKnownToolPaths = (toolId: string): string[] => {
if (toolId === "claude") {
paths.push(path.join(home, ".claude", "bin", posixName));
}
// Devin CLI installs to ~/.local/share/devin/bin/devin (Linux)
// or via shell installer to ~/.devin/bin/devin
if (toolId === "devin") {
paths.push(path.join(home, ".local", "share", "devin", "bin", "devin"));
paths.push(path.join(home, ".devin", "bin", "devin"));
}
}
}