diff --git a/open-sse/executors/codebuddy-intl.js b/open-sse/executors/codebuddy-intl.js new file mode 100644 index 00000000..06fe4326 --- /dev/null +++ b/open-sse/executors/codebuddy-intl.js @@ -0,0 +1,30 @@ +import { DefaultExecutor } from "./default.js"; + +/** + * CodeBuddyIntlExecutor — talks to https://www.codebuddy.ai/v2/chat/completions + * + * Same OpenAI-compatible-but-stream-only gateway behavior as codebuddy-cn: + * non-stream requests are rejected, and reasoning is surfaced only when the + * request carries the IDE's OpenAI-style reasoning params. Force stream and + * mirror reasoning_summary exactly like CodeBuddyExecutor. + */ +export class CodeBuddyIntlExecutor extends DefaultExecutor { + constructor() { + super("codebuddy-intl"); + } + + transformRequest(model, body, stream, credentials) { + const transformed = super.transformRequest(model, body, stream, credentials); + transformed.stream = true; + + const eff = transformed.reasoning_effort; + if (eff === "none" || eff === "off") { + delete transformed.reasoning_effort; + } else if (eff) { + transformed.reasoning_summary = "auto"; + } + return transformed; + } +} + +export default CodeBuddyIntlExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index b6091b9d..c9340c10 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -20,6 +20,11 @@ import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; +import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +import { WorkBuddyExecutor } from "./workbuddy.js"; +import TraeExecutor from "./trae.js"; +import ZedExecutor from "./zed.js"; +import WindsurfExecutor from "./windsurf.js"; import { DefaultExecutor } from "./default.js"; const executors = { @@ -50,6 +55,11 @@ const executors = { "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free "codebuddy-cn": new CodeBuddyExecutor(), + "codebuddy-intl": new CodeBuddyIntlExecutor(), + workbuddy: new WorkBuddyExecutor(), + trae: new TraeExecutor(), + zed: new ZedExecutor(), + windsurf: new WindsurfExecutor(), }; const defaultCache = new Map(); @@ -88,3 +98,8 @@ export { CommandCodeExecutor } from "./commandcode.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; +export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +export { WorkBuddyExecutor } from "./workbuddy.js"; +export { default as TraeExecutor } from "./trae.js"; +export { default as ZedExecutor } from "./zed.js"; +export { default as WindsurfExecutor } from "./windsurf.js"; diff --git a/open-sse/executors/trae.js b/open-sse/executors/trae.js new file mode 100644 index 00000000..347d5307 --- /dev/null +++ b/open-sse/executors/trae.js @@ -0,0 +1,22 @@ +import { DefaultExecutor } from "./default.js"; + +// Trae executor — inject x-cloudide-token (raw access token) + Authorization Bearer. +// Mirrors trae_account.rs request_trae_json header set. +export default class TraeExecutor extends DefaultExecutor { + constructor() { + super("trae"); + } + + buildHeaders(credentials, stream = true) { + const headers = super.buildHeaders(credentials, stream); + const token = credentials?.accessToken; + if (token) { + // Raw token (no Bearer prefix) on x-cloudide-token — matches official client. + headers["x-cloudide-token"] = token; + headers["Authorization"] = `Bearer ${token}`; + } + return headers; + } + + // TODO verify: if Chat is JSON-RPC shaped, override transformRequest here. +} diff --git a/open-sse/executors/windsurf.js b/open-sse/executors/windsurf.js new file mode 100644 index 00000000..d9b19ec3 --- /dev/null +++ b/open-sse/executors/windsurf.js @@ -0,0 +1,42 @@ +import { DefaultExecutor } from "./default.js"; + +// Windsurf chat = Codeium binary protobuf gRPC-Web. +// The .proto schema for exa.server_pb.ServerService is NOT in either source +// repo, so request/response encode+decode cannot be implemented truthfully. +// Auth, headers, quota are wired; the chat payload is intentionally a hard +// failure rather than a fabricated protobuf body. +export class WindsurfExecutor extends DefaultExecutor { + constructor() { + super("windsurf"); + } + + buildHeaders(credentials, stream = true) { + const headers = { + "Content-Type": "application/proto", + "Connect-Protocol-Version": "1", + ideName: "Windsurf", + extensionName: "codeium.windsurf", + ...(this.config.headers || {}), + }; + // apiKey from RegisterUser (sk-ws-..., Firebase-derived, or Devin ide_token). + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers["Authorization"] = `Bearer ${token}`; + return headers; + } + + // TODO(proto): implement once Codeium server_pb .proto is recovered. + // - encode request: chat history + model + system → protobuf bytes + // - decode response: stream protobuf frames → OpenAI-shaped chunks + async execute() { + throw new Error( + "Windsurf chat (Codeium protobuf) not yet implemented — needs .proto schema. Auth/quota wired." + ); + } + + async refreshCredentials() { + // Windsurf apiKey is long-lived (like cursor); refresh handled out-of-band. + return null; + } +} + +export default WindsurfExecutor; diff --git a/open-sse/executors/workbuddy.js b/open-sse/executors/workbuddy.js new file mode 100644 index 00000000..b306b18d --- /dev/null +++ b/open-sse/executors/workbuddy.js @@ -0,0 +1,31 @@ +import { DefaultExecutor } from "./default.js"; + +/** + * WorkBuddyExecutor — talks to https://www.codebuddy.cn/v2/chat/completions + * + * WorkBuddy is a B2B/enterprise skin of CodeBuddy CN (same codebuddy.cn + * OpenAI-compatible gateway). Behavior mirrors CodeBuddyExecutor: + * gateway rejects non-stream requests, and reasoning must be surfaced via + * OpenAI-style reasoning_effort + reasoning_summary:"auto" (vendor-native + * thinking shapes are not honored by the unified gateway). + */ +export class WorkBuddyExecutor extends DefaultExecutor { + constructor() { + super("workbuddy"); + } + + transformRequest(model, body, stream, credentials) { + const transformed = super.transformRequest(model, body, stream, credentials); + transformed.stream = true; + + const eff = transformed.reasoning_effort; + if (eff === "none" || eff === "off") { + delete transformed.reasoning_effort; + } else if (eff) { + transformed.reasoning_summary = "auto"; + } + return transformed; + } +} + +export default WorkBuddyExecutor; diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js new file mode 100644 index 00000000..d7ffb401 --- /dev/null +++ b/open-sse/executors/zed.js @@ -0,0 +1,305 @@ +// ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator +// (cloud.zed.dev/completions), a multi-format proxy fronting +// Anthropic/OpenAI/Google/xAI depending on the requested model. +// +// Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line +// response stream (`{"event": }` / `{"status": ...}` / +// `[DONE]`), authenticated with a short-lived LLM bearer token exchanged from +// the RSA-decrypted access_token (see open-sse/shared/zedAuth.js). The +// provider-shaped chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped) +// depending on which upstream Zed fronts for the model — translated back to +// OpenAI Chat Completions by reusing the existing translators. +// +// Ported from OmniRoute open-sse/executors/zed-hosted.ts. Overrides execute() +// entirely (does NOT use DefaultExecutor's pipeline) because the Zed wire +// shape (thread envelope, LLM-token exchange, NDJSON status frames) doesn't +// fit the generic transformRequest/buildUrl contract. + +import { BaseExecutor } from "./base.js"; +import { FORMATS } from "../translator/formats.js"; +import { initState } from "../translator/index.js"; +import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js"; +import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.js"; +import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js"; +import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js"; +import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js"; +import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js"; +import { + ZED_HEADERS, + resolveZedModels, + zedLlmFetch, +} from "../shared/zedAuth.js"; + +const ZED_PROVIDER = { + anthropic: "Anthropic", + openai: "OpenAi", + google: "Google", + xai: "XAi", +}; + +function normalizeZedProvider(value, model) { + const raw = String(value || "").toLowerCase(); + if (raw === "anthropic") return ZED_PROVIDER.anthropic; + if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai; + if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google; + if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai; + + const m = String(model || "").toLowerCase(); + if (m.includes("claude")) return ZED_PROVIDER.anthropic; + if (m.includes("gemini")) return ZED_PROVIDER.google; + if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai; + return ZED_PROVIDER.openai; +} + +function buildProviderRequest(provider, model, body, stream, credentials) { + if (provider === ZED_PROVIDER.anthropic) { + return openaiToClaudeRequest(model, body, true); + } + if (provider === ZED_PROVIDER.google) { + return openaiToGeminiRequest(model, body, true); + } + if (provider === ZED_PROVIDER.openai) { + return openaiToOpenAIResponsesRequest(model, body, true, credentials); + } + // xAI is OpenAI-shaped — forward as-is. + return { ...(body || {}), model, stream: stream !== false }; +} + +function initProviderState(provider, model) { + if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE); + if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI); + if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES); + const state = initState(FORMATS.OPENAI); + state.model = model; + return state; +} + +function convertProviderEvent(provider, event, state) { + if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state); + return event; +} + +function createErrorChunk(model, message) { + return { + id: `chatcmpl-zed-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" }, + ], + }; +} + +function enqueueSseObject(controller, encoder, chunk) { + if (!chunk) return; + const items = Array.isArray(chunk) ? chunk : [chunk]; + for (const item of items) { + if (!item) continue; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`)); + } +} + +function unwrapZedLine(line) { + let text = line.replace(/\r$/, "").trim(); + if (!text) return null; + if (text.startsWith("data:")) text = text.slice(5).trimStart(); + if (text === "[DONE]") return { done: true }; + try { + const parsed = JSON.parse(text); + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) { + return { event: parsed.event }; + } + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) { + return { status: parsed.status }; + } + return { event: parsed }; + } catch { + return null; + } +} + +function normalizeStatus(status) { + if (!status) return null; + if (typeof status === "string") return { type: status }; + if (typeof status === "object") { + const key = Object.keys(status)[0]; + if (key && typeof status[key] === "object") return { type: key, ...status[key] }; + return status; + } + return null; +} + +function wrapZedCompletionStream(response, provider, model) { + if (!response.ok || !response.body) return response; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const state = initProviderState(provider, model); + let buffer = ""; + let done = false; + + const finish = (controller) => { + if (done) return; + const finalChunk = convertProviderEvent(provider, null, state); + enqueueSseObject(controller, encoder, finalChunk); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + done = true; + }; + + const processLine = (line, controller) => { + if (done) return; + const payload = unwrapZedLine(line); + if (!payload) return; + if (payload.done) { + finish(controller); + return; + } + if (payload.status) { + const status = normalizeStatus(payload.status); + if (status?.type === "failed" || status?.failed) { + const failed = status.failed || status; + const message = String(failed.message || failed.error || failed.code || "request failed"); + enqueueSseObject(controller, encoder, createErrorChunk(model, message)); + finish(controller); + } else if (status?.type === "stream_ended" || status === "stream_ended") { + finish(controller); + } + return; + } + const converted = convertProviderEvent(provider, payload.event, state); + enqueueSseObject(controller, encoder, converted); + }; + + const transformed = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + } + }, + flush(controller) { + buffer += decoder.decode(); + if (buffer) { + processLine(buffer, controller); + buffer = ""; + } + finish(controller); + }, + }), + ); + + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }); +} + +class ZedExecutor extends BaseExecutor { + constructor() { + super("zed"); + } + + async resolveModel(model, credentials, signal, log) { + try { + const catalog = await resolveZedModels(credentials, { config: this.config, signal }); + let raw = catalog?.rawById?.get(model) ?? null; + if (!raw) { + const refreshed = await resolveZedModels(credentials, { + config: this.config, + signal, + forceRefresh: true, + }); + raw = refreshed?.rawById?.get(model) ?? null; + } + return { raw, provider: normalizeZedProvider(raw?.provider, model) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`); + return { raw: null, provider: normalizeZedProvider(null, model) }; + } + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const { provider } = await this.resolveModel(model, credentials, signal, log); + const providerRequest = buildProviderRequest(provider, model, body, stream, credentials); + const bodyRecord = body || {}; + const payload = { + thread_id: bodyRecord.thread_id || credentials?._clientSessionId, + prompt_id: bodyRecord.prompt_id, + provider, + model, + provider_request: providerRequest, + }; + + const response = await zedLlmFetch(credentials, "/completions", { + config: this.config, + signal, + fetchOptions: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/x-ndjson, text/event-stream, */*", + "User-Agent": "9router/zed", + "x-zed-version": this.config?.appVersion?.toString() || "0.200.0", + [ZED_HEADERS.clientSupportsStatus]: "true", + [ZED_HEADERS.clientSupportsStreamEnded]: "true", + }, + body: JSON.stringify(payload), + }, + }); + + const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response; + return { + response: wrapped, + url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`, + headers: { "Content-Type": "application/json", Authorization: "Bearer " }, + transformedBody: payload, + }; + } + + parseError(response, bodyText) { + let parsed = null; + try { + parsed = JSON.parse(bodyText || "{}"); + } catch { + parsed = null; + } + + const errorObj = parsed?.error || undefined; + const code = parsed?.code || errorObj?.code || ""; + const rawMessage = + parsed?.message || errorObj?.message || bodyText || response.statusText; + if (code === "trial_blocked") { + return { + status: response.status, + message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`, + }; + } + if (code) { + return { status: response.status, message: `Zed ${code}: ${rawMessage}` }; + } + return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` }; + } + + async refreshCredentials() { + // Zed uses a long-lived RSA-decrypted access_token — no OAuth refresh. + return null; + } + + needsRefresh() { + return false; + } +} + +export default ZedExecutor; diff --git a/open-sse/providers/registry/codebuddy-intl.js b/open-sse/providers/registry/codebuddy-intl.js new file mode 100644 index 00000000..4faaf804 --- /dev/null +++ b/open-sse/providers/registry/codebuddy-intl.js @@ -0,0 +1,74 @@ +// CodeBuddy international (codebuddy.ai) — mirrors codebuddy-cn registry shape, +// swapping the Tencent CN domain for the .ai endpoint set discovered in +// cockpit-tools/src-tauri/src/modules/codebuddy_oauth.rs. All OAuth/plugin URLs +// use the /v2/plugin prefix with platform=ide (CN uses platform=CLI). +export default { + id: "codebuddy-intl", + alias: "cbai", + uiAlias: "cbai", + hidden: false, + priority: 90, + display: { + name: "CodeBuddy", + icon: "smart_toy", + color: "#006EFF", + website: "https://www.codebuddy.ai", + notice: { + signupUrl: "https://www.codebuddy.ai", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + transport: { + // Chat gateway is OpenAI-compatible SSE (same /v2/chat/completions path as CN). + baseUrl: "https://www.codebuddy.ai/v2/chat/completions", + forceStream: true, + // CodeBuddy intl speaks the same unified OpenAI reasoning_effort shape as CN. + thinkingFormat: "openai", + headers: { + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "IDE", + "X-IDE-Name": "IDE", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + // Same model lineup exposed by the CN gateway — intl backend is the same catalog. + models: [ + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "glm-5.1", name: "GLM-5.1" }, + { id: "glm-5.0", name: "GLM-5.0" }, + { id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" }, + { id: "glm-5v-turbo", name: "GLM-5v-Turbo" }, + { id: "glm-4.7", name: "GLM-4.7" }, + { id: "minimax-m3", name: "MiniMax-M3" }, + { id: "minimax-m2.7", name: "MiniMax-M2.7" }, + { id: "kimi-k2.7", name: "Kimi-K2.7-Code" }, + { id: "kimi-k2.6", name: "Kimi-K2.6" }, + { id: "kimi-k2.5", name: "Kimi-K2.5" }, + { id: "hy3-preview", name: "Hy3 Preview" }, + { id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" }, + { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" }, + ], + oauth: { + baseUrl: "https://www.codebuddy.ai", + stateUrl: "https://www.codebuddy.ai/v2/plugin/auth/state", + tokenUrl: "https://www.codebuddy.ai/v2/plugin/auth/token", + refreshUrl: "https://www.codebuddy.ai/v2/plugin/auth/token/refresh", + userAgent: "IDE/2.63.2 CodeBuddy/2.63.2", + platform: "ide", + pollInterval: 5000, + }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index cc1025a1..f083d79d 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -99,6 +99,11 @@ import p96 from "./xiaomi-mimo.js"; import p97 from "./xiaomi-tokenplan.js"; import p98 from "./youcom.js"; import p99 from "./alims-intl.js"; +import p100 from "./codebuddy-intl.js"; +import p101 from "./workbuddy.js"; +import p102 from "./trae.js"; +import p103 from "./zed.js"; +import p104 from "./windsurf.js"; export default [ p0, @@ -201,4 +206,9 @@ export default [ p97, p98, p99, + p100, + p101, + p102, + p103, + p104, ]; diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js index 4ee2b52f..abcc873b 100644 --- a/open-sse/providers/registry/qoder.js +++ b/open-sse/providers/registry/qoder.js @@ -25,18 +25,22 @@ export default { }, }, models: [ - // { id: "auto", name: "Qoder Auto" }, - // { id: "ultimate", name: "Qoder Ultimate" }, - // { id: "performance", name: "Qoder Performance" }, - // { id: "efficient", name: "Qoder Efficient" }, - // { id: "lite", name: "Qoder Lite" }, - // { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" }, - { id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" }, - // { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" }, - // { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" }, - // { id: "gm51model", name: "GLM 5.1 (Qoder)" }, - // { id: "kmodel", name: "Kimi K2.6 (Qoder)" }, - // { id: "mmodel", name: "MiniMax M2.7 (Qoder)" }, + { id: "qoder-rome-30ba3b", name: "Qoder ROME" }, + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "minimax-m3", name: "MiniMax M3" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, + { id: "qwen3-max", name: "Qwen3 Max" }, + { id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" }, + { id: "kimi-k2-0905", name: "Kimi K2 0905" }, + { id: "qwen3-max-preview", name: "Qwen3 Max Preview" }, + { id: "kimi-k2", name: "Kimi K2" }, + { id: "deepseek-v3.2", name: "DeepSeek-V3.2-Exp" }, + { id: "deepseek-r1", name: "DeepSeek R1" }, + { id: "deepseek-v3", name: "DeepSeek V3" }, + { id: "qwen3-32b", name: "Qwen3 32B" }, + { id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" }, + { id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" }, + { id: "qwen3-235b", name: "Qwen3 235B" }, ], oauth: { openApiBaseUrl: "https://openapi.qoder.sh", diff --git a/open-sse/providers/registry/trae.js b/open-sse/providers/registry/trae.js new file mode 100644 index 00000000..d99ace61 --- /dev/null +++ b/open-sse/providers/registry/trae.js @@ -0,0 +1,77 @@ +// Trae (ByteDance marscode) provider registry entry. +// Auth + exchange URLs verified from cockpit-tools/src-tauri/src/modules/trae_oauth.rs. +// Region origins verified from trae_account.rs lines 63-66. +// Chat endpoint path /cloudide/api/v3/trae/Chat is GUESSED (TODO verify upstream). +export default { + id: "trae", + alias: "tr", + uiAlias: "tr", + aliases: ["marscode"], + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth"], + display: { + name: "Trae", + icon: "bolt", + color: "#FF6A00", + textIcon: "TR", + website: "https://www.trae.ai", + notice: { signupUrl: "https://www.trae.ai" }, + }, + transport: { + // IDE flow (cockpit-tools verified): x-cloudide-token auth, OpenAI-shaped SSE. + baseUrl: "https://api.marscode.com/cloudide/api/v3/trae/Chat", + format: "openai", + headers: { + "x-app-version": "3.5.54", + "x-app-type": "stable", + "x-env": "production", + "client_id": "ono9krqynydwx5", + "User-Agent": "Trae/1.0.0 antigravity-cockpit-tools", + }, + // Auth: x-cloudide-token + Authorization: Bearer — injected by executor buildHeaders. + auth: { + combined: true, + header: "x-cloudide-token", + scheme: "raw", + }, + usage: { + url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + }, + regions: { + cn: "https://api.marscode.com", + sg: "https://api.trae.ai", + us: "https://www.trae.ai", + }, + defaultRegion: "cn", + }, + oauth: { + clientId: "ono9krqynydwx5", + clientSecret: "-", + platform: "trae", + pollInterval: 1500, + // Login guidance returns LoginHost for browser open. + loginGuidanceUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", + // ExchangeToken: refresh -> access (POST JSON, body below). + tokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + exchangeTokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + refreshUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + userInfoUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + // Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven. + refresh: { encoding: "json" }, + }, + // Model catalog sourced from OmniRoute (IDE flow, core-normal.trae.ai). + models: [ + { id: "auto", name: "Auto (Server Picks)" }, + { id: "work", name: "Work (Fast)" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, + { id: "minimax-m3", name: "MiniMax M3" }, + { id: "minimax-m2.7", name: "MiniMax M2.7" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "gpt-5.4", name: "GPT 5.4" }, + { id: "gpt-5.2", name: "GPT 5.2" }, + ], + features: { usage: true }, +}; diff --git a/open-sse/providers/registry/windsurf.js b/open-sse/providers/registry/windsurf.js new file mode 100644 index 00000000..9d4222c8 --- /dev/null +++ b/open-sse/providers/registry/windsurf.js @@ -0,0 +1,146 @@ +// Windsurf provider registry — Firebase+Codeium+Devin auth chain. +// Chat transport is Codeium protobuf gRPC-Web: endpoint + schema are GUESS, +// the cockpit-tools source only documents auth/quota (SeatManagement) paths. +export default { + id: "windsurf", + alias: "ws", + uiAlias: "ws", + display: { + name: "Windsurf", + icon: "surfing", + color: "#14B8A6", + website: "https://windsurf.com", + notice: { signupUrl: "https://windsurf.com" }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth", "apikey"], + + // TODO(chat): Codeium ServerService protobuf schema unknown — endpoint is a guess. + transport: { + // GUESS: Codeium chat lives under /exa.server_pb.ServerService/GetChatMessage. + baseUrl: "https://server.codeium.com/exa.server_pb.ServerService/GetChatMessage", + format: "windsurf", + headers: { + "Content-Type": "application/proto", + "Connect-Protocol-Version": "1", + "ideName": "Windsurf", + "extensionName": "codeium.windsurf", + }, + // Bearer of apiKey (sk-ws-... / Firebase-derived / Devin session) — Connect-Protocol scheme unverified. + auth: { combined: true, header: "Authorization" }, + }, + + // Auth chain (4 terminal paths, all yield apiKey): + // 1) OAuth web → Firebase JWT → POST register.windsurf.com/.../RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} + // 2) sk-ws-... direct API key (apiKey used as metadata.apiKey on GetUserStatus) + // 3) Firebase JWT (eyJ...) → same RegisterUser exchange as #1 + // 4) Devin auth1_... → self-serve chain → ide_token used as apiKey on server.self-serve.windsurf.com + oauth: { + clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u", + firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY", + firebaseSignInUrl: "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword", + registerUrl: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser", + apiServerUrl: "https://server.codeium.com", + auth1ApiServerUrl: "https://server.self-serve.windsurf.com", + platform: "windsurf", + // Quota (Connect RPC, protobuf): POST windsurf.com/_backend/.../GetPlanStatus, + // headers Content-Type:application/proto + Connect-Protocol-Version:1 + X-Auth-Token:, + // body = field1:session_token, field2:varint 1. + quotaUrl: "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus", + }, + + // Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x). + // Source: OmniRoute registry (guanxiaol/WindsurfPoolAPI). Dot-notation ids; the + // executor MODEL_ALIAS_MAP would map these to Windsurf modelUid once proto chat + // is implemented. contextLength dropped — 9router schema uses id+name only. + 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" }, + // Claude Opus 4.7 — effort-tiered + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max" }, + { id: "claude-opus-4.7-xhigh", name: "Claude Opus 4.7 XHigh" }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High" }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium" }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low" }, + { id: "claude-opus-4.7-review", name: "Claude Opus 4.7 Review" }, + // Claude Sonnet/Opus 4.6 + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M" }, + { id: "claude-sonnet-4.6-1m", name: "Claude Sonnet 4.6 1M" }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking" }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking" }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, + // Claude 4.5 + { id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 Thinking" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 Thinking" }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + // GPT-5.5 — effort-tiered + { id: "gpt-5.5-xhigh-fast", name: "GPT-5.5 XHigh Fast" }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh" }, + { id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" }, + { id: "gpt-5.5-high", name: "GPT-5.5 High" }, + { id: "gpt-5.5-medium-fast", name: "GPT-5.5 Medium Fast" }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium" }, + { id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low" }, + { id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" }, + { id: "gpt-5.5-none", name: "GPT-5.5 None" }, + // GPT-5.4 — effort-tiered + { id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 XHigh Fast" }, + { id: "gpt-5.4-xhigh", name: "GPT-5.4 XHigh" }, + { id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { id: "gpt-5.4-high", name: "GPT-5.4 High" }, + { id: "gpt-5.4-medium-fast", name: "GPT-5.4 Medium Fast" }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium" }, + { id: "gpt-5.4-low-fast", name: "GPT-5.4 Low Fast" }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low" }, + { id: "gpt-5.4-none-fast", name: "GPT-5.4 None Fast" }, + { id: "gpt-5.4-none", name: "GPT-5.4 None" }, + { id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini XHigh" }, + { id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" }, + { id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini Medium" }, + { id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" }, + // GPT-5.3 Codex + { id: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex XHigh Fast" }, + { id: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex XHigh" }, + { id: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" }, + { id: "gpt-5.3-codex-medium-fast", name: "GPT-5.3 Codex Medium Fast" }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium" }, + { id: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" }, + // GPT-5.2 / 5 + { id: "gpt-5.2-xhigh", name: "GPT-5.2 XHigh" }, + { id: "gpt-5.2-high", name: "GPT-5.2 High" }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium" }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low" }, + { id: "gpt-5.2-none", name: "GPT-5.2 None" }, + { id: "gpt-5", name: "GPT-5" }, + // GPT-4.1 / 4o + { id: "gpt-4.1", name: "GPT-4.1" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano" }, + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + // Gemini + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High" }, + { id: "gemini-3.0-flash-medium", name: "Gemini 3 Flash Medium" }, + { id: "gemini-3.0-flash-low", name: "Gemini 3 Flash Low" }, + { id: "gemini-3.0-flash-minimal", name: "Gemini 3 Flash Minimal" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + // Others + { id: "deepseek-v4", name: "DeepSeek V4" }, + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5.1", name: "GLM-5.1" }, + ], +}; diff --git a/open-sse/providers/registry/workbuddy.js b/open-sse/providers/registry/workbuddy.js new file mode 100644 index 00000000..8adffe7f --- /dev/null +++ b/open-sse/providers/registry/workbuddy.js @@ -0,0 +1,73 @@ +export default { + id: "workbuddy", + // Short model prefix (wb/glm-5.2). WorkBuddy is a B2B/enterprise skin of + // CodeBuddy CN (same codebuddy.cn backend), so models mirror codebuddy-cn. + alias: "wb", + uiAlias: "wb", + hidden: false, + priority: 90, + display: { + name: "WorkBuddy", + icon: "smart_toy", + color: "#006EFF", + website: "https://www.codebuddy.cn", + notice: { + signupUrl: "https://www.codebuddy.cn", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + transport: { + // Same OpenAI-compatible gateway as codebuddy-cn; platform=workbuddy is + // distinguished at the OAuth layer, not the chat endpoint. + baseUrl: "https://www.codebuddy.cn/v2/chat/completions", + forceStream: true, + thinkingFormat: "openai", + headers: { + "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "CLI", + "X-IDE-Name": "CLI", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + models: [ + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "glm-5.1", name: "GLM-5.1" }, + { id: "glm-5.0", name: "GLM-5.0" }, + { id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" }, + { id: "glm-5v-turbo", name: "GLM-5v-Turbo" }, + { id: "glm-4.7", name: "GLM-4.7" }, + { id: "minimax-m3", name: "MiniMax-M3" }, + { id: "minimax-m2.7", name: "MiniMax-M2.7" }, + { id: "kimi-k2.7", name: "Kimi-K2.7-Code" }, + { id: "kimi-k2.6", name: "Kimi-K2.6" }, + { id: "kimi-k2.5", name: "Kimi-K2.5" }, + { id: "hy3-preview", name: "Hy3 Preview" }, + { id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" }, + { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" }, + ], + oauth: { + // Same codebuddy.cn host as codebuddy-cn; only platform param differs + // (workbuddy vs CLI). Prefix /v2/plugin matches cockpit-tools Rust. + baseUrl: "https://www.codebuddy.cn", + stateUrl: "https://www.codebuddy.cn/v2/plugin/auth/state", + tokenUrl: "https://www.codebuddy.cn/v2/plugin/auth/token", + refreshUrl: "https://www.codebuddy.cn/v2/plugin/auth/token/refresh", + userAgent: "CLI/2.63.2 CodeBuddy/2.63.2", + platform: "workbuddy", + pollInterval: 5000, + }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js new file mode 100644 index 00000000..b7476525 --- /dev/null +++ b/open-sse/providers/registry/zed.js @@ -0,0 +1,72 @@ +// Zed provider — RSA keypair callback auth (NOT standard OAuth). +// Source of truth: .repo/cockpit-tools/src-tauri/src/modules/zed_oauth.rs + zed_account.rs. +export default { + id: "zed", + priority: 10, + alias: "zd", + uiAlias: "zd", + display: { + name: "Zed", + icon: "code", + color: "#A855F7", + website: "https://zed.dev", + notice: { + signupUrl: "https://zed.dev/native_app_signin", + }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + + transport: { + // Zed hosted LLM aggregator (OmniRoute-verified): cloud.zed.dev/completions is a + // multi-format proxy fronting Anthropic/OpenAI/Google/xAI depending on the model. + // Wire protocol = NDJSON/SSE-ish stream authenticated with a short-lived LLM bearer + // token exchanged from the RSA-decrypted access_token (see open-sse/shared/zedAuth + // in OmniRoute). cockpit-tools only covered the RSA login + cloud.zed.dev quota path. + baseUrl: "https://cloud.zed.dev/completions", + format: "openai", + forceStream: true, + headers: { + "content-type": "application/json", + }, + // Auth scheme is non-standard: "Authorization: " plus a duplicate + // x-zed-cloud-token header (verified in zed_account.rs build_authorization_header + + // cloud fetch). Executor builds both; scheme here is a marker for config-driven tooling. + auth: { + combined: true, + header: "Authorization", + scheme: " ", // placeholder — real value built in executor + }, + usage: { + url: "https://cloud.zed.dev/client/users/me", // verified in zed_account.rs + }, + // Live catalog discovery — Zed's hosted model list changes frequently and is fetched + // per-connection rather than hardcoded (OmniRoute pattern). + modelsUrl: "https://cloud.zed.dev/models", + }, + + // Empty static catalog + passthrough: Zed fronts a rotating set of upstream models + // (Claude/GPT/Gemini/Grok). Resolved live via modelsUrl; any client-sent model id is + // forwarded as-is rather than validated against a frozen list. + models: [], + passthroughModels: true, + + oauth: { + // Zed auth flow is RSA-based, NOT OAuth2/PKCE: + // 1. App generates RSA-2048 keypair locally (PKCS#1 DER, URL-safe base64). + // 2. Bind random TCP port on 127.0.0.1. + // 3. Open https://zed.dev/native_app_signin?native_app_port={port}&native_app_public_key={pub}. + // 4. After login, browser redirects http://127.0.0.1:{port}/?user_id=...&access_token=... + // where access_token = base64(RSA-encrypted plaintext token). + // 5. Decrypt with private key (OAEP-SHA256, fallback PKCS1v15). Store user_id + plaintext token. + // No clientId/clientSecret/tokenUrl/refreshUrl — long-lived access_token, no refresh. + authorizeUrl: "https://zed.dev/native_app_signin", + platform: "zed", + rsaKeyExchange: true, // new flag: signals frontend/router this flow needs local RSA + TCP listener. + }, + + features: { + usage: true, + }, +}; diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 55f4582e..c8cced77 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -13,6 +13,11 @@ import { refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshWorkbuddyToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, } from "./tokenRefresh/providers.js"; @@ -29,6 +34,11 @@ export { refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshWorkbuddyToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, }; @@ -138,6 +148,11 @@ const REFRESH_HANDLERS = { "grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log), gcli: (c, log) => refreshXaiToken(c.refreshToken, log), "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), + "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), + workbuddy: (c, log) => refreshWorkbuddyToken(c.refreshToken, log), + trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), + zed: () => refreshZedToken(), + windsurf: (c, log) => refreshWindsurfToken(c, log), // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log), "kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log), diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index 3fc8c4b2..d82bfdeb 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -668,3 +668,198 @@ export async function refreshCodebuddyToken(refreshToken, log) { }; }, log); } + +export async function refreshCodebuddyIntlToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("codebuddy-intl", refreshToken, async () => { + const oauth = PROVIDER_OAUTH["codebuddy-intl"] || {}; + const response = await fetch(oauth.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": oauth.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-Refresh-Token": refreshToken, + "X-Auth-Refresh-Source": "plugin", + "X-Product": "SaaS", + }, + body: "{}", + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy intl token", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = await response.json(); + if (data.code !== 0 || !data.data?.accessToken) { + log?.error?.("TOKEN_REFRESH", "CodeBuddy intl token refresh returned no token", { + code: data.code, + msg: data.msg, + }); + return null; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy intl token", { + hasNewAccessToken: !!data.data.accessToken, + hasNewRefreshToken: !!data.data.refreshToken, + expiresIn: data.data.expiresIn, + }); + + return { + accessToken: data.data.accessToken, + refreshToken: data.data.refreshToken || refreshToken, + expiresIn: data.data.expiresIn, + }; + }, log); +} + +export async function refreshWorkbuddyToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("workbuddy", refreshToken, async () => { + const oauth = PROVIDER_OAUTH["workbuddy"] || {}; + const response = await fetch(oauth.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": oauth.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.cn", + "X-Refresh-Token": refreshToken, + "X-Auth-Refresh-Source": "plugin", + "X-Product": "SaaS", + }, + body: "{}", + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh WorkBuddy token", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = await response.json(); + if (data.code !== 0 || !data.data?.accessToken) { + log?.error?.("TOKEN_REFRESH", "WorkBuddy token refresh returned no token", { + code: data.code, + msg: data.msg, + }); + return null; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed WorkBuddy token", { + hasNewAccessToken: !!data.data.accessToken, + hasNewRefreshToken: !!data.data.refreshToken, + expiresIn: data.data.expiresIn, + }); + + return { + accessToken: data.data.accessToken, + refreshToken: data.data.refreshToken || refreshToken, + expiresIn: data.data.expiresIn, + }; + }, log); +} + +// Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}. +// Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}. +// Source: cockpit-tools/src-tauri/src/modules/trae_oauth.rs (TRAE_EXCHANGE_TOKEN_PATH). +export async function refreshTraeToken(refreshToken, credentials, log) { + if (!refreshToken) return null; + const oauth = PROVIDER_OAUTH.trae || {}; + const url = oauth.exchangeTokenUrl || oauth.tokenUrl; + if (!url) { + log?.warn?.("TOKEN_REFRESH", "No Trae exchangeTokenUrl configured"); + return null; + } + + return dedupRefresh("trae", refreshToken, async () => { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "Trae/1.0.0 antigravity-cockpit-tools", + }, + body: JSON.stringify({ + ClientID: oauth.clientId || "ono9krqynydwx5", + RefreshToken: refreshToken, + ClientSecret: oauth.clientSecret || "-", + UserID: "", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Trae token", { + status: response.status, + error: errorText, + }); + return null; + } + + const payload = await response.json(); + const result = payload?.Result || payload?.result || payload; + const accessToken = result?.AccessToken || result?.accessToken; + if (!accessToken) { + log?.error?.("TOKEN_REFRESH", "Trae refresh returned no AccessToken", { payload }); + return null; + } + + const newRefresh = result?.RefreshToken || result?.refreshToken || refreshToken; + const expiresAt = result?.ExpiresAt || result?.expiresAt; + let expiresIn; + if (typeof expiresAt === "number") { + expiresIn = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); + } else if (typeof expiresAt === "string") { + const ms = new Date(expiresAt).getTime() - Date.now(); + expiresIn = ms > 0 ? Math.floor(ms / 1000) : undefined; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Trae token", { + hasNewAccessToken: !!accessToken, + hasNewRefreshToken: newRefresh !== refreshToken, + expiresIn, + }); + + return { + accessToken, + refreshToken: newRefresh, + expiresIn, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing Trae token: ${error.message}`); + return null; + } + }, log); +} + +// Zed access_token is long-lived; auth flow returns no refresh_token. +// No refresh possible — re-login required when token expires/revoked. +// Mirrors cursor/kilocode null-refresh pattern. +export function refreshZedToken() { + return null; +} + +// Windsurf apiKey is the long-lived terminal credential (no OAuth2 refresh_token +// grant yields a fresh apiKey). Refresh handled out-of-band by the caller. +// TODO(firebase): if short-lived Firebase JWT credentials must be refreshed, +// re-run RegisterUser with the refreshed Firebase JWT (separate code path). +export async function refreshWindsurfToken(credentials, log) { + log?.info?.( + "TOKEN_REFRESH", + "windsurf: apiKey is long-lived (no refresh_token flow) — skipping" + ); + return null; +} diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js new file mode 100644 index 00000000..5f38298b --- /dev/null +++ b/open-sse/shared/zedAuth.js @@ -0,0 +1,416 @@ +// Zed hosted LLM aggregator — auth + model-catalog helpers. +// Ported from OmniRoute open-sse/shared/zedAuth.ts (plain JS, no TS types). +// +// Zed's cloud (cloud.zed.dev) authenticates native apps with a self-generated RSA +// keypair instead of a registered OAuth client_id/secret: +// 1. Client generates an ephemeral RSA keypair. +// 2. Sends the public key to zed.dev/native_app_signin. +// 3. User signs in via browser; Zed redirects to a local callback with the +// access token RSA-encrypted against the public key. +// 4. Client decrypts locally with the private key that never left the host. +// No embedded client_id/secret — the credential is a per-login keypair. + +import crypto from "node:crypto"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +export const ZED_WEB_BASE_URL = "https://zed.dev"; +export const ZED_CLOUD_BASE_URL = "https://cloud.zed.dev"; +export const ZED_LLM_BASE_URL = "https://cloud.zed.dev"; + +export const ZED_HEADERS = { + expiredToken: "x-zed-expired-token", + outdatedToken: "x-zed-outdated-token", + clientSupportsStatus: "x-zed-client-supports-status-messages", + clientSupportsStreamEnded: + "x-zed-client-supports-stream-ended-request-completion-status", + serverSupportsStatus: "x-zed-server-supports-status-messages", + clientSupportsXai: "x-zed-client-supports-x-ai", + systemId: "x-zed-system-id", +}; + +const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:"; +const LLM_TOKEN_TTL_MS = 50 * 60 * 1000; +const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; + +const llmTokenCache = new Map(); +const modelCache = new Map(); +const modelInflight = new Map(); + +function b64url(value) { + return Buffer.from(value).toString("base64url"); +} + +function b64urlPadded(buf) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function fromB64url(value) { + return Buffer.from(String(value || ""), "base64url").toString("utf8"); +} + +function normalizeBaseUrl(baseUrl, fallback) { + return String(baseUrl || fallback).replace(/\/+$/, ""); +} + +function zedUrl(config, key, path, fallbackBase) { + const base = normalizeBaseUrl(config?.[key], fallbackBase); + return `${base}${path}`; +} + +/** Encode a PEM private key as an opaque verifier (flows through the OAuth codeVerifier slot). */ +export function encodeZedPrivateKeyVerifier(privateKeyPem) { + return `${PRIVATE_KEY_PREFIX}${b64url(privateKeyPem)}`; +} + +export function decodeZedPrivateKeyVerifier(verifier) { + const value = String(verifier || ""); + if (!value.startsWith(PRIVATE_KEY_PREFIX)) { + throw new Error("Missing Zed private key verifier; restart the login flow"); + } + return fromB64url(value.slice(PRIVATE_KEY_PREFIX.length)); +} + +/** Generate a fresh RSA keypair + the zed.dev native_app_signin URL for it. */ +export function createZedNativeAuthData(config = {}, options = {}) { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "der" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + + const nativeAppPort = Number( + options.nativeAppPort || config.defaultNativeAppPort || 58443, + ); + const systemId = options.systemId || crypto.randomUUID(); + const publicKeyString = b64urlPadded(publicKey); + const signInUrl = new URL( + `${normalizeBaseUrl(config.webBaseUrl, ZED_WEB_BASE_URL)}/native_app_signin`, + ); + signInUrl.searchParams.set("native_app_port", String(nativeAppPort)); + signInUrl.searchParams.set("native_app_public_key", publicKeyString); + if (systemId) signInUrl.searchParams.set("system_id", systemId); + + return { + authUrl: signInUrl.toString(), + privateKeyVerifier: encodeZedPrivateKeyVerifier(privateKey), + nativeAppPort, + systemId, + publicKey: publicKeyString, + }; +} + +/** Parse the pasted native-app callback URL/JSON/query into userId + encrypted token. */ +export function parseZedCallbackPayload(input) { + const raw = String(input || "").trim(); + if (!raw) throw new Error("Missing Zed callback URL"); + + let data = {}; + try { + data = JSON.parse(raw); + } catch { + let url; + try { + url = new URL(raw); + } catch { + try { + url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + } catch { + throw new Error("Invalid Zed callback URL"); + } + } + url.searchParams.forEach((value, key) => { + data[key] = value; + }); + } + + const userId = data.user_id || data.userId; + const encryptedAccessToken = data.access_token || data.accessToken || data.token; + if (!userId || !encryptedAccessToken) { + throw new Error("Zed callback must include user_id and access_token"); + } + return { userId: String(userId), encryptedAccessToken: String(encryptedAccessToken) }; +} + +/** Decrypt the RSA-encrypted access token using the stored private key. */ +export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) { + const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier); + const encrypted = Buffer.from(String(encryptedAccessToken), "base64url"); + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + encrypted, + ) + .toString("utf8"); + } catch (oaepError) { + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + encrypted, + ) + .toString("utf8"); + } catch { + const message = oaepError instanceof Error ? oaepError.message : String(oaepError); + throw new Error(`Failed to decrypt Zed access token: ${message}`); + } + } +} + +export function buildZedUserAuthHeader(credentials) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId; + const accessToken = credentials?.accessToken || credentials?.apiKey; + if (!userId || !accessToken) { + throw new Error("Zed credential is missing userId or accessToken"); + } + return `${userId} ${accessToken}`; +} + +function getSystemId(credentials) { + return String( + credentials?.providerSpecificData?.systemId || credentials?.systemId || "", + ); +} + +async function fetchJson(url, options) { + const res = await proxyAwareFetch(url, options); + const text = await res.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + } + if (!res.ok) { + const message = + data?.message || data?.error?.message || data?.error || text || `HTTP ${res.status}`; + const err = new Error(String(message)); + err.status = res.status; + err.body = data; + throw err; + } + return data; +} + +export async function fetchZedAuthenticatedUser(credentials, options = {}) { + const config = options.config || {}; + const headers = { + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), { + method: "GET", + headers, + signal: options.signal ?? undefined, + }); +} + +function normalizeOrganizationId(value) { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object" && value !== null) { + if (typeof value[0] === "string") return value[0]; + if (typeof value.id === "string") return value.id; + } + return String(value); +} + +export function resolveZedOrganizationId(credentials, userInfo = null) { + const psd = credentials?.providerSpecificData || {}; + const explicit = normalizeOrganizationId(psd.organizationId || psd.defaultOrganizationId); + if (explicit) return explicit; + const fromUser = normalizeOrganizationId( + userInfo?.default_organization_id || userInfo?.defaultOrganizationId, + ); + if (fromUser) return fromUser; + const orgs = userInfo?.organizations || []; + const org = orgs.find((item) => item?.is_personal) || orgs[0]; + return normalizeOrganizationId(org?.id); +} + +function zedUserCacheKey(credentials, organizationId) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId || "unknown"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${userId}:${organizationId || "default"}:${token.slice(-16)}`; +} + +function zedModelCacheKey(credentials) { + const psd = credentials?.providerSpecificData || {}; + const org = psd.organizationId || psd.defaultOrganizationId || "default"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${psd.userId || "unknown"}:${org}:${token.slice(-16)}`; +} + +export async function fetchZedLlmToken(credentials, options = {}) { + const config = options.config || {}; + let organizationId = options.organizationId || resolveZedOrganizationId(credentials); + if (!organizationId) { + const userInfo = await fetchZedAuthenticatedUser(credentials, options); + organizationId = resolveZedOrganizationId(credentials, userInfo); + } + if (!organizationId) throw new Error("No Zed organization selected"); + + const cacheKey = zedUserCacheKey(credentials, organizationId); + const cached = llmTokenCache.get(cacheKey); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.token; + + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + const data = await fetchJson( + zedUrl(config, "cloudBaseUrl", "/client/llm_tokens", ZED_CLOUD_BASE_URL), + { + method: "POST", + headers, + body: JSON.stringify({ organization_id: organizationId }), + signal: options.signal ?? undefined, + }, + ); + const token = + typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value; + if (!token) throw new Error("Zed did not return an LLM token"); + llmTokenCache.set(cacheKey, { token, expiresAt: Date.now() + LLM_TOKEN_TTL_MS }); + return token; +} + +export function shouldRefreshZedLlmToken(response) { + return ( + response?.status === 401 || + !!response?.headers?.has?.(ZED_HEADERS.expiredToken) || + !!response?.headers?.has?.(ZED_HEADERS.outdatedToken) + ); +} + +export async function zedLlmFetch(credentials, path, options = {}) { + const config = options.config || {}; + const url = zedUrl(config, "llmBaseUrl", path, ZED_LLM_BASE_URL); + const buildRequest = async (forceRefresh) => { + const token = await fetchZedLlmToken(credentials, { ...options, forceRefresh }); + return proxyAwareFetch(url, { + ...options.fetchOptions, + headers: { + ...(options.fetchOptions?.headers || {}), + Authorization: `Bearer ${token}`, + }, + signal: options.signal ?? undefined, + }); + }; + + let response = await buildRequest(false); + if (shouldRefreshZedLlmToken(response)) { + response = await buildRequest(true); + } + return response; +} + +function normalizeZedModelId(id) { + if (!id) return ""; + if (typeof id === "string") return id; + if (typeof id === "object" && id !== null) { + if (typeof id[0] === "string") return id[0]; + if (typeof id.id === "string") return id.id; + } + return String(id); +} + +export function mapZedModel(model) { + const id = normalizeZedModelId(model?.id); + if (!id) return null; + return { + id, + name: model.display_name || model.displayName || id, + provider: model.provider, + isLatest: !!model.is_latest, + contextLength: model.max_token_count ?? model.maxTokenCount, + contextLengthInMaxMode: model.max_token_count_in_max_mode ?? model.maxTokenCountInMaxMode, + maxOutputTokens: model.max_output_tokens ?? model.maxOutputTokens, + supportsTools: !!model.supports_tools, + supportsImages: !!model.supports_images, + supportsThinking: !!model.supports_thinking, + supportsDisablingThinking: !!model.supports_disabling_thinking, + supportsFastMode: !!model.supports_fast_mode, + supportsServerSideCompaction: !!model.supports_server_side_compaction, + supportedEffortLevels: model.supported_effort_levels ?? model.supportedEffortLevels ?? [], + supportsStreamingTools: !!model.supports_streaming_tools, + supportsParallelToolCalls: !!model.supports_parallel_tool_calls, + isDisabled: !!model.is_disabled, + disabledReason: model.disabled_reason ?? null, + }; +} + +/** Resolve (and cache) the live Zed model catalog. Never hardcoded — always a live fetch. */ +export async function resolveZedModels(credentials, options = {}) { + if (!credentials?.accessToken) return null; + const key = zedModelCacheKey(credentials); + const cached = modelCache.get(key); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached; + + const existing = modelInflight.get(key); + if (existing && !options.forceRefresh) return existing; + + const promise = (async () => { + const response = await zedLlmFetch(credentials, "/models", { + ...options, + fetchOptions: { + method: "GET", + headers: { + Accept: "application/json", + [ZED_HEADERS.clientSupportsXai]: "true", + }, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Zed models failed: ${response.status} ${text}`); + } + const data = await response.json(); + const rawModels = Array.isArray(data?.models) ? data.models : []; + const models = rawModels + .map(mapZedModel) + .filter(Boolean) + .filter((model) => !model.isDisabled); + const rawById = new Map(); + for (const raw of rawModels) { + const id = normalizeZedModelId(raw?.id); + if (id) rawById.set(id, raw); + } + const entry = { + expiresAt: Date.now() + MODEL_CACHE_TTL_MS, + models, + rawModels, + rawById, + defaultModel: normalizeZedModelId(data?.default_model ?? data?.defaultModel), + defaultFastModel: normalizeZedModelId(data?.default_fast_model ?? data?.defaultFastModel), + recommendedModels: (data?.recommended_models || data?.recommendedModels || []) + .map(normalizeZedModelId) + .filter(Boolean), + }; + modelCache.set(key, entry); + return entry; + })(); + + modelInflight.set(key, promise); + try { + return await promise; + } finally { + if (modelInflight.get(key) === promise) modelInflight.delete(key); + } +} + +export function clearZedCaches() { + llmTokenCache.clear(); + modelCache.clear(); + modelInflight.clear(); +} diff --git a/public/providers/codebuddy-intl.png b/public/providers/codebuddy-intl.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/codebuddy-intl.png differ diff --git a/public/providers/trae.png b/public/providers/trae.png new file mode 100644 index 00000000..c056daf0 Binary files /dev/null and b/public/providers/trae.png differ diff --git a/public/providers/windsurf.png b/public/providers/windsurf.png new file mode 100644 index 00000000..c97179a2 Binary files /dev/null and b/public/providers/windsurf.png differ diff --git a/public/providers/workbuddy.png b/public/providers/workbuddy.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/workbuddy.png differ diff --git a/public/providers/zed.png b/public/providers/zed.png new file mode 100644 index 00000000..009c3e9d Binary files /dev/null and b/public/providers/zed.png differ