diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 52e769aa19..ab45f93498 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1066,6 +1066,227 @@ export const REGISTRY: Record = { 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", diff --git a/open-sse/executors/devin-cli.ts b/open-sse/executors/devin-cli.ts new file mode 100644 index 0000000000..528cd25308 --- /dev/null +++ b/open-sse/executors/devin-cli.ts @@ -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).type === "text") { + text += String((p as Record).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 { + return {}; + } + + transformRequest(): unknown { + return null; + } + + async execute({ model, body, stream: _stream, credentials, signal, log }: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const b = (body ?? {}) as Record; + 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({ + 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; + 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 | 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 | 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)?.messages }, + }; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Try to extract text from a final ACP session/prompt result object. */ +function extractResultText(result: Record): 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 | undefined; + if (msg && typeof msg.content === "string") return msg.content; + const msgs = result.messages as Array> | undefined; + if (Array.isArray(msgs)) { + return msgs + .filter((m) => m.role === "assistant") + .map((m) => String(m.content || "")) + .join("\n"); + } + return ""; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 03b4d1b079..9dbf0a9349 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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"; diff --git a/open-sse/executors/windsurf.ts b/open-sse/executors/windsurf.ts new file mode 100644 index 0000000000..684cc2eb6d --- /dev/null +++ b/open-sse/executors/windsurf.ts @@ -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 = { + // ── 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).type === "text") { + content += String((part as Record).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 { + 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; + transformedBody: unknown; + }> { + const apiKey = credentials.accessToken || credentials.apiKey || ""; + const wsModel = resolveWsModelId(model); + + // Parse OpenAI messages from request body + const b = (body ?? {}) as Record; + 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({ + 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({ + 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 = { + 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 into a single Uint8Array. */ +async function readStream(readable: ReadableStream): Promise { + 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); +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 5001730875..5a679e3203 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -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 | 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]; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 6a32388d20..304ab4c729 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -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", }; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 5c13cb3c79..b747eff146 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -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; diff --git a/src/lib/oauth/providers/windsurf.ts b/src/lib/oauth/providers/windsurf.ts new file mode 100644 index 0000000000..4a9a573ec1 --- /dev/null +++ b/src/lib/oauth/providers/windsurf.ts @@ -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, + }, + }; + }, +}; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 05dbcb5d46..937f055765 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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 diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index f5d9abcaae..8dec6100f6 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -67,6 +67,24 @@ const CLI_TOOLS: Record = { 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")); + } } }