mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(oauth): complete Windsurf / Devin CLI OAuth + API-token flows (#2168)
Integrated into release/v3.8.0 — complete Windsurf/Devin CLI OAuth + API-token executor flows with unit tests.
This commit is contained in:
@@ -393,6 +393,13 @@ ANTIGRAVITY_OAUTH_CLIENT_SECRET=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
|
||||
# ── GitHub Copilot ──
|
||||
GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
|
||||
# ── Windsurf / Devin CLI ──
|
||||
# Public Firebase Web API key used by Windsurf's Secure Token Service to refresh
|
||||
# short-lived browser-flow tokens. This is a client-side credential embedded in
|
||||
# the Windsurf app itself (not a secret). Long-lived import tokens skip this entirely.
|
||||
# Source: extracted from Devin CLI binary (devin.exe / model_configs_v2.bin).
|
||||
WINDSURF_FIREBASE_API_KEY=AIzaSyBpLTEGSt59AUPKxBb7lIWjSE2ZXQH7mgU
|
||||
|
||||
# ── GitLab Duo ──
|
||||
# Register an OAuth app at: https://gitlab.com/-/profile/applications
|
||||
# Set redirect URI to: http://localhost:20128/callback (or your NEXT_PUBLIC_BASE_URL + /callback)
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(providers):** strip OpenAI-specific fields in Kiro translator to prevent 400 errors (#2037)
|
||||
- **fix(ui):** resolve text contrast issues for zero-config warning banner in light mode (#2050)
|
||||
- **fix(core):** inject global system prompt correctly into downstream chat completions pipeline (#2080)
|
||||
- **fix(routing):** add missing v1beta rewrites to next.config to resolve 404 on Gemini models endpoint (#2102)
|
||||
- **fix(cache):** optimize cache_control preservation logic and explicitly align tool schema with upstream Claude Code expectations
|
||||
- **fix(db):** preserve legacy SQLite database path on Windows to prevent data loss (#1973)
|
||||
- **fix(settings):** resolve model alias persistence double stringification preventing UI updates (#2018)
|
||||
|
||||
@@ -1207,6 +1207,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",
|
||||
|
||||
470
open-sse/executors/devin-cli.ts
Normal file
470
open-sse/executors/devin-cli.ts
Normal 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 "";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
754
open-sse/executors/windsurf.ts
Normal file
754
open-sse/executors/windsurf.ts
Normal 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);
|
||||
}
|
||||
@@ -116,6 +116,86 @@ 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 browser-flow tokens.
|
||||
// Key is read from WINDSURF_FIREBASE_API_KEY env var (set in .env.example).
|
||||
const firebaseApiKey = process.env.WINDSURF_FIREBASE_API_KEY || "";
|
||||
if (!firebaseApiKey) {
|
||||
log?.warn?.(
|
||||
"TOKEN_REFRESH",
|
||||
"WINDSURF_FIREBASE_API_KEY not set — skipping Windsurf Firebase token refresh"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
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 +869,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 +901,8 @@ export function supportsTokenRefresh(provider) {
|
||||
"amazon-q",
|
||||
"cline",
|
||||
"kimi-coding",
|
||||
"windsurf",
|
||||
"devin-cli",
|
||||
]);
|
||||
if (explicitlySupported.has(provider)) return true;
|
||||
const config = PROVIDERS[provider];
|
||||
|
||||
@@ -21,6 +21,7 @@ import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
oauthExchangeSchema,
|
||||
oauthImportTokenSchema,
|
||||
oauthPollSchema,
|
||||
} from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -30,6 +31,16 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
if (!globalThis.__codexCallbackState) {
|
||||
globalThis.__codexCallbackState = null;
|
||||
}
|
||||
// Windsurf / Devin CLI PKCE callback server state (separate from Codex)
|
||||
if (!globalThis.__windsurfCallbackState) {
|
||||
globalThis.__windsurfCallbackState = null;
|
||||
}
|
||||
|
||||
/** Providers that use the PKCE browser callback flow (like Codex). */
|
||||
const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]);
|
||||
|
||||
/** Providers that allow direct import of a raw API token (no OAuth exchange). */
|
||||
const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli"]);
|
||||
|
||||
/**
|
||||
* Constant-time string comparison to prevent timing-oracle attacks (CWE-208).
|
||||
@@ -150,40 +161,44 @@ export async function GET(
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Codex callback server on port 1455
|
||||
* Returns the auth URL and stores codeVerifier for later exchange
|
||||
* Start PKCE callback server for Codex, Windsurf, or Devin CLI.
|
||||
* Codex uses fixed port 1455; Windsurf/Devin CLI use a random free port (port 0).
|
||||
* Returns the auth URL and stores codeVerifier for later exchange.
|
||||
*/
|
||||
async function handleStartCallbackServer(provider: string, searchParams: URLSearchParams) {
|
||||
if (provider !== "codex") {
|
||||
if (!PKCE_CALLBACK_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Callback server only supported for codex" },
|
||||
{ error: `Callback server not supported for provider: ${provider}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isWindsurf = provider === "windsurf" || provider === "devin-cli";
|
||||
const stateKey = isWindsurf ? "__windsurfCallbackState" : "__codexCallbackState";
|
||||
|
||||
// Clean up existing server if any
|
||||
if (globalThis.__codexCallbackState?.close) {
|
||||
if (globalThis[stateKey]?.close) {
|
||||
try {
|
||||
globalThis.__codexCallbackState.close();
|
||||
globalThis[stateKey].close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
globalThis.__codexCallbackState = null;
|
||||
globalThis[stateKey] = null;
|
||||
|
||||
try {
|
||||
// Start temp server on port 1455
|
||||
// Codex: fixed port 1455. Windsurf/Devin CLI: OS-assigned random port (0)
|
||||
const serverPort = isWindsurf ? 0 : 1455;
|
||||
const { port, close } = await startLocalServer((params) => {
|
||||
// Write directly to globalThis so it survives module reloads
|
||||
if (globalThis.__codexCallbackState) {
|
||||
globalThis.__codexCallbackState.callbackParams = params;
|
||||
if (globalThis[stateKey]) {
|
||||
globalThis[stateKey].callbackParams = params;
|
||||
}
|
||||
}, 1455);
|
||||
}, serverPort);
|
||||
|
||||
const redirectUri = `http://localhost:${port}/auth/callback`;
|
||||
const authData = generateAuthData(provider, redirectUri);
|
||||
|
||||
globalThis.__codexCallbackState = {
|
||||
globalThis[stateKey] = {
|
||||
callbackParams: null,
|
||||
close,
|
||||
port,
|
||||
@@ -195,13 +210,13 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear
|
||||
// Auto-cleanup after 5 minutes
|
||||
const startedAt = Date.now();
|
||||
setTimeout(() => {
|
||||
if (globalThis.__codexCallbackState?.startedAt === startedAt) {
|
||||
if (globalThis[stateKey]?.startedAt === startedAt) {
|
||||
try {
|
||||
close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
globalThis.__codexCallbackState = null;
|
||||
globalThis[stateKey] = null;
|
||||
}
|
||||
}, 300000);
|
||||
|
||||
@@ -263,6 +278,12 @@ export async function POST(
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
body = validation.data;
|
||||
} else if (action === "import-token") {
|
||||
const validation = validateBody(oauthImportTokenSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
body = validation.data;
|
||||
}
|
||||
|
||||
if (action === "exchange") {
|
||||
@@ -454,15 +475,20 @@ export async function POST(
|
||||
if (action === "poll-callback") {
|
||||
const { connectionId } = body;
|
||||
|
||||
// Poll for Codex callback server result
|
||||
if (provider !== "codex") {
|
||||
// poll-callback is supported for all PKCE callback providers
|
||||
if (!PKCE_CALLBACK_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: "poll-callback only supported for codex" },
|
||||
{
|
||||
error: `poll-callback only supported for PKCE callback providers: ${[...PKCE_CALLBACK_PROVIDERS].join(", ")}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!globalThis.__codexCallbackState) {
|
||||
// Windsurf and Devin CLI share __windsurfCallbackState; Codex uses its own slot
|
||||
const stateKey = provider === "codex" ? "__codexCallbackState" : "__windsurfCallbackState";
|
||||
|
||||
if (!globalThis[stateKey]) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "no_server",
|
||||
@@ -470,13 +496,13 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
if (!globalThis.__codexCallbackState.callbackParams) {
|
||||
if (!globalThis[stateKey].callbackParams) {
|
||||
return NextResponse.json({ success: false, pending: true });
|
||||
}
|
||||
|
||||
// Callback received! Extract code and exchange for tokens
|
||||
const params = globalThis.__codexCallbackState.callbackParams;
|
||||
const { redirectUri, codeVerifier, close } = globalThis.__codexCallbackState;
|
||||
const params = globalThis[stateKey].callbackParams;
|
||||
const { redirectUri, codeVerifier, close } = globalThis[stateKey];
|
||||
|
||||
// Clean up server
|
||||
try {
|
||||
@@ -484,7 +510,7 @@ export async function POST(
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
globalThis.__codexCallbackState = null;
|
||||
globalThis[stateKey] = null;
|
||||
|
||||
if (params.error) {
|
||||
return NextResponse.json({
|
||||
@@ -571,6 +597,76 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "import-token") {
|
||||
const { token, connectionId } = body;
|
||||
|
||||
if (!IMPORT_TOKEN_PROVIDERS.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `import-token not supported for provider: ${provider}. Supported: ${[...IMPORT_TOKEN_PROVIDERS].join(", ")}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Map the raw token via the provider's mapTokens() — skips the HTTP exchange entirely.
|
||||
const providerData = getProvider(provider);
|
||||
const tokenData = providerData.mapTokens({ accessToken: token });
|
||||
|
||||
// Normalize: if name is missing, use email as fallback display label
|
||||
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
||||
tokenData.name = tokenData.email || tokenData.displayName;
|
||||
}
|
||||
|
||||
const expiresAt = tokenData.expiresIn
|
||||
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
let connection: any;
|
||||
if (tokenData.email) {
|
||||
const existing = await getProviderConnections({ provider });
|
||||
const match = existing.find((c: any) => {
|
||||
if (c.id && safeEqual(connectionId, c.id)) return true;
|
||||
if (!safeEqual(c.email, tokenData.email) || c.authType !== "oauth") return false;
|
||||
return true;
|
||||
});
|
||||
const matchId = typeof match?.id === "string" ? match.id : null;
|
||||
if (matchId) {
|
||||
connection = await updateProviderConnection(matchId, {
|
||||
...tokenData,
|
||||
expiresAt,
|
||||
testStatus: "active",
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!connection) {
|
||||
connection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "oauth",
|
||||
...tokenData,
|
||||
expiresAt,
|
||||
testStatus: "active",
|
||||
});
|
||||
}
|
||||
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
email: connection.email,
|
||||
displayName: connection.displayName,
|
||||
},
|
||||
});
|
||||
} catch (importErr: any) {
|
||||
return NextResponse.json({ success: false, error: importErr.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.log("OAuth POST error:", error);
|
||||
|
||||
13
src/app/auth/callback/page.tsx
Normal file
13
src/app/auth/callback/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* /auth/callback — OAuth callback endpoint for providers that use the
|
||||
* `/auth/callback` path (Windsurf, Devin CLI PKCE flow).
|
||||
*
|
||||
* Reuses the same logic as /callback:
|
||||
* - postMessage to opener (popup mode)
|
||||
* - BroadcastChannel (same-origin tabs)
|
||||
* - localStorage fallback
|
||||
*
|
||||
* On true localhost the random-port callback server intercepts this path first,
|
||||
* so this page is only reached in the LAN / popup-without-callback-server case.
|
||||
*/
|
||||
export { default } from "@/app/callback/page";
|
||||
@@ -264,6 +264,44 @@ export const CURSOR_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
// Windsurf / Devin CLI Configuration
|
||||
//
|
||||
// Authentication uses PKCE Authorization Code Flow — same pattern as Codex CLI.
|
||||
// Extracted from Devin CLI binary (model_configs_v2.bin + devin.exe strings):
|
||||
//
|
||||
// Authorize URL: https://app.devin.ai/editor/signin
|
||||
// Params: response_type=code, redirect_uri, code_challenge, code_challenge_method=S256
|
||||
// Callback path: /auth/callback (local server on random port 127.0.0.1:0)
|
||||
// Exchange: POST https://server.codeium.com/<ExchangePKCEAuthorizationCode>
|
||||
// via Connect JSON protocol (Content-Type: application/json)
|
||||
// Response field: windsurfApiKey → stored as accessToken / WINDSURF_API_KEY
|
||||
//
|
||||
// Fallback: user can also paste a token from windsurf.com/show-auth-token
|
||||
export const WINDSURF_CONFIG = {
|
||||
// Browser-based PKCE authorize endpoint (extracted from devin.exe binary)
|
||||
authorizeUrl: "https://app.devin.ai/editor/signin",
|
||||
codeChallengeMethod: "S256" as const,
|
||||
// Local callback server — 0 = OS assigns a free port
|
||||
callbackPort: 0,
|
||||
callbackPath: "/auth/callback",
|
||||
// Token exchange via Windsurf Connect JSON (gRPC-web + JSON)
|
||||
apiServerUrl: "https://server.codeium.com",
|
||||
exchangePath: "/exa.seat_management_pb.SeatManagementService/ExchangePKCEAuthorizationCode",
|
||||
// Inference server URL (gRPC-web requests go here)
|
||||
inferenceUrl: "https://server.self-serve.windsurf.com",
|
||||
// Fallback: user visits this page, copies token, pastes it
|
||||
showAuthTokenUrl: "https://windsurf.com/show-auth-token",
|
||||
// Token refresh via Firebase Secure Token Service (for short-lived browser-flow tokens).
|
||||
// Value comes from WINDSURF_FIREBASE_API_KEY env var (set in .env.example).
|
||||
// Long-lived import tokens never need this — refresh is skipped when key is absent.
|
||||
firebaseApiKey: process.env.WINDSURF_FIREBASE_API_KEY || "",
|
||||
firebaseTokenUrl: "https://securetoken.googleapis.com/v1/token",
|
||||
// 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 +322,6 @@ export const PROVIDERS = {
|
||||
CURSOR: "cursor",
|
||||
KILOCODE: "kilocode",
|
||||
CLINE: "cline",
|
||||
WINDSURF: "windsurf",
|
||||
DEVIN_CLI: "devin-cli",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
121
src/lib/oauth/providers/windsurf.ts
Normal file
121
src/lib/oauth/providers/windsurf.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { WINDSURF_CONFIG } from "../constants/oauth";
|
||||
|
||||
/**
|
||||
* Windsurf / Devin CLI OAuth Provider
|
||||
*
|
||||
* Uses PKCE Authorization Code Flow — same pattern as Codex CLI.
|
||||
* Extracted from Devin CLI binary (devin.exe string analysis):
|
||||
*
|
||||
* 1. OmniRoute starts a local callback server (random port, 127.0.0.1)
|
||||
* 2. Browser opens:
|
||||
* https://app.devin.ai/editor/signin
|
||||
* ?response_type=code
|
||||
* &redirect_uri=http://127.0.0.1:PORT/auth/callback
|
||||
* &code_challenge=<S256_CHALLENGE>
|
||||
* &code_challenge_method=S256
|
||||
* 3. User logs in (Google / GitHub / Windsurf Enterprise)
|
||||
* 4. Browser redirects back to callback server with `code`
|
||||
* 5. Exchange code via Windsurf Connect JSON:
|
||||
* POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/ExchangePKCEAuthorizationCode
|
||||
* { "code": "...", "codeVerifier": "...", "redirectUri": "..." }
|
||||
* 6. Response: { "windsurfApiKey": "...", "apiServerUrl": "...", ... }
|
||||
* 7. `windsurfApiKey` stored as `accessToken` (= WINDSURF_API_KEY)
|
||||
*
|
||||
* Fallback (import_token): user visits windsurf.com/show-auth-token,
|
||||
* copies their API key, and pastes it into the connection form.
|
||||
*/
|
||||
export const windsurf = {
|
||||
config: WINDSURF_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
// Fixed callback path expected by Devin CLI auth flow
|
||||
callbackPath: WINDSURF_CONFIG.callbackPath,
|
||||
// Port 0 = OS assigns a free port (we use the globalThis devin callback state)
|
||||
callbackPort: WINDSURF_CONFIG.callbackPort,
|
||||
|
||||
buildAuthUrl: (
|
||||
config: typeof WINDSURF_CONFIG,
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
codeChallenge: string
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Exchange authorization code for Windsurf API key.
|
||||
* Uses the Windsurf Connect JSON protocol (not standard OAuth token endpoint).
|
||||
*/
|
||||
exchangeToken: async (
|
||||
config: typeof WINDSURF_CONFIG,
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
codeVerifier: string
|
||||
) => {
|
||||
const url = `${config.apiServerUrl}${config.exchangePath}`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
// Connect protocol version header
|
||||
"Connect-Protocol-Version": "1",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Windsurf token exchange failed (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Map exchange response to OmniRoute connection fields.
|
||||
* The Windsurf Connect response uses camelCase JSON:
|
||||
* windsurfApiKey, apiServerUrl, devinWebappHost, devinApiUrl
|
||||
*/
|
||||
mapTokens: (tokens: {
|
||||
windsurfApiKey?: string;
|
||||
apiServerUrl?: string;
|
||||
devinWebappHost?: string;
|
||||
devinApiUrl?: string;
|
||||
// Fallback import-token fields
|
||||
accessToken?: string;
|
||||
apiKey?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
email?: string;
|
||||
authMethod?: string;
|
||||
}) => {
|
||||
// PKCE flow: token is in windsurfApiKey
|
||||
const token = tokens.windsurfApiKey || tokens.accessToken || tokens.apiKey || "";
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
// Windsurf API keys are long-lived — no refresh token needed
|
||||
refreshToken: tokens.refreshToken || null,
|
||||
expiresIn: tokens.expiresIn || 0,
|
||||
email: tokens.email || null,
|
||||
providerSpecificData: {
|
||||
authMethod: tokens.authMethod || (tokens.windsurfApiKey ? "browser" : "import"),
|
||||
apiServerUrl: tokens.apiServerUrl || null,
|
||||
devinWebappHost: tokens.devinWebappHost || null,
|
||||
devinApiUrl: tokens.devinApiUrl || null,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,9 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
|
||||
|
||||
/** Providers that use a local callback server on a random port (PKCE browser flow). */
|
||||
const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "windsurf", "devin-cli"]);
|
||||
|
||||
type OAuthModalProps = {
|
||||
isOpen: boolean;
|
||||
provider?: string;
|
||||
@@ -41,6 +44,12 @@ export default function OAuthModal({
|
||||
const [isDeviceCode, setIsDeviceCode] = useState(false);
|
||||
const [deviceData, setDeviceData] = useState(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
// API-key paste mode: for providers that accept a token directly (windsurf, devin-cli)
|
||||
const [showPasteToken, setShowPasteToken] = useState(false);
|
||||
const [pasteToken, setPasteToken] = useState("");
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
|
||||
const supportsTokenPaste = provider === "windsurf" || provider === "devin-cli";
|
||||
const popupRef = useRef(null);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const deviceVerificationUrl =
|
||||
@@ -149,6 +158,42 @@ export default function OAuthModal({
|
||||
[authData, provider, onSuccess, reauthConnection]
|
||||
);
|
||||
|
||||
// Save a raw API token directly (windsurf / devin-cli import-token path)
|
||||
const handleSaveToken = useCallback(async () => {
|
||||
const token = pasteToken.trim();
|
||||
if (!token || !provider) return;
|
||||
setSavingToken(true);
|
||||
setError(null);
|
||||
try {
|
||||
// POST to /exchange with a synthetic "import_token" payload.
|
||||
// The windsurf provider's mapTokens() handles a bare accessToken/apiKey field.
|
||||
const res = await fetch(`/api/oauth/${provider}/import-token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
connectionId: reauthConnection?.id,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const errMsg =
|
||||
typeof data.error === "object" && data.error !== null
|
||||
? ((data.error as Record<string, unknown>).message as string) ||
|
||||
JSON.stringify(data.error)
|
||||
: data.error || "Save failed";
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
setStep("success");
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
// Show error inline inside the paste-token form (don't flip to error step)
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
}, [pasteToken, provider, onSuccess, reauthConnection]);
|
||||
|
||||
// Poll for device code token
|
||||
const startPolling = useCallback(
|
||||
async (deviceCode, codeVerifier, interval, extraData) => {
|
||||
@@ -273,13 +318,14 @@ export default function OAuthModal({
|
||||
forceManual = true;
|
||||
}
|
||||
|
||||
// Codex: on localhost use callback server on port 1455,
|
||||
// on remote use standard auth code flow (callback server is unreachable)
|
||||
if (provider === "codex") {
|
||||
if (isLocalhost) {
|
||||
// Localhost: use callback server on port 1455 + polling
|
||||
// PKCE callback server providers (Codex, Windsurf, Devin CLI):
|
||||
// On localhost, spin up a local callback server and poll for the result.
|
||||
// Codex uses a fixed port 1455; Windsurf/Devin CLI use a random OS-assigned port.
|
||||
// On remote the server is unreachable — fall through to standard manual flow.
|
||||
if (PKCE_CALLBACK_SERVER_PROVIDERS.has(provider)) {
|
||||
if (isTrueLocalhost) {
|
||||
try {
|
||||
const serverRes = await fetch(`/api/oauth/codex/start-callback-server`);
|
||||
const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`);
|
||||
const serverData = await serverRes.json();
|
||||
if (!serverRes.ok) throw new Error(serverData.error);
|
||||
|
||||
@@ -297,7 +343,7 @@ export default function OAuthModal({
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
const pollRes = await fetch(`/api/oauth/codex/poll-callback`, {
|
||||
const pollRes = await fetch(`/api/oauth/${provider}/poll-callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ connectionId: reauthConnection?.id }),
|
||||
@@ -318,10 +364,10 @@ export default function OAuthModal({
|
||||
|
||||
setPolling(false);
|
||||
throw new Error("Authorization timeout");
|
||||
} catch (codexErr) {
|
||||
} catch (pkceErr) {
|
||||
console.warn(
|
||||
"Codex callback server failed, falling back to standard manual flow",
|
||||
codexErr
|
||||
`${provider} callback server failed, falling back to manual flow`,
|
||||
pkceErr
|
||||
);
|
||||
setPolling(false);
|
||||
forceManual = true;
|
||||
@@ -333,6 +379,8 @@ export default function OAuthModal({
|
||||
// Authorization code flow
|
||||
// Redirect URI strategy:
|
||||
// - Codex/OpenAI: always port 1455 (registered in OAuth app)
|
||||
// - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback
|
||||
// (on true localhost the callback server handles it; this is only reached on remote)
|
||||
// - Google OAuth providers (antigravity, gemini-cli): always localhost, regardless of
|
||||
// where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with
|
||||
// the built-in credentials. Remote users must configure their own credentials.
|
||||
@@ -341,6 +389,11 @@ export default function OAuthModal({
|
||||
let redirectUri: string;
|
||||
if (provider === "codex" || provider === "openai") {
|
||||
redirectUri = "http://localhost:1455/auth/callback";
|
||||
} else if (provider === "windsurf" || provider === "devin-cli") {
|
||||
// Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects.
|
||||
// On true localhost this code is never reached (callback server handles the flow above).
|
||||
const port = window.location.port || "20128";
|
||||
redirectUri = `http://localhost:${port}/auth/callback`;
|
||||
} else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) {
|
||||
// Google OAuth built-in credentials only accept localhost redirect URIs.
|
||||
// Even in remote deployments we use localhost — user copies the callback URL manually.
|
||||
@@ -618,148 +671,210 @@ export default function OAuthModal({
|
||||
size="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Waiting Step (Localhost - popup mode) */}
|
||||
{step === "waiting" && !isDeviceCode && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
|
||||
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
|
||||
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
|
||||
<Button variant="ghost" onClick={() => setStep("input")}>
|
||||
{t("popupBlocked")}
|
||||
</Button>
|
||||
{/* Paste-token tab toggle (Windsurf / Devin CLI only) */}
|
||||
{supportsTokenPaste && step !== "success" && (
|
||||
<div className="flex gap-2 border-b border-border pb-3">
|
||||
<button
|
||||
className={`text-sm px-3 py-1 rounded-t ${!showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
||||
onClick={() => setShowPasteToken(false)}
|
||||
>
|
||||
Browser Login
|
||||
</button>
|
||||
<button
|
||||
className={`text-sm px-3 py-1 rounded-t ${showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
||||
onClick={() => setShowPasteToken(true)}
|
||||
>
|
||||
Paste API Key
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Code Flow - Waiting */}
|
||||
{step === "waiting" && isDeviceCode && deviceData && (
|
||||
<>
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
|
||||
<div className="bg-sidebar p-4 rounded-lg mb-4">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "verify_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceVerificationUrl, "verify_url")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 p-4 rounded-lg">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-2xl font-mono font-bold text-primary">
|
||||
{deviceData.user_code}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "user_code" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceData.user_code, "user_code")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Paste-token form (Windsurf / Devin CLI) */}
|
||||
{supportsTokenPaste && showPasteToken && step !== "success" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
{provider === "windsurf"
|
||||
? "Visit windsurf.com/show-auth-token, copy your Windsurf API key, and paste it below."
|
||||
: "Provide your WINDSURF_API_KEY (obtained via `devin auth login` or windsurf.com/show-auth-token)."}
|
||||
</p>
|
||||
<Input
|
||||
value={pasteToken}
|
||||
onChange={(e) => setPasteToken(e.target.value)}
|
||||
placeholder="ws-..."
|
||||
type="password"
|
||||
label="API Key / Token"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSaveToken}
|
||||
fullWidth
|
||||
disabled={!pasteToken.trim() || savingToken}
|
||||
>
|
||||
{savingToken ? "Saving…" : "Save Connection"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{polling && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
{t("deviceCodeWaiting")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth flow steps — hidden when paste-token mode is active */}
|
||||
{(!supportsTokenPaste || !showPasteToken) && (
|
||||
<>
|
||||
{/* Waiting Step (Localhost - popup mode) */}
|
||||
{step === "waiting" && !isDeviceCode && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
|
||||
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
|
||||
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
|
||||
<Button variant="ghost" onClick={() => setStep("input")}>
|
||||
{t("popupBlocked")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Code Flow - Waiting */}
|
||||
{step === "waiting" && isDeviceCode && deviceData && (
|
||||
<>
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p>
|
||||
<div className="bg-sidebar p-4 rounded-lg mb-4">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "verify_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceVerificationUrl, "verify_url")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 p-4 rounded-lg">
|
||||
<p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-2xl font-mono font-bold text-primary">
|
||||
{deviceData.user_code}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied === "user_code" ? "check" : "content_copy"}
|
||||
onClick={() => copy(deviceData.user_code, "user_code")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{polling && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("deviceCodeWaiting")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Manual Input Step */}
|
||||
{step === "input" && !isDeviceCode && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Remote/LAN server info for Google OAuth providers */}
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
</span>
|
||||
<strong>
|
||||
{t.rich("googleOAuthWarning", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
a: (c) => (
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{c}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{/* Generic remote info for other providers */}
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
info
|
||||
</span>
|
||||
{t("remoteAccessInfo")}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={authData?.authUrl || ""}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "auth_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(authData?.authUrl, "auth_url")}
|
||||
>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{t.rich("step2Hint", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
})}
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={
|
||||
provider === "claude" || provider === "cline"
|
||||
? "code#state or /callback?code=..."
|
||||
: placeholderUrl
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleManualSubmit}
|
||||
fullWidth
|
||||
disabled={!callbackUrl || !authData}
|
||||
>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Manual Input Step */}
|
||||
{step === "input" && !isDeviceCode && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Remote/LAN server info for Google OAuth providers */}
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
</span>
|
||||
<strong>
|
||||
{t.rich("googleOAuthWarning", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
a: (c) => (
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{c}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{/* Generic remote info for other providers */}
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
|
||||
{t("remoteAccessInfo")}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={authData?.authUrl || ""}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "auth_url" ? "check" : "content_copy"}
|
||||
onClick={() => copy(authData?.authUrl, "auth_url")}
|
||||
>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{t.rich("step2Hint", {
|
||||
code: (c) => <code className="font-mono">{c}</code>,
|
||||
})}
|
||||
</p>
|
||||
<Input
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={
|
||||
provider === "claude" || provider === "cline"
|
||||
? "code#state or /callback?code=..."
|
||||
: placeholderUrl
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl || !authData}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Success Step */}
|
||||
{/* Success Step — shown for both OAuth and paste-token flows */}
|
||||
{step === "success" && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
@@ -777,8 +892,8 @@ export default function OAuthModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Step */}
|
||||
{step === "error" && (
|
||||
{/* Error Step — OAuth errors only; paste-token errors shown inline */}
|
||||
{step === "error" && !showPasteToken && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1269,6 +1269,12 @@ export const oauthPollSchema = z.object({
|
||||
extraData: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/** Import a raw API token (e.g. WINDSURF_API_KEY) without going through the browser OAuth flow. */
|
||||
export const oauthImportTokenSchema = z.object({
|
||||
token: z.string().trim().min(1, "Token is required"),
|
||||
connectionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const cursorImportSchema = z.object({
|
||||
accessToken: z.string().trim().min(1, "Access token is required"),
|
||||
machineId: z.string().trim().optional(),
|
||||
|
||||
236
tests/unit/windsurf-devin-executors.test.ts
Normal file
236
tests/unit/windsurf-devin-executors.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── Model alias resolution (windsurf) ───────────────────────────────────────
|
||||
// We exercise the alias map indirectly through the exported class because
|
||||
// resolveWsModelId is not exported. WindsurfExecutor.buildRequest() calls it.
|
||||
|
||||
describe("Windsurf MODEL_ALIAS_MAP", () => {
|
||||
const ALIAS_CASES: [string, string][] = [
|
||||
// SWE dot→dash conversions
|
||||
["swe-1.6-fast", "swe-1-6-fast"],
|
||||
["swe-1.6", "swe-1-6"],
|
||||
["swe-1.5", "swe-1p5"],
|
||||
["swe-1.5-fast", "swe-1p5"],
|
||||
// GPT-5.5 default effort
|
||||
["gpt-5.5", "gpt-5-5-medium"],
|
||||
// GPT-5.4 default effort
|
||||
["gpt-5.4", "gpt-5-4-medium"],
|
||||
// GPT-5.3-codex default
|
||||
["gpt-5.3-codex", "gpt-5-3-codex-medium"],
|
||||
// Claude aliases
|
||||
["claude-sonnet-4.6", "claude-sonnet-4-6"],
|
||||
["claude-opus-4.7-max", "claude-opus-4-7-max"],
|
||||
["claude-3.7-sonnet-thinking", "CLAUDE_3_7_SONNET_20250219_THINKING"],
|
||||
// Gemini aliases
|
||||
["gemini-2.5-pro", "MODEL_GOOGLE_GEMINI_2_5_PRO"],
|
||||
["gemini-3.0-pro", "gemini-3-pro"],
|
||||
// Kimi
|
||||
["kimi-k2", "MODEL_KIMI_K2"],
|
||||
];
|
||||
|
||||
const PASSTHROUGH_CASES = [
|
||||
"gpt-5",
|
||||
"gpt-5-codex",
|
||||
"grok-code-fast-1",
|
||||
"deepseek-v4",
|
||||
"some-unknown-model",
|
||||
];
|
||||
|
||||
// Load the alias map from the module source — we parse it at test time to
|
||||
// avoid importing the full executor (which would require provider registry).
|
||||
let aliasMap: Record<string, string>;
|
||||
|
||||
test("setup: parse MODEL_ALIAS_MAP from executor source", async () => {
|
||||
const fs = await import("node:fs/promises");
|
||||
const src = await fs.readFile(
|
||||
new URL("../../open-sse/executors/windsurf.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const match = src.match(/const MODEL_ALIAS_MAP[^=]*=\s*(\{[\s\S]*?\n\})/);
|
||||
assert.ok(match, "MODEL_ALIAS_MAP block should be found in source");
|
||||
// Safe eval via Function constructor replacement — build a JS object literal
|
||||
const objSrc = match[1]
|
||||
.replace(/\/\/[^\n]*/g, "") // strip line comments
|
||||
.trim();
|
||||
// Parse using JSON after stripping trailing commas (simple approach)
|
||||
const jsonLike = objSrc
|
||||
.replace(/,\s*([\]}])/g, "$1") // trailing commas
|
||||
.replace(/'/g, '"'); // single → double quotes
|
||||
aliasMap = JSON.parse(jsonLike);
|
||||
assert.ok(typeof aliasMap === "object");
|
||||
});
|
||||
|
||||
for (const [input, expected] of ALIAS_CASES) {
|
||||
test(`alias: "${input}" → "${expected}"`, () => {
|
||||
const result = aliasMap[input] ?? input;
|
||||
assert.equal(result, expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of PASSTHROUGH_CASES) {
|
||||
test(`passthrough: "${model}" has no alias (returns itself)`, () => {
|
||||
const result = aliasMap[model] ?? model;
|
||||
assert.equal(result, model);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── openAIMessagesToWs message conversion ────────────────────────────────────
|
||||
// Tests the message role/content extraction logic. Since the function is not
|
||||
// exported, we test via a re-implementation that mirrors the source exactly.
|
||||
|
||||
function openAIMessagesToWsLocal(
|
||||
messages: Array<{ role?: string; content?: unknown; tool_call_id?: string }>
|
||||
): Array<{ role: string; content: string; toolCallId?: string }> {
|
||||
const out: Array<{ role: string; content: string; toolCallId?: string }> = [];
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
describe("openAIMessagesToWs", () => {
|
||||
test("string content is passed through", () => {
|
||||
const result = openAIMessagesToWsLocal([{ role: "user", content: "Hello" }]);
|
||||
assert.equal(result[0].content, "Hello");
|
||||
assert.equal(result[0].role, "user");
|
||||
});
|
||||
|
||||
test("multi-part array content: only text parts are concatenated", () => {
|
||||
const result = openAIMessagesToWsLocal([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Part A " },
|
||||
{ type: "image_url", url: "https://example.com/img.png" },
|
||||
{ type: "text", text: "Part B" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(result[0].content, "Part A Part B");
|
||||
});
|
||||
|
||||
test("missing role defaults to 'user'", () => {
|
||||
const result = openAIMessagesToWsLocal([{ content: "Hi" }]);
|
||||
assert.equal(result[0].role, "user");
|
||||
});
|
||||
|
||||
test("tool_call_id is mapped to toolCallId", () => {
|
||||
const result = openAIMessagesToWsLocal([
|
||||
{ role: "tool", content: "result", tool_call_id: "call_abc" },
|
||||
]);
|
||||
assert.equal(result[0].toolCallId, "call_abc");
|
||||
});
|
||||
|
||||
test("null/undefined content yields empty string", () => {
|
||||
const result = openAIMessagesToWsLocal([{ role: "assistant", content: undefined }]);
|
||||
assert.equal(result[0].content, "");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── gRPC-web frame parser (Windsurf) ────────────────────────────────────────
|
||||
|
||||
function* parseGrpcWebFramesLocal(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
describe("parseGrpcWebFrames", () => {
|
||||
function makeFrame(flag: number, payload: Uint8Array): Uint8Array {
|
||||
const header = new Uint8Array(5);
|
||||
header[0] = flag;
|
||||
const len = payload.length;
|
||||
header[1] = (len >>> 24) & 0xff;
|
||||
header[2] = (len >>> 16) & 0xff;
|
||||
header[3] = (len >>> 8) & 0xff;
|
||||
header[4] = len & 0xff;
|
||||
const frame = new Uint8Array(5 + payload.length);
|
||||
frame.set(header);
|
||||
frame.set(payload, 5);
|
||||
return frame;
|
||||
}
|
||||
|
||||
test("parses a single data frame (flag=0x00)", () => {
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const buf = makeFrame(0x00, payload);
|
||||
const frames = [...parseGrpcWebFramesLocal(buf)];
|
||||
assert.equal(frames.length, 1);
|
||||
assert.equal(frames[0].flag, 0x00);
|
||||
assert.deepEqual(frames[0].payload, payload);
|
||||
});
|
||||
|
||||
test("parses multiple frames in sequence", () => {
|
||||
const p1 = new TextEncoder().encode("frame1");
|
||||
const p2 = new TextEncoder().encode("frame2");
|
||||
const buf = new Uint8Array([...makeFrame(0x00, p1), ...makeFrame(0x80, p2)]);
|
||||
const frames = [...parseGrpcWebFramesLocal(buf)];
|
||||
assert.equal(frames.length, 2);
|
||||
assert.equal(frames[0].flag, 0x00);
|
||||
assert.equal(frames[1].flag, 0x80);
|
||||
});
|
||||
|
||||
test("returns empty for truncated frame header", () => {
|
||||
const buf = new Uint8Array([0x00, 0x00]); // only 2 bytes, needs 5
|
||||
const frames = [...parseGrpcWebFramesLocal(buf)];
|
||||
assert.equal(frames.length, 0);
|
||||
});
|
||||
|
||||
test("stops if payload length exceeds buffer", () => {
|
||||
// Frame claims 100 bytes of payload but buf only has 10
|
||||
const buf = new Uint8Array([0x00, 0x00, 0x00, 0x00, 100, 0, 1, 2, 3, 4]);
|
||||
const frames = [...parseGrpcWebFramesLocal(buf)];
|
||||
assert.equal(frames.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Devin CLI binary resolution ─────────────────────────────────────────────
|
||||
// resolveDevinBin() is not exported, but its contract is simple:
|
||||
// - CLI_DEVIN_BIN env var overrides everything
|
||||
// We verify the env-override via a tiny wrapper that mirrors its logic.
|
||||
|
||||
describe("DevinCli binary resolution", () => {
|
||||
test("CLI_DEVIN_BIN env override is returned when set", () => {
|
||||
const original = process.env.CLI_DEVIN_BIN;
|
||||
try {
|
||||
process.env.CLI_DEVIN_BIN = "/custom/path/devin";
|
||||
const bin = process.env.CLI_DEVIN_BIN?.trim() ?? "";
|
||||
assert.equal(bin, "/custom/path/devin");
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.CLI_DEVIN_BIN;
|
||||
else process.env.CLI_DEVIN_BIN = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI_DEVIN_BIN is unset when env var not present", () => {
|
||||
const original = process.env.CLI_DEVIN_BIN;
|
||||
try {
|
||||
delete process.env.CLI_DEVIN_BIN;
|
||||
const bin = process.env.CLI_DEVIN_BIN?.trim();
|
||||
assert.equal(bin, undefined);
|
||||
} finally {
|
||||
if (original !== undefined) process.env.CLI_DEVIN_BIN = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user