From 039c4dbc7274a96a14d850d81f1911eccdfc8777 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 23 Jul 2026 15:37:24 +0700 Subject: [PATCH 01/34] feat(providers): add trae/windsurf/zed/workbuddy/codebuddy-intl + icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New providers: trae, windsurf, zed, workbuddy, codebuddy-intl (registry + executor, wired into executors/index.js + registry/index.js) - zed: port hosted cloud proxy from OmniRoute — RSA access-token → short-lived LLM token exchange (shared/zedAuth.js) + NDJSON {event}/{status}/[DONE] stream translated back to OpenAI via Claude/Gemini/OpenAI-Responses translators - Provider icons (128x128 png) for the 5 new providers - qoder + tokenRefresh provider tweaks Co-Authored-By: Claude Fable 5 --- open-sse/executors/codebuddy-intl.js | 30 ++ open-sse/executors/index.js | 15 + open-sse/executors/trae.js | 22 + open-sse/executors/windsurf.js | 42 ++ open-sse/executors/workbuddy.js | 31 ++ open-sse/executors/zed.js | 305 +++++++++++++ open-sse/providers/registry/codebuddy-intl.js | 74 ++++ open-sse/providers/registry/index.js | 10 + open-sse/providers/registry/qoder.js | 28 +- open-sse/providers/registry/trae.js | 77 ++++ open-sse/providers/registry/windsurf.js | 146 ++++++ open-sse/providers/registry/workbuddy.js | 73 +++ open-sse/providers/registry/zed.js | 72 +++ open-sse/services/tokenRefresh.js | 15 + open-sse/services/tokenRefresh/providers.js | 195 ++++++++ open-sse/shared/zedAuth.js | 416 ++++++++++++++++++ public/providers/codebuddy-intl.png | Bin 0 -> 17688 bytes public/providers/trae.png | Bin 0 -> 2328 bytes public/providers/windsurf.png | Bin 0 -> 4193 bytes public/providers/workbuddy.png | Bin 0 -> 17688 bytes public/providers/zed.png | Bin 0 -> 21456 bytes 21 files changed, 1539 insertions(+), 12 deletions(-) create mode 100644 open-sse/executors/codebuddy-intl.js create mode 100644 open-sse/executors/trae.js create mode 100644 open-sse/executors/windsurf.js create mode 100644 open-sse/executors/workbuddy.js create mode 100644 open-sse/executors/zed.js create mode 100644 open-sse/providers/registry/codebuddy-intl.js create mode 100644 open-sse/providers/registry/trae.js create mode 100644 open-sse/providers/registry/windsurf.js create mode 100644 open-sse/providers/registry/workbuddy.js create mode 100644 open-sse/providers/registry/zed.js create mode 100644 open-sse/shared/zedAuth.js create mode 100644 public/providers/codebuddy-intl.png create mode 100644 public/providers/trae.png create mode 100644 public/providers/windsurf.png create mode 100644 public/providers/workbuddy.png create mode 100644 public/providers/zed.png 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 0000000000000000000000000000000000000000..c836282f29512e354728c001c7f49086f401af0a GIT binary patch literal 17688 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 XY#OJ9MLDRA00000NkvXXu0mjfL3md! literal 0 HcmV?d00001 diff --git a/public/providers/trae.png b/public/providers/trae.png new file mode 100644 index 0000000000000000000000000000000000000000..c056daf0a3d813af9020f23ee09bd0515bd91bcb GIT binary patch literal 2328 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

YVtCWuO@ zUgC_W>o7$})*B>wy>6-{c#fRGv{rlWyrBI-qvVI;$NM4T$JF6Twgm6#c?-DtnEqCj zLP8JE*6ZNI*fRxinMm9`kwKA%%v+~CS#gcK0bD8rn=ER7?Qbb;? z(;O+CJ4{!Qk!Wmm#4<}=4$b>Ue^B6=n7W7fDsN~@v9lpb#OxAcB9Vb|9m8?+MMro<2E~T?)S5&W?7{6iUU|t-ngVuS) z76{5-n*9W`pazXK5T3v7T##{~r&(8qM0oyE4X{}G_e+cDyUrha-F-LP6Y zeDLi=(eR%f=<-QN*sU&+ocFfl;IMv>gGlEaiB_zODlLnV$&7hDy^1Y*_-zoYih147 zm}ncrd#c}}R+h=_P@#2Q?2+&7xIuo7l0=g?5~Qi~{I&P@%4K-i9%1qCk3a%I}B z0Fj^kj|8|!m>Q+Hldnr-e08|458DZk8C$qAuyw+=$rQm|XMUC?-(PP05d5egv$}~> z**t@A1s5bV{B$y3pJ4w?$&@x=WO$*Wr}ePsPsBK2{mhuZzLLLy9x#JJ`v=OKjZi{y~tL}#p)%FPqk_!e)Qvw zUIkt87O%GpnfDmgHvv%DHX|GlP70M* zxn5nE%KM?h&)O*Svg{SnY94T2!jr2fq#Wps@&gUQvQ?FQd$>|#4ME!KCHmG&P~5Zj zs_pw$zt2sYrjhw;}gZe38n zo~SJl%!3n;m6x*h#*f)CK{EC^tSGN;2MW0-fZ{Bk5OD%>l>W7ZanmB2Ywr5R;7gq@ z<^BwW{~(oZRDbPDSS=wqY}F_*d``1V#}-E*e(aXS?tU52^Ym!;jsY=@##}`tOL|ujjrC|D^^wW7a=o;bj+Qvz zrdmQTZ>3+hkYst5C|;e`qF?vLl9o8%_ zO$8tt`D#cY99GoOU%b_s?_4!4V<^oq3=#Tn^Vg@ z@e(TsvlB1>nYtNa{z;FX)mMjtN_{Pp5tQ{Ky=K9Y(VYdY&=+2WQex^aadEO&I^uUh9AndvCpAF$Tg*{~V}BRXQ0Btbbk!1MaJihX2Y)JNo6# z*za0??6p^3$Kwlf9w=j{eW2rY?b~*Rmxf$kOY<40TbDPSmu=Xs>1uoD5+(arN(~qt zm)rYMk#M@g4Vg%}CApnVVx+1yzVC*u>Y^{!5GQe`xYh1FA#fpYg)_qE1HT{Gp3!&9 z(}qPIcX!ye?3lveCUi~Jm)y81p*4n=Hn>vC6)Uv2?S8uLU}u{ZC;UX%EzpieCz>^^ zy?rlsVT}&o+w0b*CWhs$X!mY|wxC!Nef#s`URS75JfElHJ)g$k%sA$HFhTx7DVj3C z4||V))c1~RV`Mj}UZkq-=2EHdi+K?ET<%9yuv-X&_nCE#&)^F-5M78@;5ns4At`y& w8>i0lni=`3q(xiLyz0LgbN@F$&uXk(xOVrLIa+vG{*Cu&Z6mEZO^4|J0j36OUH||9 literal 0 HcmV?d00001 diff --git a/public/providers/workbuddy.png b/public/providers/workbuddy.png new file mode 100644 index 0000000000000000000000000000000000000000..c836282f29512e354728c001c7f49086f401af0a GIT binary patch literal 17688 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 XY#OJ9MLDRA00000NkvXXu0mjfL3md! literal 0 HcmV?d00001 diff --git a/public/providers/zed.png b/public/providers/zed.png new file mode 100644 index 0000000000000000000000000000000000000000..009c3e9d1152a9a363969146105afe88fbe07337 GIT binary patch literal 21456 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 literal 0 HcmV?d00001 From 53a8b5ed55b27839a3d0981b2184c8ea89f0e7fc Mon Sep 17 00:00:00 2001 From: Biuzai OpenClaw Agent Date: Thu, 23 Jul 2026 15:47:08 +0700 Subject: [PATCH 02/34] feat(providers): add Gemini 3.6 Flash and Gemini 3.5 Flash Lite models --- open-sse/providers/pricing.js | 8 +++++++- open-sse/providers/registry/antigravity.js | 4 ++++ open-sse/providers/registry/gemini.js | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index c2831fdb..9a0768ef 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -57,7 +57,13 @@ export const MODEL_PRICING = { "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, // === Gemini === - "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.5-flash-lite": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.375 }, + "gemini-3.5-flash-high": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, "gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 }, diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index dd7fbc02..6818e764 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -44,6 +44,10 @@ export default { clientSecret: "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", }, models: [ + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, + { id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" }, diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index 5c811042..d8e0b3c0 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -34,6 +34,8 @@ export default { }, }, models: [ + { id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" }, + { id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, From 57b3b2c175b00a82a27bfec8f14a77b99a387ceb Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Thu, 23 Jul 2026 16:02:01 +0700 Subject: [PATCH 03/34] fix(console-log): initialize capture at server boot + prevent SSE proxy buffering Initialize initConsoleLogCapture() via Next.js instrumentation register() hook so logs are captured from startup in headless/Docker deployments, and add X-Accel-Buffering/Cache-Control headers to the SSE stream route to prevent reverse proxies from buffering the initial payload. --- src/app/api/translator/console-logs/stream/route.js | 3 ++- src/instrumentation.js | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/instrumentation.js diff --git a/src/app/api/translator/console-logs/stream/route.js b/src/app/api/translator/console-logs/stream/route.js index 5eb3ae37..51ed3702 100644 --- a/src/app/api/translator/console-logs/stream/route.js +++ b/src/app/api/translator/console-logs/stream/route.js @@ -83,8 +83,9 @@ export async function GET(request) { return new Response(stream, { headers: { "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", + "X-Accel-Buffering": "no", }, }); } diff --git a/src/instrumentation.js b/src/instrumentation.js new file mode 100644 index 00000000..f511eae2 --- /dev/null +++ b/src/instrumentation.js @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { initConsoleLogCapture } = await import("@/lib/consoleLogBuffer"); + initConsoleLogCapture(); + } +} From c85a5c57bafce1078737a8938d10826a7737ac1c Mon Sep 17 00:00:00 2001 From: zie Date: Thu, 23 Jul 2026 16:06:16 +0700 Subject: [PATCH 04/34] fix(usage): record exact embedding tokens --- open-sse/handlers/embeddingsCore.js | 1 + src/sse/handlers/embeddings.js | 26 +++++- .../unit/embedding-usage-persistence.test.js | 91 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/unit/embedding-usage-persistence.test.js diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js index 5a4c92ba..aa81117c 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.js @@ -116,6 +116,7 @@ export async function handleEmbeddingsCore({ return { success: true, + usage: normalized.usage || null, response: new Response(JSON.stringify(normalized), { headers: { "Content-Type": "application/json", diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index cde0d41e..45e4d171 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -12,6 +12,16 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; +import { saveRequestUsage } from "@/lib/usageDb.js"; + +function exactEmbeddingUsage(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw) || raw.estimated === true) return null; + const promptTokens = raw.prompt_tokens ?? raw.input_tokens; + const completionTokens = raw.completion_tokens ?? raw.output_tokens ?? 0; + const totalTokens = raw.total_tokens; + if (!Number.isSafeInteger(promptTokens) || promptTokens <= 0 || completionTokens !== 0 || totalTokens !== promptTokens) return null; + return { prompt_tokens: promptTokens, completion_tokens: 0, total_tokens: totalTokens }; +} /** * Handle embeddings request for the SSE/Next.js server. @@ -124,7 +134,21 @@ export async function handleEmbeddings(request) { } }); - if (result.success) return result.response; + if (result.success) { + const usage = exactEmbeddingUsage(result.usage); + if (usage) { + saveRequestUsage({ + provider, + model, + connectionId: credentials.connectionId, + apiKey, + endpoint: url.pathname, + tokens: usage, + status: "success", + }).catch(() => {}); + } + return result.response; + } const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); diff --git a/tests/unit/embedding-usage-persistence.test.js b/tests/unit/embedding-usage-persistence.test.js new file mode 100644 index 00000000..9fcef0d7 --- /dev/null +++ b/tests/unit/embedding-usage-persistence.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + handleEmbeddingsCore: vi.fn(), + saveRequestUsage: vi.fn(), +})); + +vi.mock("../../src/sse/services/auth.js", () => ({ + getProviderCredentials: async () => ({ + apiKey: "provider-secret", + connectionId: "connection-a", + connectionName: "Provider A", + }), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + extractApiKey: () => "client-key", + isValidApiKey: vi.fn(), +})); +vi.mock("@/lib/localDb", () => ({ getSettings: async () => ({ requireApiKey: false }) })); +vi.mock("../../src/sse/services/model.js", () => ({ + getModelInfo: async () => ({ provider: "openai", model: "text-embedding-3-small" }), +})); +vi.mock("../../open-sse/handlers/embeddingsCore.js", () => ({ + handleEmbeddingsCore: mocks.handleEmbeddingsCore, +})); +vi.mock("../../open-sse/utils/error.js", () => ({ + errorResponse: (status, message) => Response.json({ error: message }, { status }), + unavailableResponse: (status, message) => Response.json({ error: message }, { status }), +})); +vi.mock("../../src/sse/utils/logger.js", () => ({ + request: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn(), maskKey: vi.fn(), +})); +vi.mock("../../src/sse/services/tokenRefresh.js", () => ({ + updateProviderCredentials: vi.fn(), + checkAndRefreshToken: async (_provider, credentials) => credentials, +})); +vi.mock("@/lib/usageDb.js", () => ({ saveRequestUsage: mocks.saveRequestUsage })); + +import { handleEmbeddings } from "../../src/sse/handlers/embeddings.js"; + +describe("embedding usage persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.saveRequestUsage.mockResolvedValue(undefined); + mocks.handleEmbeddingsCore.mockResolvedValue({ + success: true, + usage: { prompt_tokens: 12, total_tokens: 12 }, + response: Response.json({ data: [] }), + }); + }); + + it("records exact provider usage for successful embedding requests", async () => { + await handleEmbeddings(new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }), + })); + + expect(mocks.saveRequestUsage).toHaveBeenCalledWith(expect.objectContaining({ + provider: "openai", + model: "text-embedding-3-small", + connectionId: "connection-a", + apiKey: "client-key", + endpoint: "/v1/embeddings", + status: "success", + tokens: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 }, + })); + }); + + it.each([ + null, + {}, + { prompt_tokens: 0, total_tokens: 0 }, + { prompt_tokens: "12", total_tokens: 12 }, + { prompt_tokens: 12, total_tokens: 13 }, + { prompt_tokens: 12, completion_tokens: 1, total_tokens: 12 }, + { prompt_tokens: 12, total_tokens: 12, estimated: true }, + ])("does not record inexact usage %#", async (usage) => { + mocks.handleEmbeddingsCore.mockResolvedValue({ + success: true, + usage, + response: Response.json({ data: [] }), + }); + + await handleEmbeddings(new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }), + })); + + expect(mocks.saveRequestUsage).not.toHaveBeenCalled(); + }); +}); From e45bd73d6e5c9aeb5cc6ba3eaab15d2ed6c0e8ac Mon Sep 17 00:00:00 2001 From: ryanngit Date: Thu, 23 Jul 2026 16:17:12 +0700 Subject: [PATCH 05/34] fix(tunnel): preserve successor cloudflared PID Make PID cleanup conditional on the exiting child still owning the PID file so a stale exit cannot erase a replacement tunnel's PID. Only null the in-memory process when the exiting child is current. Explicit disable keeps unconditional cleanup. --- src/lib/tunnel/cloudflare/cloudflared.js | 8 ++--- src/lib/tunnel/cloudflare/pid.js | 8 +++-- tests/unit/tunnel-pid-ownership.test.js | 39 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/unit/tunnel-pid-ownership.test.js diff --git a/src/lib/tunnel/cloudflare/cloudflared.js b/src/lib/tunnel/cloudflare/cloudflared.js index 38e4bf74..09458fc5 100644 --- a/src/lib/tunnel/cloudflare/cloudflared.js +++ b/src/lib/tunnel/cloudflare/cloudflared.js @@ -239,8 +239,8 @@ export async function spawnCloudflared(tunnelToken) { }); child.on("exit", (code, signal) => { - cloudflaredProcess = null; - clearPid(); + if (cloudflaredProcess === child) cloudflaredProcess = null; + clearPid(child.pid); const wasConnected = resolved; // true = already connected successfully if (!resolved) { resolved = true; @@ -372,8 +372,8 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) { }); child.on("exit", (code, signal) => { - cloudflaredProcess = null; - clearPid(); + if (cloudflaredProcess === child) cloudflaredProcess = null; + clearPid(child.pid); // Deliberate kill (restart/disable) — exit silently, no error noise if (intentionalKill) { intentionalKill = false; diff --git a/src/lib/tunnel/cloudflare/pid.js b/src/lib/tunnel/cloudflare/pid.js index 919837c5..77c63a82 100644 --- a/src/lib/tunnel/cloudflare/pid.js +++ b/src/lib/tunnel/cloudflare/pid.js @@ -16,8 +16,12 @@ export function loadPid() { return null; } -export function clearPid() { +export function clearPid(expectedPid = null) { try { - if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + if (!fs.existsSync(PID_FILE)) return false; + if (expectedPid !== null && loadPid() !== expectedPid) return false; + fs.unlinkSync(PID_FILE); + return true; } catch { /* ignore */ } + return false; } diff --git a/tests/unit/tunnel-pid-ownership.test.js b/tests/unit/tunnel-pid-ownership.test.js new file mode 100644 index 00000000..57bc2bd0 --- /dev/null +++ b/tests/unit/tunnel-pid-ownership.test.js @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("cloudflared PID ownership", () => { + let dataDir; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-tunnel-pid-")); + process.env.DATA_DIR = dataDir; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env.DATA_DIR; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("does not let an old child clear its successor PID", async () => { + const { clearPid, loadPid, savePid } = await import("../../src/lib/tunnel/cloudflare/pid.js"); + + savePid(100); + savePid(200); + clearPid(100); + + expect(loadPid()).toBe(200); + + clearPid(200); + expect(loadPid()).toBeNull(); + }); + + it("releases PID and process ownership for the exiting child only", () => { + const source = fs.readFileSync(new URL("../../src/lib/tunnel/cloudflare/cloudflared.js", import.meta.url), "utf8"); + + expect(source.match(/clearPid\(child\.pid\)/g)).toHaveLength(2); + expect(source.match(/cloudflaredProcess === child/g)).toHaveLength(2); + }); +}); From 007d372724341fe1ced899fb3c110c60968fd046 Mon Sep 17 00:00:00 2001 From: ankit1324 Date: Thu, 23 Jul 2026 16:26:22 +0700 Subject: [PATCH 06/34] fix(kiro): normalize dashboard thinking intensity models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip the generic dashboard model(level) suffix before resolving Kiro synthetic -thinking/-agentic variants so the upstream request no longer carries an invalid parenthesized model id. Map explicit levels to native Kiro effort fields only for supported Claude/GPT model families, and stop advertising native levels for unsupported legacy Kiro models. Applies to both OpenAI→Kiro and direct Claude→Kiro routes. --- open-sse/config/kiroConstants.js | 35 ++++++++++++++++++- open-sse/providers/thinkingLevels.js | 2 ++ open-sse/translator/request/claude-to-kiro.js | 13 ++++--- open-sse/translator/request/openai-to-kiro.js | 13 ++++--- tests/translator/claude-kiro-direct.test.js | 12 +++++++ tests/unit/openai-to-kiro.test.js | 25 +++++++++++++ tests/unit/thinking-levels-kiro.test.js | 14 ++++++++ 7 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 tests/unit/thinking-levels-kiro.test.js diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 511ae919..2f97256d 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -15,7 +15,7 @@ * fiction. The suffix is stripped before the request leaves this process. */ -import { extractThinking } from "../translator/concerns/thinkingUnified.js"; +import { extractThinking, parseSuffix } from "../translator/concerns/thinkingUnified.js"; import { effortToBudget } from "../translator/concerns/thinking.js"; export const KIRO_AGENTIC_SUFFIX = "-agentic"; @@ -40,6 +40,39 @@ export function resolveDefaultProfileArn(authMethod) { export const KIRO_THINKING_BUDGET_DEFAULT = 16000; +/** + * Resolve a Kiro model after consuming the generic model(level) suffix. + * The suffix is a 9router request override, not part of Kiro's upstream model id. + */ +export function resolveKiroModelIntent(model) { + const { cleanModel, override } = parseSuffix(model); + return { + model: cleanModel, + ...resolveKiroModel(cleanModel), + thinkingOverride: override, + }; +} + +/** Apply a parsed model(level) override without mutating the caller's body. */ +export function applyKiroThinkingOverride(body, override) { + if (!override) return body; + + const next = { ...body }; + if (override.mode === "budget") { + delete next.output_config; + delete next.reasoning_effort; + delete next.reasoning; + next.thinking = { type: "enabled", budget_tokens: override.budget }; + return next; + } + + next.output_config = { + ...(body.output_config || {}), + effort: override.mode === "level" ? override.level : override.mode, + }; + return next; +} + export const KIRO_AGENTIC_SYSTEM_PROMPT = ` # CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY) diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index ba998ee7..7b638700 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -2,6 +2,7 @@ // Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY). import { getCapabilitiesForModel } from "./capabilities.js"; import { matchPattern } from "./pricing.js"; +import { resolveKiroEffortPath } from "../config/kiroConstants.js"; // Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. const L = { @@ -39,6 +40,7 @@ const PATTERN_THINKING = [ // Returns valid thinking levels for a model, or null when the model has no reasoning. export function getThinkingLevels(provider, model) { + if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; const caps = getCapabilitiesForModel(provider, model); if (!caps.reasoning) return null; const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 98531c73..51b9c7f8 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -27,7 +27,8 @@ import { FORMATS } from "../formats.js"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, @@ -389,10 +390,12 @@ export function claudeToKiroRequest(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); - const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); - const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); // Guard 1: no client tools → flatten all tool interactions to text. if (!clientProvidedTools) { diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 069feffa..798c827f 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -8,7 +8,8 @@ import { v4 as uuidv4 } from "uuid"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, @@ -524,10 +525,12 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); - const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); - const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 3c2d964b..3fca7be1 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -98,6 +98,18 @@ describe("Claude → Kiro (direct route)", () => { expect(out.systemPrompt).toContain("24576"); }); + it("normalizes an unsupported Kiro intensity suffix while preserving agentic behavior", () => { + const out = C2K( + { messages: [{ role: "user", content: "hello" }] }, + null, + "claude-sonnet-4.5-thinking-agentic(high)", + ); + + expect(out.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-4.5"); + expect(out.additionalModelRequestFields).toBeUndefined(); + expect(out.systemPrompt).toContain("CHUNKED WRITE PROTOCOL"); + }); + it("maps output_config.effort high to Kiro CLI-style additionalModelRequestFields for effort models", () => { const out = C2K({ output_config: { effort: "high" }, diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index dbaf7c85..17773bdb 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -424,6 +424,31 @@ describe("openaiToKiroRequest", () => { expect(result.additionalModelRequestFields).toBeUndefined(); }); + it.each([ + ["claude-sonnet-4.5-thinking-agentic(high)", "claude-sonnet-4.5"], + ["glm-5-thinking-agentic(medium)", "glm-5"], + ])("normalizes unsupported Kiro intensity suffix for %s", (model, upstream) => { + const result = openaiToKiroRequest(model, { + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe(upstream); + expect(result.additionalModelRequestFields).toBeUndefined(); + expect(systemPromptOf(result)).toContain("CHUNKED WRITE PROTOCOL"); + }); + + it("maps a supported Kiro Claude intensity suffix to native effort fields", () => { + const result = openaiToKiroRequest("claude-sonnet-5-thinking-agentic(high)", { + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-5"); + expect(result.additionalModelRequestFields).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "high" }, + }); + }); + it("does not send additionalModelRequestFields for date-suffixed Claude 4 model ids", () => { const body = { reasoning_effort: "high", diff --git a/tests/unit/thinking-levels-kiro.test.js b/tests/unit/thinking-levels-kiro.test.js new file mode 100644 index 00000000..edb3b7aa --- /dev/null +++ b/tests/unit/thinking-levels-kiro.test.js @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; + +describe("getThinkingLevels for Kiro", () => { + it("does not advertise native intensity for legacy Kiro models", () => { + expect(getThinkingLevels("kiro", "claude-sonnet-4.5")).toBeNull(); + expect(getThinkingLevels("kiro", "glm-5")).toBeNull(); + }); + + it("advertises native levels for supported Kiro models", () => { + expect(getThinkingLevels("kiro", "claude-sonnet-5")).toContain("high"); + expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toContain("xhigh"); + }); +}); From 3c17d3406b974926f745bb6f1cc4543d5fbdd4aa Mon Sep 17 00:00:00 2001 From: jacardl Date: Thu, 23 Jul 2026 16:24:07 +0700 Subject: [PATCH 07/34] fix(jina-reader): recover after transient errors and use JSON POST API Clear stale provider error code and account lock after a successful web fetch (the core fetch handler never consumed the onRequestSuccess callback), switch Jina Reader to its documented JSON POST request, and parse the Title: metadata line before falling back to a Markdown heading. --- open-sse/handlers/fetch/index.js | 16 +++- src/sse/handlers/fetch.js | 4 +- src/sse/services/auth.js | 8 +- .../unit/fetch-success-clears-account.test.js | 91 +++++++++++++++++++ tests/unit/jina-reader-fetch.test.js | 66 ++++++++++++++ 5 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 tests/unit/fetch-success-clears-account.test.js create mode 100644 tests/unit/jina-reader-fetch.test.js diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.js index da1c2303..187bd60e 100644 --- a/open-sse/handlers/fetch/index.js +++ b/open-sse/handlers/fetch/index.js @@ -49,7 +49,10 @@ function truncate(text, max) { } function parseJinaTitle(text) { - const m = String(text || "").match(/^\s*#\s+(.+)$/m); + const source = String(text || ""); + const metadataTitle = source.match(/^\s*Title:\s*(.+)$/mi); + if (metadataTitle) return metadataTitle[1].trim(); + const m = source.match(/^\s*#\s+(.+)$/m); return m ? m[1].trim() : null; } @@ -151,11 +154,14 @@ async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPe } async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { - const target = `https://r.jina.ai/${encodeURIComponent(url)}`; const upstreamStart = Date.now(); - const r = await tryFetch(target, { - method: "GET", - headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} + const r = await tryFetch("https://r.jina.ai/", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ url }) }, timeoutMs); if (!r.ok) { diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index db62a8c7..0005095c 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -195,13 +195,11 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s providerSpecificData: newCreds.providerSpecificData, testStatus: "active" }); - }, - onRequestSuccess: async () => { - await clearAccountError(credentials.connectionId, credentials); } }); if (result.success) { + await clearAccountError(credentials.connectionId, credentials); return new Response(JSON.stringify(result.data), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index f931209b..36fd6c49 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -285,7 +285,13 @@ export async function clearAccountError(connectionId, currentConnection, model = // Only reset error state if no active locks remain if (remainingActiveLocks.length === 0) { - Object.assign(clearObj, { testStatus: "active", lastError: null, lastErrorAt: null, backoffLevel: 0 }); + Object.assign(clearObj, { + testStatus: "active", + lastError: null, + errorCode: null, + lastErrorAt: null, + backoffLevel: 0 + }); } await updateProviderConnection(connectionId, clearObj); diff --git a/tests/unit/fetch-success-clears-account.test.js b/tests/unit/fetch-success-clears-account.test.js new file mode 100644 index 00000000..eb8aa267 --- /dev/null +++ b/tests/unit/fetch-success-clears-account.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderCredentials: vi.fn(), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + extractApiKey: vi.fn(() => null), + isValidApiKey: vi.fn(), + getSettings: vi.fn(), + getCombos: vi.fn(), + handleFetchCore: vi.fn(), + checkAndRefreshToken: vi.fn(), +})); + +vi.mock("@/sse/services/auth.js", () => ({ + getProviderCredentials: mocks.getProviderCredentials, + markAccountUnavailable: mocks.markAccountUnavailable, + clearAccountError: mocks.clearAccountError, + extractApiKey: mocks.extractApiKey, + isValidApiKey: mocks.isValidApiKey, +})); + +vi.mock("@/lib/localDb", () => ({ + getSettings: mocks.getSettings, + getCombos: mocks.getCombos, +})); + +vi.mock("open-sse/handlers/fetch/index.js", () => ({ + handleFetchCore: mocks.handleFetchCore, +})); + +vi.mock("@/sse/services/tokenRefresh.js", () => ({ + checkAndRefreshToken: mocks.checkAndRefreshToken, + updateProviderCredentials: vi.fn(), +})); + +vi.mock("@/sse/utils/logger.js", () => ({ + request: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + maskKey: vi.fn(() => "masked"), +})); + +vi.mock("@/shared/utils/ssrfGuard.js", () => ({ + assertPublicUrl: vi.fn(), +})); + +import { handleFetch } from "@/sse/handlers/fetch.js"; + +describe("web fetch account state", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireApiKey: false }); + mocks.getCombos.mockResolvedValue([]); + mocks.getProviderCredentials.mockResolvedValue({ + apiKey: "jina-test-key", + connectionId: "jina-connection", + connectionName: "Jina Test", + _connection: { + testStatus: "unavailable", + lastError: "old error", + modelLock___all: "2026-01-01T00:00:00.000Z", + }, + }); + mocks.checkAndRefreshToken.mockImplementation(async (_provider, credentials) => credentials); + mocks.handleFetchCore.mockResolvedValue({ + success: true, + data: { provider: "jina-reader", content: { text: "ok" } }, + }); + }); + + it("clears a stale provider lock after a successful fetch", async () => { + const response = await handleFetch(new Request("http://localhost/v1/web/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "jina-reader", + url: "https://example.com/article", + }), + })); + + expect(response.status).toBe(200); + expect(mocks.clearAccountError).toHaveBeenCalledWith( + "jina-connection", + expect.objectContaining({ connectionName: "Jina Test" }), + ); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/jina-reader-fetch.test.js b/tests/unit/jina-reader-fetch.test.js new file mode 100644 index 00000000..3a6512da --- /dev/null +++ b/tests/unit/jina-reader-fetch.test.js @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleFetchCore } from "../../open-sse/handlers/fetch/index.js"; + +const originalFetch = global.fetch; + +describe("Jina Reader fetch", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("uses Jina's JSON POST API instead of embedding the URL in the path", async () => { + global.fetch.mockResolvedValueOnce(new Response([ + "Title: Example page", + "", + "URL Source: https://example.com/article", + "", + "Markdown Content:", + "Hello", + ].join("\n"))); + + const result = await handleFetchCore({ + url: "https://example.com/article", + format: "markdown", + provider: "jina-reader", + providerConfig: { timeoutMs: 30000 }, + credentials: { apiKey: "jina-test-key" }, + }); + + expect(result.success).toBe(true); + expect(result.data.title).toBe("Example page"); + expect(global.fetch).toHaveBeenCalledTimes(1); + + const [requestUrl, init] = global.fetch.mock.calls[0]; + expect(requestUrl).toBe("https://r.jina.ai/"); + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ + "content-type": "application/json", + authorization: "Bearer jina-test-key", + }); + expect(JSON.parse(init.body)).toEqual({ url: "https://example.com/article" }); + }); + + it("returns the upstream status and error body", async () => { + global.fetch.mockResolvedValueOnce(new Response( + JSON.stringify({ detail: "Payment required" }), + { status: 402, headers: { "Content-Type": "application/json" } }, + )); + + const result = await handleFetchCore({ + url: "https://example.com/article", + provider: "jina-reader", + providerConfig: { timeoutMs: 30000 }, + credentials: { apiKey: "jina-test-key" }, + }); + + expect(result).toMatchObject({ + success: false, + status: 402, + }); + expect(result.error).toContain("Payment required"); + }); +}); From 783e271c167381497807ad7332b770cb5d8cb7e2 Mon Sep 17 00:00:00 2001 From: Phuong Lambert Date: Thu, 23 Jul 2026 16:33:03 +0700 Subject: [PATCH 08/34] feat(gemini): add Gemini 3.6 Flash tier routing and 3.5 Flash Lite Add gemini-3.6-flash tiered (high/medium/low) for Antigravity routing via upstreamModelId "gemini-3.6-flash-tiered(level)" + thinkingLevel, plus gemini-3.6-flash and gemini-3.5-flash-lite direct API models. - getModelUpstreamId: split (level) suffix before lookup, re-append after - Antigravity executor: preserve transformed body.model - MITM extractModel: parse thinkingLevel for tiered model (default medium) - Isolate Cloud Code endpoints: discovery (loadCodeAssist/onboardUser/ quota) on PROD cloudcode-pa, chat transport on daily-cloudcode-pa to bypass prod 429 --- cli/src/cli/menus/providers.js | 5 + open-sse/config/appConstants.js | 15 ++- open-sse/config/providerModels.js | 10 +- open-sse/executors/antigravity.js | 2 +- open-sse/providers/registry/antigravity.js | 9 +- open-sse/providers/shared.js | 2 +- open-sse/services/projectId.js | 15 ++- src/mitm/config.js | 43 +++++- src/mitm/server.js | 38 +----- src/shared/constants/cliTools.js | 7 +- src/sse/handlers/chat.js | 2 +- tests/__baseline__/known-fails.txt | 1 - tests/__baseline__/providers-baseline.json | 4 +- tests/unit/antigravity-retry-hook.test.js | 4 +- tests/unit/gemini-36-integration.test.js | 144 +++++++++++++++++++++ 15 files changed, 236 insertions(+), 65 deletions(-) create mode 100644 tests/unit/gemini-36-integration.test.js diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 291ae674..7e28ec64 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -53,6 +53,9 @@ const PROVIDER_MODELS = { { id: "glm-4.7" }, ], ag: [ + { id: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low" }, { id: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-low" }, { id: "gemini-3.5-flash-extra-low" }, @@ -95,6 +98,8 @@ const PROVIDER_MODELS = { { id: "claude-3-5-sonnet-20241022" }, ], gemini: [ + { id: "gemini-3.6-flash" }, + { id: "gemini-3.5-flash-lite" }, { id: "gemini-3-pro-preview" }, { id: "gemini-2.5-pro" }, { id: "gemini-2.5-flash" }, diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 2934fc88..5e3ec4be 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -134,10 +134,19 @@ export const ANTIGRAVITY_HEADERS = { "User-Agent": ANTIGRAVITY_IDE_USER_AGENT }; -// Cloud Code Assist API +// Cloud Code Assist API endpoints differ by client ecosystem. export const CLOUD_CODE_API = { - loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + "gemini-cli": { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, + // Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host + // rejects these auth/onboarding calls. Only chat traffic uses the daily host + // (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429). + antigravity: { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, }; export const LOAD_CODE_ASSIST_HEADERS = { diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 108806e8..860f7097 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -4,7 +4,6 @@ import REGISTRY from "../providers/registry/index.js"; import { PROVIDER_MODELS } from "../providers/index.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; - export { PROVIDER_MODELS }; @@ -70,8 +69,13 @@ export function getModelUpstreamId(aliasOrId, modelId) { const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId; const models = PROVIDER_MODELS[aliasOrId]; const found = findModel(models, baseId, aliasOrId); - if (found?.upstreamModelId) return found.upstreamModelId + suffix; - if (found?.id) return found.id + suffix; + const resolvedId = found?.upstreamModelId || found?.id; + if (resolvedId) { + const presetMatch = resolvedId.match(/\([^()]+\)\s*$/); + const presetSuffix = presetMatch?.[0] || ""; + const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId; + return resolvedBase + (suffix || presetSuffix); + } if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) { return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix; } diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index f669e3a7..9ca61853 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -264,7 +264,7 @@ export class AntigravityExecutor extends BaseExecutor { return { ...body, project: projectId, - model: model, + model: body.model || model, userAgent: "antigravity", requestType: "agent", requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }), diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 6818e764..a4ca7346 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -36,6 +36,7 @@ export default { }, }, usage: { + // Discovery (quota/project) on PROD; daily host rejects these. quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", tokenUrl: "https://oauth2.googleapis.com/token", @@ -44,9 +45,9 @@ export default { clientSecret: "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", }, models: [ - { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, - { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, - { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" }, { id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, @@ -71,7 +72,7 @@ export default { "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", ], - apiEndpoint: "https://cloudcode-pa.googleapis.com", + apiEndpoint: "https://daily-cloudcode-pa.googleapis.com", apiVersion: "v1internal", loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 6f6a2c20..85b256a1 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -58,7 +58,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1"; // Keep this static even when 9router runs on Linux: the provider profile is // intentionally matching the IDE client, not the server host. export const ANTIGRAVITY_IDE_VERSION = "2.1.1"; -export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`; // Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth) diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js index f9a24e1a..3801ac69 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -83,7 +83,7 @@ startCacheCleanup(); * @param {string} accessToken - Valid OAuth access token * @returns {Promise} Real project ID or null */ -export async function getProjectIdForConnection(connectionId, accessToken) { +export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") { if (!connectionId || !accessToken) return null; // Return cached value if still fresh @@ -102,7 +102,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { const promise = (async () => { try { - const projectId = await fetchProjectId(accessToken, controller.signal); + const projectId = await fetchProjectId(accessToken, controller.signal, provider); if (projectId) { projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); return projectId; @@ -155,8 +155,9 @@ export function removeConnection(connectionId) { * @param {AbortSignal} signal * @returns {Promise} */ -async function fetchProjectId(accessToken, signal) { - const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { +async function fetchProjectId(accessToken, signal, provider) { + const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"]; + const response = await fetch(endpoints.loadCodeAssist, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), @@ -185,7 +186,7 @@ async function fetchProjectId(accessToken, signal) { } } - return onboardUser(accessToken, tierID, signal); + return onboardUser(accessToken, tierID, signal, endpoints); } /** @@ -196,7 +197,7 @@ async function fetchProjectId(accessToken, signal) { * @param {AbortSignal} externalSignal – propagated from the connection's AbortController * @returns {Promise} */ -async function onboardUser(accessToken, tierID, externalSignal) { +async function onboardUser(accessToken, tierID, externalSignal, endpoints) { console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; @@ -213,7 +214,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { externalSignal?.addEventListener("abort", forwardAbort); try { - const response = await fetch(CLOUD_CODE_API.onboardUser, { + const response = await fetch(endpoints.onboardUser, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify(reqBody), diff --git a/src/mitm/config.js b/src/mitm/config.js index e109ec95..ffbe07e1 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -86,4 +86,45 @@ function getToolForHost(host) { return null; } -module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost }; +function isBinaryData(buffer) { + if (!buffer || buffer.length === 0) return false; + const sample = buffer.slice(0, Math.min(100, buffer.length)); + let nonPrintable = 0; + for (let i = 0; i < sample.length; i++) { + const byte = sample[i]; + if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { + nonPrintable++; + } + if (byte > 0x7E) nonPrintable++; + } + return (nonPrintable / sample.length) > 0.3; +} + +// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState. +function extractModel(url, body) { + const urlMatch = url.match(/\/models\/([^/:]+)/); + const urlModel = urlMatch?.[1] || null; + + if (isBinaryData(body)) return urlModel; + + try { + const parsed = JSON.parse(body.toString()); + if (parsed.conversationState) { + return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; + } + const model = urlModel || parsed.model || null; + if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") { + const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel + || parsed.generationConfig?.thinkingConfig?.thinkingLevel; + const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase()) + ? String(rawLevel).toLowerCase() + : "medium"; + return `gemini-3.6-flash-${level}`; + } + return model; + } catch { + return urlModel; + } +} + +module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel }; diff --git a/src/mitm/server.js b/src/mitm/server.js index d675e6a3..ce432eee 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -7,7 +7,7 @@ const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); -const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); +const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { generateCert, getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); @@ -96,42 +96,6 @@ function collectBodyRaw(req) { }); } -// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState -function extractModel(url, body) { - const urlMatch = url.match(/\/models\/([^/:]+)/); - if (urlMatch) return urlMatch[1]; - - // Skip parsing if body is binary (AWS EventStream, Protocol Buffers, etc.) - if (isBinaryData(body)) return null; - - try { - const parsed = JSON.parse(body.toString()); - if (parsed.conversationState) { - return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; - } - return parsed.model || null; - } catch { return null; } -} - -// Detect binary data vs JSON text -function isBinaryData(buffer) { - if (!buffer || buffer.length === 0) return false; - // AWS EventStream signature: first 4 bytes = frame length (big-endian uint32) - // Check for non-printable chars in first 100 bytes (common in binary protocols) - const sample = buffer.slice(0, Math.min(100, buffer.length)); - let nonPrintable = 0; - for (let i = 0; i < sample.length; i++) { - const byte = sample[i]; - // Count non-ASCII printable chars (excluding whitespace) - if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { - nonPrintable++; - } - if (byte > 0x7E) nonPrintable++; - } - // If >30% non-printable, treat as binary - return (nonPrintable / sample.length) > 0.3; -} - function getMappedModel(tool, model) { if (!model) return null; try { diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index b501e884..81dcd9d2 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -8,9 +8,12 @@ export const MITM_TOOLS = { description: "Google Antigravity IDE with MITM", configType: "mitm", mitmDomain: "daily-cloudcode-pa.googleapis.com", - modelAliases: ["gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"], + modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"], defaultModels: [ - { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low" }, + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", alias: "gemini-3.6-flash-low" }, + { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low", mandatory: true }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)", alias: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)", alias: "gemini-3.5-flash-extra-low" }, { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)", alias: "gemini-3.1-pro-low" }, diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 77f450c1..af2914a4 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -219,7 +219,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Ensure real project ID is available for providers that need it (P0 fix: cold miss) if ((provider === "antigravity" || provider === "gemini-cli") && !refreshedCredentials.projectId) { - const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken); + const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken, provider); if (pid) { refreshedCredentials.projectId = pid; // Persist to DB in background so subsequent requests have it immediately diff --git a/tests/__baseline__/known-fails.txt b/tests/__baseline__/known-fails.txt index 9ffd63f9..49fd6a11 100644 --- a/tests/__baseline__/known-fails.txt +++ b/tests/__baseline__/known-fails.txt @@ -1,4 +1,3 @@ -tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 232c5d59..e8f64967 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -25,7 +25,7 @@ }, "antigravity": { "baseUrls": [ - "https://cloudcode-pa.googleapis.com" + "https://daily-cloudcode-pa.googleapis.com" ], "format": "antigravity", "headers": { @@ -881,4 +881,4 @@ }, "format": "openai" } -} \ No newline at end of file +} diff --git a/tests/unit/antigravity-retry-hook.test.js b/tests/unit/antigravity-retry-hook.test.js index 989dd88f..adb8bd25 100644 --- a/tests/unit/antigravity-retry-hook.test.js +++ b/tests/unit/antigravity-retry-hook.test.js @@ -67,8 +67,8 @@ describe("antigravity computeRetryDelay hook (D3)", () => { expect(out.request.tools[0].functionDeclarations.map(fn => fn.name)).toEqual(["read_file"]); }); - it("registry uses the official IDE cloudcode host and user agent", () => { - expect(antigravity.transport.baseUrls).toEqual(["https://cloudcode-pa.googleapis.com"]); + it("registry uses the daily IDE cloudcode host and user agent", () => { + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64"); }); diff --git a/tests/unit/gemini-36-integration.test.js b/tests/unit/gemini-36-integration.test.js new file mode 100644 index 00000000..e401f923 --- /dev/null +++ b/tests/unit/gemini-36-integration.test.js @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { getModelUpstreamId } from "../../open-sse/config/providerModels.js"; +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; +import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js"; +import antigravity from "../../open-sse/providers/registry/antigravity.js"; +import geminiCli from "../../open-sse/providers/registry/gemini-cli.js"; +import gemini from "../../open-sse/providers/registry/gemini.js"; +import { MODEL_PRICING } from "../../open-sse/providers/pricing.js"; +import { + getProjectIdForConnection, + removeConnection, +} from "../../open-sse/services/projectId.js"; + +const require = createRequire(import.meta.url); +const mitmConfig = require("../../src/mitm/config.js"); +const here = dirname(fileURLToPath(import.meta.url)); + +function cloudCodeResponse(projectId) { + return { + ok: true, + json: async () => ({ cloudaicompanionProject: { id: projectId } }), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Gemini Cloud Code endpoint isolation", () => { + it("keeps Gemini CLI on the official cloudcode host", async () => { + const connectionId = "gemini-cli-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("gemini-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "gemini-cli"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + expect(geminiCli.transport.baseUrl).toBe("https://cloudcode-pa.googleapis.com/v1internal"); + removeConnection(connectionId); + }); + + it("uses the prod cloudcode host for Antigravity discovery but daily for chat", async () => { + const connectionId = "antigravity-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("antigravity-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "antigravity"); + + // Discovery (loadCodeAssist) on PROD — daily host rejects auth/onboarding calls. + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + // Chat transport still uses the daily host to bypass prod 429. + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); + removeConnection(connectionId); + }); +}); + +describe("Gemini 3.6 Antigravity tiers", () => { + it.each(["high", "medium", "low"])( + "maps the %s tier to the shared upstream model with matching thinking level", + (tier) => { + const publicModel = `gemini-3.6-flash-${tier}`; + const upstreamModel = getModelUpstreamId("ag", publicModel); + const body = { + model: stripThinkingSuffix(upstreamModel), + request: { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + generationConfig: {}, + }, + }; + + applyThinking("antigravity", upstreamModel, body, "antigravity"); + const finalBody = new AntigravityExecutor().transformRequest( + publicModel, + body, + true, + { projectId: "project", connectionId: "connection" } + ); + + expect(upstreamModel).toBe(`gemini-3.6-flash-tiered(${tier})`); + expect(finalBody.model).toBe("gemini-3.6-flash-tiered"); + expect(finalBody.request.generationConfig.thinkingConfig).toEqual({ + thinkingLevel: tier, + includeThoughts: true, + }); + } + ); +}); + +describe("Gemini 3.6 MITM model extraction", () => { + it("exports the model extractor from the side-effect-free MITM config module", () => { + expect(mitmConfig.extractModel).toBeTypeOf("function"); + }); + + it.each(["high", "medium", "low"])("extracts the %s thinking tier", (tier) => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe(`gemini-3.6-flash-${tier}`); + }); + + it("defaults invalid or missing thinking levels to medium", () => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe("gemini-3.6-flash-medium"); + }); +}); + +describe("Gemini 3.6 catalogs and pricing", () => { + it("exposes the direct Gemini API models and their pricing", () => { + const ids = gemini.models.map((model) => model.id); + expect(ids).toContain("gemini-3.6-flash"); + expect(ids).toContain("gemini-3.5-flash-lite"); + expect(MODEL_PRICING["gemini-3.6-flash"]).toMatchObject({ input: 1.5, output: 7.5 }); + expect(MODEL_PRICING["gemini-3.5-flash-lite"]).toMatchObject({ input: 0.3, output: 2.5 }); + }); + + it("keeps the standalone CLI Gemini catalog synchronized", () => { + const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8"); + const geminiCatalog = source.match(/\n gemini: \[([\s\S]*?)\n \],/)?.[1] || ""; + + expect(geminiCatalog).toContain("gemini-3.6-flash"); + expect(geminiCatalog).toContain("gemini-3.5-flash-lite"); + }); +}); From 8e04fe1734eaee6458de1ecbb9a76d3bbab9986d Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 25 Jul 2026 17:25:19 +0700 Subject: [PATCH 09/34] feat(oauth): zed/trae/windsurf providers + harden callback proxies - zed live model discovery; codebuddy-intl handler; remove duplicate workbuddy - split oauth providers.js into per-provider files (facade re-export) - fold 5 standard refresh providers into config-driven generic - hide trae/windsurf from registry (no tool calling support) - fix login-CSRF + SSRF on trae/windsurf/zed local callback proxies via loopback-origin guard + strict state validation + apiOrigins allowlist - move zed RSA private key transit to POST body; redact proxy logs Co-Authored-By: Claude Fable 5 --- open-sse/executors/index.js | 3 - open-sse/executors/trae.js | 345 +++- open-sse/executors/windsurf.js | 598 +++++- open-sse/executors/workbuddy.js | 31 - open-sse/providers/registry/index.js | 12 +- open-sse/providers/registry/trae.js | 27 +- open-sse/providers/registry/windsurf.js | 27 +- open-sse/providers/registry/workbuddy.js | 73 - open-sse/services/tokenRefresh.js | 3 - open-sse/services/tokenRefresh/providers.js | 355 +--- open-sse/services/usage/grok-cli.js | 15 + .../api/oauth/[provider]/[action]/route.js | 136 +- src/app/api/v1/models/route.js | 19 +- src/lib/oauth/constants/oauth.js | 78 + src/lib/oauth/providers.js | 1719 +---------------- src/lib/oauth/providers/_shared.js | 14 + src/lib/oauth/providers/antigravity.js | 122 ++ src/lib/oauth/providers/claude.js | 60 + src/lib/oauth/providers/cline.js | 62 + src/lib/oauth/providers/clinepass.js | 62 + src/lib/oauth/providers/codebuddy-cn.js | 80 + src/lib/oauth/providers/codebuddy-intl.js | 74 + src/lib/oauth/providers/codex.js | 69 + src/lib/oauth/providers/cursor.js | 19 + src/lib/oauth/providers/gemini-cli.js | 85 + src/lib/oauth/providers/github.js | 98 + src/lib/oauth/providers/gitlab.js | 64 + src/lib/oauth/providers/grok-cli.js | 130 ++ src/lib/oauth/providers/iflow.js | 91 + src/lib/oauth/providers/index.js | 241 +++ src/lib/oauth/providers/kilocode.js | 60 + src/lib/oauth/providers/kimchi.js | 75 + src/lib/oauth/providers/kimi.js | 81 + src/lib/oauth/providers/kiro.js | 151 ++ src/lib/oauth/providers/qoder.js | 102 + src/lib/oauth/providers/qwen.js | 56 + src/lib/oauth/providers/trae.js | 263 +++ src/lib/oauth/providers/windsurf.js | 132 ++ src/lib/oauth/providers/xai.js | 96 + src/lib/oauth/providers/zed.js | 62 + src/lib/oauth/utils/ideDetect.js | 57 + src/lib/oauth/utils/server.js | 333 +++- src/shared/components/OAuthModal.js | 211 +- tests/unit/grok-cli-usage.test.js | 37 + tests/unit/token-refresh-generic.test.js | 146 ++ tests/unit/windsurf-executor.test.js | 198 ++ 46 files changed, 4569 insertions(+), 2203 deletions(-) delete mode 100644 open-sse/executors/workbuddy.js delete mode 100644 open-sse/providers/registry/workbuddy.js create mode 100644 src/lib/oauth/providers/_shared.js create mode 100644 src/lib/oauth/providers/antigravity.js create mode 100644 src/lib/oauth/providers/claude.js create mode 100644 src/lib/oauth/providers/cline.js create mode 100644 src/lib/oauth/providers/clinepass.js create mode 100644 src/lib/oauth/providers/codebuddy-cn.js create mode 100644 src/lib/oauth/providers/codebuddy-intl.js create mode 100644 src/lib/oauth/providers/codex.js create mode 100644 src/lib/oauth/providers/cursor.js create mode 100644 src/lib/oauth/providers/gemini-cli.js create mode 100644 src/lib/oauth/providers/github.js create mode 100644 src/lib/oauth/providers/gitlab.js create mode 100644 src/lib/oauth/providers/grok-cli.js create mode 100644 src/lib/oauth/providers/iflow.js create mode 100644 src/lib/oauth/providers/index.js create mode 100644 src/lib/oauth/providers/kilocode.js create mode 100644 src/lib/oauth/providers/kimchi.js create mode 100644 src/lib/oauth/providers/kimi.js create mode 100644 src/lib/oauth/providers/kiro.js create mode 100644 src/lib/oauth/providers/qoder.js create mode 100644 src/lib/oauth/providers/qwen.js create mode 100644 src/lib/oauth/providers/trae.js create mode 100644 src/lib/oauth/providers/windsurf.js create mode 100644 src/lib/oauth/providers/xai.js create mode 100644 src/lib/oauth/providers/zed.js create mode 100644 src/lib/oauth/utils/ideDetect.js create mode 100644 tests/unit/token-refresh-generic.test.js create mode 100644 tests/unit/windsurf-executor.test.js diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index c9340c10..7191facd 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -21,7 +21,6 @@ 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"; @@ -56,7 +55,6 @@ const executors = { 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(), @@ -99,7 +97,6 @@ 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 index 347d5307..59f29be3 100644 --- a/open-sse/executors/trae.js +++ b/open-sse/executors/trae.js @@ -1,22 +1,339 @@ -import { DefaultExecutor } from "./default.js"; +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.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 { +// Trae executor — SOLO remote agent API. +// +// Flow: +// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } } +// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id} +// → text/event-stream. Assistant text streams in `plan_item` events under +// the `thought` field (cumulative per plan-item id). `token_usage` carries +// usage; `done` ends the turn; `error` carries upstream errors. +// +// Auth: header `Authorization: Cloud-IDE-JWT ` (RS256, ~14-day lifetime). +// Identity fields for common_params live in credentials.providerSpecificData. + +const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); +const TRAE_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +function flattenQuery(messages) { + const parts = []; + for (const m of messages) { + let content = ""; + if (typeof m.content === "string") content = m.content; + else if (Array.isArray(m.content)) { + content = m.content + .map((p) => { + if (typeof p === "string") return p; + if (p && typeof p === "object") return String(p.text ?? ""); + return ""; + }) + .join(""); + } + if (m.role === "system") parts.push(`[System]\n${content}`); + else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`); + else parts.push(content); + } + // Trae expects query as a JSON-encoded string of typed content blocks. + return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]); +} + +export default class TraeExecutor extends BaseExecutor { constructor() { - super("trae"); + super("trae", PROVIDERS.trae); + } + + base() { + return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, ""); } 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; + const token = credentials?.accessToken || ""; + const psd = credentials?.providerSpecificData || {}; + return { + Authorization: `Cloud-IDE-JWT ${token}`, + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": psd.appLanguage || "en", + "x-user-region": psd.userRegion || "US", + Referer: "https://solo.trae.ai/", + "User-Agent": TRAE_UA, + Accept: stream ? "text/event-stream" : "application/json", + }; } - // TODO verify: if Chat is JSON-RPC shaped, override transformRequest here. + // SOLO session modes: "code" (model picker) vs "work" (fast auto lane). + resolveMode(model) { + const m = (model || "").trim().toLowerCase(); + if (m === "work" || m === "auto-work" || m === "solo-work") { + return { mode: "work", strategy: "auto", modelName: "" }; + } + const auto = !m || m === "auto"; + return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model }; + } + + // common_params is a JSON-encoded string embedded inside initial_message. + commonParams(psd, mode, sessionId) { + const cp = { + language: "en-us", + app_language: psd.appLanguage || "en", + quality: "stable", + app_version: psd.appVersion || "1.0.0.1229", + web_id: psd.webId || "", + user_identity: psd.userIdentity || "Free", + is_freshman: "0", + biz_user_id: psd.bizUserId || "", + user_unique_id: psd.userUniqueId || "", + scope: psd.scope || "marscode-us", + tenant: psd.tenant || "marscode", + region: psd.region || "US-East", + aiRegion: psd.aiRegion || psd.region || "US-East", + is_privacy_mode: 0, + privacy_mode: "off", + solo_chat_mode: mode, + }; + if (sessionId) cp.biz_session_id = sessionId; + return JSON.stringify(cp); + } + + // POST /chat_sessions — creates a session and submits the first turn. + async createSession(headers, query, model, psd, signal) { + const { mode, strategy, modelName } = this.resolveMode(model); + const body = { + mode, + environment_id: "default", + initial_message: { + chat_session_id: "", + content: [], + query, + model_name: modelName, + agent_type: "solo_agent_remote", + model_selection_strategy: strategy, + common_params: this.commonParams(psd, mode), + }, + env: "remote", + auto_create_project: false, + origin: "web", + }; + const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }, null); + const text = await res.text(); + if (!res.ok) throw new Error(`[${res.status}] ${text}`); + const json = JSON.parse(text); + if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`); + return { sessionId: json.data.chat_session_id, messageId: json.data.message_id }; + } + + // GET /events SSE → invoke onEvent(eventType, dataObj) per frame. + // Resolves when `done`/`error` arrives, the stream ends, or timeout fires. + async streamEvents(headers, sessionId, replyTo, onEvent, signal) { + const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; + const ctrl = new AbortController(); + if (signal?.aborted) ctrl.abort(); + const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS); + const onAbort = () => ctrl.abort(); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + try { + const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null); + if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let ev = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + if (line.startsWith("event:")) ev = line.slice(6).trim(); + else if (line.startsWith("data:")) { + const payload = line.slice(5).trim(); + let data; + try { data = JSON.parse(payload); } catch { data = { _raw: payload }; } + if (onEvent(ev, data)) { + await reader.cancel().catch(() => {}); + return; + } + } else if (line === "") ev = null; + } + } + } finally { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } + } + + async execute({ model, body, stream, credentials, signal }) { + const headers = this.buildHeaders(credentials, stream !== false); + const psd = credentials?.providerSpecificData || {}; + const query = flattenQuery(body?.messages || []); + const responseId = `chatcmpl-trae-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const errResponse = (status, message) => new Response( + JSON.stringify({ error: { message, type: "api_error", code: "" } }), + { status, headers: { "Content-Type": "application/json" } } + ); + + let session; + try { + session = await this.createSession(headers, query, model, psd, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + + // Shared per-turn state: plan_item thoughts (cumulative, longest wins). + const order = []; + const thoughts = {}; + let sent = 0; + let usage = null; + let errorEvent = null; + const renderNewText = (data) => { + const pid = data.id; + if (!pid) return ""; + if (!(pid in thoughts)) order.push(pid); + const t = data.thought || ""; + if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t; + const full = order.map((i) => thoughts[i]).join(""); + const piece = full.slice(sent); + sent = full.length; + return piece; + }; + + if (stream !== false) { + const enc = new TextEncoder(); + const sse = new ReadableStream({ + start: async (controller) => { + const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)); + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") { + const piece = renderNewText(data); + if (piece) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: piece }, finish_reason: null }], + }); + } + } + return ev === "done"; + }, signal); + if (errorEvent) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" }, + }); + } else { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + if (usage) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + usage: { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }, + }); + } + } + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + return { + response: new Response(sse, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Non-streaming: drive to completion, return chat.completion JSON. + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") renderNewText(data); + return ev === "done"; + }, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + if (errorEvent) { + return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body }; + } + const content = order.map((i) => thoughts[i]).join(""); + const out = { + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + }; + if (usage) { + out.usage = { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }; + } + return { + response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via + // ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js. + async refreshCredentials() { + return null; + } } diff --git a/open-sse/executors/windsurf.js b/open-sse/executors/windsurf.js index d9b19ec3..8526df17 100644 --- a/open-sse/executors/windsurf.js +++ b/open-sse/executors/windsurf.js @@ -1,40 +1,586 @@ -import { DefaultExecutor } from "./default.js"; +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; +import { randomUUID } from "node:crypto"; -// 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 { +// WindsurfExecutor — Codeium gRPC-web chat. +// +// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto). +// Service: exa.language_server_pb.LanguageServerService +// Method: GetChatMessage (unary request → streamed CompletionChunk frames) +// +// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived) +// — placed in Metadata.api_key protobuf field of every request + Bearer header. + +const WS_BASE_URL = "https://server.codeium.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 map (catalog name → Windsurf wire name) ───────────────────── +const MODEL_ALIAS_MAP = { + // ── Cognition SWE ─────────────────────────────────────────────────────── + "swe-1.6-fast": "swe-1-6-fast", + "swe-1.6": "swe-1-6", + "swe-1.5-fast": "swe-1-5-fast", + "swe-1.5": "swe-1-5", + // ── Claude Opus 4.7 — effort-tiered ───────────────────────────────────── + "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", + // ── 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", + // ── 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", + // ── 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": "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-4o": "MODEL_CHAT_GPT_4O_2024_08_06", + // ── 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-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", + // ── Others ────────────────────────────────────────────────────────────── + "deepseek-v4": "deepseek-v4", + "kimi-k2.6": "kimi-k2-6", + "kimi-k2.5": "kimi-k2-5", + "glm-5.1": "glm-5-1", +}; + +export function resolveWsModelId(model) { + return MODEL_ALIAS_MAP[model] ?? model; +} + +// ─── Minimal protobuf encoder ──────────────────────────────────────────────── +// Wire types: 0 = varint, 2 = length-delimited. + +function encodeVarint(value) { + const bytes = []; + 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) { + 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(); + +function encodeField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + return concatBytes([tag, len, payload]); +} + +function encodeString(fieldNum, value) { + return encodeField(fieldNum, TEXT_ENC.encode(value)); +} + +function encodeMessage(fieldNum, msg) { + return encodeField(fieldNum, msg); +} + +// ─── Protobuf message builders ─────────────────────────────────────────────── + +function buildMetadata(apiKey, sessionId) { + 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) { + return encodeString(1, model); +} + +function buildChatMessage(msg) { + const parts = [encodeString(1, msg.role), encodeString(2, msg.content)]; + if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId)); + return concatBytes(parts); +} + +export function buildGetChatMessageRequest(apiKey, model, messages) { + const sessionId = randomUUID(); + const cascadeId = randomUUID(); + + const parts = [ + 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 ──────────────────────────────────────────────────────── + +export function grpcWebFrame(payload) { + const frame = new Uint8Array(5 + payload.length); + frame[0] = 0x00; // 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 → ContentChunk { field 1: string text } +// field 2 → ToolCallChunk (skipped) +// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } } +// field 4 → ErrorChunk { field 1: string message } + +function readVarint(buf, offset) { + 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]; +} + +function decodeStringField(buf, targetField) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [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; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return null; +} + +function decodeDoneChunk(buf) { + // DoneChunk: field 1 = UsageStats (nested) + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + let offset = 0; + let usageBytes = null; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len); + offset += len; + } else if (wireType === 0) { + let v; + [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; + [tag, offset] = readVarint(usageBytes, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 0) { + let v; + [v, offset] = readVarint(usageBytes, offset); + if (fieldNum === 1) promptTokens = v; + else if (fieldNum === 2) completionTokens = v; + } else if (wireType === 2) { + let len; + [len, offset] = readVarint(usageBytes, offset); + offset += len; + } else { + break; + } + } + return [promptTokens, completionTokens]; +} + +export function decodeCompletionChunk(buf) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + + if (fieldNum === 1) { + const text = decodeStringField(payload, 1); + if (text !== null) return { kind: "content", text }; + } else if (fieldNum === 3) { + const usage = decodeDoneChunk(payload); + return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] }; + } else if (fieldNum === 4) { + 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; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return { kind: "unknown" }; +} + +// ─── OpenAI messages → Windsurf wire ───────────────────────────────────────── + +function openAIMessagesToWs(messages) { + const out = []; + 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.type === "text") { + content += String(part.text || ""); + } + } + } + out.push({ role, content, toolCallId: m.tool_call_id }); + } + return out; +} + +// ─── WindsurfExecutor ──────────────────────────────────────────────────────── + +export class WindsurfExecutor extends BaseExecutor { constructor() { - super("windsurf"); + super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL }); + } + + buildUrl() { + return WS_CHAT_URL; } buildHeaders(credentials, stream = true) { - const headers = { - "Content-Type": "application/proto", - "Connect-Protocol-Version": "1", - ideName: "Windsurf", - extensionName: "codeium.windsurf", - ...(this.config.headers || {}), + const token = credentials?.accessToken || credentials?.apiKey || ""; + return { + "Content-Type": "application/grpc-web+proto", + Accept: "application/grpc-web+proto", + // Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "User-Agent": `windsurf/${WS_IDE_VERSION}`, + "X-Grpc-Web": "1", }; - // 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." - ); + // Request body is built manually in execute() — requires model + messages. + transformRequest() { + return null; } + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) { + const apiKey = credentials?.accessToken || credentials?.apiKey || ""; + const wsModel = resolveWsModelId(model); + + const b = body ?? {}; + const rawMessages = Array.isArray(b.messages) ? b.messages : []; + let wsMessages = openAIMessagesToWs(rawMessages); + if (wsMessages.length === 0) { + wsMessages.push({ role: "user", content: "" }); + } + + const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages); + const framedPayload = grpcWebFrame(protoPayload); + + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials); + if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders); + + log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`); + + const upstream = await proxyAwareFetch(url, { + method: "POST", + headers, + body: framedPayload, + signal, + }, proxyOptions); + + if (!upstream.ok && upstream.status !== 200) { + return { response: upstream, url, headers, transformedBody: protoPayload }; + } + + const sseResponse = this.transformToSSE(upstream, model); + return { response: sseResponse, url, headers, transformedBody: protoPayload }; + } + + // Convert a gRPC-web binary response into an OpenAI-compatible SSE stream. + transformToSSE(upstream, model) { + const responseId = `chatcmpl-ws-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const executor = this; + + const sseStream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + let roleEmitted = false; + let totalText = ""; + let promptTokens = 0; + let completionTokens = 0; + let hadError = null; + + const emit = (data) => controller.enqueue(enc.encode(data)); + + try { + let pending = new Uint8Array(0); + const reader = upstream.body?.getReader(); + + const handleFrame = (flag, payload) => { + 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]}`; + } + return; + } + if (flag !== 0x00) return; // skip unknown flags + + const chunk = executor.constructor.decodeCompletionChunk + ? executor.constructor.decodeCompletionChunk(payload) + : 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; + } + }; + + const drainFrames = () => { + let offset = 0; + while (offset + 5 <= pending.length) { + const flag = pending[offset]; + const len = + (pending[offset + 1] << 24) | + (pending[offset + 2] << 16) | + (pending[offset + 3] << 8) | + pending[offset + 4]; + if (len < 0 || offset + 5 + len > pending.length) break; + handleFrame(flag, pending.slice(offset + 5, offset + 5 + len)); + offset += 5 + len; + } + if (offset > 0) pending = pending.slice(offset); + }; + + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + pending = pending.length === 0 ? value : concatBytes([pending, value]); + drainFrames(); + } + } finally { + reader.releaseLock(); + } + } + drainFrames(); + + 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; + } + + // Unary fallback: nothing streamed but text decoded → emit as one chunk. + 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`); + } + + const finishPayload = { + 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?.message ? String(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(); + }, + }); + + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + // apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band. async refreshCredentials() { - // Windsurf apiKey is long-lived (like cursor); refresh handled out-of-band. return null; } } diff --git a/open-sse/executors/workbuddy.js b/open-sse/executors/workbuddy.js deleted file mode 100644 index b306b18d..00000000 --- a/open-sse/executors/workbuddy.js +++ /dev/null @@ -1,31 +0,0 @@ -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/providers/registry/index.js b/open-sse/providers/registry/index.js index f083d79d..7f3867e8 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -100,10 +100,11 @@ 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"; +// Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk). +// Re-enable by uncommenting both the import and the array entry below. +// import p102 from "./trae.js"; import p103 from "./zed.js"; -import p104 from "./windsurf.js"; +// import p104 from "./windsurf.js"; export default [ p0, @@ -207,8 +208,7 @@ export default [ p98, p99, p100, - p101, - p102, + // p102, // trae — hidden, no tool calling p103, - p104, + // p104, // windsurf — hidden, no tool calling ]; diff --git a/open-sse/providers/registry/trae.js b/open-sse/providers/registry/trae.js index d99ace61..2b4ace60 100644 --- a/open-sse/providers/registry/trae.js +++ b/open-sse/providers/registry/trae.js @@ -1,7 +1,8 @@ // 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). +// Chat = SOLO remote agent API: +// POST {base}/chat_sessions → {data:{chat_session_id, message_id}} +// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE +// Auth: Authorization: Cloud-IDE-JWT export default { id: "trae", alias: "tr", @@ -20,21 +21,19 @@ export default { 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", + // SOLO remote agent base — verified working chat endpoint. + baseUrl: "https://core-normal.trae.ai/api/remote/v1", 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", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": "en", + "Referer": "https://solo.trae.ai/", }, - // Auth: x-cloudide-token + Authorization: Bearer — injected by executor buildHeaders. + // Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders. auth: { combined: true, - header: "x-cloudide-token", - scheme: "raw", + header: "Authorization", + scheme: "Cloud-IDE-JWT", }, usage: { url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", @@ -61,7 +60,7 @@ export default { // 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). + // Model catalog (IDE flow, core-normal.trae.ai). models: [ { id: "auto", name: "Auto (Server Picks)" }, { id: "work", name: "Work (Fast)" }, diff --git a/open-sse/providers/registry/windsurf.js b/open-sse/providers/registry/windsurf.js index 9d4222c8..f0b23fa8 100644 --- a/open-sse/providers/registry/windsurf.js +++ b/open-sse/providers/registry/windsurf.js @@ -1,6 +1,7 @@ // 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. +// Chat = Codeium gRPC-web protobuf: +// POST {base} Content-Type: application/grpc-web+proto +// Service: exa.language_server_pb.LanguageServerService / GetChatMessage export default { id: "windsurf", alias: "ws", @@ -17,19 +18,16 @@ export default { 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", + baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage", + format: "openai", headers: { - "Content-Type": "application/proto", - "Connect-Protocol-Version": "1", - "ideName": "Windsurf", - "extensionName": "codeium.windsurf", + "Content-Type": "application/grpc-web+proto", + "Accept": "application/grpc-web+proto", + "X-Grpc-Web": "1", }, - // Bearer of apiKey (sk-ws-... / Firebase-derived / Devin session) — Connect-Protocol scheme unverified. - auth: { combined: true, header: "Authorization" }, + // apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key. + auth: { combined: true, header: "Authorization", scheme: "Bearer" }, }, // Auth chain (4 terminal paths, all yield apiKey): @@ -52,9 +50,8 @@ export default { }, // 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. + // Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid. + // contextLength dropped — 9router schema uses id+name only. models: [ // Cognition / SWE { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, diff --git a/open-sse/providers/registry/workbuddy.js b/open-sse/providers/registry/workbuddy.js deleted file mode 100644 index 8adffe7f..00000000 --- a/open-sse/providers/registry/workbuddy.js +++ /dev/null @@ -1,73 +0,0 @@ -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/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index c8cced77..634fd633 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -14,7 +14,6 @@ import { refreshCopilotToken, refreshCodebuddyToken, refreshCodebuddyIntlToken, - refreshWorkbuddyToken, refreshTraeToken, refreshZedToken, refreshWindsurfToken, @@ -35,7 +34,6 @@ export { refreshCopilotToken, refreshCodebuddyToken, refreshCodebuddyIntlToken, - refreshWorkbuddyToken, refreshTraeToken, refreshZedToken, refreshWindsurfToken, @@ -149,7 +147,6 @@ const REFRESH_HANDLERS = { 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), diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index d82bfdeb..7c13ae77 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -31,10 +31,68 @@ export async function refreshXaiToken(refreshToken, log) { }, log); } +// Per-provider refresh variants for the generic path. Keys not listed fall back +// to the default form-encoded OAuth2 refresh with client_id + client_secret. +const REFRESH_PROFILES = { + claude: { + bodyFormat: "json", + includeClientSecret: false, + url: () => OAUTH_ENDPOINTS.anthropic.token, + dedupKey: "claude", + }, + qwen: { + url: () => OAUTH_ENDPOINTS.qwen.token, + dedupKey: "qwen", + parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {}, + }, + iflow: { + url: () => OAUTH_ENDPOINTS.iflow.token, + dedupKey: "iflow", + extraHeaders: (creds, cfg) => ({ + Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`, + }), + }, + github: { + url: () => OAUTH_ENDPOINTS.github.token, + dedupKey: "github", + includeClientSecret: (cfg) => !!cfg?.clientSecret, + }, + kimi: { + dedupKey: "kimi", + extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId), + }, +}; + +function resolveRefreshUrl(provider, config, profile) { + if (profile?.url) { + try { return profile.url(); } catch { /* fall through */ } + } + return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null; +} + +function buildRefreshBody(profile, config, refreshToken) { + const fmt = profile?.bodyFormat === "json" ? "json" : "form"; + const includeSecret = profile?.includeClientSecret === undefined + ? true + : typeof profile.includeClientSecret === "function" + ? profile.includeClientSecret(config) + : profile.includeClientSecret; + const payload = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + }; + if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret; + if (fmt === "json") return { format: "json", body: JSON.stringify(payload) }; + return { format: "form", body: new URLSearchParams(payload) }; +} + export async function refreshAccessToken(provider, refreshToken, credentials, log) { const config = PROVIDERS[provider]; + const profile = REFRESH_PROFILES[provider] || {}; + const url = resolveRefreshUrl(provider, config, profile); - if (!config || !config.refreshUrl) { + if (!config || !url) { log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); return null; } @@ -44,21 +102,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo return null; } - return dedupRefresh(provider, refreshToken, async () => { + const dedupKey = profile.dedupKey || provider; + + return dedupRefresh(dedupKey, refreshToken, async () => { try { - const response = await fetch(config.refreshUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); + const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken); + const headers = { + "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded", + Accept: "application/json", + ...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}), + }; + const response = await fetch(url, { method: "POST", headers, body }); if (!response.ok) { const errorText = await response.text(); @@ -81,6 +135,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, + ...(profile.parse ? (profile.parse(tokens) || {}) : {}), }; } catch (error) { log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { @@ -92,82 +147,14 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo } // CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers +// Delegate to refreshAccessToken("kimi", ...) — profile carries the X-Msh headers. export async function refreshKimiToken(refreshToken, credentials, log) { - const config = PROVIDERS.kimi; - if (!config?.refreshUrl || !config?.clientId) { - log?.warn?.("TOKEN_REFRESH", "No Kimi refresh URL/clientId configured"); - return null; - } - if (!refreshToken) return null; - - return dedupRefresh("kimi", refreshToken, async () => { - try { - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - ...buildKimiHeaders(credentials?.providerSpecificData?.deviceId), - }; - const response = await fetch(config.refreshUrl, { - method: "POST", - headers, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - }), - }); - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", `Failed to refresh token for kimi`, { - status: response.status, - error: errorText, - }); - return null; - } - const tokens = await response.json(); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Error refreshing token for kimi`, { error: error.message }); - return null; - } - }, log); + return refreshAccessToken("kimi", refreshToken, credentials, log); } +// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...). export async function refreshClaudeOAuthToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("claude", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); - return null; - } - - const tokens = await response.json(); - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); - return null; - } - }, log); + return refreshAccessToken("claude", refreshToken, {}, log); } export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { @@ -204,58 +191,9 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l }, log); } +// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...). export async function refreshQwenToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("qwen", refreshToken, async () => { - const endpoint = OAUTH_ENDPOINTS.qwen.token; - - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.qwen.clientId, - }), - }); - - if (response.status === 200) { - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - providerSpecificData: tokens.resource_url - ? { resourceUrl: tokens.resource_url } - : undefined, - }; - } else { - const errorText = await response.text().catch(() => ""); - log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { - status: response.status, - error: errorText, - }); - } - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { - error: error.message, - }); - } - - log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); - return null; - }, log); + return refreshAccessToken("qwen", refreshToken, {}, log); } export function classifyOAuthRefreshError(errorText = "", status = 0) { @@ -480,95 +418,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log, }, log); } +// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...). export async function refreshIflowToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("iflow", refreshToken, async () => { - const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); - - const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.iflow.clientId, - client_secret: PROVIDERS.iflow.clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("iflow", refreshToken, {}, log); } +// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...). export async function refreshGitHubToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("github", refreshToken, async () => { - const params = { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.github.clientId, - }; - if (PROVIDERS.github.clientSecret) { - params.client_secret = PROVIDERS.github.clientSecret; - } - - const response = await fetch(OAUTH_ENDPOINTS.github.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams(params), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("github", refreshToken, {}, log); } export async function refreshCopilotToken(githubAccessToken, log) { @@ -720,60 +577,8 @@ export async function refreshCodebuddyIntlToken(refreshToken, log) { }, 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 || {}; diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index 865baeb0..8fcddee0 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -198,6 +198,21 @@ export function parseGrokCliBilling(billing, user = null) { }; } + // SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is + // the single total used %; productUsage is a breakdown legend, NOT independent + // quotas — never split it into separate bars. + const usedPct = unwrapVal( + config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent, + NaN, + ); + if (Number.isFinite(usedPct) && usedPct >= 0) { + quotas["Weekly SuperGrok"] = makeQuota({ + used: Math.max(0, Math.min(100, usedPct)), + total: 100, + resetAt: periodEnd, + }); + } + // Opportunistic richer credit envelopes (future / other account types) const creditBags = [ root.credits, diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index eb4a7940..f85bbfa1 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; -import { - getProvider, - generateAuthData, - exchangeTokens, - requestDeviceCode, - pollForToken +import { + getProvider, + generateAuthData, + exchangeTokens, + requestDeviceCode, + pollForToken } from "@/lib/oauth/providers"; import { createProviderConnection } from "@/models"; import { @@ -18,7 +18,24 @@ import { registerXaiSession, getXaiSessionStatus, clearXaiSession, + startTraeProxy, + stopTraeProxy, + registerTraeSession, + getTraeSessionStatus, + clearTraeSession, + startWindsurfProxy, + stopWindsurfProxy, + registerWindsurfSession, + getWindsurfSessionStatus, + clearWindsurfSession, + startZedProxy, + stopZedProxy, + registerZedSession, + getZedSessionStatus, + clearZedSession, } from "@/lib/oauth/utils/server"; +import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect"; +import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth"; async function completeXaiManualCode(code, state) { const session = state ? getXaiSessionStatus(state) : null; @@ -77,13 +94,34 @@ export async function GET(request, { params }) { const reservedParams = new Set(["redirect_uri"]); const meta = {}; searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; }); + // Zed: derive native_app_port from the local callback URL so the RSA keypair + // is bound to the port the proxy is actually listening on. + if (provider === "zed") { + try { const p = new URL(redirectUri).port; if (p) meta.nativeAppPort = p; } catch { /* ignore */ } + } const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined); return NextResponse.json(authData); } if (action === "start-proxy") { + // Trae/Windsurf/Zed use a dynamic-port local callback server (singleton session, + // state is registered separately via /register-session after /authorize). + if (provider === "trae") { + const result = await startTraeProxy(); + return NextResponse.json(result); + } + if (provider === "windsurf") { + const result = await startWindsurfProxy(); + return NextResponse.json(result); + } + if (provider === "zed") { + // Prefer ZED_HOSTED_CONFIG.defaultNativeAppPort (58443) so the browser redirect + // matches what Zed expects; falls back to a random port if it's busy. + const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort); + return NextResponse.json(result); + } if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); + return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); } const appPort = searchParams.get("app_port"); if (!appPort) { @@ -105,18 +143,24 @@ export async function GET(request, { params }) { } if (action === "poll-status") { - if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Poll only supported for codex/xai" }, { status: 400 }); - } const state = searchParams.get("state"); if (!state) { return NextResponse.json({ error: "Missing state" }, { status: 400 }); } - const session = provider === "xai" ? getXaiSessionStatus(state) : getCodexSessionStatus(state); + let session; + if (provider === "trae") session = getTraeSessionStatus(state); + else if (provider === "windsurf") session = getWindsurfSessionStatus(state); + else if (provider === "zed") session = getZedSessionStatus(state); + else if (provider === "xai") session = getXaiSessionStatus(state); + else if (provider === "codex") session = getCodexSessionStatus(state); + else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); if (!session) return NextResponse.json({ status: "unknown" }); if (session.status === "done" || session.status === "error") { const payload = { ...session }; - if (provider === "xai") clearXaiSession(state); + if (provider === "trae") clearTraeSession(state); + else if (provider === "windsurf") clearWindsurfSession(state); + else if (provider === "zed") clearZedSession(state); + else if (provider === "xai") clearXaiSession(state); else clearCodexSession(state); return NextResponse.json(payload); } @@ -124,14 +168,24 @@ export async function GET(request, { params }) { } if (action === "stop-proxy") { - if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); - } - if (provider === "xai") stopXaiProxy(); - else stopCodexProxy(); + if (provider === "trae") stopTraeProxy(); + else if (provider === "windsurf") stopWindsurfProxy(); + else if (provider === "zed") stopZedProxy(); + else if (provider === "xai") stopXaiProxy(); + else if (provider === "codex") stopCodexProxy(); + else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); return NextResponse.json({ success: true }); } + if (action === "ide-status") { + // Detect whether the IDE is installed locally (used by import-token UX). + if (provider !== "trae" && provider !== "windsurf") { + return NextResponse.json({ error: "ide-status only supported for trae/windsurf" }, { status: 400 }); + } + const status = await detectIdeInstalled(provider); + return NextResponse.json(status); + } + if (action === "device-code") { const providerData = getProvider(provider); if (providerData.flowType !== "device_code") { @@ -158,6 +212,7 @@ export async function GET(request, { params }) { "kimi-coding", "kilocode", "codebuddy-cn", + "codebuddy-intl", "qoder", "grok-cli", ]; @@ -196,9 +251,54 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 }); } + if (action === "register-session") { + // Register proxy session out of URL query (state) + body (codeVerifier). + // Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs. + const state = searchParams.get("state") || body?.state; + if (!state) return NextResponse.json({ error: "Missing state" }, { status: 400 }); + let ok = false; + if (provider === "trae") ok = registerTraeSession({ state }); + else if (provider === "windsurf") ok = registerWindsurfSession({ state }); + else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier }); + else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 }); + return NextResponse.json({ success: ok }); + } + if (action === "exchange") { const { code, redirectUri, codeVerifier, state, meta } = body; + // Trae/Windsurf: code is either a raw callback URL or a pasted token. + // exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction. + if (provider === "trae" || provider === "windsurf") { + const token = typeof code === "string" ? code.trim() : ""; + if (!token) { + return NextResponse.json({ error: "Missing token or callback URL" }, { status: 400 }); + } + try { + const tokenData = await exchangeTokens(provider, token, null, null, state); + const connection = await createProviderConnection({ + provider, + authType: provider === "windsurf" ? "api_key" : "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + } + }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } + } + // Detect if "code" is actually a raw JWT access token (starts with eyJ) if (code && code.startsWith("eyJ") && code.includes(".")) { const { extractCodexAccountInfo } = await import("@/lib/oauth/providers"); @@ -280,7 +380,7 @@ export async function POST(request, { params }) { } // Providers that don't use PKCE for device code - const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn"]; + const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn", "codebuddy-intl"]; let result; if (noPkceProviders.includes(provider)) { // kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity) diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index e07745e3..26c9d010 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -14,6 +14,7 @@ import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; +import { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; @@ -104,7 +105,23 @@ const LIVE_MODEL_RESOLVERS = { providerSpecificData: conn.providerSpecificData || {}, }, { log: console }); return result?.models?.length ? { models: result.models } : null; - } + }, + zed: async (conn) => { + const result = await resolveZedModels({ + accessToken: conn.accessToken, + providerSpecificData: conn.providerSpecificData || {}, + }); + if (!result?.models?.length) return null; + return { + models: result.models + .filter((m) => !m.isDisabled) + .map((m) => ({ + id: m.id, + name: m.name, + capabilities: m.supportsTools ? { tools: true } : undefined, + })), + }; + }, }; const parseOpenAIStyleModels = (data) => { diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 53015258..d66e32c5 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -117,6 +117,9 @@ export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] }; // CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow) export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] }; +// CodeBuddy International — same shape as CN, .ai domain (mirror of codebuddy-cn). +export const CODEBUDDY_INTL_CONFIG = { ...PROVIDER_OAUTH["codebuddy-intl"] }; + // Kimchi OAuth Configuration (Browser token callback flow) export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; @@ -124,6 +127,77 @@ export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; // Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; +// Trae (ByteDance marscode) OAuth — authorization_code flow with local callback. +// 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost} +// 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb} +// 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true +// 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt} +// 5) POST GetUserInfo (x-cloudide-token) → email/name +export const TRAE_CONFIG = { + clientId: "ono9krqynydwx5", + clientSecret: "-", + loginGuidanceUrls: [ + "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", + "https://api.trae.ai/cloudide/api/v3/trae/GetLoginGuidance", + "https://www.trae.ai/cloudide/api/v3/trae/GetLoginGuidance", + ], + apiOrigins: [ + "https://api.marscode.com", + "https://api.trae.ai", + "https://www.trae.ai", + "https://www.marscode.com", + ], + exchangeTokenPath: "/cloudide/api/v3/trae/oauth/ExchangeToken", + getUserInfoPath: "/cloudide/api/v3/trae/GetUserInfo", + authorizationPath: "/authorization", + callbackPath: "/callback", + minAppVersion: "3.5.54", + defaultAppVersion: "3.5.54", + defaultAppType: "stable", + defaultPluginVersion: "local", + // service machine id is derived at runtime; device_id "0" is the stable default + defaultDeviceId: "0", + userAgent: "Trae/1.0.0 antigravity-cockpit-tools", + webUrl: "https://www.trae.ai", + authScheme: "Cloud-IDE-JWT", + tokenLifetimeDays: 14, + oauthTimeoutMs: 600_000, +}; + +// Windsurf / Devin CLI OAuth — authorization_code (implicit) flow with local callback. +// 1) Browser opens windsurf.com/windsurf/signin?response_type=token&client_id=...&redirect_uri=${cb} +// 2) Redirect → ${cb}?access_token=${firebaseJWT}&state=... +// 3) POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} +// 4) POST GetOneTimeAuthToken → GetCurrentUser (best-effort email/plan) +export const WINDSURF_CONFIG = { + clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u", + authBaseUrl: "https://www.windsurf.com", + signInPath: "/windsurf/signin", + registerApiBaseUrl: "https://register.windsurf.com", + registerPath: "/exa.seat_management_pb.SeatManagementService/RegisterUser", + oneTimeAuthPath: "/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken", + currentUserPath: "/exa.seat_management_pb.SeatManagementService/GetCurrentUser", + planStatusPath: "/exa.seat_management_pb.SeatManagementService/GetPlanStatus", + userStatusPath: "/exa.seat_management_pb.SeatManagementService/GetUserStatus", + defaultApiServerUrl: "https://server.codeium.com", + firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY", + callbackPath: "/windsurf-auth-callback", + userAgent: "antigravity-cockpit-tools", + oauthTimeoutMs: 600_000, +}; + +// Zed hosted LLM aggregator — RSA keypair native-app auth (NOT OAuth). +// Client generates ephemeral RSA-2048 keypair; user signs in at zed.dev/native_app_signin; +// Zed redirects to local callback with access_token RSA-encrypted against our public key. +// See open-sse/shared/zedAuth.js for the keypair/decrypt helpers. +export const ZED_HOSTED_CONFIG = { + webBaseUrl: "https://zed.dev", + cloudBaseUrl: "https://cloud.zed.dev", + llmBaseUrl: "https://cloud.zed.dev", + defaultNativeAppPort: 58443, + oauthTimeoutMs: 600_000, +}; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -147,6 +221,10 @@ export const PROVIDERS = { CLINEPASS: "clinepass", GITLAB: "gitlab", CODEBUDDY: "codebuddy-cn", + CODEBUDDY_INTL: "codebuddy-intl", KIMCHI: "kimchi", GROK_CLI: "grok-cli", + TRAE: "trae", + WINDSURF: "windsurf", + ZED: "zed", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index e995567e..8adfe8c4 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -1,1718 +1 @@ -/** - * OAuth Provider Configurations and Handlers - * Centralized DRY approach for all OAuth providers - */ - -// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime -import "open-sse/index.js"; -import crypto from "crypto"; - -import { generatePKCE, generateState } from "./utils/pkce"; -import { - CLAUDE_CONFIG, - CODEX_CONFIG, - GEMINI_CONFIG, - QWEN_CONFIG, - QODER_CONFIG, - IFLOW_CONFIG, - ANTIGRAVITY_CONFIG, - GITHUB_CONFIG, - KIRO_CONFIG, - assertValidAwsRegion, - CURSOR_CONFIG, - KIMI_CONFIG, - KILOCODE_CONFIG, - CLINE_CONFIG, - CLINEPASS_CONFIG, - GITLAB_CONFIG, - CODEBUDDY_CONFIG, - KIMCHI_CONFIG, - GROK_CLI_CONFIG, - getOAuthClientMetadata, -} from "./constants/oauth"; -import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; -import { - validateXaiOAuthEndpoint, - decodeXaiIdTokenEmail, - extractEmailFromAccessToken, - extractCodexAccountInfo, - fetchKiroProfileArn, -} from "./providerHelpers"; - -export { extractCodexAccountInfo, fetchKiroProfileArn }; - -// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package -let cachedXaiDiscovery = null; - -async function discoverXaiEndpoints() { - if (cachedXaiDiscovery) return cachedXaiDiscovery; - try { - const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } }); - if (res.ok) { - const data = await res.json(); - cachedXaiDiscovery = { - authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"), - tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"), - }; - return cachedXaiDiscovery; - } - } catch { /* fall through to static fallback */ } - cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl }; - return cachedXaiDiscovery; -} - -// Provider configurations -const PROVIDERS = { - claude: { - config: CLAUDE_CONFIG, - flowType: "authorization_code_pkce", - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - const params = new URLSearchParams({ - code: "true", - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - state: state, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { - // Parse code - may contain state after # - let authCode = code; - let codeState = ""; - if (authCode.includes("#")) { - const parts = authCode.split("#"); - authCode = parts[0]; - codeState = parts[1] || ""; - } - - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - code: authCode, - state: codeState || state, - grant_type: "authorization_code", - client_id: config.clientId, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }), - }, - - codex: { - config: CODEX_CONFIG, - flowType: "authorization_code_pkce", - fixedPort: CODEX_CONFIG.fixedPort, - callbackPath: CODEX_CONFIG.callbackPath, - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - const params = { - response_type: "code", - client_id: config.clientId, - redirect_uri: redirectUri, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - ...config.extraParams, - state: state, - }; - const queryString = Object.entries(params) - .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) - .join("&"); - return `${config.authorizeUrl}?${queryString}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - code: code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - mapTokens: (tokens) => { - const info = extractCodexAccountInfo(tokens.id_token); - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - expiresIn: tokens.expires_in, - lastRefreshAt: new Date().toISOString(), - }; - const email = info.email || extractEmailFromAccessToken(tokens.access_token); - if (email) mapped.email = email; - if (info.chatgptAccountId || info.chatgptPlanType) { - mapped.providerSpecificData = { - chatgptAccountId: info.chatgptAccountId, - chatgptPlanType: info.chatgptPlanType, - }; - } - return mapped; - }, - }, - - xai: { - config: XAI_CONFIG, - flowType: "authorization_code_pkce", - fixedPort: XAI_CONFIG.loopbackPort, - callbackPath: XAI_CONFIG.callbackPath, - pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, - prepareConfig: async (config) => { - const endpoints = await discoverXaiEndpoints(); - return { - ...config, - authorizeUrl: endpoints.authorizeUrl, - tokenUrl: endpoints.tokenUrl, - }; - }, - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - // Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer - const nonce = crypto.randomBytes(16).toString("hex"); - const params = { - response_type: "code", - client_id: config.clientId, - redirect_uri: redirectUri, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - state, - nonce, - plan: "generic", - referrer: "cli-proxy-api", - }; - const qs = Object.entries(params) - .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) - .join("&"); - return `${config.authorizeUrl}?${qs}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`xAI token exchange failed: ${error}`); - } - return await response.json(); - }, - mapTokens: (tokens) => { - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }; - const email = decodeXaiIdTokenEmail(tokens.id_token); - if (email) mapped.email = email; - if (tokens.id_token) { - mapped.providerSpecificData = { idToken: tokens.id_token }; - } - return mapped; - }, - }, - - // Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com - "grok-cli": { - config: GROK_CLI_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const body = new URLSearchParams({ - client_id: config.clientId, - scope: config.scope, - }); - // Official CLI sends referrer=grok-build - if (config.referrer) body.set("referrer", config.referrer); - - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - }, - body, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Grok CLI device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: deviceCode, - client_id: config.clientId, - }), - }); - - let data; - try { - data = await response.json(); - } catch { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - // Device flow: 400 + authorization_pending is expected while user authorizes - const pending = - data?.error === "authorization_pending" || - data?.error === "slow_down"; - return { - ok: response.ok || pending, - data, - }; - }, - postExchange: async (tokens) => { - // Best-effort user profile from cli-chat-proxy (non-fatal) - try { - const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - "x-xai-token-auth": "xai-grok-cli", - "x-grok-client-version": "0.2.93", - }, - }); - if (res.ok) return { user: await res.json() }; - } catch { - /* ignore */ - } - return { user: null }; - }, - mapTokens: (tokens, extra) => { - const email = - decodeXaiIdTokenEmail(tokens.id_token) || - extractEmailFromAccessToken(tokens.access_token) || - extra?.user?.email || - null; - const userId = - extra?.user?.userId || - extra?.user?.principalId || - null; - const displayName = [extra?.user?.firstName, extra?.user?.lastName] - .filter(Boolean) - .join(" ") - .trim() || null; - - const expiresAt = tokens.expires_in - ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() - : null; - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - expiresIn: tokens.expires_in, - // Surface an absolute expiry so the proactive refresh path - // (shouldRefreshCredentials / checkAndRefreshToken) can refresh the - // xAI token before it silently expires ~40-45 min after login. - // Without this, only the reactive 401 path in chatCore would refresh, - // causing intermittent "token expired" failures for Grok CLI. - expiresAt, - scope: tokens.scope, - // Top-level for dashboard connection cards - email: email || undefined, - displayName: displayName || undefined, - // Mirror identity into providerSpecificData so GrokCliExecutor can set - // x-email / x-userid without depending on top-level credential shape. - providerSpecificData: { - authMethod: "device_code", - idToken: tokens.id_token || null, - email: email || null, - userId, - hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null, - subscriptionTier: extra?.user?.subscriptionTier ?? null, - }, - }; - }, - }, - - "gemini-cli": { - config: GEMINI_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Fetch user info - const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; - - // Fetch project ID - let projectId = ""; - try { - const projectRes = await fetch( - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - metadata: getOAuthClientMetadata(), - mode: 1, - }), - } - ); - if (projectRes.ok) { - const data = await projectRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - } - } catch (e) { - console.log("Failed to fetch project ID:", e); - } - - return { userInfo, projectId }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - email: extra?.userInfo?.email, - projectId: extra?.projectId, - }), - }, - - antigravity: { - config: ANTIGRAVITY_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Numeric enums matching Antigravity binary ClientMetadata - const loadHeaders = { - "Authorization": `Bearer ${tokens.access_token}`, - "Content-Type": "application/json", - "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, - "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, - "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, - "x-request-source": "local", - }; - const metadata = getOAuthClientMetadata(); - - // Fetch user info - const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - "x-request-source": "local", - }, - }); - const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; - - // Load Code Assist to get project ID and tier - let projectId = ""; - let tierId = "legacy-tier"; - try { - const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { - method: "POST", - headers: loadHeaders, - body: JSON.stringify({ metadata }), - }); - if (loadRes.ok) { - const data = await loadRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - if (Array.isArray(data.allowedTiers)) { - for (const tier of data.allowedTiers) { - if (tier.isDefault && tier.id) { - tierId = tier.id.trim(); - break; - } - } - } - } - } catch (e) { - console.log("Failed to load code assist:", e); - } - - // Fire-and-forget onboarding — does not block DB save - if (projectId) { - const doOnboard = async () => { - for (let i = 0; i < 10; i++) { - try { - const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { - method: "POST", - headers: loadHeaders, - body: JSON.stringify({ tierId, metadata }), - }); - if (onboardRes.ok) { - const result = await onboardRes.json(); - if (result.done === true) break; - } - } catch (e) { - break; - } - await new Promise(resolve => setTimeout(resolve, 5000)); - } - }; - doOnboard().catch(() => {}); - } - - return { userInfo, projectId }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - email: extra?.userInfo?.email, - projectId: extra?.projectId, - }), - }, - - iflow: { - config: IFLOW_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - loginMethod: config.extraParams.loginMethod, - type: config.extraParams.type, - redirect: redirectUri, - state: state, - client_id: config.clientId, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - // Create Basic Auth header - const basicAuth = Buffer.from( - `${config.clientId}:${config.clientSecret}` - ).toString("base64"); - - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: code, - redirect_uri: redirectUri, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Fetch user info (MUST succeed to get API key) - const userInfoRes = await fetch( - `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!userInfoRes.ok) { - const errorText = await userInfoRes.text(); - throw new Error(`Failed to fetch user info: ${errorText}`); - } - - const result = await userInfoRes.json(); - if (!result.success) { - throw new Error(`User info request failed: ${result.message || 'Unknown error'}`); - } - - const userInfo = result.data || {}; - - // Validate API key (critical for iFlow) - if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { - throw new Error("Empty API key returned from iFlow"); - } - - // Validate email/phone - const email = userInfo.email?.trim() || userInfo.phone?.trim(); - if (!email) { - throw new Error("Missing account email/phone in user info"); - } - - return { userInfo }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - apiKey: extra?.userInfo?.apiKey, - email: extra?.userInfo?.email || extra?.userInfo?.phone, - displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, - }), - }, - - qoder: { - config: QODER_CONFIG, - flowType: "device_code", - // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated - // locally, the user lands on qoder.com/device/selectAccounts in the - // browser, and we poll openapi.qoder.sh until a `dt-...` token appears. - requestDeviceCode: async (config) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const flow = new QoderService().initiateDeviceFlow(); - // Match the device_code shape the rest of the OAuthModal expects - // (device_code, user_code, verification_uri[_complete], interval). - // The poll endpoint identifies us by nonce+verifier, not by a - // server-issued device_code, so we plumb our own values through: - // device_code = nonce (modal forwards as deviceCode on poll) - // codeVerifier = our PKCE verifier (route forwards as codeVerifier) - return { - device_code: flow.nonce, - user_code: flow.nonce.slice(0, 8).toUpperCase(), - verification_uri: config.loginUrl, - verification_uri_complete: flow.verificationUriComplete, - expires_in: 300, - interval: 2, - codeVerifier: flow.codeVerifier, - _qoderNonce: flow.nonce, - _qoderMachineId: flow.machineId, - }; - }, - pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const svc = new QoderService(); - const nonce = deviceCode || extraData?._qoderNonce; - const verifier = codeVerifier || extraData?._qoderVerifier; - if (!nonce || !verifier) { - return { - ok: false, - data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, - }; - } - let result; - try { - result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); - } catch (err) { - return { - ok: false, - data: { error: "poll_failed", error_description: err.message }, - }; - } - if (result.status === "pending") { - return { ok: false, data: { error: "authorization_pending" } }; - } - // Best-effort profile lookup so we have a name/email to display. - const userInfo = await svc.fetchUserInfo(result.accessToken); - // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, - // which already falls back to "now + 30 days" when the upstream - // omits expiry. Floor to a sane minimum (1 day) so a stale or - // skewed upstream timestamp doesn't truncate the stored token below - // something useful. - const minSeconds = 24 * 60 * 60; - const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); - const expiresIn = Math.max(minSeconds, remainingSeconds); - return { - ok: true, - data: { - access_token: result.accessToken, - refresh_token: result.refreshToken, - expires_in: expiresIn, - _qoderUserId: result.userId, - _qoderMachineId: extraData?._qoderMachineId || "", - _qoderName: userInfo.name, - _qoderEmail: userInfo.email, - _qoderOrganizationId: userInfo.organizationId, - }, - }; - }, - mapTokens: (tokens) => { - const rawEmail = (tokens._qoderEmail || "").trim(); - const displayName = (tokens._qoderName || "").trim() || null; - const userId = tokens._qoderUserId || ""; - // Dedup in createProviderConnection requires a non-empty email. When - // fetchUserInfo silently fails (returns ""), fall back to a stable - // synthetic identifier derived from userId so re-logins update the - // existing row instead of accumulating "Account N" duplicates. - const email = rawEmail || (userId ? `qoder-user-${userId}` : null); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - expiresIn: tokens.expires_in, - email, - displayName, - providerSpecificData: { - authMethod: "device", - userId, - machineId: tokens._qoderMachineId || "", - organizationId: tokens._qoderOrganizationId || "", - }, - }; - }, - }, - - qwen: { - config: QWEN_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config, codeChallenge) => { - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: config.clientId, - device_code: deviceCode, - code_verifier: codeVerifier, - }), - }); - - return { - ok: response.ok, - data: await response.json(), - }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - providerSpecificData: { resourceUrl: tokens.resource_url }, - }), - }, - - github: { - config: GITHUB_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - scope: config.scopes, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - // Handle response properly - if not ok, try to get error as text first - let data; - try { - data = await response.json(); - } catch (e) { - // If response is not JSON, get as text - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - return { - ok: response.ok, - data: data, - }; - }, - postExchange: async (tokens) => { - // Get Copilot token using GitHub access token - const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; - - // Get user info from GitHub - const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - const userInfo = userRes.ok ? await userRes.json() : {}; - - return { copilotToken, userInfo }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - name: extra?.userInfo?.login || extra?.userInfo?.name, - displayName: extra?.userInfo?.name || extra?.userInfo?.login, - email: extra?.userInfo?.email || null, - providerSpecificData: { - copilotToken: extra?.copilotToken?.token, - copilotTokenExpiresAt: extra?.copilotToken?.expires_at, - githubUserId: extra?.userInfo?.id, - githubLogin: extra?.userInfo?.login, - githubName: extra?.userInfo?.name, - githubEmail: extra?.userInfo?.email, - }, - }), - }, - - kiro: { - config: KIRO_CONFIG, - flowType: "device_code", - // Kiro uses AWS SSO OIDC - requires client registration first - requestDeviceCode: async (config, codeChallenge, options = {}) => { - const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; - const region = trimmedRegion || "us-east-1"; - assertValidAwsRegion(region); - const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; - const startUrl = trimmedStartUrl || config.startUrl; - const authMethod = options.authMethod === "idc" ? "idc" : "builder-id"; - const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`; - const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`; - - // Step 1: Register client with AWS SSO OIDC - const registerRes = await fetch(registerClientUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientName: config.clientName, - clientType: config.clientType, - scopes: config.scopes, - grantTypes: config.grantTypes, - issuerUrl: config.issuerUrl, - }), - }); - - if (!registerRes.ok) { - const error = await registerRes.text(); - throw new Error(`Client registration failed: ${error}`); - } - - const clientInfo = await registerRes.json(); - - // Step 2: Request device authorization - const deviceRes = await fetch(deviceAuthUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientId: clientInfo.clientId, - clientSecret: clientInfo.clientSecret, - startUrl, - }), - }); - - if (!deviceRes.ok) { - const error = await deviceRes.text(); - throw new Error(`Device authorization failed: ${error}`); - } - - const deviceData = await deviceRes.json(); - - // Return combined data for polling - return { - device_code: deviceData.deviceCode, - user_code: deviceData.userCode, - verification_uri: deviceData.verificationUri, - verification_uri_complete: deviceData.verificationUriComplete, - expires_in: deviceData.expiresIn, - interval: deviceData.interval || 5, - // Store client credentials for token exchange - _clientId: clientInfo.clientId, - _clientSecret: clientInfo.clientSecret, - _region: region, - _authMethod: authMethod, - _startUrl: startUrl, - }; - }, - pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const region = extraData?._region || "us-east-1"; - assertValidAwsRegion(region); - const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; - const response = await fetch(tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientId: extraData?._clientId, - clientSecret: extraData?._clientSecret, - deviceCode: deviceCode, - grantType: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - let data; - try { - data = await response.json(); - } catch (e) { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - // AWS SSO OIDC returns camelCase - if (data.accessToken) { - return { - ok: true, - data: { - access_token: data.accessToken, - refresh_token: data.refreshToken, - expires_in: data.expiresIn, - profile_arn: data?.profileArn || null, - // Store client credentials for refresh - _clientId: extraData?._clientId, - _clientSecret: extraData?._clientSecret, - _region: extraData?._region, - _authMethod: extraData?._authMethod, - _startUrl: extraData?._startUrl, - }, - }; - } - - return { - ok: false, - data: { - error: data.error || "authorization_pending", - error_description: data.error_description || data.message, - }, - }; - }, - mapTokens: (tokens) => { - const email = extractEmailFromAccessToken(tokens.access_token); - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - email, - providerSpecificData: { - profileArn: tokens?.profile_arn || null, - clientId: tokens._clientId, - clientSecret: tokens._clientSecret, - region: tokens._region || "us-east-1", - authMethod: tokens._authMethod || "builder-id", - startUrl: tokens._startUrl || KIRO_CONFIG.startUrl, - }, - }; - return mapped; - }, - }, - - cursor: { - config: CURSOR_CONFIG, - flowType: "import_token", - // Cursor uses import token flow - tokens are extracted from local SQLite database - // No OAuth flow needed, handled by /api/oauth/cursor/import route - mapTokens: (tokens) => ({ - accessToken: tokens.accessToken, - refreshToken: null, // Cursor doesn't have public refresh endpoint - expiresIn: tokens.expiresIn || 86400, - providerSpecificData: { - machineId: tokens.machineId, - authMethod: "imported", - }, - }), - }, - - // Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`; - // `kimi-coding` remains an alias key so old UI/API routes still resolve. - kimi: { - config: KIMI_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); - const deviceId = crypto.randomUUID(); - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - ...buildKimiHeaders(deviceId), - }; - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers, - body: new URLSearchParams({ client_id: config.clientId }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - const data = await response.json(); - const authorizeDeviceUrl = config.authorizeDeviceUrl || "https://www.kimi.com/code/authorize_device"; - return { - device_code: data.device_code, - user_code: data.user_code, - verification_uri: data.verification_uri || authorizeDeviceUrl, - verification_uri_complete: - data.verification_uri_complete || - `${authorizeDeviceUrl}?user_code=${data.user_code}`, - expires_in: data.expires_in, - interval: data.interval || 5, - _kimiDeviceId: deviceId, - }; - }, - pollToken: async (config, deviceCode, _codeVerifier, extraData) => { - const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); - const deviceId = extraData?._kimiDeviceId; - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - ...buildKimiHeaders(deviceId), - }; - const response = await fetch(config.tokenUrl, { - method: "POST", - headers, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: config.clientId, - device_code: deviceCode, - }), - }); - let data; - try { - data = await response.json(); - } catch { - data = { error: "invalid_response", error_description: "non-json token response" }; - } - // CLIProxyAPI: Kimi returns 200 for pending states with error field - if (data.error === "authorization_pending" || data.error === "slow_down") { - return { ok: true, data }; - } - if (data.access_token && deviceId) data._kimiDeviceId = deviceId; - return { ok: response.ok || !!data.access_token || !!data.error, data }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - providerSpecificData: { - authMethod: "device_code", - ...(tokens._kimiDeviceId ? { deviceId: tokens._kimiDeviceId } : {}), - }, - }), - }, - kilocode: { - config: KILOCODE_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(config.initiateUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (!response.ok) { - if (response.status === 429) { - throw new Error("Too many pending authorization requests. Please try again later."); - } - const error = await response.text(); - throw new Error(`Device auth initiation failed: ${error}`); - } - const data = await response.json(); - return { - device_code: data.code, - user_code: data.code, - verification_uri: data.verificationUrl, - verification_uri_complete: data.verificationUrl, - expires_in: data.expiresIn || 300, - interval: 3, - }; - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); - if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } }; - if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } }; - if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } }; - if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } }; - const data = await response.json(); - if (data.status === "approved" && data.token) { - // Fetch profile to get orgId for X-Kilocode-OrganizationID header - let orgId = null; - try { - const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, { - headers: { "Authorization": `Bearer ${data.token}` } - }); - if (profileRes.ok) { - const profile = await profileRes.json(); - orgId = profile.organizations?.[0]?.id || null; - } - } catch {} - return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } }; - } - return { ok: false, data: { error: "authorization_pending" } }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: null, - expiresIn: null, - email: tokens._userEmail, - ...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}), - }), - }, - - cline: { - config: CLINE_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri) => { - const params = new URLSearchParams({ - client_type: "extension", - callback_url: redirectUri, - redirect_uri: redirectUri, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - try { - // Cline encodes token data as base64 in the code param - let base64 = code; - const padding = 4 - (base64.length % 4); - if (padding !== 4) base64 += "=".repeat(padding); - const decoded = Buffer.from(base64, "base64").toString("utf-8"); - const lastBrace = decoded.lastIndexOf("}"); - if (lastBrace === -1) throw new Error("No JSON found in decoded code"); - const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - email: tokenData.email, - firstName: tokenData.firstName, - lastName: tokenData.lastName, - expires_at: tokenData.expiresAt, - }; - } catch (e) { - const response = await fetch(config.tokenExchangeUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Cline token exchange failed: ${error}`); - } - const data = await response.json(); - return { - access_token: data.data?.accessToken || data.accessToken, - refresh_token: data.data?.refreshToken || data.refreshToken, - email: data.data?.userInfo?.email || "", - expires_at: data.data?.expiresAt || data.expiresAt, - }; - } - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_at - ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) - : 3600, - email: tokens.email, - providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, - }), - }, - clinepass: { - config: CLINEPASS_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri) => { - const params = new URLSearchParams({ - client_type: "extension", - callback_url: redirectUri, - redirect_uri: redirectUri, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - try { - // Cline encodes token data as base64 in the code param - let base64 = code; - const padding = 4 - (base64.length % 4); - if (padding !== 4) base64 += "=".repeat(padding); - const decoded = Buffer.from(base64, "base64").toString("utf-8"); - const lastBrace = decoded.lastIndexOf("}"); - if (lastBrace === -1) throw new Error("No JSON found in decoded code"); - const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - email: tokenData.email, - firstName: tokenData.firstName, - lastName: tokenData.lastName, - expires_at: tokenData.expiresAt, - }; - } catch (e) { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`ClinePass token exchange failed: ${error}`); - } - const data = await response.json(); - return { - access_token: data.data?.accessToken || data.accessToken, - refresh_token: data.data?.refreshToken || data.refreshToken, - email: data.data?.userInfo?.email || "", - expires_at: data.data?.expiresAt || data.expiresAt, - }; - } - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_at - ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) - : 3600, - email: tokens.email, - providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, - }), - }, - // GitLab Duo - Authorization Code Flow with PKCE - // Supports two login modes via loginMode metadata: "oauth" (default) or "pat" - gitlab: { - config: GITLAB_CONFIG, - flowType: "authorization_code_pkce", - buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => { - const baseUrl = meta.baseUrl || config.defaultBaseUrl; - const clientId = meta.clientId || ""; - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - response_type: "code", - state, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - }); - return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => { - const baseUrl = meta.baseUrl || config.defaultBaseUrl; - const clientId = meta.clientId || ""; - const clientSecret = meta.clientSecret || ""; - const body = new URLSearchParams({ - client_id: clientId, - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }); - if (clientSecret) body.set("client_secret", clientSecret); - const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: body.toString(), - }); - if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`); - const tokens = await response.json(); - // Fetch user info - const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - const user = userRes.ok ? await userRes.json() : {}; - return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - providerSpecificData: { - username: tokens._user?.username || "", - email: tokens._user?.email || tokens._user?.public_email || "", - name: tokens._user?.name || "", - baseUrl: tokens._baseUrl, - clientId: tokens._clientId, - authKind: "oauth", - }, - }), - }, - - // CodeBuddy (Tencent) - Browser OAuth Polling Flow - // 1. POST stateUrl → get { state, authUrl } - // 2. Open authUrl in browser - // 3. Poll tokenUrl with state until success (code 0) or timeout - "codebuddy-cn": { - config: CODEBUDDY_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": config.userAgent, - "X-Requested-With": "XMLHttpRequest", - "X-Domain": "copilot.tencent.com", - "X-No-Authorization": "true", - "X-No-User-Id": "true", - "X-Product": "SaaS", - }, - body: "{}", - }); - if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`); - const data = await response.json(); - if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { - throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`); - } - return { - device_code: data.data.state, - verification_uri: data.data.authUrl, - user_code: "", - interval: config.pollInterval / 1000, - _isCodeBuddy: true, - }; - }, - pollToken: async (config, deviceCode) => { - // CodeBuddy polls the token endpoint via GET with the state as a query - // param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=... - const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { - method: "GET", - headers: { - Accept: "application/json", - "User-Agent": config.userAgent, - "X-Requested-With": "XMLHttpRequest", - "X-Domain": "copilot.tencent.com", - "X-No-Authorization": "true", - "X-No-User-Id": "true", - "X-No-Enterprise-Id": "true", - "X-No-Department-Info": "true", - "X-Product": "SaaS", - }, - }); - if (!response.ok) return { ok: false, data: { error: "request_failed" } }; - const data = await response.json(); - // code 11217 = pending (RetryFetchToken), code 0 = success - if (data.code === 0 && data.data?.accessToken) { - return { - ok: true, - data: { - access_token: data.data.accessToken, - refresh_token: data.data.refreshToken || "", - token_type: data.data.tokenType || "Bearer", - expires_in: data.data.expiresIn, - }, - }; - } - if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; - return { ok: false, data: { error: data.msg || "unknown_error" } }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in || 86400, - providerSpecificData: {}, - }), - }, - - kimchi: { - config: KIMCHI_CONFIG, - flowType: "browser_token", - buildAuthUrl: (config, redirectUri, state) => { - const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, ""); - const params = new URLSearchParams({ - callback: redirectUri, - state, - }); - return `${baseUrl}/cli-auth?${params.toString()}`; - }, - exchangeToken: async (config, token) => { - const accessToken = String(token || "").trim(); - if (!accessToken) { - throw new Error("Missing Kimchi token"); - } - - const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers"; - const validationRes = await fetch(validationUrl, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - if (!validationRes.ok) { - throw new Error(`Kimchi token validation failed: ${validationRes.status}`); - } - - let userInfo = {}; - if (config.userInfoUrl) { - try { - const userRes = await fetch(config.userInfoUrl, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - if (userRes.ok) { - userInfo = await userRes.json(); - } - } catch { - userInfo = {}; - } - } - - return { - access_token: accessToken, - token_type: "Bearer", - _kimchiUser: userInfo, - }; - }, - mapTokens: (tokens) => { - const user = tokens._kimchiUser || {}; - const userId = user.id ? String(user.id) : ""; - const username = user.username || ""; - const email = user.email || (userId ? `kimchi-user-${userId}` : null); - return { - accessToken: tokens.access_token, - refreshToken: null, - email, - displayName: user.name || username || null, - providerSpecificData: { - authMethod: "browser_token", - userId, - username, - }, - }; - }, - }, -}; - -/** - * Get provider handler - */ -export function getProvider(name) { - // Legacy kimi-coding → kimi (dual-auth merge) - const key = name === "kimi-coding" ? "kimi" : name; - const provider = PROVIDERS[key]; - if (!provider) { - throw new Error(`Unknown provider: ${name}`); - } - return provider; -} - -/** - * Get all provider names - */ -export function getProviderNames() { - return Object.keys(PROVIDERS); -} - -/** - * Generate auth data for a provider - * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) - */ -export async function generateAuthData(providerName, redirectUri, meta) { - const provider = getProvider(providerName); - const config = provider.prepareConfig - ? await provider.prepareConfig(provider.config, meta || {}) - : provider.config; - const { codeVerifier, codeChallenge, state } = generatePKCE(provider.pkceVerifierBytes); - - let authUrl; - if (provider.flowType === "device_code") { - // Device code flow doesn't have auth URL upfront - authUrl = null; - } else if (provider.flowType === "authorization_code_pkce") { - authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {}); - } else { - authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {}); - } - - return { - authUrl, - state, - codeVerifier, - codeChallenge, - redirectUri, - flowType: provider.flowType, - fixedPort: provider.fixedPort, - callbackPath: provider.callbackPath || "/callback", - }; -} - -/** - * Exchange code for tokens - * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) - */ -export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { - const provider = getProvider(providerName); - const config = provider.prepareConfig - ? await provider.prepareConfig(provider.config, meta || {}) - : provider.config; - - const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {}); - - let extra = null; - if (provider.postExchange) { - extra = await provider.postExchange(tokens); - } - - return provider.mapTokens(tokens, extra); -} - -/** - * Request device code (for device_code flow) - */ -export async function requestDeviceCode(providerName, codeChallenge, options) { - const provider = getProvider(providerName); - if (provider.flowType !== "device_code") { - throw new Error(`Provider ${providerName} does not support device code flow`); - } - return await provider.requestDeviceCode(provider.config, codeChallenge, options || {}); -} - -/** - * Poll for token (for device_code flow) - * @param {string} providerName - Provider name - * @param {string} deviceCode - Device code from requestDeviceCode - * @param {string} codeVerifier - PKCE code verifier (optional for some providers) - * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) - */ -export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { - const provider = getProvider(providerName); - if (provider.flowType !== "device_code") { - throw new Error(`Provider ${providerName} does not support device code flow`); - } - - const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); - - if (result.ok) { - // For device code flows, success is only when we have an access token - if (result.data.access_token) { - // Call postExchange to get additional data (copilotToken, userInfo, etc.) - let extra = null; - if (provider.postExchange) { - extra = await provider.postExchange(result.data); - } - const tokens = provider.mapTokens(result.data, extra); - // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 - if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) { - const profileArn = await fetchKiroProfileArn(tokens.accessToken); - if (profileArn) tokens.providerSpecificData.profileArn = profileArn; - } - return { success: true, tokens }; - } else { - // Check if it's still pending authorization - if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { - // This is not a failure, just still waiting - return { - success: false, - error: result.data.error, - errorDescription: result.data.error_description || result.data.message, - pending: result.data.error === 'authorization_pending' - }; - } else { - // Actual error - return { - success: false, - error: result.data.error || 'no_access_token', - errorDescription: result.data.error_description || result.data.message || 'No access token received' - }; - } - } - } - - return { success: false, error: result.data.error, errorDescription: result.data.error_description }; -} - -// Run-once guard across the process lifetime -let codexBackfillDone = false; - -// Backfill email + chatgpt account info for existing codex OAuth connections missing them -export async function backfillCodexEmails() { - if (codexBackfillDone) return; - codexBackfillDone = true; - try { - const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); - const connections = await getProviderConnections(); - const targets = connections.filter((c) => { - if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false; - const hasEmail = !!c.email; - const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId; - return !hasEmail || !hasAccountInfo; - }); - for (const conn of targets) { - const info = extractCodexAccountInfo(conn.idToken); - if (!info.email && !info.chatgptAccountId) continue; - const patch = {}; - if (!conn.email && info.email) patch.email = info.email; - if (info.chatgptAccountId || info.chatgptPlanType) { - patch.providerSpecificData = { - ...(conn.providerSpecificData || {}), - chatgptAccountId: info.chatgptAccountId, - chatgptPlanType: info.chatgptPlanType, - }; - } - if (Object.keys(patch).length) { - await updateProviderConnection(conn.id, patch); - } - } - } catch (err) { - codexBackfillDone = false; - console.log("backfillCodexEmails failed:", err?.message || err); - } -} +export * from "./providers/index.js"; diff --git a/src/lib/oauth/providers/_shared.js b/src/lib/oauth/providers/_shared.js new file mode 100644 index 00000000..e08f5965 --- /dev/null +++ b/src/lib/oauth/providers/_shared.js @@ -0,0 +1,14 @@ +// Shared helpers used across provider entry files (currently trae + windsurf). + +export function extractJsonPath(root, paths) { + for (const path of paths) { + let cur = root; + for (const key of path) { + if (cur == null || typeof cur !== "object") { cur = undefined; break; } + cur = cur[key]; + } + if (typeof cur === "string" && cur.trim()) return cur.trim(); + if (typeof cur === "number") return String(cur); + } + return null; +} diff --git a/src/lib/oauth/providers/antigravity.js b/src/lib/oauth/providers/antigravity.js new file mode 100644 index 00000000..6cf28e81 --- /dev/null +++ b/src/lib/oauth/providers/antigravity.js @@ -0,0 +1,122 @@ +import { ANTIGRAVITY_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js"; + +const antigravity = { + config: ANTIGRAVITY_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Numeric enums matching Antigravity binary ClientMetadata + const loadHeaders = { + "Authorization": `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, + "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, + "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, + "x-request-source": "local", + }; + const metadata = getOAuthClientMetadata(); + + // Fetch user info + const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "x-request-source": "local", + }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Load Code Assist to get project ID and tier + let projectId = ""; + let tierId = "legacy-tier"; + try { + const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { + method: "POST", + headers: loadHeaders, + body: JSON.stringify({ metadata }), + }); + if (loadRes.ok) { + const data = await loadRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + if (Array.isArray(data.allowedTiers)) { + for (const tier of data.allowedTiers) { + if (tier.isDefault && tier.id) { + tierId = tier.id.trim(); + break; + } + } + } + } + } catch (e) { + console.log("Failed to load code assist:", e); + } + + // Fire-and-forget onboarding — does not block DB save + if (projectId) { + const doOnboard = async () => { + for (let i = 0; i < 10; i++) { + try { + const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { + method: "POST", + headers: loadHeaders, + body: JSON.stringify({ tierId, metadata }), + }); + if (onboardRes.ok) { + const result = await onboardRes.json(); + if (result.done === true) break; + } + } catch (e) { + break; + } + await new Promise(resolve => setTimeout(resolve, 5000)); + } + }; + doOnboard().catch(() => {}); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), +}; + +export default antigravity; diff --git a/src/lib/oauth/providers/claude.js b/src/lib/oauth/providers/claude.js new file mode 100644 index 00000000..498b6b81 --- /dev/null +++ b/src/lib/oauth/providers/claude.js @@ -0,0 +1,60 @@ +import { CLAUDE_CONFIG } from "../constants/oauth.js"; + +const claude = { + config: CLAUDE_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + code: "true", + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state: state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // Parse code - may contain state after # + let authCode = code; + let codeState = ""; + if (authCode.includes("#")) { + const parts = authCode.split("#"); + authCode = parts[0]; + codeState = parts[1] || ""; + } + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + code: authCode, + state: codeState || state, + grant_type: "authorization_code", + client_id: config.clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }), +}; + +export default claude; diff --git a/src/lib/oauth/providers/cline.js b/src/lib/oauth/providers/cline.js new file mode 100644 index 00000000..5c94357b --- /dev/null +++ b/src/lib/oauth/providers/cline.js @@ -0,0 +1,62 @@ +import { CLINE_CONFIG } from "../constants/oauth.js"; + +const cline = { + config: CLINE_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenExchangeUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Cline token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), +}; + +export default cline; diff --git a/src/lib/oauth/providers/clinepass.js b/src/lib/oauth/providers/clinepass.js new file mode 100644 index 00000000..a49a2119 --- /dev/null +++ b/src/lib/oauth/providers/clinepass.js @@ -0,0 +1,62 @@ +import { CLINEPASS_CONFIG } from "../constants/oauth.js"; + +const clinepass = { + config: CLINEPASS_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ClinePass token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), +}; + +export default clinepass; diff --git a/src/lib/oauth/providers/codebuddy-cn.js b/src/lib/oauth/providers/codebuddy-cn.js new file mode 100644 index 00000000..82be6b51 --- /dev/null +++ b/src/lib/oauth/providers/codebuddy-cn.js @@ -0,0 +1,80 @@ +import { CODEBUDDY_CONFIG } from "../constants/oauth.js"; + +// CodeBuddy (Tencent) - Browser OAuth Polling Flow +// 1. POST stateUrl → get { state, authUrl } +// 2. Open authUrl in browser +// 3. Poll tokenUrl with state until success (code 0) or timeout +const codebuddyCn = { + config: CODEBUDDY_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-Product": "SaaS", + }, + body: "{}", + }); + if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`); + const data = await response.json(); + if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { + throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`); + } + return { + device_code: data.data.state, + verification_uri: data.data.authUrl, + user_code: "", + interval: config.pollInterval / 1000, + _isCodeBuddy: true, + }; + }, + pollToken: async (config, deviceCode) => { + // CodeBuddy polls the token endpoint via GET with the state as a query + // param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=... + const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", + "X-Product": "SaaS", + }, + }); + if (!response.ok) return { ok: false, data: { error: "request_failed" } }; + const data = await response.json(); + // code 11217 = pending (RetryFetchToken), code 0 = success + if (data.code === 0 && data.data?.accessToken) { + return { + ok: true, + data: { + access_token: data.data.accessToken, + refresh_token: data.data.refreshToken || "", + token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, + }, + }; + } + if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; + return { ok: false, data: { error: data.msg || "unknown_error" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in || 86400, + providerSpecificData: {}, + }), +}; + +export default codebuddyCn; diff --git a/src/lib/oauth/providers/codebuddy-intl.js b/src/lib/oauth/providers/codebuddy-intl.js new file mode 100644 index 00000000..e82e430f --- /dev/null +++ b/src/lib/oauth/providers/codebuddy-intl.js @@ -0,0 +1,74 @@ +import { CODEBUDDY_INTL_CONFIG } from "../constants/oauth.js"; + +// CodeBuddy International — mirrors codebuddy-cn flow against the .ai domain. +const codebuddyIntl = { + config: CODEBUDDY_INTL_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-Product": "SaaS", + }, + body: "{}", + }); + if (!response.ok) throw new Error(`CodeBuddy Intl state request failed: ${await response.text()}`); + const data = await response.json(); + if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { + throw new Error(`CodeBuddy Intl state error: ${data.msg || "missing state/authUrl"}`); + } + return { + device_code: data.data.state, + verification_uri: data.data.authUrl, + user_code: "", + interval: config.pollInterval / 1000, + _isCodeBuddy: true, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", + "X-Product": "SaaS", + }, + }); + if (!response.ok) return { ok: false, data: { error: "request_failed" } }; + const data = await response.json(); + if (data.code === 0 && data.data?.accessToken) { + return { + ok: true, + data: { + access_token: data.data.accessToken, + refresh_token: data.data.refreshToken || "", + token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, + }, + }; + } + if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; + return { ok: false, data: { error: data.msg || "unknown_error" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in || 86400, + providerSpecificData: {}, + }), +}; + +export default codebuddyIntl; diff --git a/src/lib/oauth/providers/codex.js b/src/lib/oauth/providers/codex.js new file mode 100644 index 00000000..be4cbe65 --- /dev/null +++ b/src/lib/oauth/providers/codex.js @@ -0,0 +1,69 @@ +import { CODEX_CONFIG } from "../constants/oauth.js"; +import { extractCodexAccountInfo, extractEmailFromAccessToken } from "../providerHelpers.js"; + +const codex = { + config: CODEX_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: CODEX_CONFIG.fixedPort, + callbackPath: CODEX_CONFIG.callbackPath, + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + ...config.extraParams, + state: state, + }; + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${config.authorizeUrl}?${queryString}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => { + const info = extractCodexAccountInfo(tokens.id_token); + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + lastRefreshAt: new Date().toISOString(), + }; + const email = info.email || extractEmailFromAccessToken(tokens.access_token); + if (email) mapped.email = email; + if (info.chatgptAccountId || info.chatgptPlanType) { + mapped.providerSpecificData = { + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + return mapped; + }, +}; + +export default codex; diff --git a/src/lib/oauth/providers/cursor.js b/src/lib/oauth/providers/cursor.js new file mode 100644 index 00000000..a6aab0dc --- /dev/null +++ b/src/lib/oauth/providers/cursor.js @@ -0,0 +1,19 @@ +import { CURSOR_CONFIG } from "../constants/oauth.js"; + +const cursor = { + config: CURSOR_CONFIG, + flowType: "import_token", + // Cursor uses import token flow - tokens are extracted from local SQLite database + // No OAuth flow needed, handled by /api/oauth/cursor/import route + mapTokens: (tokens) => ({ + accessToken: tokens.accessToken, + refreshToken: null, // Cursor doesn't have public refresh endpoint + expiresIn: tokens.expiresIn || 86400, + providerSpecificData: { + machineId: tokens.machineId, + authMethod: "imported", + }, + }), +}; + +export default cursor; diff --git a/src/lib/oauth/providers/gemini-cli.js b/src/lib/oauth/providers/gemini-cli.js new file mode 100644 index 00000000..1e4d3d0b --- /dev/null +++ b/src/lib/oauth/providers/gemini-cli.js @@ -0,0 +1,85 @@ +import { GEMINI_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js"; + +const geminiCli = { + config: GEMINI_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Fetch project ID + let projectId = ""; + try { + const projectRes = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: getOAuthClientMetadata(), + mode: 1, + }), + } + ); + if (projectRes.ok) { + const data = await projectRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + } + } catch (e) { + console.log("Failed to fetch project ID:", e); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), +}; + +export default geminiCli; diff --git a/src/lib/oauth/providers/github.js b/src/lib/oauth/providers/github.js new file mode 100644 index 00000000..518652f4 --- /dev/null +++ b/src/lib/oauth/providers/github.js @@ -0,0 +1,98 @@ +import { GITHUB_CONFIG } from "../constants/oauth.js"; + +const github = { + config: GITHUB_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scopes, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + // Handle response properly - if not ok, try to get error as text first + let data; + try { + data = await response.json(); + } catch (e) { + // If response is not JSON, get as text + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + return { + ok: response.ok, + data: data, + }; + }, + postExchange: async (tokens) => { + // Get Copilot token using GitHub access token + const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; + + // Get user info from GitHub + const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const userInfo = userRes.ok ? await userRes.json() : {}; + + return { copilotToken, userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + name: extra?.userInfo?.login || extra?.userInfo?.name, + displayName: extra?.userInfo?.name || extra?.userInfo?.login, + email: extra?.userInfo?.email || null, + providerSpecificData: { + copilotToken: extra?.copilotToken?.token, + copilotTokenExpiresAt: extra?.copilotToken?.expires_at, + githubUserId: extra?.userInfo?.id, + githubLogin: extra?.userInfo?.login, + githubName: extra?.userInfo?.name, + githubEmail: extra?.userInfo?.email, + }, + }), +}; + +export default github; diff --git a/src/lib/oauth/providers/gitlab.js b/src/lib/oauth/providers/gitlab.js new file mode 100644 index 00000000..eacc0fa9 --- /dev/null +++ b/src/lib/oauth/providers/gitlab.js @@ -0,0 +1,64 @@ +import { GITLAB_CONFIG } from "../constants/oauth.js"; + +// GitLab Duo - Authorization Code Flow with PKCE +// Supports two login modes via loginMode metadata: "oauth" (default) or "pat" +const gitlab = { + config: GITLAB_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => { + const baseUrl = meta.baseUrl || config.defaultBaseUrl; + const clientId = meta.clientId || ""; + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + state, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }); + return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => { + const baseUrl = meta.baseUrl || config.defaultBaseUrl; + const clientId = meta.clientId || ""; + const clientSecret = meta.clientSecret || ""; + const body = new URLSearchParams({ + client_id: clientId, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + if (clientSecret) body.set("client_secret", clientSecret); + const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: body.toString(), + }); + if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`); + const tokens = await response.json(); + // Fetch user info + const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const user = userRes.ok ? await userRes.json() : {}; + return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + providerSpecificData: { + username: tokens._user?.username || "", + email: tokens._user?.email || tokens._user?.public_email || "", + name: tokens._user?.name || "", + baseUrl: tokens._baseUrl, + clientId: tokens._clientId, + authKind: "oauth", + }, + }), +}; + +export default gitlab; diff --git a/src/lib/oauth/providers/grok-cli.js b/src/lib/oauth/providers/grok-cli.js new file mode 100644 index 00000000..2bd853b2 --- /dev/null +++ b/src/lib/oauth/providers/grok-cli.js @@ -0,0 +1,130 @@ +import { GROK_CLI_CONFIG } from "../constants/oauth.js"; +import { decodeXaiIdTokenEmail, extractEmailFromAccessToken } from "../providerHelpers.js"; + +// Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com +const grokCli = { + config: GROK_CLI_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const body = new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + }); + // Official CLI sends referrer=grok-build + if (config.referrer) body.set("referrer", config.referrer); + + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Grok CLI device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: config.clientId, + }), + }); + + let data; + try { + data = await response.json(); + } catch { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // Device flow: 400 + authorization_pending is expected while user authorizes + const pending = + data?.error === "authorization_pending" || + data?.error === "slow_down"; + return { + ok: response.ok || pending, + data, + }; + }, + postExchange: async (tokens) => { + // Best-effort user profile from cli-chat-proxy (non-fatal) + try { + const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": "0.2.93", + }, + }); + if (res.ok) return { user: await res.json() }; + } catch { + /* ignore */ + } + return { user: null }; + }, + mapTokens: (tokens, extra) => { + const email = + decodeXaiIdTokenEmail(tokens.id_token) || + extractEmailFromAccessToken(tokens.access_token) || + extra?.user?.email || + null; + const userId = + extra?.user?.userId || + extra?.user?.principalId || + null; + const displayName = [extra?.user?.firstName, extra?.user?.lastName] + .filter(Boolean) + .join(" ") + .trim() || null; + + const expiresAt = tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() + : null; + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + // Surface an absolute expiry so the proactive refresh path + // (shouldRefreshCredentials / checkAndRefreshToken) can refresh the + // xAI token before it silently expires ~40-45 min after login. + // Without this, only the reactive 401 path in chatCore would refresh, + // causing intermittent "token expired" failures for Grok CLI. + expiresAt, + scope: tokens.scope, + // Top-level for dashboard connection cards + email: email || undefined, + displayName: displayName || undefined, + // Mirror identity into providerSpecificData so GrokCliExecutor can set + // x-email / x-userid without depending on top-level credential shape. + providerSpecificData: { + authMethod: "device_code", + idToken: tokens.id_token || null, + email: email || null, + userId, + hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null, + subscriptionTier: extra?.user?.subscriptionTier ?? null, + }, + }; + }, +}; + +export default grokCli; diff --git a/src/lib/oauth/providers/iflow.js b/src/lib/oauth/providers/iflow.js new file mode 100644 index 00000000..7dcb32aa --- /dev/null +++ b/src/lib/oauth/providers/iflow.js @@ -0,0 +1,91 @@ +import { IFLOW_CONFIG } from "../constants/oauth.js"; + +const iflow = { + config: IFLOW_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + loginMethod: config.extraParams.loginMethod, + type: config.extraParams.type, + redirect: redirectUri, + state: state, + client_id: config.clientId, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + // Create Basic Auth header + const basicAuth = Buffer.from( + `${config.clientId}:${config.clientSecret}` + ).toString("base64"); + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info (MUST succeed to get API key) + const userInfoRes = await fetch( + `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!userInfoRes.ok) { + const errorText = await userInfoRes.text(); + throw new Error(`Failed to fetch user info: ${errorText}`); + } + + const result = await userInfoRes.json(); + if (!result.success) { + throw new Error(`User info request failed: ${result.message || 'Unknown error'}`); + } + + const userInfo = result.data || {}; + + // Validate API key (critical for iFlow) + if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { + throw new Error("Empty API key returned from iFlow"); + } + + // Validate email/phone + const email = userInfo.email?.trim() || userInfo.phone?.trim(); + if (!email) { + throw new Error("Missing account email/phone in user info"); + } + + return { userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: extra?.userInfo?.apiKey, + email: extra?.userInfo?.email || extra?.userInfo?.phone, + displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, + }), +}; + +export default iflow; diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js new file mode 100644 index 00000000..8449ecef --- /dev/null +++ b/src/lib/oauth/providers/index.js @@ -0,0 +1,241 @@ +// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime +import "open-sse/index.js"; + +import { generatePKCE } from "../utils/pkce.js"; +import { extractCodexAccountInfo, fetchKiroProfileArn } from "../providerHelpers.js"; + +import claude from "./claude.js"; +import codex from "./codex.js"; +import xai from "./xai.js"; +import grokCli from "./grok-cli.js"; +import geminiCli from "./gemini-cli.js"; +import antigravity from "./antigravity.js"; +import iflow from "./iflow.js"; +import qoder from "./qoder.js"; +import qwen from "./qwen.js"; +import github from "./github.js"; +import kiro from "./kiro.js"; +import cursor from "./cursor.js"; +import kimi from "./kimi.js"; +import kilocode from "./kilocode.js"; +import cline from "./cline.js"; +import clinepass from "./clinepass.js"; +import gitlab from "./gitlab.js"; +import codebuddyCn from "./codebuddy-cn.js"; +import codebuddyIntl from "./codebuddy-intl.js"; +import kimchi from "./kimchi.js"; +import trae from "./trae.js"; +import windsurf from "./windsurf.js"; +import zed from "./zed.js"; + +// Provider configurations +const PROVIDERS = { + claude, + codex, + xai, + "grok-cli": grokCli, + "gemini-cli": geminiCli, + antigravity, + iflow, + qoder, + qwen, + github, + kiro, + cursor, + kimi, + kilocode, + cline, + clinepass, + gitlab, + "codebuddy-cn": codebuddyCn, + "codebuddy-intl": codebuddyIntl, + kimchi, + trae, + windsurf, + zed, +}; + +export { PROVIDERS }; + +// Re-export helpers that other files import from this path +export { extractCodexAccountInfo, fetchKiroProfileArn }; + +/** + * Get provider handler + */ +export function getProvider(name) { + // Legacy kimi-coding → kimi (dual-auth merge) + const key = name === "kimi-coding" ? "kimi" : name; + const provider = PROVIDERS[key]; + if (!provider) { + throw new Error(`Unknown provider: ${name}`); + } + return provider; +} + +/** + * Get all provider names + */ +export function getProviderNames() { + return Object.keys(PROVIDERS); +} + +/** + * Generate auth data for a provider + * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) + */ +export async function generateAuthData(providerName, redirectUri, meta) { + const provider = getProvider(providerName); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; + const { codeVerifier: pkceVerifier, codeChallenge, state: pkceState } = generatePKCE(provider.pkceVerifierBytes); + // Trae uses loginTraceID (set by prepareConfig) as the callback matcher, not PKCE state. + const state = config.loginTraceID || pkceState; + // Zed: codeVerifier carries the encoded RSA private key (from prepareConfig), not a PKCE verifier. + const codeVerifier = config.privateKeyVerifier || pkceVerifier; + + let authUrl; + if (provider.flowType === "device_code") { + // Device code flow doesn't have auth URL upfront + authUrl = null; + } else if (provider.flowType === "authorization_code_pkce") { + authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {}); + } else { + authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {}); + } + + return { + authUrl, + state, + codeVerifier, + codeChallenge, + redirectUri, + flowType: provider.flowType, + fixedPort: provider.fixedPort, + callbackPath: provider.callbackPath || "/callback", + }; +} + +/** + * Exchange code for tokens + * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) + */ +export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { + const provider = getProvider(providerName); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; + + const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {}); + + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(tokens); + } + + return provider.mapTokens(tokens, extra); +} + +/** + * Request device code (for device_code flow) + */ +export async function requestDeviceCode(providerName, codeChallenge, options) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + return await provider.requestDeviceCode(provider.config, codeChallenge, options || {}); +} + +/** + * Poll for token (for device_code flow) + * @param {string} providerName - Provider name + * @param {string} deviceCode - Device code from requestDeviceCode + * @param {string} codeVerifier - PKCE code verifier (optional for some providers) + * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) + */ +export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + + const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); + + if (result.ok) { + // For device code flows, success is only when we have an access token + if (result.data.access_token) { + // Call postExchange to get additional data (copilotToken, userInfo, etc.) + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(result.data); + } + const tokens = provider.mapTokens(result.data, extra); + // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 + if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) { + const profileArn = await fetchKiroProfileArn(tokens.accessToken); + if (profileArn) tokens.providerSpecificData.profileArn = profileArn; + } + return { success: true, tokens }; + } else { + // Check if it's still pending authorization + if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { + // This is not a failure, just still waiting + return { + success: false, + error: result.data.error, + errorDescription: result.data.error_description || result.data.message, + pending: result.data.error === 'authorization_pending' + }; + } else { + // Actual error + return { + success: false, + error: result.data.error || 'no_access_token', + errorDescription: result.data.error_description || result.data.message || 'No access token received' + }; + } + } + } + + return { success: false, error: result.data.error, errorDescription: result.data.error_description }; +} + +// Run-once guard across the process lifetime +let codexBackfillDone = false; + +// Backfill email + chatgpt account info for existing codex OAuth connections missing them +export async function backfillCodexEmails() { + if (codexBackfillDone) return; + codexBackfillDone = true; + try { + const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); + const connections = await getProviderConnections(); + const targets = connections.filter((c) => { + if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false; + const hasEmail = !!c.email; + const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId; + return !hasEmail || !hasAccountInfo; + }); + for (const conn of targets) { + const info = extractCodexAccountInfo(conn.idToken); + if (!info.email && !info.chatgptAccountId) continue; + const patch = {}; + if (!conn.email && info.email) patch.email = info.email; + if (info.chatgptAccountId || info.chatgptPlanType) { + patch.providerSpecificData = { + ...(conn.providerSpecificData || {}), + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + if (Object.keys(patch).length) { + await updateProviderConnection(conn.id, patch); + } + } + } catch (err) { + codexBackfillDone = false; + console.log("backfillCodexEmails failed:", err?.message || err); + } +} diff --git a/src/lib/oauth/providers/kilocode.js b/src/lib/oauth/providers/kilocode.js new file mode 100644 index 00000000..c74d7bbc --- /dev/null +++ b/src/lib/oauth/providers/kilocode.js @@ -0,0 +1,60 @@ +import { KILOCODE_CONFIG } from "../constants/oauth.js"; + +const kilocode = { + config: KILOCODE_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.initiateUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) { + if (response.status === 429) { + throw new Error("Too many pending authorization requests. Please try again later."); + } + const error = await response.text(); + throw new Error(`Device auth initiation failed: ${error}`); + } + const data = await response.json(); + return { + device_code: data.code, + user_code: data.code, + verification_uri: data.verificationUrl, + verification_uri_complete: data.verificationUrl, + expires_in: data.expiresIn || 300, + interval: 3, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); + if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } }; + if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } }; + if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } }; + if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } }; + const data = await response.json(); + if (data.status === "approved" && data.token) { + // Fetch profile to get orgId for X-Kilocode-OrganizationID header + let orgId = null; + try { + const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, { + headers: { "Authorization": `Bearer ${data.token}` } + }); + if (profileRes.ok) { + const profile = await profileRes.json(); + orgId = profile.organizations?.[0]?.id || null; + } + } catch {} + return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } }; + } + return { ok: false, data: { error: "authorization_pending" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: null, + expiresIn: null, + email: tokens._userEmail, + ...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}), + }), +}; + +export default kilocode; diff --git a/src/lib/oauth/providers/kimchi.js b/src/lib/oauth/providers/kimchi.js new file mode 100644 index 00000000..7920f85e --- /dev/null +++ b/src/lib/oauth/providers/kimchi.js @@ -0,0 +1,75 @@ +import { KIMCHI_CONFIG } from "../constants/oauth.js"; + +const kimchi = { + config: KIMCHI_CONFIG, + flowType: "browser_token", + buildAuthUrl: (config, redirectUri, state) => { + const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, ""); + const params = new URLSearchParams({ + callback: redirectUri, + state, + }); + return `${baseUrl}/cli-auth?${params.toString()}`; + }, + exchangeToken: async (config, token) => { + const accessToken = String(token || "").trim(); + if (!accessToken) { + throw new Error("Missing Kimchi token"); + } + + const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers"; + const validationRes = await fetch(validationUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (!validationRes.ok) { + throw new Error(`Kimchi token validation failed: ${validationRes.status}`); + } + + let userInfo = {}; + if (config.userInfoUrl) { + try { + const userRes = await fetch(config.userInfoUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (userRes.ok) { + userInfo = await userRes.json(); + } + } catch { + userInfo = {}; + } + } + + return { + access_token: accessToken, + token_type: "Bearer", + _kimchiUser: userInfo, + }; + }, + mapTokens: (tokens) => { + const user = tokens._kimchiUser || {}; + const userId = user.id ? String(user.id) : ""; + const username = user.username || ""; + const email = user.email || (userId ? `kimchi-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: null, + email, + displayName: user.name || username || null, + providerSpecificData: { + authMethod: "browser_token", + userId, + username, + }, + }; + }, +}; + +export default kimchi; diff --git a/src/lib/oauth/providers/kimi.js b/src/lib/oauth/providers/kimi.js new file mode 100644 index 00000000..613fc4e3 --- /dev/null +++ b/src/lib/oauth/providers/kimi.js @@ -0,0 +1,81 @@ +import crypto from "crypto"; +import { KIMI_CONFIG } from "../constants/oauth.js"; + +// Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`; +// `kimi-coding` remains an alias key so old UI/API routes still resolve. +const kimi = { + config: KIMI_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); + const deviceId = crypto.randomUUID(); + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...buildKimiHeaders(deviceId), + }; + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers, + body: new URLSearchParams({ client_id: config.clientId }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + const data = await response.json(); + const authorizeDeviceUrl = config.authorizeDeviceUrl || "https://www.kimi.com/code/authorize_device"; + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri || authorizeDeviceUrl, + verification_uri_complete: + data.verification_uri_complete || + `${authorizeDeviceUrl}?user_code=${data.user_code}`, + expires_in: data.expires_in, + interval: data.interval || 5, + _kimiDeviceId: deviceId, + }; + }, + pollToken: async (config, deviceCode, _codeVerifier, extraData) => { + const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); + const deviceId = extraData?._kimiDeviceId; + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...buildKimiHeaders(deviceId), + }; + const response = await fetch(config.tokenUrl, { + method: "POST", + headers, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + }), + }); + let data; + try { + data = await response.json(); + } catch { + data = { error: "invalid_response", error_description: "non-json token response" }; + } + // CLIProxyAPI: Kimi returns 200 for pending states with error field + if (data.error === "authorization_pending" || data.error === "slow_down") { + return { ok: true, data }; + } + if (data.access_token && deviceId) data._kimiDeviceId = deviceId; + return { ok: response.ok || !!data.access_token || !!data.error, data }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { + authMethod: "device_code", + ...(tokens._kimiDeviceId ? { deviceId: tokens._kimiDeviceId } : {}), + }, + }), +}; + +export default kimi; diff --git a/src/lib/oauth/providers/kiro.js b/src/lib/oauth/providers/kiro.js new file mode 100644 index 00000000..993cabeb --- /dev/null +++ b/src/lib/oauth/providers/kiro.js @@ -0,0 +1,151 @@ +import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth.js"; +import { extractEmailFromAccessToken } from "../providerHelpers.js"; + +const kiro = { + config: KIRO_CONFIG, + flowType: "device_code", + // Kiro uses AWS SSO OIDC - requires client registration first + requestDeviceCode: async (config, codeChallenge, options = {}) => { + const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; + const region = trimmedRegion || "us-east-1"; + assertValidAwsRegion(region); + const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; + const startUrl = trimmedStartUrl || config.startUrl; + const authMethod = options.authMethod === "idc" ? "idc" : "builder-id"; + const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`; + const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`; + + // Step 1: Register client with AWS SSO OIDC + const registerRes = await fetch(registerClientUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientName: config.clientName, + clientType: config.clientType, + scopes: config.scopes, + grantTypes: config.grantTypes, + issuerUrl: config.issuerUrl, + }), + }); + + if (!registerRes.ok) { + const error = await registerRes.text(); + throw new Error(`Client registration failed: ${error}`); + } + + const clientInfo = await registerRes.json(); + + // Step 2: Request device authorization + const deviceRes = await fetch(deviceAuthUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: clientInfo.clientId, + clientSecret: clientInfo.clientSecret, + startUrl, + }), + }); + + if (!deviceRes.ok) { + const error = await deviceRes.text(); + throw new Error(`Device authorization failed: ${error}`); + } + + const deviceData = await deviceRes.json(); + + // Return combined data for polling + return { + device_code: deviceData.deviceCode, + user_code: deviceData.userCode, + verification_uri: deviceData.verificationUri, + verification_uri_complete: deviceData.verificationUriComplete, + expires_in: deviceData.expiresIn, + interval: deviceData.interval || 5, + // Store client credentials for token exchange + _clientId: clientInfo.clientId, + _clientSecret: clientInfo.clientSecret, + _region: region, + _authMethod: authMethod, + _startUrl: startUrl, + }; + }, + pollToken: async (config, deviceCode, codeVerifier, extraData) => { + const region = extraData?._region || "us-east-1"; + assertValidAwsRegion(region); + const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: extraData?._clientId, + clientSecret: extraData?._clientSecret, + deviceCode: deviceCode, + grantType: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + let data; + try { + data = await response.json(); + } catch (e) { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // AWS SSO OIDC returns camelCase + if (data.accessToken) { + return { + ok: true, + data: { + access_token: data.accessToken, + refresh_token: data.refreshToken, + expires_in: data.expiresIn, + profile_arn: data?.profileArn || null, + // Store client credentials for refresh + _clientId: extraData?._clientId, + _clientSecret: extraData?._clientSecret, + _region: extraData?._region, + _authMethod: extraData?._authMethod, + _startUrl: extraData?._startUrl, + }, + }; + } + + return { + ok: false, + data: { + error: data.error || "authorization_pending", + error_description: data.error_description || data.message, + }, + }; + }, + mapTokens: (tokens) => { + const email = extractEmailFromAccessToken(tokens.access_token); + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + email, + providerSpecificData: { + profileArn: tokens?.profile_arn || null, + clientId: tokens._clientId, + clientSecret: tokens._clientSecret, + region: tokens._region || "us-east-1", + authMethod: tokens._authMethod || "builder-id", + startUrl: tokens._startUrl || KIRO_CONFIG.startUrl, + }, + }; + return mapped; + }, +}; + +export default kiro; diff --git a/src/lib/oauth/providers/qoder.js b/src/lib/oauth/providers/qoder.js new file mode 100644 index 00000000..fa46a92d --- /dev/null +++ b/src/lib/oauth/providers/qoder.js @@ -0,0 +1,102 @@ +import { QODER_CONFIG } from "../constants/oauth.js"; + +const qoder = { + config: QODER_CONFIG, + flowType: "device_code", + // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated + // locally, the user lands on qoder.com/device/selectAccounts in the + // browser, and we poll openapi.qoder.sh until a `dt-...` token appears. + requestDeviceCode: async (config) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const flow = new QoderService().initiateDeviceFlow(); + // Match the device_code shape the rest of the OAuthModal expects + // (device_code, user_code, verification_uri[_complete], interval). + // The poll endpoint identifies us by nonce+verifier, not by a + // server-issued device_code, so we plumb our own values through: + // device_code = nonce (modal forwards as deviceCode on poll) + // codeVerifier = our PKCE verifier (route forwards as codeVerifier) + return { + device_code: flow.nonce, + user_code: flow.nonce.slice(0, 8).toUpperCase(), + verification_uri: config.loginUrl, + verification_uri_complete: flow.verificationUriComplete, + expires_in: 300, + interval: 2, + codeVerifier: flow.codeVerifier, + _qoderNonce: flow.nonce, + _qoderMachineId: flow.machineId, + }; + }, + pollToken: async (config, deviceCode, codeVerifier, extraData) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const svc = new QoderService(); + const nonce = deviceCode || extraData?._qoderNonce; + const verifier = codeVerifier || extraData?._qoderVerifier; + if (!nonce || !verifier) { + return { + ok: false, + data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, + }; + } + let result; + try { + result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); + } catch (err) { + return { + ok: false, + data: { error: "poll_failed", error_description: err.message }, + }; + } + if (result.status === "pending") { + return { ok: false, data: { error: "authorization_pending" } }; + } + // Best-effort profile lookup so we have a name/email to display. + const userInfo = await svc.fetchUserInfo(result.accessToken); + // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, + // which already falls back to "now + 30 days" when the upstream + // omits expiry. Floor to a sane minimum (1 day) so a stale or + // skewed upstream timestamp doesn't truncate the stored token below + // something useful. + const minSeconds = 24 * 60 * 60; + const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); + const expiresIn = Math.max(minSeconds, remainingSeconds); + return { + ok: true, + data: { + access_token: result.accessToken, + refresh_token: result.refreshToken, + expires_in: expiresIn, + _qoderUserId: result.userId, + _qoderMachineId: extraData?._qoderMachineId || "", + _qoderName: userInfo.name, + _qoderEmail: userInfo.email, + _qoderOrganizationId: userInfo.organizationId, + }, + }; + }, + mapTokens: (tokens) => { + const rawEmail = (tokens._qoderEmail || "").trim(); + const displayName = (tokens._qoderName || "").trim() || null; + const userId = tokens._qoderUserId || ""; + // Dedup in createProviderConnection requires a non-empty email. When + // fetchUserInfo silently fails (returns ""), fall back to a stable + // synthetic identifier derived from userId so re-logins update the + // existing row instead of accumulating "Account N" duplicates. + const email = rawEmail || (userId ? `qoder-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + email, + displayName, + providerSpecificData: { + authMethod: "device", + userId, + machineId: tokens._qoderMachineId || "", + organizationId: tokens._qoderOrganizationId || "", + }, + }; + }, +}; + +export default qoder; diff --git a/src/lib/oauth/providers/qwen.js b/src/lib/oauth/providers/qwen.js new file mode 100644 index 00000000..e8048654 --- /dev/null +++ b/src/lib/oauth/providers/qwen.js @@ -0,0 +1,56 @@ +import { QWEN_CONFIG } from "../constants/oauth.js"; + +const qwen = { + config: QWEN_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config, codeChallenge) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + code_verifier: codeVerifier, + }), + }); + + return { + ok: response.ok, + data: await response.json(), + }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { resourceUrl: tokens.resource_url }, + }), +}; + +export default qwen; diff --git a/src/lib/oauth/providers/trae.js b/src/lib/oauth/providers/trae.js new file mode 100644 index 00000000..5e7df52b --- /dev/null +++ b/src/lib/oauth/providers/trae.js @@ -0,0 +1,263 @@ +import crypto from "crypto"; +import { TRAE_CONFIG } from "../constants/oauth.js"; +import { extractJsonPath } from "./_shared.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Trae (ByteDance marscode) OAuth helpers +// ─────────────────────────────────────────────────────────────────────────── + +// Per-login device context. No IDE access in 9router, so use stable defaults. +function buildTraeDeviceContext() { + return { + plugin_version: TRAE_CONFIG.defaultPluginVersion, + machine_id: crypto.randomUUID(), + device_id: TRAE_CONFIG.defaultDeviceId, + x_device_brand: "unknown", + x_device_type: "unknown", + x_os_version: "unknown", + x_env: "", + x_app_version: TRAE_CONFIG.defaultAppVersion, + x_app_type: TRAE_CONFIG.defaultAppType, + }; +} + +// POST GetLoginGuidance → { Result: { LoginHost } } +async function fetchTraeLoginGuidance(loginTraceId) { + const body = JSON.stringify({ loginTraceID: loginTraceId, login_trace_id: loginTraceId }); + let lastErr = "no successful response"; + for (const url of TRAE_CONFIG.loginGuidanceUrls) { + try { + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + }, + body, + }); + if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; } + const data = await res.json(); + const loginHost = extractJsonPath(data, [ + ["Result", "LoginHost"], ["Result", "loginHost"], ["Result", "LoginURL"], + ["result", "loginHost"], ["data", "Result", "LoginHost"], ["data", "loginHost"], + ["LoginHost"], ["loginHost"], + ]); + if (loginHost) return loginHost; + lastErr = `${url} missing LoginHost`; + } catch (e) { lastErr = `${url} ${e.message}`; } + } + throw new Error(`Trae GetLoginGuidance failed: ${lastErr}`); +} + +// Build the browser verification URL the user opens to sign in. +function buildTraeVerificationUrl(loginHost, loginTraceId, callbackUrl, ctx) { + const url = new URL(loginHost.startsWith("http") ? loginHost : `https://${loginHost.replace(/^\/+/, "")}`); + url.pathname = TRAE_CONFIG.authorizationPath; + const p = new URLSearchParams(); + p.set("login_version", "1"); + p.set("auth_from", "trae"); + p.set("login_channel", "native_ide"); + p.set("plugin_version", ctx.plugin_version); + p.set("auth_type", "local"); + p.set("client_id", TRAE_CONFIG.clientId); + p.set("redirect", "0"); + p.set("login_trace_id", loginTraceId); + p.set("auth_callback_url", callbackUrl); + p.set("machine_id", ctx.machine_id); + p.set("device_id", ctx.device_id); + p.set("x_device_id", ctx.device_id); + p.set("x_machine_id", ctx.machine_id); + p.set("x_device_brand", ctx.x_device_brand); + p.set("x_device_type", ctx.x_device_type); + p.set("x_os_version", ctx.x_os_version); + p.set("x_env", ctx.x_env); + p.set("x_app_version", ctx.x_app_version); + p.set("x_app_type", ctx.x_app_type); + url.search = p.toString(); + return url.toString(); +} + +// Parse the Trae OAuth callback (query string or full URL). +// Expected: ?isRedirect=true&refreshToken=...&loginHost=...[&x-cloudide-token=...] +function parseTraeCallback(raw) { + const text = String(raw || "").trim(); + let queryStr = text; + if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1); + if (text.startsWith("#")) queryStr = text.slice(1); + const params = Object.fromEntries(new URLSearchParams(queryStr)); + const pick = (keys) => { + for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); } + return null; + }; + const err = pick(["error", "error_code", "errorCode"]); + if (err) { + const desc = pick(["error_description", "error_desc", "message"]); + throw new Error(desc ? `Trae auth failed: ${err} (${desc})` : `Trae auth failed: ${err}`); + } + const refreshToken = pick(["refreshToken", "refresh_token", "RefreshToken"]); + if (!refreshToken) throw new Error("Trae callback missing refreshToken"); + const loginHost = pick(["loginHost", "login_host", "LoginHost", "host", "consoleHost"]); + if (!loginHost) throw new Error("Trae callback missing loginHost"); + const cloudideToken = pick(["x-cloudide-token", "xCloudideToken", "accessToken", "access_token", "token"]); + return { refreshToken, loginHost, cloudideToken }; +} + +// Allowed API origins for ExchangeToken/GetUserInfo — hardcoded HTTPS allowlist only. +// loginHost from the callback is intentionally NOT honored (SSRF guard: a callback +// attacker could otherwise point this at internal hosts/cloud metadata). +function traeApiOrigins() { + return [...TRAE_CONFIG.apiOrigins]; +} + +// POST ExchangeToken {ClientID, RefreshToken, ClientSecret, UserID} → {Result:{AccessToken,RefreshToken,ExpiresAt}} +async function fetchTraeExchangeToken(refreshToken, cloudideToken) { + const body = JSON.stringify({ + ClientID: TRAE_CONFIG.clientId, + RefreshToken: refreshToken, + ClientSecret: TRAE_CONFIG.clientSecret, + UserID: "", + }); + let lastErr = "no successful response"; + for (const origin of traeApiOrigins()) { + const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.exchangeTokenPath}`; + try { + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + }; + if (cloudideToken) headers["x-cloudide-token"] = cloudideToken; + const res = await fetch(url, { method: "POST", headers, body }); + const text = await res.text(); + if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; } + let data; try { data = JSON.parse(text); } catch { lastErr = `${url} invalid JSON`; continue; } + const accessToken = extractJsonPath(data, [ + ["Result", "AccessToken"], ["Result", "accessToken"], ["result", "access_token"], ["accessToken"], + ]); + if (!accessToken) { + const msg = extractJsonPath(data, [["message"], ["msg"], ["error"], ["Result", "Message"]]) || "missing AccessToken"; + lastErr = `${url} ${msg}`; + continue; + } + return { + accessToken, + refreshToken: extractJsonPath(data, [["Result", "RefreshToken"], ["result", "refresh_token"], ["refreshToken"]]) || refreshToken, + expiresIn: null, // ExchangeToken returns ExpiresAt (absolute), converted below + expiresAt: extractJsonPath(data, [["Result", "ExpiresAt"], ["Result", "expiresAt"], ["result", "expires_at"], ["expiresAt"]]), + }; + } catch (e) { lastErr = `${url} ${e.message}`; } + } + throw new Error(`Trae ExchangeToken failed: ${lastErr}`); +} + +// POST GetUserInfo with x-cloudide-token → identity fields used by SOLO common_params. +async function fetchTraeUserInfo(accessToken) { + for (const origin of traeApiOrigins()) { + const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.getUserInfoPath}`; + try { + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + "x-cloudide-token": accessToken, + }, + body: JSON.stringify({}), + }); + if (!res.ok) continue; + const data = await res.json(); + return { + email: extractJsonPath(data, [ + ["Result", "NonPlainTextEmail"], ["Result", "Email"], ["Result", "email"], + ["email"], ["data", "email"], + ]), + name: extractJsonPath(data, [ + ["Result", "ScreenName"], ["Result", "Nickname"], ["Result", "Name"], + ["result", "nickname"], ["nickname"], ["name"], + ]), + aiRegion: extractJsonPath(data, [["Result", "AIRegion"], ["Result", "aiRegion"], ["aiRegion"]]), + region: extractJsonPath(data, [["Result", "Region"], ["Result", "region"], ["region"]]), + tenant: extractJsonPath(data, [["Result", "TenantID"], ["Result", "tenantId"], ["tenantId"]]), + userId: extractJsonPath(data, [["Result", "UserID"], ["Result", "userId"], ["userId"]]), + }; + } catch { /* try next origin */ } + } + return { email: null, name: null }; +} + +// Map AIRegion (e.g. "SG", "US") → SOLO scope used in common_params. +function traeScopeForRegion(aiRegion) { + const r = (aiRegion || "").toLowerCase(); + if (r === "sg" || r.includes("singapore")) return "marscode-sg"; + if (r === "cn" || r.includes("cn") || r.includes("china")) return "marscode-cn"; + return "marscode-us"; +} + +// Trae — browser OAuth: GetLoginGuidance → verification URL +// → local callback (refreshToken+loginHost) → ExchangeToken → GetUserInfo. +// state === config.loginTraceID so the proxy can match the callback. +const trae = { + config: TRAE_CONFIG, + flowType: "authorization_code", + callbackPath: TRAE_CONFIG.callbackPath, + prepareConfig: async (config) => { + const loginTraceID = crypto.randomUUID(); + const loginHost = await fetchTraeLoginGuidance(loginTraceID); + return { ...config, loginTraceID, loginHost }; + }, + buildAuthUrl: (config, redirectUri, state) => { + const ctx = buildTraeDeviceContext(); + const traceId = config.loginTraceID || state; + return buildTraeVerificationUrl(config.loginHost, traceId, redirectUri, ctx); + }, + exchangeToken: async (config, code) => { + const trimmed = String(code || "").trim(); + // Paste-token mode: raw Cloud-IDE-JWT (no refresh exchange) + const looksCallback = /[?=&]/.test(trimmed) && (trimmed.includes("refreshToken") || trimmed.includes("refresh_token")); + if (!looksCallback) { + // Strip "Cloud-IDE-JWT " / "Bearer " prefix users paste from the Authorization header + const clean = trimmed.replace(/^(Cloud-IDE-JWT|Bearer)\s+/i, ""); + return { accessToken: clean, refreshToken: null, expiresIn: TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60, _authMethod: "imported" }; + } + const { refreshToken, cloudideToken } = parseTraeCallback(trimmed); + return { ...(await fetchTraeExchangeToken(refreshToken, cloudideToken)), _authMethod: "oauth" }; + }, + postExchange: async (tokens) => { + const userInfo = await fetchTraeUserInfo(tokens.accessToken); + return { userInfo }; + }, + mapTokens: (tokens, extra) => { + const expiresIn = tokens.expiresIn + || (tokens.expiresAt ? Math.max(60, Number(tokens.expiresAt) - Math.floor(Date.now() / 1000)) : TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60); + const ui = extra?.userInfo || {}; + const aiRegion = ui.aiRegion || "US-East"; + // SOLO common_params defaults — identity fields web_id/biz_user_id are not + // exposed by GetUserInfo; empty strings are accepted upstream (verified). + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn, + email: ui.email || undefined, + displayName: ui.name || undefined, + providerSpecificData: { + authMethod: tokens._authMethod || "oauth", + aiRegion, + region: ui.region || aiRegion, + tenant: ui.tenant || "marscode", + userId: ui.userId || "", + scope: traeScopeForRegion(aiRegion), + webId: "", + bizUserId: "", + userUniqueId: "", + appLanguage: "en", + appVersion: TRAE_CONFIG.defaultAppVersion, + userRegion: aiRegion === "SG" ? "SG" : "US", + userIdentity: "Free", + }, + }; + }, +}; + +export default trae; diff --git a/src/lib/oauth/providers/windsurf.js b/src/lib/oauth/providers/windsurf.js new file mode 100644 index 00000000..b7635832 --- /dev/null +++ b/src/lib/oauth/providers/windsurf.js @@ -0,0 +1,132 @@ +import { WINDSURF_CONFIG } from "../constants/oauth.js"; +import { extractJsonPath } from "./_shared.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Windsurf OAuth helpers +// ─────────────────────────────────────────────────────────────────────────── + +async function windsurfSeatRequest(baseUrl, path, body) { + const url = `${baseUrl.replace(/\/$/, "")}${path}`; + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": WINDSURF_CONFIG.userAgent, + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`Windsurf ${path} HTTP ${res.status}: ${text.slice(0, 200)}`); + try { return JSON.parse(text); } catch { throw new Error(`Windsurf ${path} invalid JSON`); } +} + +// Parse Windsurf callback (query string or full URL): ?access_token=...&state=... +function parseWindsurfCallback(raw, expectedState) { + const text = String(raw || "").trim(); + let queryStr = text; + if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1); + if (text.startsWith("#")) queryStr = text.slice(1); + const params = Object.fromEntries(new URLSearchParams(queryStr)); + const pick = (keys) => { + for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); } + return null; + }; + const err = pick(["error"]); + if (err) { + const desc = pick(["error_description"]); + throw new Error(desc ? `Windsurf auth failed: ${err} (${desc})` : `Windsurf auth failed: ${err}`); + } + const accessToken = pick(["access_token", "token"]); + if (!accessToken) throw new Error("Windsurf callback missing access_token"); + const state = pick(["state"]); + if (expectedState && state && state !== expectedState) { + throw new Error("Windsurf callback state mismatch"); + } + return { firebaseIdToken: accessToken }; +} + +// POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} +async function fetchWindsurfRegisterUser(firebaseIdToken) { + const data = await windsurfSeatRequest(WINDSURF_CONFIG.registerApiBaseUrl, WINDSURF_CONFIG.registerPath, { + firebase_id_token: firebaseIdToken, + }); + const apiKey = extractJsonPath(data, [["apiKey"], ["api_key"]]); + if (!apiKey) throw new Error("Windsurf RegisterUser missing apiKey"); + const apiServerUrl = extractJsonPath(data, [["apiServerUrl"], ["api_server_url"]]) || WINDSURF_CONFIG.defaultApiServerUrl; + const name = extractJsonPath(data, [["name"]]); + return { apiKey, apiServerUrl, name }; +} + +// Best-effort: GetOneTimeAuthToken → GetCurrentUser → email/name. +async function fetchWindsurfUserInfo(apiServerUrl, firebaseIdToken) { + try { + const authRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.oneTimeAuthPath, { firebaseIdToken }); + const authToken = extractJsonPath(authRes, [["authToken"], ["auth_token"]]); + if (!authToken) return { email: null, name: null }; + const userRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.currentUserPath, { + authToken, + includeSubscription: true, + }); + const user = userRes.user || userRes; + return { + email: extractJsonPath(user, [["email"]]), + name: extractJsonPath(user, [["name"]]), + }; + } catch { return { email: null, name: null }; } +} + +// Windsurf — browser OAuth: windsurf.com/signin → +// local callback (firebase JWT) → RegisterUser → apiKey (used as credential). +const windsurf = { + config: WINDSURF_CONFIG, + flowType: "authorization_code", + callbackPath: WINDSURF_CONFIG.callbackPath, + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + response_type: "token", + client_id: config.clientId, + redirect_uri: redirectUri, + state, + prompt: "login", + redirect_parameters_type: "query", + workflow: "onboarding", + }); + return `${config.authBaseUrl}${config.signInPath}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + const trimmed = String(code || "").trim(); + const looksCallback = trimmed.includes("?") || trimmed.includes("access_token="); + if (!looksCallback) { + // Paste-token mode: sk-ws-... apiKey OR firebase JWT (eyJ...). Strip "Bearer " if pasted. + const clean = trimmed.replace(/^Bearer\s+/i, ""); + if (clean.startsWith("sk-ws-")) { + return { accessToken: clean, refreshToken: null, expiresIn: null, apiServerUrl: config.defaultApiServerUrl, firebaseIdToken: null, _authMethod: "imported" }; + } + const reg = await fetchWindsurfRegisterUser(clean); + return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken: clean, _authMethod: "imported" }; + } + const { firebaseIdToken } = parseWindsurfCallback(trimmed, state); + const reg = await fetchWindsurfRegisterUser(firebaseIdToken); + return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken, _authMethod: "oauth" }; + }, + postExchange: async (tokens) => { + if (!tokens.firebaseIdToken) return { userInfo: { email: null, name: null } }; + const info = await fetchWindsurfUserInfo(tokens.apiServerUrl, tokens.firebaseIdToken); + return { userInfo: info }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.accessToken, + refreshToken: null, + expiresIn: null, + email: extra?.userInfo?.email || undefined, + displayName: extra?.userInfo?.name || undefined, + providerSpecificData: { + authMethod: tokens._authMethod || "oauth", + apiServerUrl: tokens.apiServerUrl, + firebaseIdToken: tokens.firebaseIdToken, + }, + }), +}; + +export default windsurf; diff --git a/src/lib/oauth/providers/xai.js b/src/lib/oauth/providers/xai.js new file mode 100644 index 00000000..6cf8b76d --- /dev/null +++ b/src/lib/oauth/providers/xai.js @@ -0,0 +1,96 @@ +import crypto from "crypto"; +import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "../constants/xai.js"; +import { validateXaiOAuthEndpoint, decodeXaiIdTokenEmail } from "../providerHelpers.js"; + +// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package +let cachedXaiDiscovery = null; + +async function discoverXaiEndpoints() { + if (cachedXaiDiscovery) return cachedXaiDiscovery; + try { + const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } }); + if (res.ok) { + const data = await res.json(); + cachedXaiDiscovery = { + authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"), + tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"), + }; + return cachedXaiDiscovery; + } + } catch { /* fall through to static fallback */ } + cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl }; + return cachedXaiDiscovery; +} + +const xai = { + config: XAI_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: XAI_CONFIG.loopbackPort, + callbackPath: XAI_CONFIG.callbackPath, + pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, + prepareConfig: async (config) => { + const endpoints = await discoverXaiEndpoints(); + return { + ...config, + authorizeUrl: endpoints.authorizeUrl, + tokenUrl: endpoints.tokenUrl, + }; + }, + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + // Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer + const nonce = crypto.randomBytes(16).toString("hex"); + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + nonce, + plan: "generic", + referrer: "cli-proxy-api", + }; + const qs = Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + return `${config.authorizeUrl}?${qs}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`xAI token exchange failed: ${error}`); + } + return await response.json(); + }, + mapTokens: (tokens) => { + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }; + const email = decodeXaiIdTokenEmail(tokens.id_token); + if (email) mapped.email = email; + if (tokens.id_token) { + mapped.providerSpecificData = { idToken: tokens.id_token }; + } + return mapped; + }, +}; + +export default xai; diff --git a/src/lib/oauth/providers/zed.js b/src/lib/oauth/providers/zed.js new file mode 100644 index 00000000..3343976a --- /dev/null +++ b/src/lib/oauth/providers/zed.js @@ -0,0 +1,62 @@ +import { ZED_HOSTED_CONFIG } from "../constants/oauth.js"; +import { + createZedNativeAuthData, + parseZedCallbackPayload, + decryptZedAccessToken, + fetchZedAuthenticatedUser, + resolveZedOrganizationId, +} from "open-sse/shared/zedAuth.js"; + +// Zed — RSA keypair native-app flow (NOT OAuth). prepareConfig generates a fresh +// keypair; buildAuthUrl returns the native_app_signin URL; exchangeToken decrypts +// the RSA-encrypted access token from the local callback. +const zed = { + config: ZED_HOSTED_CONFIG, + flowType: "authorization_code", + callbackPath: "/", + prepareConfig: async (config, meta) => { + // native_app_port is the local callback port (passed via meta from start-proxy). + const nativeAppPort = Number(meta?.nativeAppPort) || ZED_HOSTED_CONFIG.defaultNativeAppPort; + const auth = createZedNativeAuthData(config, { nativeAppPort }); + return { ...config, ...auth }; + }, + buildAuthUrl: (config, redirectUri, state) => config.authUrl, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // code = raw callback URL/query; codeVerifier = encoded private key verifier. + const { userId, encryptedAccessToken } = parseZedCallbackPayload(code); + const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier); + return { accessToken, userId, systemId: config.systemId }; + }, + postExchange: async (tokens) => { + const credentials = { + accessToken: tokens.accessToken, + providerSpecificData: { userId: tokens.userId, systemId: tokens.systemId }, + }; + let userInfo = null; + try { + userInfo = await fetchZedAuthenticatedUser(credentials, { config: ZED_HOSTED_CONFIG }); + } catch { /* best-effort */ } + const organizationId = resolveZedOrganizationId(credentials, userInfo); + return { + userInfo, + organizationId, + email: userInfo?.email || null, + name: userInfo?.name || userInfo?.display_name || null, + }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.accessToken, + refreshToken: null, + expiresIn: null, + email: extra?.email || undefined, + displayName: extra?.name || undefined, + providerSpecificData: { + authMethod: "oauth", + userId: tokens.userId, + systemId: tokens.systemId, + organizationId: extra?.organizationId || "", + }, + }), +}; + +export default zed; diff --git a/src/lib/oauth/utils/ideDetect.js b/src/lib/oauth/utils/ideDetect.js new file mode 100644 index 00000000..a1200bcf --- /dev/null +++ b/src/lib/oauth/utils/ideDetect.js @@ -0,0 +1,57 @@ +import fs from "fs/promises"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +// Install paths per provider per platform — Trae and standard Windsurf IDE locations. +const IDE_PATHS = { + trae: { + darwin: ["/Applications/Trae.app"], + win32: [ + path.join(process.env.LOCALAPPDATA || "", "Programs", "Trae", "Trae.exe"), + path.join(process.env.ProgramFiles || "", "Trae", "Trae.exe"), + ], + linux: ["/usr/bin/trae", "/usr/local/bin/trae", "/opt/trae", "/opt/Trae"], + }, + windsurf: { + darwin: ["/Applications/Windsurf.app"], + win32: [ + path.join(process.env.LOCALAPPDATA || "", "Programs", "Windsurf", "Windsurf.exe"), + path.join(process.env.ProgramFiles || "", "Windsurf", "Windsurf.exe"), + ], + linux: ["/usr/bin/windsurf", "/usr/local/bin/windsurf", "/opt/windsurf", "/opt/Windsurf"], + }, +}; + +const IDE_BINARIES = { + trae: "trae", + windsurf: "windsurf", +}; + +async function pathExists(p) { + try { await fs.access(p); return true; } catch { return false; } +} + +async function checkBinary(bin) { + try { + const cmd = os.platform() === "win32" ? `where ${bin}` : `which ${bin}`; + await execAsync(cmd, { windowsHide: true }); + return true; + } catch { return false; } +} + +// Returns { installed: boolean, path: string|null } for the given provider's IDE. +export async function detectIdeInstalled(providerId) { + const platform = os.platform(); + const paths = IDE_PATHS[providerId]; + if (!paths) return { installed: false, path: null }; + for (const p of paths[platform] || []) { + if (p && await pathExists(p)) return { installed: true, path: p }; + } + const bin = IDE_BINARIES[providerId]; + if (bin && await checkBinary(bin)) return { installed: true, path: bin }; + return { installed: false, path: null }; +} diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index b11a9a44..56eb67b1 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -1,6 +1,16 @@ import http from "http"; import { URL } from "url"; -import { CODEX_CONFIG } from "../constants/oauth.js"; +import { CODEX_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, ZED_HOSTED_CONFIG } from "../constants/oauth.js"; + +// Loopback origin guard for local callback proxies. +// Legit OAuth redirects are top-level navigations (no `Origin` header); a cross-site +// page issuing `fetch(..., {mode:"no-cors"})` to scan + hit 127.0.0.1 always sends +// `Origin: https://attacker`. Reject any non-loopback Origin to block login-CSRF. +function isLoopbackOrigin(origin) { + if (!origin) return true; // navigation redirect — allow + return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin); +} + /** * Start a local HTTP server to receive OAuth callback @@ -424,3 +434,324 @@ export function stopXaiProxy() { } } +// ─────────────────────────────────────────────────────────────────────────── +// Trae dynamic-port proxy. Singleton session (one connect at a time per provider). +// Callback path = /callback with params refreshToken + loginHost. +// ─────────────────────────────────────────────────────────────────────────── + +let traeProxyServer = null; +let traeProxyTimeout = null; +let traeProxyPort = null; +let traeSession = null; + +export function registerTraeSession({ state }) { + if (!state) return false; + traeSession = { state, status: "pending", createdAt: Date.now() }; + return true; +} +export function getTraeSessionStatus(state) { + if (!traeSession) return null; + if (state && traeSession.state !== state) return null; + return traeSession; +} +export function clearTraeSession(state) { + if (!state || (traeSession && traeSession.state === state)) traeSession = null; +} + +export function startTraeProxy() { + return new Promise((resolve) => { + if (traeProxyServer) { + resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== TRAE_CONFIG.callbackPath && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = traeSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Trae login session")); + return; + } + // Anti-CSRF: reject cross-origin fetches (legit redirects send no Origin), + // and reject state mismatch when state is present. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + const cbState = url.searchParams.get("state"); + if (cbState && session.state && cbState !== session.state) { + session.status = "error"; + session.error = "Trae callback state mismatch"; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, session.error)); + stopTraeProxy(); + return; + } + // Pass the raw callback query to exchangeTokens → parseTraeCallback + const rawCallback = `${url.pathname}?${url.searchParams.toString()}`; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("trae", rawCallback); + const connection = await createProviderConnection({ + provider: "trae", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopTraeProxy(); + } + }); + server.listen(0, "127.0.0.1", () => { + traeProxyServer = server; + traeProxyPort = server.address().port; + traeProxyTimeout = setTimeout(() => stopTraeProxy(), TRAE_CONFIG.oauthTimeoutMs); + resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` }); + }); + server.on("error", (err) => resolve({ success: false, reason: err.message })); + }); +} + +export function stopTraeProxy() { + if (traeProxyTimeout) { clearTimeout(traeProxyTimeout); traeProxyTimeout = null; } + if (traeProxyServer) { traeProxyServer.close(); traeProxyServer = null; } + traeProxyPort = null; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Windsurf dynamic-port proxy. Singleton session. +// Callback path = /windsurf-auth-callback with params access_token (firebase JWT) + state. +// ─────────────────────────────────────────────────────────────────────────── + +let windsurfProxyServer = null; +let windsurfProxyTimeout = null; +let windsurfProxyPort = null; +let windsurfSession = null; + +export function registerWindsurfSession({ state }) { + if (!state) return false; + windsurfSession = { state, status: "pending", createdAt: Date.now() }; + return true; +} +export function getWindsurfSessionStatus(state) { + if (!windsurfSession) return null; + if (state && windsurfSession.state !== state) return null; + return windsurfSession; +} +export function clearWindsurfSession(state) { + if (!state || (windsurfSession && windsurfSession.state === state)) windsurfSession = null; +} + +export function startWindsurfProxy() { + return new Promise((resolve) => { + if (windsurfProxyServer) { + resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== WINDSURF_CONFIG.callbackPath) { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = windsurfSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Windsurf login session")); + return; + } + // Anti-CSRF: reject cross-origin fetches, and require state present + matching. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + const cbState = url.searchParams.get("state"); + if (!cbState || !session.state || cbState !== session.state) { + session.status = "error"; + session.error = "Windsurf callback state mismatch"; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, session.error)); + stopWindsurfProxy(); + return; + } + const rawCallback = `${url.pathname}?${url.searchParams.toString()}`; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("windsurf", rawCallback, null, null, session.state); + const connection = await createProviderConnection({ + provider: "windsurf", + authType: "api_key", + ...tokenData, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopWindsurfProxy(); + } + }); + server.listen(0, "127.0.0.1", () => { + windsurfProxyServer = server; + windsurfProxyPort = server.address().port; + windsurfProxyTimeout = setTimeout(() => stopWindsurfProxy(), WINDSURF_CONFIG.oauthTimeoutMs); + resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` }); + }); + server.on("error", (err) => resolve({ success: false, reason: err.message })); + }); +} + +export function stopWindsurfProxy() { + if (windsurfProxyTimeout) { clearTimeout(windsurfProxyTimeout); windsurfProxyTimeout = null; } + if (windsurfProxyServer) { windsurfProxyServer.close(); windsurfProxyServer = null; } + windsurfProxyPort = null; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Zed RSA native-app proxy. Singleton session. +// Callback: GET http://127.0.0.1:/?user_id=...&access_token= +// The proxy decrypts the access token using the private key stored in session.codeVerifier. +// ─────────────────────────────────────────────────────────────────────────── + +let zedProxyServer = null; +let zedProxyTimeout = null; +let zedProxyPort = null; +let zedSession = null; + +export function registerZedSession({ state, codeVerifier }) { + if (!state || !codeVerifier) return false; + zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() }; + return true; +} +export function getZedSessionStatus(state) { + if (!zedSession) return null; + if (state && zedSession.state !== state) return null; + return zedSession; +} +export function clearZedSession(state) { + if (!state || (zedSession && zedSession.state === state)) zedSession = null; +} + +export function startZedProxy(preferredPort = 0) { + return new Promise((resolve) => { + if (zedProxyServer) { + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + // Log path + redacted params (access_token is the RSA-encrypted credential). + const redacted = Object.fromEntries(url.searchParams); + for (const k of ["access_token", "user_id", "code_verifier", "state"]) { + if (redacted[k]) redacted[k] = ""; + } + console.log("[Zed proxy]", req.method, url.pathname, JSON.stringify(redacted)); + if (url.pathname !== "/" && url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = zedSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Zed login session")); + return; + } + // Anti-CSRF: Zed tokens are RSA-encrypted to our keypair so they can't be + // forged cross-site, but still reject cross-origin fetches for defense-in-depth. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + // Pass raw callback path+query to exchangeTokens → parseZedCallbackPayload. + // codeVerifier carries the encoded RSA private key for decryption. + const rawCallback = url.search ? `${url.pathname}?${url.searchParams.toString()}` : url.pathname; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("zed", rawCallback, null, session.codeVerifier, session.state); + const connection = await createProviderConnection({ + provider: "zed", + authType: "oauth", + ...tokenData, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopZedProxy(); + } + }); + const tryPort = Number(preferredPort) || 0; + server.on("error", (err) => { + // If the preferred port (e.g. 58443) is busy, fall back to a random port. + if (err.code === "EADDRINUSE" && tryPort !== 0) { + console.log(`[Zed proxy] port ${tryPort} busy, falling back to random`); + server.listen(0, "127.0.0.1", () => { + zedProxyServer = server; + zedProxyPort = server.address().port; + zedProxyTimeout = setTimeout(() => stopZedProxy(), ZED_HOSTED_CONFIG.oauthTimeoutMs); + console.log(`[Zed proxy] listening on random port ${zedProxyPort}`); + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + }); + } else { + console.log(`[Zed proxy] listen error: ${err.message}`); + resolve({ success: false, reason: err.message }); + } + }); + server.listen(tryPort, "127.0.0.1", () => { + zedProxyServer = server; + zedProxyPort = server.address().port; + zedProxyTimeout = setTimeout(() => { console.log("[Zed proxy] timeout, stopping"); stopZedProxy(); }, ZED_HOSTED_CONFIG.oauthTimeoutMs); + console.log(`[Zed proxy] listening on port ${zedProxyPort}`); + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + }); + }); +} + +export function stopZedProxy() { + console.log(`[Zed proxy] stopping (port ${zedProxyPort || "-"})`); + if (zedProxyTimeout) { clearTimeout(zedProxyTimeout); zedProxyTimeout = null; } + if (zedProxyServer) { zedProxyServer.close(); zedProxyServer = null; } + zedProxyPort = null; +} + diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index f734f6ab..808a7a8b 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -5,6 +5,31 @@ import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +// Providers using the dynamic-port local callback proxy. +// Browser OAuth: popup → auto callback → auto exchange → poll-status. +const PROXY_OAUTH_PROVIDERS = new Set(["trae", "windsurf", "zed"]); + +// Providers offering a paste-token fallback (import-token flow). +// UX warns if the IDE (which issues the token) is not installed. +const PASTE_TOKEN_PROVIDERS = { + trae: { + label: "Cloud-IDE-JWT", + instructions: + "Sign in at trae.ai (or solo.trae.ai), open DevTools → Network, copy the Cloud-IDE-JWT token from any request's Authorization header (~14-day lifetime).", + placeholder: "Paste Cloud-IDE-JWT here...", + ideName: "Trae", + ideOptional: true, // token can be grabbed from DevTools without the IDE + }, + windsurf: { + label: "Windsurf API key", + instructions: + "In the Windsurf/VS Code IDE, run the \"Windsurf: Provide Auth Token\" command, then copy the displayed sk-ws-... key.", + placeholder: "Paste sk-ws-... key here...", + ideName: "Windsurf", + ideOptional: false, + }, +}; + /** * OAuth Modal Component * - Localhost: Auto callback via popup message @@ -18,6 +43,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const [isDeviceCode, setIsDeviceCode] = useState(false); const [deviceData, setDeviceData] = useState(null); const [polling, setPolling] = useState(false); + // trae/windsurf: choose between browser OAuth (proxy) and paste-token (import) + const [authMode, setAuthMode] = useState("browser"); // "browser" | "paste-token" + const [pasteToken, setPasteToken] = useState(""); + const [ideStatus, setIdeStatus] = useState(null); const popupRef = useRef(null); const pollingAbortRef = useRef(false); const openedRef = useRef(false); @@ -150,12 +179,50 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setPolling(false); }, [provider, onSuccess]); + // Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange. + const startProxyFlow = useCallback(async (providerId) => { + // 1. Start the local callback server (returns a dynamic port + callback URL). + const startRes = await fetch(`/api/oauth/${providerId}/start-proxy`); + const startData = await startRes.json(); + if (!startRes.ok || !startData.success || !startData.callbackUrl) { + throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`); + } + // 2. Build the authorize URL with redirect_uri = proxy callback URL. + const authorizeUrl = new URL(`/api/oauth/${providerId}/authorize`, window.location.origin); + authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl); + const authRes = await fetch(authorizeUrl); + const authData = await authRes.json(); + if (!authRes.ok) throw new Error(authData.error); + // 3. Register the session so the proxy can match the incoming callback. + // Zed also passes code_verifier (encodes the RSA private key for decrypt); + // sent via POST body so the private key never lands in URL/query logs. + const regBody = { state: authData.state }; + if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier; + await fetch(`/api/oauth/${providerId}/register-session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(regBody), + }); + // 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status. + setAuthData({ ...authData, proxyProvider: providerId }); + setStep("waiting"); + popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700"); + if (!popupRef.current) setStep("input"); // popup blocked → fall back to manual paste + }, []); + // Start OAuth flow const startOAuthFlow = useCallback(async () => { if (!provider) return; try { setError(null); + // Trae/Windsurf: proxy OAuth (browser mode) — handled by dedicated flow. + // Paste-token mode is handled by handleManualSubmit (no /authorize call). + if (PROXY_OAUTH_PROVIDERS.has(provider) && authMode === "browser") { + await startProxyFlow(provider); + return; + } + // Device code flow providers (must match oauth providers with flowType: "device_code") const deviceCodeProviders = [ "github", @@ -165,6 +232,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, "kimi-coding", "kilocode", "codebuddy-cn", + "codebuddy-intl", "qoder", "grok-cli", ]; @@ -329,7 +397,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setError(err.message); setStep("error"); } - }, [provider, isLocalhost, startPolling, oauthMeta, idcConfig]); + }, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]); // Reset state and start OAuth when modal opens useEffect(() => { @@ -343,7 +411,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setIsDeviceCode(false); setDeviceData(null); setPolling(false); + setAuthMode("browser"); + setPasteToken(""); + setIdeStatus(null); pollingAbortRef.current = false; + // Best-effort IDE detection for paste-token providers (Trae/Windsurf) + if (PASTE_TOKEN_PROVIDERS[provider]) { + fetch(`/api/oauth/${provider}/ide-status`) + .then((r) => r.json()) + .then((data) => setIdeStatus(data)) + .catch(() => setIdeStatus({ installed: false, path: null })); + } startOAuthFlow(); } else if (!isOpen) { // Abort polling and cleanup proxy when modal closes @@ -353,13 +431,26 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, fetch("/api/oauth/codex/stop-proxy").catch(() => {}); } else if (provider === "xai") { fetch("/api/oauth/xai/stop-proxy").catch(() => {}); + } else if (provider === "trae") { + fetch("/api/oauth/trae/stop-proxy").catch(() => {}); + } else if (provider === "windsurf") { + fetch("/api/oauth/windsurf/stop-proxy").catch(() => {}); + } else if (provider === "zed") { + fetch("/api/oauth/zed/stop-proxy").catch(() => {}); } } }, [isOpen, provider, startOAuthFlow]); - // Fixed-port server-side mode: poll status (proxy auto-exchanges + saves DB) + // Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port): + // poll status until the proxy auto-exchanges and saves the connection. useEffect(() => { - const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null; + const pollProvider = authData?.codexServerSide + ? "codex" + : authData?.xaiServerSide + ? "xai" + : authData?.proxyProvider + ? authData.proxyProvider + : null; if (!pollProvider || !authData?.state) return; if (callbackProcessedRef.current) return; let cancelled = false; @@ -487,8 +578,38 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, try { setError(null); + // Paste-token mode (Trae/Windsurf): token goes straight to /exchange + if (authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider]) { + const token = pasteToken.trim(); + if (!token) throw new Error("Missing token"); + const res = await fetch(`/api/oauth/${provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: token }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setStep("success"); + onSuccess?.(); + return; + } + const input = callbackUrl.trim(); + // Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL + if (PROXY_OAUTH_PROVIDERS.has(provider) && input) { + const res = await fetch(`/api/oauth/${provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: input, state: authData?.state }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setStep("success"); + onSuccess?.(); + return; + } + // Detect raw JWT access token (starts with eyJ) — skip URL parsing if (input.startsWith("eyJ") && input.includes(".")) { await exchangeTokens(input, null); @@ -538,6 +659,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, fetch("/api/oauth/codex/stop-proxy").catch(() => {}); } else if (provider === "xai") { fetch("/api/oauth/xai/stop-proxy").catch(() => {}); + } else if (provider === "trae") { + fetch("/api/oauth/trae/stop-proxy").catch(() => {}); + } else if (provider === "windsurf") { + fetch("/api/oauth/windsurf/stop-proxy").catch(() => {}); + } else if (provider === "zed") { + fetch("/api/oauth/zed/stop-proxy").catch(() => {}); } onClose(); }, [onClose, provider]); @@ -556,8 +683,82 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return (

- {/* Waiting + Manual Input combined (non-device-code) */} - {(step === "waiting" || step === "input") && !isDeviceCode && ( + {/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */} + {PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && ( + <> +
+ + +
+ + {authMode === "browser" && ( + <> + {step === "waiting" && ( +
+ progress_activity + Waiting for browser authorization… +
+ )} + {step === "input" && ( +
+

+ Popup was blocked. After authorizing in the browser, paste the full callback URL here: +

+ setCallbackUrl(e.target.value)} + placeholder="http://127.0.0.1:.../callback?..." + className="font-mono text-xs" + /> +
+ + +
+
+ )} + + )} + + {authMode === "paste-token" && ( +
+ {ideStatus && !ideStatus.installed && ( +
+ {PASTE_TOKEN_PROVIDERS[provider].ideName} IDE not detected. + {PASTE_TOKEN_PROVIDERS[provider].ideOptional + ? " You can still grab the token from DevTools." + : ` Install ${PASTE_TOKEN_PROVIDERS[provider].ideName} IDE to get the token, or use "Sign in with browser".`} +
+ )} +

{PASTE_TOKEN_PROVIDERS[provider].instructions}

+ setPasteToken(e.target.value)} + placeholder={PASTE_TOKEN_PROVIDERS[provider].placeholder} + className="font-mono text-xs" + /> +
+ + +
+
+ )} + + )} + + {/* Waiting + Manual Input combined (non-device-code, non-proxy) */} + {(step === "waiting" || step === "input") && !isDeviceCode && !PROXY_OAUTH_PROVIDERS.has(provider) && ( <> {/* Option A: Auto via popup */}
diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js index 0c52a2cd..84414b90 100644 --- a/tests/unit/grok-cli-usage.test.js +++ b/tests/unit/grok-cli-usage.test.js @@ -113,6 +113,43 @@ describe("parseGrokCliBilling", () => { expect(parsed.exhausted).toBe(false); }); + it("maps creditUsagePercent to a single Weekly SuperGrok bar (not productUsage)", () => { + const parsed = parseGrokCliBilling( + { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-07-17T12:42:26.494595+00:00", + end: "2026-07-24T12:42:26.494595+00:00", + }, + creditUsagePercent: 99.0, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + productUsage: [ + { product: "GrokBuild", usagePercent: 97.0 }, + { product: "GrokImagine", usagePercent: 2.0 }, + ], + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + billingPeriodStart: "2026-07-17T12:42:26.494595+00:00", + billingPeriodEnd: "2026-07-24T12:42:26.494595+00:00", + }, + }, + { subscriptionTier: "XPremiumPlus", hasGrokCodeAccess: true }, + ); + // Single shared-pool bar from creditUsagePercent + expect(parsed.quotas["Weekly SuperGrok"]).toMatchObject({ + used: 99, + total: 100, + remainingPercentage: 1, + resetAt: "2026-07-24T12:42:26.494Z", + unlimited: false, + }); + // productUsage must NOT become independent quota bars + expect(Object.keys(parsed.quotas)).toEqual(["Weekly SuperGrok"]); + expect(parsed.exhausted).toBe(false); + }); + it("maps current monthly fields and snake-case subscription tier", () => { const parsed = parseGrokCliBilling({ monthlyLimit: { val: 1000 }, diff --git a/tests/unit/token-refresh-generic.test.js b/tests/unit/token-refresh-generic.test.js new file mode 100644 index 00000000..d4507c2e --- /dev/null +++ b/tests/unit/token-refresh-generic.test.js @@ -0,0 +1,146 @@ +/** + * Generic OAuth2 token refresh — config-driven profiles. + * + * Verifies refreshAccessToken() handles the 5 foldable providers + * (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table, + * while preserving the legacy generic path for unknown providers. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const originalFetch = global.fetch; + +function mockFetchOnce(payload, { ok = true, status = 200 } = {}) { + const fn = vi.fn().mockResolvedValue({ + ok, + status, + json: () => Promise.resolve(payload), + text: () => Promise.resolve(JSON.stringify(payload)), + }); + global.fetch = fn; + return fn; +} + +describe("refreshAccessToken — config-driven profiles", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => { + const fm = mockFetchOnce({ + access_token: "qw-acc", + refresh_token: "qw-refresh-rotated", + expires_in: 7200, + resource_url: "https://dashscope.aliyuncs.com", + }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console); + + expect(out).toEqual({ + accessToken: "qw-acc", + refreshToken: "qw-refresh-rotated", + expiresIn: 7200, + providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" }, + }); + const [url, init] = fm.mock.calls[0]; + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("qw-old-refresh"); + expect(body.get("client_id")).toBeTruthy(); + }); + + it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => { + const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("iflow", "if-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Authorization"]).toMatch(/^Basic /); + const body = new URLSearchParams(init.body); + expect(body.get("client_id")).toBeTruthy(); + expect(body.get("client_secret")).toBeTruthy(); + }); + + it("github: omits client_secret when config has none", async () => { + const fm = mockFetchOnce({ access_token: "gh-acc", expires_in: 28800 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("github", "gh-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("client_secret")).toBeNull(); + expect(out.accessToken).toBe("gh-acc"); + expect(out.refreshToken).toBe("gh-old"); + }); + + it("kimi: merges X-Msh-* headers from credentials.providerSpecificData.deviceId", async () => { + const fm = mockFetchOnce({ access_token: "km-acc", expires_in: 86400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("kimi", "km-old", { + providerSpecificData: { deviceId: "dev-xyz" }, + }, console); + + const headers = fm.mock.calls[0][1].headers; + // Kimi's buildKimiHeaders must contribute at least one X-Msh- header + const mshKeys = Object.keys(headers).filter((k) => k.toLowerCase().startsWith("x-msh-")); + expect(mshKeys.length).toBeGreaterThan(0); + }); + + it("claude: JSON body, client_id only (no client_secret)", async () => { + const fm = mockFetchOnce({ access_token: "cl-acc", refresh_token: "cl-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("claude", "cl-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(init.body); + expect(parsed.grant_type).toBe("refresh_token"); + expect(parsed.client_id).toBeTruthy(); + expect(parsed).not.toHaveProperty("client_secret"); + }); + + it("returns null on non-ok response", async () => { + mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "dead", {}, console); + expect(out).toBeNull(); + }); + + it("returns null when refreshToken missing", async () => { + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "", {}, console); + expect(out).toBeNull(); + }); + + it("dedupes concurrent calls with same refresh token (same dedupKey)", async () => { + const fm = mockFetchOnce({ access_token: "dd-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const creds = { providerSpecificData: { deviceId: "d" } }; + await Promise.all([ + refreshAccessToken("kimi", "dup-refresh", creds, console), + refreshAccessToken("kimi", "dup-refresh", creds, console), + ]); + expect(fm).toHaveBeenCalledTimes(1); + }); +}); + +describe("refreshAccessToken — legacy generic path (no profile)", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => { + const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("cline", "gen-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("client_id")).toBeTruthy(); + }); +}); diff --git a/tests/unit/windsurf-executor.test.js b/tests/unit/windsurf-executor.test.js new file mode 100644 index 00000000..db8864f9 --- /dev/null +++ b/tests/unit/windsurf-executor.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest"; +import { + resolveWsModelId, + buildGetChatMessageRequest, + grpcWebFrame, + decodeCompletionChunk, + default as WindsurfExecutor, +} from "open-sse/executors/windsurf.js"; +import { PROVIDERS } from "open-sse/config/providers.js"; + +// ─── Protobuf helpers for building expected wire bytes in tests ────────────── + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { bytes.push((v & 0x7f) | 0x80); v >>>= 7; } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} +function encodeLenField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + const out = new Uint8Array(tag.length + len.length + payload.length); + out.set(tag, 0); out.set(len, tag.length); out.set(payload, tag.length + len.length); + return out; +} +function encodeStringField(fieldNum, str) { + return encodeLenField(fieldNum, new TextEncoder().encode(str)); +} + +describe("windsurf MODEL_ALIAS_MAP", () => { + it("maps SWE models to snake-case wire names", () => { + expect(resolveWsModelId("swe-1.6-fast")).toBe("swe-1-6-fast"); + expect(resolveWsModelId("swe-1.5")).toBe("swe-1-5"); + }); + it("maps Claude 4.5 to MODEL_PRIVATE_* aliases", () => { + expect(resolveWsModelId("claude-sonnet-4.5")).toBe("MODEL_PRIVATE_2"); + expect(resolveWsModelId("claude-opus-4.5")).toBe("MODEL_CLAUDE_4_5_OPUS"); + }); + it("applies default effort level for bare gpt-5.x ids", () => { + expect(resolveWsModelId("gpt-5.5")).toBe("gpt-5-5-medium"); + expect(resolveWsModelId("gpt-5.4")).toBe("gpt-5-4-medium"); + }); + it("passes through unknown ids as-is", () => { + expect(resolveWsModelId("custom-model")).toBe("custom-model"); + }); +}); + +describe("grpcWebFrame", () => { + it("prepends a 5-byte header: 0x00 flag + big-endian length", () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]); + const frame = grpcWebFrame(payload); + expect(frame[0]).toBe(0x00); + const view = new DataView(frame.buffer); + expect(view.getUint32(1, false)).toBe(5); // big-endian length + expect(Array.from(frame.slice(5))).toEqual([1, 2, 3, 4, 5]); + }); + it("encodes empty payload as a 5-byte frame", () => { + const frame = grpcWebFrame(new Uint8Array(0)); + expect(frame.length).toBe(5); + expect(frame[0]).toBe(0x00); + }); +}); + +describe("buildGetChatMessageRequest", () => { + it("emits metadata (field 1), cascade_id (2), model (3), messages (4+)", () => { + const payload = buildGetChatMessageRequest("sk-ws-test", "swe-1.6", [ + { role: "user", content: "hello" }, + ]); + expect(payload.length).toBeGreaterThan(10); + // First byte 0x0a = field 1, wire type 2 (length-delimited) → metadata present + expect(payload[0]).toBe(0x0a); + }); + + it("embeds the apiKey inside the metadata sub-message", () => { + const payload = buildGetChatMessageRequest("sk-ws-secret", "gpt-5", []); + // The metadata bytes are the first length-delimited field — should contain the key. + const asString = new TextDecoder().decode(payload); + expect(asString).toContain("sk-ws-secret"); + // And the IDE identification fields. + expect(asString).toContain("windsurf"); + expect(asString).toContain("3.14.0"); + }); + + it("appends one field-4 message per chat message", () => { + // Proper top-level protobuf field counter (byte 0x22 collides with content bytes). + const countField = (buf, target) => { + let offset = 0; + let count = 0; + while (offset < buf.length) { + let result = 0, shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + const fieldNum = result >>> 3; + const wireType = result & 0x07; + if (wireType === 2) { + let len = 0, ls = 0; + while (offset < buf.length) { + const b = buf[offset++]; + len |= (b & 0x7f) << ls; + if ((b & 0x80) === 0) break; + ls += 7; + } + if (fieldNum === target) count++; + offset += len; + } else if (wireType === 0) { + while (offset < buf.length) { + const b = buf[offset++]; + if ((b & 0x80) === 0) break; + } + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return count; + }; + const one = buildGetChatMessageRequest("k", "m", [{ role: "user", content: "a" }]); + const two = buildGetChatMessageRequest("k", "m", [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ]); + expect(countField(one, 4)).toBe(1); + expect(countField(two, 4)).toBe(2); + }); +}); + +describe("decodeCompletionChunk", () => { + it("decodes a ContentChunk (field 1 → text)", () => { + const chunk = encodeLenField(1, encodeStringField(1, "hello world")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "content", text: "hello world" }); + }); + + it("decodes an ErrorChunk (field 4 → message)", () => { + const chunk = encodeLenField(4, encodeStringField(1, "quota exhausted")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "error", message: "quota exhausted" }); + }); + + it("decodes a DoneChunk (field 3 → UsageStats with prompt/completion tokens)", () => { + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + const usage = new Uint8Array([...encodeVarint((1 << 3) | 0), ...encodeVarint(42), ...encodeVarint((2 << 3) | 0), ...encodeVarint(99)]); + const doneChunk = encodeLenField(3, encodeLenField(1, usage)); + const decoded = decodeCompletionChunk(doneChunk); + expect(decoded.kind).toBe("done"); + expect(decoded.promptTokens).toBe(42); + expect(decoded.completionTokens).toBe(99); + }); + + it("returns { kind: 'unknown' } for empty buffer", () => { + expect(decodeCompletionChunk(new Uint8Array(0))).toEqual({ kind: "unknown" }); + }); +}); + +describe("WindsurfExecutor class", () => { + it("constructor wires config from PROVIDERS.windsurf", () => { + const ex = new WindsurfExecutor(); + expect(ex.provider).toBe("windsurf"); + expect(ex.config).toBeDefined(); + expect(ex.config.baseUrl).toContain("server.self-serve.windsurf.com"); + expect(typeof ex.execute).toBe("function"); + }); + + it("buildHeaders emits grpc-web+proto + Bearer token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({ accessToken: "sk-ws-abc" }); + expect(h["Content-Type"]).toBe("application/grpc-web+proto"); + expect(h.Accept).toBe("application/grpc-web+proto"); + expect(h["X-Grpc-Web"]).toBe("1"); + expect(h.Authorization).toBe("Bearer sk-ws-abc"); + expect(h["User-Agent"]).toMatch(/^windsurf\//); + }); + + it("buildHeaders omits Authorization when no token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({}); + expect(h.Authorization).toBeUndefined(); + }); + + it("buildUrl returns the GetChatMessage endpoint", () => { + const ex = new WindsurfExecutor(); + expect(ex.buildUrl()).toBe("https://server.self-serve.windsurf.com/exa.language_server_pb.LanguageServerService/GetChatMessage"); + }); + + it("PROVIDERS.windsurf baseUrl is the chat endpoint (registry in sync)", () => { + expect(PROVIDERS.windsurf.baseUrl).toBe( + "https://server.self-serve.windsurf.com/exa.language_server_pb.LanguageServerService/GetChatMessage" + ); + }); +}); From 41c9e6be87ea86f67f41ceefe00cf63d29ac1c3f Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 25 Jul 2026 17:30:02 +0700 Subject: [PATCH 10/34] feat(claude): bump default Opus to claude-opus-5 Co-Authored-By: Claude Fable 5 --- open-sse/providers/capabilities.js | 3 ++- open-sse/providers/registry/claude.js | 3 +-- src/shared/constants/cliTools.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 5bd7f6d8..2c456da4 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -71,7 +71,8 @@ export function capabilitiesFromServiceKind(kind) { * otherwise mis-match. Only declare deltas vs DEFAULT. */ export const MODEL_CAPABILITIES = { - // Claude 4.6/4.7/4.8 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + // Claude 4.6/4.7/4.8/5 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + "claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index a2912701..8b030e5a 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -60,10 +60,9 @@ export default { }, }, models: [ + { id: "claude-opus-5", name: "Claude Opus 5" }, { id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, - { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, ], oauth: { diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 81dcd9d2..fc49d36a 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -108,7 +108,7 @@ export const CLI_TOOLS = { settingsFile: "~/.claude/settings.json", defaultModels: [ { id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" }, - { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-8" }, + { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-5" }, { id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" }, { id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, ], @@ -360,7 +360,7 @@ amp --model "{{model}}" }, ], defaultModels: [ - { id: "claude-opus-4-7", name: "Claude Opus 4.7", alias: "opus", defaultValue: "cc/claude-opus-4-7" }, + { id: "claude-opus-5", name: "Claude Opus 5", alias: "opus", defaultValue: "cc/claude-opus-5" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "sonnet", defaultValue: "cc/claude-sonnet-4-6" }, { id: "gpt-5.5", name: "GPT 5.5", alias: "gpt5", defaultValue: "cx/gpt-5.5" }, { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" }, From aa0448f7e2fc533d9db91238ad58b8027b5d4f96 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 25 Jul 2026 17:30:11 +0700 Subject: [PATCH 11/34] fix(refresh): rotate refresh_token between retry attempts Rotating-RT providers (xAI/grok-cli) issue a new refresh_token on every refresh; mutate credentials in-place so refreshWithRetry reuses the fresh RT instead of the already-consumed one. Co-Authored-By: Claude Fable 5 --- open-sse/handlers/chatCore.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 4e3475f4..4f91e020 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -330,7 +330,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Handle 401/403 - try token refresh (skip for noAuth providers) if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { try { - const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); + // Mutate credentials after each successful refresh: rotating refresh_token + // providers (xAI/grok-cli) issue a new RT on every refresh; without this, + // refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT → + // invalid_grant → auth_failed retryable=false. + const newCredentials = await refreshWithRetry(async () => { + const result = await executor.refreshCredentials(credentials, log); + if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) { + if (result.accessToken) credentials.accessToken = result.accessToken; + credentials.refreshToken = result.refreshToken; + } + return result; + }, 3, log); if (newCredentials?.accessToken || newCredentials?.copilotToken) { if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`); Object.assign(credentials, newCredentials); From de2da19a9e9b25c72791f24b269bc087de16cd62 Mon Sep 17 00:00:00 2001 From: decolua Date: Sun, 26 Jul 2026 10:03:10 +0700 Subject: [PATCH 12/34] feat(providers): add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent Register 9 new upstream providers with logos and update the auto-generated registry index. Refresh providers/alias baselines and extend the alias token allowlist so verify-alias stays green. Co-Authored-By: Claude Fable 5 --- open-sse/providers/registry/api-airforce.js | 40 +++++++++ open-sse/providers/registry/baidu.js | 42 +++++++++ open-sse/providers/registry/bazaarlink.js | 56 ++++++++++++ open-sse/providers/registry/bluesminds.js | 45 ++++++++++ open-sse/providers/registry/index.js | 20 +++++ open-sse/providers/registry/kilo-gateway.js | 34 +++++++ open-sse/providers/registry/llm7.js | 34 +++++++ open-sse/providers/registry/morph.js | 29 ++++++ open-sse/providers/registry/sambanova.js | 30 +++++++ open-sse/providers/registry/tencent.js | 31 +++++++ public/providers/api-airforce.png | Bin 0 -> 4779 bytes public/providers/baidu.png | Bin 0 -> 4323 bytes public/providers/bazaarlink.png | Bin 0 -> 4783 bytes public/providers/bluesminds.png | Bin 0 -> 4692 bytes public/providers/kilo-gateway.png | Bin 0 -> 3124 bytes public/providers/llm7.png | Bin 0 -> 7699 bytes public/providers/morph.png | Bin 2584 -> 12207 bytes public/providers/sambanova.png | Bin 0 -> 10909 bytes public/providers/tencent.png | Bin 0 -> 1312 bytes tests/__baseline__/alias-baseline.json | 53 ++++++++++- tests/__baseline__/providers-baseline.json | 94 +++++++++++++++++++- tests/__baseline__/verify-alias.mjs | 3 + 22 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 open-sse/providers/registry/api-airforce.js create mode 100644 open-sse/providers/registry/baidu.js create mode 100644 open-sse/providers/registry/bazaarlink.js create mode 100644 open-sse/providers/registry/bluesminds.js create mode 100644 open-sse/providers/registry/kilo-gateway.js create mode 100644 open-sse/providers/registry/llm7.js create mode 100644 open-sse/providers/registry/morph.js create mode 100644 open-sse/providers/registry/sambanova.js create mode 100644 open-sse/providers/registry/tencent.js create mode 100644 public/providers/api-airforce.png create mode 100644 public/providers/baidu.png create mode 100644 public/providers/bazaarlink.png create mode 100644 public/providers/bluesminds.png create mode 100644 public/providers/kilo-gateway.png create mode 100644 public/providers/llm7.png create mode 100644 public/providers/sambanova.png create mode 100644 public/providers/tencent.png diff --git a/open-sse/providers/registry/api-airforce.js b/open-sse/providers/registry/api-airforce.js new file mode 100644 index 00000000..ce1e070c --- /dev/null +++ b/open-sse/providers/registry/api-airforce.js @@ -0,0 +1,40 @@ +export default { + id: "api-airforce", + alias: "af", + aliases: [ + "airforce", + ], + uiAlias: "af", + display: { + name: "API.airforce", + icon: "flight", + color: "#0EA5E9", + textIcon: "AF", + website: "https://api.airforce", + notice: { + apiKeyUrl: "https://api.airforce", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.airforce/v1/chat/completions", + validateUrl: "https://api.airforce/v1/models", + headers: { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + }, + models: [ + { id: "x-ai/grok-3", name: "Grok-3 (Free)", contextLength: 131072 }, + { id: "x-ai/grok-2-1212", name: "Grok-2 1212 (Free)", contextLength: 131072 }, + { id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 }, + { id: "qwen/qwen3-32b", name: "Qwen3 32B (Free)", contextLength: 128000 }, + { id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 }, + { id: "deepseek/deepseek-v3", name: "DeepSeek V3 (Free)", contextLength: 262144 }, + ], +}; diff --git a/open-sse/providers/registry/baidu.js b/open-sse/providers/registry/baidu.js new file mode 100644 index 00000000..efba77ca --- /dev/null +++ b/open-sse/providers/registry/baidu.js @@ -0,0 +1,42 @@ +export default { + id: "baidu", + alias: "qianfan", + aliases: ["qianfan", "ernie", "baidu-qianfan"], + uiAlias: "qianfan", + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Baidu Qianfan", + icon: "search", + color: "#2932E1", + textIcon: "BD", + website: "https://cloud.baidu.com/product/qianfan.html", + notice: { + apiKeyUrl: + "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", + }, + }, + transport: { + baseUrl: "https://qianfan.baidubce.com/v2/chat/completions", + validateUrl: "https://qianfan.baidubce.com/v2/models", + }, + models: [ + { id: "ernie-5.1", name: "ERNIE 5.1" }, + { id: "ernie-5.0", name: "ERNIE 5.0" }, + { id: "ernie-x1.1", name: "ERNIE X1.1" }, + { id: "ernie-4.5-turbo-128k", name: "ERNIE 4.5 Turbo 128K" }, + { id: "ernie-4.5-turbo-32k", name: "ERNIE 4.5 Turbo 32K" }, + { id: "ernie-4.5-turbo-vl", name: "ERNIE 4.5 Turbo VL" }, + { id: "ernie-4.5-21b-a3b", name: "ERNIE 4.5 21B A3B" }, + { id: "ernie-4.5-0.3b", name: "ERNIE 4.5 0.3B" }, + { id: "ernie-4.0-8k", name: "ERNIE 4.0 8K" }, + { id: "ernie-4.0-turbo-128k", name: "ERNIE 4.0 Turbo 128K" }, + { id: "ernie-4.0-turbo-8k", name: "ERNIE 4.0 Turbo 8K" }, + { id: "ernie-3.5-8k", name: "ERNIE 3.5 8K" }, + { id: "ernie-speed-128k", name: "ERNIE Speed 128K" }, + { id: "ernie-speed-8k", name: "ERNIE Speed 8K" }, + { id: "ernie-lite-8k", name: "ERNIE Lite 8K" }, + { id: "ernie-tiny-8k", name: "ERNIE Tiny 8K" }, + ], +}; diff --git a/open-sse/providers/registry/bazaarlink.js b/open-sse/providers/registry/bazaarlink.js new file mode 100644 index 00000000..0127452f --- /dev/null +++ b/open-sse/providers/registry/bazaarlink.js @@ -0,0 +1,56 @@ +export default { + id: "bazaarlink", + alias: "bzl", + aliases: ["bazaar-link"], + uiAlias: "bzl", + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Bazaarlink", + icon: "storefront", + color: "#DC2626", + textIcon: "BZ", + website: "https://bazaarlink.ai", + notice: { apiKeyUrl: "https://bazaarlink.ai" }, + }, + transport: { + baseUrl: "https://bazaarlink.ai/api/v1/chat/completions", + validateUrl: "https://bazaarlink.ai/api/v1/models", + }, + models: [ + { id: "auto:free", name: "Auto Free (Zero Cost)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7" }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { id: "gpt-5.5", name: "GPT-5.5" }, + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + { id: "gpt-5.4-nano", name: "GPT-5.4 Nano" }, + { id: "grok-4.3", name: "Grok 4.3" }, + { id: "grok-4.20", name: "Grok 4.20" }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite" }, + { id: "gemma-4-31b-it", name: "Gemma 4 31B" }, + { id: "gemma-4-26b-a4b-it", name: "Gemma 4 26B A4B" }, + { id: "deepseek-v3.2", name: "DeepSeek V3.2" }, + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5.1", name: "GLM 5.1" }, + { id: "glm-5", name: "GLM 5" }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro" }, + { id: "mimo-v2.5", name: "MiMo-V2.5" }, + { id: "minimax-m3", name: "MiniMax M3" }, + { id: "minimax-m2.7", name: "MiniMax M2.7" }, + { id: "minimax-m2.5", name: "MiniMax M2.5" }, + { id: "llama-4-maverick", name: "Llama 4 Maverick" }, + { id: "llama-4-scout", name: "Llama 4 Scout" }, + { id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B" }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }, + { id: "mistral-large-2512", name: "Mistral Large 3" }, + { id: "mistral-medium-3.1", name: "Mistral Medium 3.1" }, + { id: "mistral-small-2603", name: "Mistral Small 4" }, + { id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super" }, + ], +}; diff --git a/open-sse/providers/registry/bluesminds.js b/open-sse/providers/registry/bluesminds.js new file mode 100644 index 00000000..298ffec8 --- /dev/null +++ b/open-sse/providers/registry/bluesminds.js @@ -0,0 +1,45 @@ +export default { + id: "bluesminds", + alias: "bm", + aliases: ["blue-sminds"], + uiAlias: "bm", + display: { + name: "BluesMinds", + icon: "psychology", + color: "#2563EB", + textIcon: "BM", + website: "https://bluesminds.com", + notice: { apiKeyUrl: "https://bluesminds.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.bluesminds.com/v1/chat/completions", + validateUrl: "https://api.bluesminds.com/v1/models", + }, + models: [ + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + { 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: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash (Exp)" }, + { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }, + { id: "deepseek-chat", name: "DeepSeek Chat" }, + { id: "qwen-plus", name: "Qwen Plus" }, + { id: "qwen-turbo", name: "Qwen Turbo" }, + { id: "kimi-k2", name: "Kimi K2" }, + { id: "kimi-k2-thinking", name: "Kimi K2 Thinking" }, + { id: "glm-4.7", name: "GLM 4.7" }, + { id: "glm-4-flash", name: "GLM 4 Flash" }, + { id: "minimax-m2.5", name: "MiniMax M2.5" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5 (VIP)" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (VIP)" }, + { id: "grok-3", name: "Grok-3 (VIP)" }, + { id: "qwen-max", name: "Qwen Max (VIP)" }, + ], +}; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 7f3867e8..3afa797d 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -104,6 +104,16 @@ import p100 from "./codebuddy-intl.js"; // Re-enable by uncommenting both the import and the array entry below. // import p102 from "./trae.js"; import p103 from "./zed.js"; +import p105 from "./api-airforce.js"; +import p106 from "./baidu.js"; +import p107 from "./bazaarlink.js"; +import p108 from "./bluesminds.js"; +import p109 from "./kilo-gateway.js"; +import p110 from "./llm7.js"; +import p111 from "./sambanova.js"; +import p112 from "./tencent.js"; +import p113 from "./morph.js"; +import p114 from "./devin-cli.js"; // import p104 from "./windsurf.js"; export default [ @@ -210,5 +220,15 @@ export default [ p100, // p102, // trae — hidden, no tool calling p103, + p105, + p106, + p107, + p108, + p109, + p110, + p111, + p112, + p113, + p114, // p104, // windsurf — hidden, no tool calling ]; diff --git a/open-sse/providers/registry/kilo-gateway.js b/open-sse/providers/registry/kilo-gateway.js new file mode 100644 index 00000000..702c00af --- /dev/null +++ b/open-sse/providers/registry/kilo-gateway.js @@ -0,0 +1,34 @@ +export default { + id: "kilo-gateway", + alias: "kgw", + aliases: [ + "kilo-gateway", + "kilogateway", + ], + uiAlias: "kgw", + display: { + name: "Kilo Gateway", + icon: "login", + color: "#8B5CF6", + textIcon: "KG", + website: "https://kilo.ai", + notice: { + apiKeyUrl: "https://kilo.ai/dashboard?tab=apiKeys", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.kilo.ai/api/gateway/chat/completions", + validateUrl: "https://api.kilo.ai/api/gateway/models", + }, + models: [ + { id: "kilo-auto/frontier", name: "Kilo Auto Frontier" }, + { id: "kilo-auto/balanced", name: "Kilo Auto Balanced" }, + { id: "kilo-auto/free", name: "Kilo Auto Free" }, + { id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)" }, + { id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" }, + { id: "arcee-ai/trinity-large-preview:free", name: "Trinity Large Preview (Free)" }, + ], +}; diff --git a/open-sse/providers/registry/llm7.js b/open-sse/providers/registry/llm7.js new file mode 100644 index 00000000..be7d99bb --- /dev/null +++ b/open-sse/providers/registry/llm7.js @@ -0,0 +1,34 @@ +export default { + id: "llm7", + alias: "llm7", + aliases: [ + "llm-7", + ], + uiAlias: "llm7", + display: { + name: "LLM7", + icon: "pool", + color: "#7C3AED", + textIcon: "L7", + website: "https://llm7.io", + notice: { + apiKeyUrl: "https://llm7.io", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.llm7.io/v1/chat/completions", + validateUrl: "https://api.llm7.io/v1/models", + }, + models: [ + { id: "gpt-4o-mini-2024-07-18", name: "GPT-4o mini (LLM7)" }, + { id: "gpt-4.1-nano-2025-04-14", name: "GPT-4.1 nano (LLM7)" }, + { id: "deepseek-r1-0528", name: "DeepSeek R1 (LLM7)" }, + { id: "qwen2.5-coder-32b-instruct", name: "Qwen2.5 Coder 32B (LLM7)" }, + ], + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/morph.js b/open-sse/providers/registry/morph.js new file mode 100644 index 00000000..0652f835 --- /dev/null +++ b/open-sse/providers/registry/morph.js @@ -0,0 +1,29 @@ +export default { + id: "morph", + alias: "morph", + aliases: ["morphllm"], + uiAlias: "morph", + display: { + name: "Morph", + icon: "change_history", + color: "#14B8A6", + textIcon: "MP", + website: "https://morphllm.com", + notice: { apiKeyUrl: "https://morphllm.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.morphllm.com/v1/chat/completions", + validateUrl: "https://api.morphllm.com/v1/models", + }, + models: [ + { id: "morph-v3-large", name: "Morph v3 Large" }, + { id: "morph-v3-fast", name: "Morph v3 Fast" }, + { id: "morph-qwen35-397b", name: "Qwen 3.5 397B (Morph)", contextLength: 262144 }, + { id: "morph-minimax27-230b", name: "MiniMax M2.7 (Morph)", contextLength: 200704 }, + { id: "morph-qwen36-27b", name: "Qwen 3.6 27B (Morph)", contextLength: 131072 }, + { id: "morph-dsv4flash", name: "DeepSeek V4 Flash (Morph)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/sambanova.js b/open-sse/providers/registry/sambanova.js new file mode 100644 index 00000000..a63d7b33 --- /dev/null +++ b/open-sse/providers/registry/sambanova.js @@ -0,0 +1,30 @@ +export default { + id: "sambanova", + alias: "samba", + aliases: ["sambanova-ai"], + uiAlias: "samba", + display: { + name: "SambaNova", + icon: "memory", + color: "#F97316", + textIcon: "SN", + website: "https://sambanova.ai", + notice: { + apiKeyUrl: "https://cloud.sambanova.ai/apis", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.sambanova.ai/v1/chat/completions", + validateUrl: "https://api.sambanova.ai/v1/models", + }, + models: [ + { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, + { id: "DeepSeek-V3.2", name: "DeepSeek V3.2" }, + { id: "Llama-4-Maverick-17B-128E-Instruct", name: "Llama 4 Maverick 17B 128E" }, + { id: "Meta-Llama-3.3-70B-Instruct", name: "Meta Llama 3.3 70B" }, + { id: "gpt-oss-120b", name: "GPT-OSS 120B" }, + ], +}; diff --git a/open-sse/providers/registry/tencent.js b/open-sse/providers/registry/tencent.js new file mode 100644 index 00000000..e4ad637a --- /dev/null +++ b/open-sse/providers/registry/tencent.js @@ -0,0 +1,31 @@ +export default { + id: "tencent", + alias: "hunyuan", + aliases: ["hunyuan", "tencent-hunyuan"], + uiAlias: "hunyuan", + display: { + name: "Tencent Hunyuan", + icon: "cloud", + color: "#0052D9", + textIcon: "HY", + website: "https://cloud.tencent.com/product/hunyuan", + notice: { + apiKeyUrl: "https://console.cloud.tencent.com/hunyuan/api-key", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + validateUrl: "https://api.hunyuan.cloud.tencent.com/v1/models", + }, + models: [ + { id: "hunyuan-turbos-latest", name: "Hunyuan TurboS Latest" }, + { id: "hunyuan-t1-latest", name: "Hunyuan T1 Latest" }, + { id: "hunyuan-pro", name: "Hunyuan Pro" }, + { id: "hunyuan-vision", name: "Hunyuan Vision" }, + { id: "hunyuan-functioncall", name: "Hunyuan FunctionCall" }, + { id: "hunyuan-lite", name: "Hunyuan Lite" }, + ], +}; diff --git a/public/providers/api-airforce.png b/public/providers/api-airforce.png new file mode 100644 index 0000000000000000000000000000000000000000..9fa71e6a7d0bf5866bb82886b94c6771e3bc3d48 GIT binary patch literal 4779 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 literal 0 HcmV?d00001 diff --git a/public/providers/baidu.png b/public/providers/baidu.png new file mode 100644 index 0000000000000000000000000000000000000000..8ad826f29c5375d92af4392ceae6f30f46746913 GIT binary patch literal 4323 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 literal 0 HcmV?d00001 diff --git a/public/providers/bluesminds.png b/public/providers/bluesminds.png new file mode 100644 index 0000000000000000000000000000000000000000..1e5ef31e5a6a835ec914647ca807fb5b725ee105 GIT binary patch literal 4692 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 literal 0 HcmV?d00001 diff --git a/public/providers/kilo-gateway.png b/public/providers/kilo-gateway.png new file mode 100644 index 0000000000000000000000000000000000000000..99ea60b00b83130e2ad9470f4e8ee12564098f02 GIT binary patch literal 3124 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 ZphR?W54ZBQ`O|LzFuZD}`x@g+{0D0mvLpZi literal 0 HcmV?d00001 diff --git a/public/providers/llm7.png b/public/providers/llm7.png new file mode 100644 index 0000000000000000000000000000000000000000..4f2a70a0a0780a4728a5afdf302ad628035050d9 GIT binary patch literal 7699 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 KnHnk6(EkAlDDw0G literal 0 HcmV?d00001 diff --git a/public/providers/morph.png b/public/providers/morph.png index 3034fa5ed9cb18c21bfd4e4a5d40a71ee34ff6c7..82f938f36f0ba8a7b7654ef1640aeb787483541e 100644 GIT binary patch literal 12207 zcmbW7RZtvE(5`U}vPiIn;OSK-B;69Gf%zq#(Yze#XuuQgMop;ke8EE|8EcezY7KFzc?gfG6@62<|!{FuIasc z(esi91f?83T}O{i`ToOoO2%VQU`nvmaUs+iNpW9lNr9|R=A-}f0 zD=o+&+6feuGWXyU!@)pBKqw*AI<7yhnHWRC!g^@vLtw8z>7jXOLv~hn%Bm;Y7Ou2I z@`jw`!fv|#u?&95`+rktPSDvK_@I}y!3rsa7AQ$t_aNVjSJ3?aAo{%w^do^QSgu#6 zOgvFFuPQ8U#&dBab|?Jv7Z_g zBX(fM)11gnjZvw3R+0Q^IHiEoo&IWOesBKDutKzG?!n~Ktnn$$$B_)i7~B26QUaVm{S0pzp)RS^|g6bHsP zS;}x}XkvUfV@i!@(Xf=6zX`nY19~IxA}nx^FzE2Y`rFyQ9$KlpEO&J z1(GB-Go#)>pA4m7==3%QJ|&MeK0X=3QSdh=TE~92xhHnov3(dPnc*)m^2ZJp6vLm_ zy#gGmTZU3Paukk)x(6E;c!5n9=66uMjd1sn7Vc)7^mF9f&{nU8_l8Z(tCMMxLNue@ zq6v5LC_<2rxZukSZ%ZPlx~UtfjbYh%=*yPH0dG$~gD4fYEb%+2kxUYp`9|!z<4?h5uV`;tyHSDS_%L{lk~JpYRIB&4LcN*ZMuefCC}s4W?i1 zvXz)5EFy?FOMglz#A-67CkA3{C>&=NEaWtQ)lg?cp#qW>k9fPu4%k`2JSDt3FZm2SfJh9=x>o17wom_{D`NA@0Nq{(RoNjdf>S*Q@Gub{@ zW|i<#CrYEBw%wl)?K~0QXxv=eERp=8Yf{VLtQ5SLx)o^WEjp;-Jt^1dV=rFsi#IKD z%wNr_Tr?LlWw^y{eHfgkgkPV9&(iu^oJ^Ye=u#ggmVSezqkNV1w|m%f`zSt@h=vgL zM)tsiF1olx-W@=)eHiOqe7n;<9HmXkc%~DDwGxu}GVTG=TQurNI(dXxeLrwUg$+X6 zRP9xKah`SmW!KImO~Yg*!8XV2Z(sH99R>AGZoL(@KaDuu6G5G3*ku`qv_p6Pl5zhF z$G%o^`94WcpO)W$IS}tVKT|Q*Z0GY^!M&%OmtlL@%FlpX5ia4${&N+{&iw|U@9}_C z%~-`I zH*$R0Ov{b4(1r6MFR<7>4xIq`o%oH{3@7Zf6AGGX2TWSd(P9tLmq6csP~r4)7xK(3 z2>;D8EO$3=dw4FVcr}cE7uT>q3C_<;_)9sd>5=u);Hmw`aRwI@A@p$t%~oKMf%X{1>$3f2UX4=`o?Ma_|KF4+BF%FTwoC)6T6N1wNL@_NEU?M5cV-KMi%l zI(yQSlgS7UZq@;iAM8$JOcygo*b&63*)l(UAatW_3P`j@*n*o*0Z&@>TnJ{eu9ij0w^N1@7v|}V74s*O&wf{ZQgRxJNL*uULrt_jcSw(JOlA|K0z2d25I$)``@{#sg6A^mP44F5{P^9 zBk$nYe95X&)HIQ`b59#{ZvsPk_gtH65nnt)u&v{Ij*GXK5|^5ZU<8j+2xdgi&nVD1|#G~bgN&VFjMR$BAVC*!&gj7$cT1vbUSnx zt~N8qC)CCDJ;x6X3OW9DQ(-KG3k-FMZS*GyhJ~uVtY%d~XwDd?-nQ1}luQF;@MjT`Wv=AiK2(GF2wmnA@{P6cqA}>f_ z=|M;ZWg`Z!#7!k%XsDGY2GJ4KZX@lCo6nYec8H)fr^I<&rJ6F z@^4mEN@K_eB@`~tRFaybh-s-Y#9)Q?hIN_->1b==@{T#r}@ z!6ug0NUD}Sx9#EpiOG%Pcf;3SiNv|PDG&-+`r-3IFI`<5Xmm>)^nOQ`WnSY0Bbm_L zJbCl?Lbb6xy7-ZMZE`;hF?ED|>_uQ;r%(|J4UA|cWb-B|<<5mHRmZR^!xV7E;WKS1 zjY|_r3h9F$RB;1uRW%#~&YizUg#$^=4hk!z;)OZd%I4G5CtSP<$6dLTu-WPd>;-IpbsABv(nh{ z-=nL7Q8$zLxN~?a#@jcIL-lNDy2wY|9x~#V3d=wUYBDK86hv~!z450dTRE+|Q z_)j}Dl$0u0XXKKF|8?wkhfQ|s9`}Ow`pSH~B`O6hl#H!p_#RL|#OuBlL!$q?-?s&JQuF^120GAQsz0OXS zl%qr}^4gcV{_%VF%2;6zW%4O^ZZFdlBNtc`lm1nxeweH`9=77KW|U8XN6izHnD-*q zN;OmH*>z|s$W}m@^Vuh11V(fWAyhsK-h^}*$5Lwne=a{ZOfTd`)b8}bRzi{Bah<1fVbBGP;fm(zyYD=r;+pu)tTHl)Kp*!>-9}ukzXwk|W3(w8zV^l)8Y& z|CH+~R|j&L9i_z{I+{WgDK~K?5^82W>=?E8Xs$R7#Hh`2@u*~|9Dxurn%>v9T+#Q* z*oJp(ojjB`o1}kiSulr;!-B(f?!l9iid;Hw-ikk7ZP98`181AvL|vg8kj+5wcn=0B zDL#!Zq$LgYR>Q27^RP0;(ahUr`14|%T-UeVQCDCD9D;r9pSy!dw!psq+8`$ru9D1i<7-S!WKa}-`AmpdgAUg6&A@hvvO>FKj04E>B>F=r z6wg|!3RKYJhrghS;<5NAA60iDoACtgIkjQ2qlo{uEW_%F`sT4VlkKB!Z!#z$PssTP zv0BVdZB~$pl7{*ha!362G)g71FF)P}lh}A9N$O)Yup0dAUsQk9^YPk-E@N zaRw0JlvZ=B;8wi+M?_|{E&c9GEq+5SZW`Ge;Tu02{ixX2u;OW9rK4-I1@h*Dtpbo_ zw|+g{$6=`U>b87UKjxz$Fm8N3kFX}=fGxcCgf>I^r=l6+L_gukC@J`#d*A8oj@I$K zJjC0oX7fJ9Pv1)>u*=L<3~zooTc)lM#|YzOfNgen!U~)|)^GL|&8~;dnu=2yCQ_?@ zX8C{7)B;e4DMVAUg1CnFqw8E=9d_+9!v)Fu_{!fch7$B7gjJ?6g~cuTEAHw5T?JMk z9;-^y06XR+Kb6A8JmCc9MBK@}u@#p|*G*T~`6iKsBBGEo{xsmzBZ_n!y@J%B)O7~u zDxYHzUdP8R>E*k96jmT>d_`i7izd|-#v|>ntb(w*KB-#eBW+0!;DvY&Kul+!Il3}8 zc4B7cQ=-EJsqYC*TI-Zz9Vk+t4xE|>{7#RsYKmoLC8)mmkza6gJ|1c%OLf1bk=sM8 z8O^a9LK}bm-g$Ki6@iIX`m33?S}f5JzS_=T}{Pi)nD*wz^}W8mm&NL|b#-veu$ zzc*V_KOx&#{+QRP{o(-)czi$Qg-0U8JMrt^f{<}HJ2kvb zM~Gp`^gDrB_3L6KHXnuEk}0*8w5o3>8~I-|skYa8RHNNbN_ZYJ4JnSKGakx~fDArg z?P88p%@eb@#)^7A%~IkcCW$(3GiQWcTZLqfi`9;|vD-&Ab8a{OrJD$~etdonurvPo#o!RM)IqEC^k!+X*1K3fb1#8g&%to z7OF`?MP_gQoEK>(W4KEq7CHXep6lSq6(q)fPW0v)8aqtb?9zNTFBml(E-h=H;dl{U zC;6-5n!<0vcjPRRRXVi?i0-VM;FdT+vPo1OH>@JpWK4j=@yA~4#n9ap9eqt$w&)UW?21|5+JHPe zfN_JW3S>8IldudMA}liycJ}QoIUQvY@9r{+Mr^5_zYvW&KUhvdV0VeRtKDr%4C}es zUqsS83tOh6hOPp?8j4mTJ4#1w<{%SS!L{l*>|5^MJF=d>or`1KB9R8UP5X3B_JzE> z;Gn{4fu*@qn`llF)RR>CD-pQmA28T%M6=6DH*fwCij${UMqR~UQ+;$O-0|TC8OX;d zy@qr8R!+hgLlWk7j`LZ?EEAvHH0jI$@P%U+N1ZY9%(XzPUA;{`IqfHd*!*%n$Hw^i*~mKt9k zLQnZwj8z?h#c7=_JpXV;p6CTBA$eYG;3l6=To%S5@4nRhTYRy?)D~5!n}8mGVKEP8 zy7XoAJ<9oL4^_3-@Y+4)_{9VEU%c3L&5kSMo%N^Em~RWHWyt^dS#)q*$-?O&AH2*; z@!v_c8vS@GIm%=dJHOA100 zgwm!;8{noirH$Yv0R;z<7W7p=uyrIA>SM{q3AUqs+SLA-JzMel`e<~*E(qcX+{TbZ zm_+xwr4;kTUu;n<^m$J?$%Abp>pb^^bUHL7EBPn~rBO?&MCzyG5D;!RqZ+Q)5x{+I zb~^@VvUFA~$%d7u3wKC1PIpqTD8LYH3dscz^0cuexF|z`4!C;dzp9x?UXT-mnPJyE z5w^jHwMKJiKp(2%Z4$4X2pCHvC0l;~AEo+#m7)W~tQlvXIaff-+n0r&|D_T*(|lPGkX4sE zMX86Yw4T>5OgR-|c|oos0Kf55o3LvC`_=soaGac4_H;R?8VTDtTZfSJ;K7P^;)*JW z{ai(zAQ!Ej=GQCUJyeF*oD7s=(876QSQ&tNPp$vg9*;H>mR`TVX104SOxWq4z{EqhWkLHG!yZf?&*655`YZx?iMCDRAJH!G#dBUv z&*WxvVkfKvaUW8~15B;T3>C$z^ndG|Z-^oVQhtJo#?~YOHv|)@3#w55^_lcSy$C)_Xz{`Q zA~!qzj|w;E(9WbsE&L1ekoSLmcZ!=NnzR}W`_R8WDAWQ9 zE8H=OdFWYv%QHAlzGaFOC^yOVh@BumVa#@S^2Gh9E5KON_3rwkti9Jz!M6+ z|2dcxEx3N_13$e6L459|VtSCrgcD6CWJ7v-BT2MNm;CAqvEjYpcyJ<~)Ig?9<|12- zwtUQs8Wmff?s;l2q@moeGg8D?cRRU||Jd_(s}&gJIGh2RGZslve@Uy($8)EDfwuIl z)0LlL@W?i=4|6!I6}?}irbd?^HPT269$X(yobJa%J}e>{+{Qc9;5Ym9k$>bl@C#r5 zC?IyBF-G+;sL!Ae#`yPSL`6k7B2_0Y+lf{UrGHZt19iIK2^Zgp8Tiug%PQzFq@W`f z=P`)mJB51jL_@|)${(Ehp)bjkQP~G@-qTU~5bb9#ctrx5;Q8E zgsmO@huMa+m){Ns6y>^^!rFj4S=f-WbnzudFEq3KGgw`iTGw(VBVsL%-zy|v>oOgg z2qp%tHKp*0PH6?li)>Ed0qbqcCJeLZ3EzBbCNvNg`hw+HRUTQLB^YS@ikC;&8IsX? zE10;G($z_BSvE)5Q;(1`gZ5dOt|cTD)-HXaZ})%~1bRzU>I~kViErv$pA2-@`+3SO z*>l;?ICNcWZ~D6iVfsBK3W5R`Jnb541s{^6ZJ!5wJ_#DJX1p}57tK=EG-;Z)J7mli z+0gegM++!rEHfEw;rcNJcvHQjC8TW93X1}U>>ykNsu7WJ$v5kNKFyMsuiNrH7~f?R zbKFydrBBP*#R-Y4%gcs&Q@)O*k|X5R_Dgr~lX*bV41~}Nt>AoYz>8i{y=R8a>2tHr zLseBMj`6gzTTBcE1n=Bo*g|)x+>9GCC%lm(V#ZPjw$4SD5+QqD(UBv4V+h(Yi1dYl zYePXNJ=Lo;2_L4oGj)zdu*y`=({rse54me_pY3R);m0FFNtvTIT{DT-D+6$gfC+#58b?ke%9k9820>q zmS`obNEYZfcsClq5;C+ zn@EQ|hQQJT7ltK=fBceJsmka7wtzc|Z09)1D(Gi6_{g?_e^1)iBTTH@-seB%6uWcL zSt!bVf9r)|xjDikpNro|NENVEn#nKX_;f#zTS^x*4}0h=vMo7iLtW+|uuS$9(L?A- z^#H4x7A*YMP`Umx=5jk%@a?F+WN4ngNYV;YjMZq5jp5BV+NE~+gNS#Ys=S^jO7kzw zMtLOi5z>ou){|E4YUy7&7Z{Odq%$P)VK@nldRg6s3wP- zs~fBH!mgZNA>QseFsvf_Yn8#hwH+R_Z{C783GQrsM>KQdVIEoGP2UG7cIz!EaU=ya zvaYxM3sUEcG05AG2(I%Ggq)5&%HZ4R?T!;!Z86HKCiqfzD7c<=G?nHmybHKKejOUu zDMG2gYgDe#6wkRtNh2LtDxbV4e@?R~X5}2X+EW)5A9z{s)oL6Jfrn@Y%Ti|&M_mKU z*X{$H_f@Lctb*6A+JDzmB@J{2W6#XP2goc<9Ot79Sj>Op39?+XgX>2tHSU;g8y)j_ zaSgC6wej_QAoje6|A#VsG{Pq~4Dg3Ro!^`>Jkd%4e{IsayE}_^k8Id^C4!{8DwNiDqn1c11+x-;3 zPwI~;KA@<9tFQa$>0LM&PRmNTQgtb>fLcqI%qYWh)^hEM&^?ko({Df4B8QSP8m$R` zx`6oBD)N!+2OxJ4h7b7CE`;m_AJu+)d!lm5rmKr1Cttl$VqSXpBhB<6H}}6h%1DgiYxf!Gr2(0dQa}L zs1=MCt6?lNyJFSBwp&%>)Mdx)E%Nty^EJ><^anT_!6)KLKrRU`KxXv3-{tl_d+m## zqknSt<=1Y6=LlENUw2G2^?df9nY1P~3WV$Vg==&gbXAfT^Mnl8MK5i%W%>ZSt26ZF zl2rzw@oaFW0~w3wLp~M~41hECf7UlM=yHmuex}Gj%blO+uaB7ue*zMOiaj-3n}XcH z32Ri2n~K2A`W1m+%PUw)mbjO1LRXU(vUG}M@2bmr^c;)$qA zCr$VWxrhE6oEG06b=bLboZPI#@N17>6J6W8e~Uda9AbNSNxBw?6w-x^xGetYV#P80 za}K`2gd!-C`V?xTP(ZySdigS~V)p1_4qS}7^|Erzt^GNo`?O1M)lb5DQv@?*oMY|6 zEi-fcxIg=k)txq5_TsDh7y`Wsu2~fm+aN6eYGO)P)GClCF0e-ZuTHX0iW~Vw4#tP9 zgkDRIPPAi6op9)qsBZVhECDBa(Hv22en*F!n1aFTV?yGkJRNhu&7=(9z<2My7yK@ToA|2_Opaq z#l|mdsrt0ZHlkLKR&dbUSEjGV`>ylK>BE2O=wr3X`_oT>--^e4p(EV@CEsCbX91h? zG7bwQmKbIjg?qxl!W|N%kIpsQ?oL>9N_fFMxpDx@F*@85#PD@<`M z(a!~k#WVH19qe>U;aHx)|Hj$%#Rnm)%Y{~T)^_H;t^F{)_MNN&9I1I?IB%3 z7)1BAo}6oEKPlv2(`;MHvRlX=a*>o#3hZp=jzt_9adAkGUcpPBT8LD2bTJ4p%TyvSS1UE&stLJmPRU!0xq;H_`y>stuE3BT{ z%d_2n1cj&GorQ1mKUBu~$?sepjJd}qaov?}1Q-`fj)uPptDKy4(H&^zSGlcDkYvUr5m}AOiOov=1-@=$D{|+Fd(J@zlFGN*BS`9vf_|ge_Q8 zC#&Kn@nqiRb}K`HLCPQm`yg@Bh4LniWSV@usA=H<)YvN6nBokz}+JM^@OtCA@Vlwh} zqCW;{=trEcF}17k2YxNjL7iLU@q3U_!Xm9daUQ_OrG@&=O+5rd-p2d^g} zI#CK_SZ67=weO+N^5B1Jn;T%>JkagQ_5V5 z7{0{U({L$~)}xIyPGi*kM5?KCM@|PRh>C;SxO`Zqvm@?-t}}jb)cLoJuxekk~IAbi>@rD$E>yeqVP{; zO6dZ-$Jf_TeHY^PM8+uWeqaFIudEnw4bA;!H@0*$x`U39z7W^Y6EPl+@DHi3Ol-=~ zQUju`z<|-A27+H%*9oIcJ(fs~I7MZZ4B(hw^iOwr! z!Ke`%^HxoDtgywvzYKRZH*RNjO7?Yy%Z!tA1!+_kKI@G-!G+B%GA?Rx_NA_o-T~-O z{BDuAv9yxEWV$%cS_s6LBsqI|U3fWyQb#qnkJ~62bm5F=cwfz0fLuWp%&NeP z^%UQL(jDuDK80o8|DYmFDm;1;nfy&bCyySKgkzxlLp%qk;81*YS7qwYnU8>#6C+r<9{dwIaPP zx8+Aa>JZz+Ndm0mdX(OQ7RfH4%iWmwYmt=)bDK))bX&DsW`pjpqc&wbg^00Bww;~YaJh(z5< z;Pf!G??7cT$bh z(2)k1*h6dw)bX*wm}ETaShZXS*X>N6_nGG(S&66Yy9VGYn`QzHA(4H}o0=LN`*g)CP2k`5g*8eeQiS zgB*R>O&12WZ=VpP{Qiadm1@Z5&4_^pEhfm;yZMjBvyq9K*P(^Kk-!Mfn?#_yBu{pbVH4my9jS1c7AFSf@+5oCLJ?%7$= z?uQAEi*oslpe0uI>5Vlhe&lcL;fFl43fv1aKA%&ok{4yKf&kGRCpVddXR^WxOvEIp&A2}?o1{DmdvWg!hC-|&uhJAOZydQUq#CHFvZ^srP&yBx@oqWx>wP% z2Tj9p(ye}d;a(iv{gLy9=!~n83|Qd8Q?AGoV7s-g&`pEn%*jgDkHAW)GC=h0W*e1V zte-j=D2B@MB0O(G%wCI)PI$W;^q3;^n&&C{TF(nMyF#tTF}>*An-Y>hG(li?bzvL_ zC=*`*+u4}D`B0oN!D$82^pxXpME*FuId3KYjX>C)8pe4PNhms7-pB?3|7EX-9J$7d zeq|CZbkE(HfZM1sHK4BOC6_%yM3R83#FZj(C5rP`ib-F83(b>ht?jBc!tH4#7O&S_ zZ4T{Nx$=wYn!NTR6E$zfG!)1RBQr;lO8I4XOro&gd43LSo@Rz+AfIy`JK@8vkeH{? zaiNI?4`wyXlT?=1cGu{0^o^4)aUDsy#csiAVav=B2+N1LhJ|9UiBhb)sMa>UH-3-G_UWU z4~7J^zRA&%tGJPV!E+OMw1}=~R^Jp^Vwym|95$Np_Qk!+`~?#!2gc$3&gnmoU$@K} z)>Rk6RfXQRzr_Q2RxKdQ>mJfF2uXVH5=!sY~lc z#@RMqpY>$9kg$G^h%ULA;FUlZ*phGFP!k=*W_!Ht`w;#^x_RFkH?*mqh_lJrU;d+O zz(YSVl26V_6;6L{3*oKor;Nee4N&xAE5e2DAlNL}yNhb6z2nwPgFlYpn0757U1_&|)5G;fmn)Hr9 zXo56BB~r$rg%U)iH*pw4QC@g6Yu>N<_wHKz?sNCq=lnQ7?mBT7ZA^KgC!hcT@S2+$ z*|V8=h+{|CcejCiKbt^-_NHi{YT)D&`w`;hWPa1y8c<}%#{f>y2>^0vVZ#6<{!eTI zIty_8R|f+?>}`PaZ;uU|504oehc$mOM=t1ZH(SdE|Kld+a{MnoEDHyzq1eQQHFFLG z0B)f}fPjoFF}9{-ZuGN52xyJ!Tp?^M{J^+M8XA?~^vvW{3Fv1@JinE&odB2oIX)8) zX%?xq_b6f5Xv*W7pk$(fhert3Ji)}s%Z|c-`f-!d2T-)QVw7~r;z6ehCcJ2anq69! zy@WWkvs5{swR?cL?l`SEh4{p;d8tEK4PpiQH!67vHf$lzVMp9asOvveN|IV>m&R}+ zPnRg~^PB)!kjcdL=iDvl5;2LKVgkQ8;Mk`?NBYt0gh6Q(J1-?z$%NjQ7OIsC;~S zQY({-PQ^vUcneJSO>eMrc~2PEjpGJ`JmxjKW8W|nFy2~6S}qrtM5`K#!^;YJMamtc z`h(qEvY}~?ecE>=Joe^3>{mRstuLcm3qPBVl3DiIi^V^zMpXuiJQ;5fxq!sLr9^Ym z3_GkIA@l%s*B1`zJpAkoZ{kqdWs_7I8tc3s-fGnDd7Pt1y2wvrK=SIV7dj&6Dt59} zbn~VtS9)&66mB9nZmlfhI$wQj`y6yW1JdZ^JReTWg3X_$HX-h7%&ONu`2JCZRCGE zYkOW@BVirc;~RQE@Z>(OAlOegM9eoy3{N=47~)wAV~F)@A2W7d-_vu0xd~oW#|w4i z+T!;suPDP;ox|<5RHb3d`;BBBC8EcR{8zY=FxU=38c5|hXQ;26WJfY3?HxB_yo@G_ z$K(VqOzO)X_j_kU*Fjmydv8EpNNs4{UjERsjs$#0@|aKg072eY2tQsM5D8kA)(6BhNG3>{Q;;; z^;DJIpwA|ht+!jZ-J37MwSN@&q(bx7pb(bvYqF?Ej#v-$4(37dy9>Em8Aus5_~&pQ z>XzTVI!(?N>1?~NKQSuuvoe)>G}g27!&r#>SClo#n$^Yz^^iMV_(*Mp`QrHE)oNi0 z5i#5Q{BOyu7=!MQR?6a~sLYt>+rAC^@;eezJsR1-P|e70a{zK#Gd`w|R8A5iQC6)| z!puTUSvJ_?lhf^rlC^7E5Z}i3Xu@50Uk!5XgXj^TniXdMQ9p~@E{vwq>8d*6lXU0w z6&_e^Vw`@u>mXfwA6-zQz_3C?1_-Cy;o8%upA7=al=r284@H4lxp zl44?)C9@0Dt#|L@o8c?=5R4B4&%=d%#{XFz?N4tp1-W9UQQ%EoWQbGsJTBXkFxt*i|ko);rl-}x~L0T9h^?V{D$}w(6!i9 zb$J>T=j|0_MbrYhV1iK0l%^_A@aC20NWNTo10>IEqkU?S74U!HuEJ!POgSiDLbh4EyBF%ho`>4>)c?{Zz%U7x#t$@}bB>_|(e zPOTPDl?prJ3N6m7(!)AeAeSTWUmurUt&4o_Q(<8xd`_h3&yBiu-=FsEV^oFl5$E0H zBdSwRodd6szR1Px7i@`>yt)D+dvCdKK1{I6lB$trnSuk4_CD@J;XFsP!MH`w=%xN3 zMwGUDp+93$R;sCXX4C3}LZsEly{|V@2DR(j9(2`r59n|QQyhzE9WVVMo1Ks z>7uT}ED>wk+%JpMUH(xM9Z)s;))QJzOvM8XnTAOib@k2FL7f5hE_U;Ae_P1Hmw!wI ze_}MW?ap3~%ps_kerU1~FPnWIqXb#+=_UoQVF;UGDP(Ab&4TDL>S9q+-}&AGU(vmp zg@voi(XJ`}XWC_0^C{ckj2u7Vd-TyZX^A(vc4pk<4xSMlXaz~04iSm7nYN>nbvXFy z?N-}{!N<_rgAfRGST1(<@pfFWzTX15QxZ){+xTw1h1AHmPZ-IBF!i4QIQ$i7LQd`1 zCd(~^AE@r+@Y|Kfk+*R-Y%rqxj$xaCbf@xnYpH_mH=*>}fV4UFJ-*wR%iBT^&qplu z3spxGm^%ZKswN%=yjevdlN?(Ua!kx+@sD?RjIg}(;d_zz*s$W^3V}tPRL)Oz=yuOP z2K=Lbr6bX=-DeYj^bX0BhqZ(>pMGI7yx)JLwMybBosP;R=@WQ;*Dc$5##sS@lUV_)#c{Of(M%OSaU*rNo`M zo+DL934Y}Cg}z=6n|(9)0-Cb*{a&>86U9$Ldhl*Q^O_r1jZ=F*ox=T8O565yJ4-oL z6<*e04bzou0|wt-YDJayI~tW5pAg zAtq9OxRN|kClgv5JkNcJWCsqClFCSL?G=An*k#$(aucGY*EdcmMpeVFVuMESMmPIx z=1s;9G9K6q*j#*6cBe^E4UNr*mme+}u%+GBiRIqn%m!LZI=G0aa08n5FNCs$?jP-$ W_gjx@Z8kaFu*{8ZjH=K#qW%LuxtRNl1gVbazWLbT=a1O1uKnpfo5TE!`y|4buHxe&6%l znfYVxbI-Z^?6daTYn>Q%Re3CQa&!OyuoM+!HNjuR(+7qMe)me44FLd~rJ}5qw$H*r zpl^WoR{Mj8^`GGmZp+Lp4hDwLb9dC$OSk^h@GAr!6z?mdCrSS6 z0r;1Mq}OT~{qi9x(kLDV`Yfrf;?6Q1()oDn*_q?|`D}VZsU0bOmwxfnJlNz0>w%2? zk?Wq0nbM;hxtY(S2R0`)-_({b3+PWMEuf@Kk?cNZChW!cRmV|z$ItQ{qusPH2^2)Q z2ovFKc*uaQ0e09|T()Z>JH;2845ak%43@EA&rb%zJ7Y2xzI4Z2@7@}iO*T+!8Vs>n z`YF*)AOp}IDJl6|i-nNnSG1E_?&r;E3uI{c@pz^%OZF?owv3P%mTBhGgFDvVxMPT_ zil*Shs{WGb7kwCr45V&<_?o`3W)v9u0#e!?+QW}p(Qp==A~mhGj_%pIr{_|buz`_R z)59MdQ{o;x>Y98jNm$93@07_xKJvk*Vq=#z$9RRTZQ{ z!Lwy1tM8UwYs6mxeN*kq0wr|s^VAr9-Y@lJW5rdh!$iU3OlWZ-YnAfcr>Q>a~8gj`)^6`PV{xD0BNV zg>6YJN1Uk+u@q+k-)DaquN05+OQ4XQG?sz_FdsFpJiXd64;jx+b&P@1FVGlry6O|q zyfsqe^~r)B{oDVOTSX`1J0s29b9g-6AK~8@H%1(z0TDL&IEC%277m@a4H?P+W|f!E zUTBuOA?M?0{g_fJ0!EM|A^A8+G!8}nkmN_ho_XdWgnJ2Izg}Bq%EG` zx4$#RLEU$If!ZN$VYtuRhsHpgFI5z!lr2~}Hvd;B5!*z3cku%8X^Vx^kCdr`?lAK( zId~v?tqzR3wsU1)41>MAE)QSnxOj;Dlqh!hy4{*zgTB6}F*FaWt;kxN?Q>0TTqTRo%0`j@FW2_$R%?{K8E`|SxS>?6 z0KGH#9Ub_=R0)4DGluea3#vS`M1?OFdW^(JfpYKj(qS|seCV2}f9dS0znICf>{?8Z z>a0hEFqohkd124hn1RCjW?#L_*~vw+%hrnaklW7I_MA9i|GEFq(`!}wWU*2s)65W~ zDv9dLG6-ks#M)o*9eh%mTv5Y>whk3}kx9gnu11aXjgbB`7(2(WU=+Bq(%$YfE>$AX z)hJ13Pt8vvj!CQTK9fsLG`pc&S}jVh9PykzesxrUgaDV=o>j?e8S=S!bXQSX)QdN&7p0BFSPc5zGk9!(G`7Z!GDhh*(LV)3VNwA)_vTBm^>Vzze;?t# zEcmSkEV#xv03^gzQ2=K+*tO#;w5vjy#@dEz^q~}w7g%GqLP$~Mlp7Ot>o#XSFomvi zwF-3hXJbvK2(EzfNRSoRev%|HK_i(AfWFb1g%Nmr(DgtHVwh zs|vCS!M=k_%qjA#c;Ja882cOxO5&o2&z9kP7bh=2*EKMlTNfJq-g5ULi-iMN;`{75 z-xP-u!;cisBG8ESl6ge#;YwP0h+RIVF?_nxE7#~(N+@y6`bEp!y~$Cr@oLj^?<6Yn z5d7w`TNF(@Wa&^@$+cWGSJym$CkA}q$nq2I&}x^eqAO`8Z!87X97xOG2;pCAX<@c$ za`hP&GrzAhGYk_09J<$PSt)e@A@^OHnf6oBrct-l8|R(gmQR+x_-F=8s@mWDHc+ER zV}bYFC_l409&DhHZv2j}90Q7%B0TUvOIM_*QVQM7eX*_YT*>LE@VqJKV*6L`1WV~c zxc7SpN3khweG*QQBjpfbdrJ;>c=sG`Kd(j=zYmU$lX?D_?|31VM0pi{rFzY&_u>+J zIGb1Sf+7jCkFV|Q12fCZx5PHswzLVo7e878;5tyDmeF<{t~X*s@8DOEPVI2ckBuN- zn#BoVn9qL6itV&^Utn)=1$}VQG%!rB{^G&c#e?LDn)ju`>8dQ}foOK5gO!!?lbpHZ zN-L7^ef?&tIj1vo$G+`)tSs+}=ho|xe-A8%QsEuj1EX_sZC6m^sLb(Ez!o>Qp$rNB zF?(4WBW$Fku}6pPi84;^iRWSf_ko9lK($;vO9`)4rmUnx$EA?Y@HfsFsJtqbY`N1H z?O~D&v;HhUXLNl72uFV1P8qv(+$;gkOZiWoq~Q;E2(@s!1H1S3-b2@V@-7Uug{}$R zJJkjX(X<~MXA{!@9N#Rld`Vx|y_5(Ea6poW8Zf-la-iQYdnU8_PEFUE30~_rstOFX zNeJpzxQEnESQ=doozAC?kv#1(&&&N=TJvo4`PK$G<=)LpG3qib+Z0lw0$q=euIiWk zOI^u4C)N#+#!hbXY#4xFdnVmU4U=2Qv0w6^X>7rUjrWdY@_~>@!jP{O_XEu6R0?MP`@F#2~izkBO$0my!S!aUVig1_i>$FtF zkErA65e<@l#oXk)Mt?u_h$kzE?q45%5b+B=pB{DHdnU%4jy#T2mY~f507}G>Wz30=I?hi3c)@h%(T`wKz({~+X%nFU z#z*T$NOcH}v@kjcv>ZJ<4=&TpuFPY3Vo9}2$7}E=Y;ZiA$@>fzL0+3=h!RvmoH83f&YF{&RsMK8%h?ChCLtZ0*2IIEIMvb$ zqCooxCLBu9A1-Ns#W4LL0Ba5BO26)y0hb;w^NIK){tLlLD+A~2)|Is%25R3sjs7-! zwuVkgF@VKrlWFXDXbXi<3A&?7S7;}!%CB7y=lEb`$|`F3@^}KA3JKfveuigf3Z|(! zAI*R#zEtynDRcxct^ce93Ro99(->3rqQDcCaWw5$JeZs>D53M((`T;&ZTB3Jf#JN5 zav>i%_TpaKaf)i^ibPHzgR!;obIeAaB#~;b7j!0X2jIVWw_?U&>E-sqP=KW_;|tg? z)e9iAjqpuI1fi`GCL72~NUy`JRq}CY=8;NSD%Ami_Q$>3r%3S45_2E$;T+UFiWagd zRujn#>Q-S09)F%Nf|>dm^B-S$v(3*pQyZvZAW-&ZRl_IUwHGd3rx`OSG29#d z{#x;ZubGNM+3%X4QdvFusmX1(fZ-$>jpi0ZPJgNok0$5jx=+)h;yX^BGG@DSq*$x@ z4k_BGLqV{|*|<+8RoUN@qH$q9 zUkm=5aP!6e;Zm<|R5OhJ9HPg8&j%#uy)4ILsl6Y1EV?qF(WS^~XDI3C>%Nu+VY%~E zl3+Ut^I@uarZgAW$9y(1e|?d~0KpUrBYU0jMu}6vF_=pg16Wf=qV2AF+5BbeI%%|R zoO_42|K%M{o)q(8`ynlt!;lFDq5uhM-rLX09@_BrV-lv>8Wl~W0?zFgz|wOxXamn&ZYqFW8}K8UM*|}{;N{7) zD+V4#tG$}Mi?W*+PCV|U$9HLdkK(hd-_L_?ce>A!v=~D!1l!gW3<-p^6<~c0U9W?E z>7HhLha`dKX>MosG%CNAV4#@msfExpu+;eHJZ70eSQ+w{Jb)5#LdbKMM;@f4LwAnZhTK#^0il)=Qp=Yi&q8L?F9u_wEmQ( z(&=(oJW!;%2?ztX>+dNkFx?vLsjPBw=xSETekLV#pu^jJLamii;q|b!19~I!QRLB1P#u zt)<{BNVDE;*f_<_rWRgkjgyS>G%%dwzGbsZhe0G9e&?wDpU5j%ns#>?`Yv&BcG%dQ1PIF#L21f8U-NVYEx zCbO+0_xU~UzITw)!G8P)AKrY2VuTB;T5^*;4mby6Ynu6A1j&EfQ|8g<;RBU3h6Q(_ zi`(1nPR}+jnkpP!5(p$rR8X=^HS0n}xpMug6k?0XZ;JQhlw1B)4OL-^Pl~So>$3sj zWnbtE5&0wGxHNge%>HkIuS~bkPFE{lQp&Pu4<3*tH-ZvmmGjPl43&)1_v=rl4{c({ zQ4Dcs*)62+WuJ8wn$W~BxzuJjGrMM3VMGwlm^fz_>4|SY(nlO0JQoe}J)&;$LKyD# z7Dn)82BA`Q+?4*L?l?wr?!xKfLE~8y#%?t=D`o|wO;ap})FGK%mP0BJ`Og4Ht&kiZ z54FE$P$>33yV-n;)1i-h(a7{xGzIg2srvCR96LM!hapIfQZn`5^4&=sOXClwp#7>x z!bx}YM?sN955~2C5i+Y2^v-rOJOEJs5%JE-BUi<#m%io>0P)D`hOXt^wf)`Znds7y zNGmp*-E(rP{?;RO%$I9u+9jfdi#Y0>1>m*h(=K)^KWVYBQo19YXG6+ z9LcR7My$qu=Kj)_7AvfnlPB2Azfg>kz_5v`0&{hleJJG;9Y$`U3I#d0WJi z{$IN8l5ls!?n#fE;A)PIkz9h7azexT;ek*M{&UE$hrej0den1=wVNmY`Taz4^(@W( z-zCn=Of25);III5Z<{fMcVp6ZoL_-DK*K+2FhMx;bq?>pK=?1F#VK`LL>(aj0;I1Y zQs#jH<(uKs$u15MT`EN<*deBcV9i&P3 z0SSY71q%>bZpX1c*Y<%-<0^tGyV;MJAfDeSG@+I{=Pliffr`lLx)0*YC z`xEgdDq9U(9qTC`+)}_zpi6|}r=Uhe>I+Ss5fe5*@fTnK4@+!OA>-9oD|U)lK8=cK zaQafX!i7CVf4%pur!~djtFj-f`xK$Qz&$GCsB*b{XWqLzGG5s@=Vr>TIz`q0E0qX9 zbOxQvEC0MERyfj)JqM$~*UCndM%cAApHS|D9uSiK48LG2*ZaQ#CJw0i{+mJ0{eqx- z-9tb2nc+U1nxOX>ICiuNAU<7vzM=BA+8NtC+=Ea0gUWYimj64WZ(fN?Q%!Bi^=2ZD zpK>ORJ7-Ozu=|~LKouJl!2gbYCi`Wkn=Y3iseXE6>nNj@|M*wA*Z4V!OtrH3{I;ERnZcMb}p zvUym^>I)1D@fZgHb<2kbk|On9WEm=E!e6k+ys4~2KMq#I5Zeao*Vfg2V*)jOcW8pr zv>LA2aHbip#YD*jyNr)Z*8&IHFq*!yH)Hhjc|&%{?Mw0Ir&4q-n0PX})qwO%TtZF4pN!_c$aE>!tMZz!IarJ=Tt(i^mfgNB z;i>QZ_9)3p^XX>j)VWLh)D!6X(T-y_8ZKJ+K1ah$68PAL_%$?gweY~@z|Z;7@7^o< zwQZ%E>AjQI*zGgY@^_RD_W>&B&GP>FW1qBOk~sP>TW7j{}z{z{&Ge9m{9pE#Cs!hU*53DM8@@3wVrm%D>=s&zg2G*)gR=Yqy zX!qf|6EZnwx?KG%*(N)|_qy5jXyZ?7a93?cb7At|A}#J8>yv7Q19h;1sm@fL(QQiz z?5QIMF&_z0?3iei31A1m4Wl4lfq^R5e>PlJk9YHX+E$BF-m2YRgn;ZkOFTM1Q_cso z?UN5{u?;2TJVK{NbiWEeuf3WRFDqA5bqP133zvG)Li|2lfNqTEL)|XM_)bNCR?~9& zP%tJw9`ZcgHXWT;5efaQp0(Ec`I$S0QU0Xkx6Rj;?WZ2$(k3C1n~o!LGd<4`@bRQ= z-N%^sN|sZ9@+gkiPcAE_SN({24Ig(REvfaPNByP4|zcInVB-bn!ME>ZJUQH^3S-Vs5ef%A)hYCCk>-oBi?OJ zR&JXOv7%s*k5CmzGd6!A8rl0}-S#1c4oD7k2BMu5oA>&nzK}bzG^vE?RLSYDL)uzz zw;1qQ7|@mLIiyRzVT)EiM4#+Rb9un}DEmTl9o8*G)F;nqDMRda*e5j^3=|k3VG?{z zR1y8b=*#U z1*E1hz)Pnv@=Fa-0@Wf7;iry_+3g?qtJkd61($DI#JMg)Y@CbT&l&gg>Zwb?x@UYF zz^Pp6rYCSM;UhCL=ei>e1A6kTd@Rw{05y>E~sL(bDLDW#eXyYU0)CWF~v!3I|lpdol zvL@9K9f&vnb=T?Hl>Cm@wr zPp%+K{*;YBSWo9D&t)s?+$e@4ccOXT!Xn=S#~KO#V-_-lrm|=N<`^L^{{CDuO5AG~ z1h`czp3-C|9+q2E`p0SE!Q|H~#8&F22Du?Pg|9IazOD`Rv zu~>)Wy-jlbf=W@A#sc&EpO1yM1ep@ddsqz`s$+@`=lg@UbogjUn4gNLV_A9jNV?38 zFh4p=3@=EN^6A3Mrg}+sRoMd~(copHR;KMx z4)@gbB}e!h-GLO^9v{}?p7szC9R=SC1(Vg}g^gsmJu|`Zz zOf5to)qFz&<-|w71F$Yi#^NMaKLY|BeqG{y!HYc z)yZ;PVC@y=(GQ*`ajQ(AL(Dh)tJ%6rt*6^OI-l2cNykUj+r6gVa@Ke(N~~l!2fhV} zs5`$wDQh~;0WA*6Y^SD?W}5_PoX;k#^dB;8uGnSs(iE62nwWQ3>wDax!AFsmnkZtvG6ug`+x8UOAl8Yoe{XeslD>(+4ofvVTtRV#II z?Ewq%h(V%*doZHx(G7=$ZAIdiH9+%}T!fOj)$00cs3!{)j%{AT`R&PpXrd%cWwHM73=JszwtEtCZ{|DU;pqKx=unU-rV}ki z!}$;w`3B*1*bPF7C4;rp8bx3oODiRUF*M+z%Dwo9=}`d^$lv82wC~lxp!Wny$d(Lu zt$kqM^kDM4!>M^Bvp2jb>e7tNDSLqKd5Z%@$%$2$T1}|^2JebK?%{$`affX*-G#A| zgMMC6#*U*rv;2DiAR$LZGWu{J%%I`f_DLcUid#45f8%lXRYYS`D1mI|KhWS6N>k5o zhQ3mvt=u;$TwF$I9Z<_~QWsbfU&+EWM0~=Y&W_~fr{(&t&C-^Jg$qb?3tF`OQ6fw< ze4@N(k%qkZ2v|X>ZvnVsln*x+pvt`@_}J{@v})nVvtL@QftvO&1FkTrKHtLmW~2Nt z-9M@JyBAG#eP1~N0OYRE1ojT%058hMSI+q_0=-~;%q;9#IRAA=c2duy@)%P(E}+FZ zKL=SyF^B#Q2YTAC_<4eAd6(eNQ3X395PugUhlxKLcgZPTl}%>ZR;*M1PTVx$ahpQB zBHmUiku(H-hm&eptaqS4x$*8&5~B!CYO~VuScP@f_VCtINKKQEGfLXtJT87~irK^| zT=|odqy44=$DnAX1e~s#ha--O%SMC{v|B*T@Hy-z(T^~anxF%fV3BBQrxq$~uzS~Br;1p)mY7D_eo}#xgSimG~$EAS_j zck}PL`~zqiA~j==PP&MPc2fD_2-oQ~w7UQ4b<=<#WeL?bKWJ&^&g0AHI!43SFj1wn zmR+y%6s3nek4m?1k%PPshHx|zoa8|?(6H2ph8ai4rWIgKkCyMZcpC=*5(|Ep-k5^C zfO%pOSUD)Z8UL<~jrLHugD`T+SWPQ zyd9IcDp4A+MZOv=l^w7pw|YZ-dv_~fbSFNto+k3RQKV&)6~VKJL%Dtee0xA zngPEUq`rV_UC=v&b&@4xEDK&leNsVcCxPlpWQMKIt^v=qA%gSOUFpWPg| zCvhQrKvp^G3o&h*^SG%glr{Lh$LyK{;ZcCbF_D}yag_Z;&yZrGzyYbDxbvf^*V2Qa zr@y^ET2*OyC=5UEhf&pYlZMU+swe>|x(w?!-Hiju^OEx?5B9m=IyS6)?tCP22RF`A z#x6ObY{~zpd_CKBTMi(B`m?Y=ILG69ls1d@HDw>O;A6bj3EM~FS+z&JPuVuE)-*(* z;G)|VVXaFgl38NweDo@6)ABP`Ys3>g{xo3d-dmjUkWgfr6ZigX+)8}OWs)!=Drnk! zv2990TEkOrLD5w95OrtpC?7NjlKgnu0Kc{KXV${VFUC0lR=C9G713F};Vi zGW6=!Dp1pLH-@cU;qUnS64yv8cWZCT(-&xu;!5AuZPRm%+E#SgaZCRWzM^A!Fjc>eUY=dVg@V(ygP5vQJiQ(J zb};uS2e3AvJ{GP7i~*0s!9Tg9z*i!BVu6-7g23OxA$ep?0{`9bu}&RhkUSx-W9 z+jUZNZRbPa?}0O!1wZ!@&}83S*?Zm->==XzW_2)o@TjD-~mFn zmKtoyP)!%UCLuY{`~r=dNK!$o%RU|237EM9iPfaT^#OQd;iRgrb{>%^R$w)ffggKOa2k{Z62pA6C2<0ta598|O$s8 z#O1>agc|5@P@C8b1hzaKr;0{0Sr-8!kWEw+gnkcqt)dnw_#1#myn8E#DA5DXm%!wO UyYUkp@YpG!D5ol0Eo~b5e|r6rV*mgE literal 0 HcmV?d00001 diff --git a/public/providers/tencent.png b/public/providers/tencent.png new file mode 100644 index 0000000000000000000000000000000000000000..6293ff7dfa64dd2aa24c2b24430ad4ed4be6579e GIT binary patch literal 1312 zcmY*ZX;4#V6#ZTn$U;m^WK%04LnIieQxFi@9vVcXK|mo0NF%EbGU8T3BKlBUgS$wP ziV~`5C?F+N1SP_gO20?@gk@+H-nR<)g_D%o@uY&~vTO=>Xuc*@Ef*8mBkMGxh5?hLFGTV89Td26I zN9mLwX6bSD8_zdmNW~o9qyE|UjKmIqY*l+HiC`^SU3R!^lS(QeZM`e=Y%ru~_3q8L zKkO9gvxYS47ruQ8ZHO``nE`C&zos^!q;9!pobUE#wV__~8T|I*>Z-sskhK-D6 zq9;#2#o{Ld{^DE%bfoRNzXD9u3^H9QI^CrvZ-P-=n{_GOy@Ly9?X$}s{l*%l=G`>A zL+CqW$On6#%GfR)%TZ{Q)|gA=!aiLLcFH2LszrYm8Y$PKY3GHAIV*l2NquH9JB5V} zw?01&jeOhzC3rx~CT2geju7RUQhJGP@~pAwj|8MhLi0(yH&>_cowo0xRHp&S68jbM z7ldjpV+QkrH4bRLg*bM=OzuVC{o}1n0sSMcu*M!9er&v0Z<%S?>Wo_&Hh4Lzp3HC~ zXVgtT@+l?Ae~qWrkTPn?rCdkNYFNX8$AQixlt*){7;KVP^hM%Q)f#Am7c#Z9Fp~Iq zJu;>BR|-pgzcc*VK#Y43grz>~^q0|F_Y$MS-xv3+ls>dTovw)R4L0I?arma`OY(JA z8a8}u0d6R)z*FU$NK|z$w=_Js-=?j*<)Rjpbh_O4qdDhvbw;sBt&)%(F#(m&88t7} zcMH!IuD`Hfm16%U6RXr>M|&LL%6{gJ-tHrA7MyxM3j^zx8r%0Pd@E zAeONi^k*Xq?UN?a(K1}`fVs_yGF)^rz~{IDIS=hhg+lFjnSq5wUNjMTjUG4A1Ph=u z)&1rYD=%=Y5EuOcZxM@ImdisnqTY;f5VF=BVF-~t=)KGe>wqx|w!s;qL_wGr6a6Bn zkBAJLh#iqgvVT#dHu`z(l8c_1!}D#asvU zE7Q!hZe^l*iy*cMrflI8fN_;19wOw880q1JYF`U!VasgxO*sejk#%?Uf*58XlGu`V zod=y2EQEz@RxSs*i#Rd^K{~!jei+(FDP!scb5!wjf-6XJGF>sya&l5YjRi;`m1H9_ zEEbI82bK)s`k068umcinOg@zbTa~`SHJ-?XU<1%MH-G}ioKh;d-vs<^-Nd1*(lOB_AAXB_;)nW9I-{!L+K3cjqIS=*!M2(NR-sC8+b&Y!0SLd~ z@ " + }, + "usage": { + "url": "https://cloud.zed.dev/client/users/me" + }, + "modelsUrl": "https://cloud.zed.dev/models" + }, + "api-airforce": { + "baseUrl": "https://api.airforce/v1/chat/completions", + "validateUrl": "https://api.airforce/v1/models", + "headers": { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy" + }, + "format": "openai" + }, + "baidu": { + "baseUrl": "https://qianfan.baidubce.com/v2/chat/completions", + "validateUrl": "https://qianfan.baidubce.com/v2/models", + "format": "openai" + }, + "bazaarlink": { + "baseUrl": "https://bazaarlink.ai/api/v1/chat/completions", + "validateUrl": "https://bazaarlink.ai/api/v1/models", + "format": "openai" + }, + "bluesminds": { + "baseUrl": "https://api.bluesminds.com/v1/chat/completions", + "validateUrl": "https://api.bluesminds.com/v1/models", + "format": "openai" + }, + "kilo-gateway": { + "baseUrl": "https://api.kilo.ai/api/gateway/chat/completions", + "validateUrl": "https://api.kilo.ai/api/gateway/models", + "format": "openai" + }, + "llm7": { + "baseUrl": "https://api.llm7.io/v1/chat/completions", + "validateUrl": "https://api.llm7.io/v1/models", + "format": "openai" + }, + "sambanova": { + "baseUrl": "https://api.sambanova.ai/v1/chat/completions", + "validateUrl": "https://api.sambanova.ai/v1/models", + "format": "openai" + }, + "tencent": { + "baseUrl": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + "validateUrl": "https://api.hunyuan.cloud.tencent.com/v1/models", + "format": "openai" + }, + "morph": { + "baseUrl": "https://api.morphllm.com/v1/chat/completions", + "validateUrl": "https://api.morphllm.com/v1/models", + "format": "openai" + }, + "devin-cli": { + "baseUrl": "devin://acp/stdio", + "format": "openai" } -} +} \ No newline at end of file diff --git a/tests/__baseline__/verify-alias.mjs b/tests/__baseline__/verify-alias.mjs index 56caca53..a3fc1f91 100644 --- a/tests/__baseline__/verify-alias.mjs +++ b/tests/__baseline__/verify-alias.mjs @@ -20,6 +20,9 @@ const ALIAS_TOKENS = [ "xmtp","xiaomi-tokenplan","cf", "cloudflare-ai","fal","fal-ai","stability","stability-ai","bfl","black-forest-labs","recraft", "topaz","runway","runwayml","jina","jina-ai","polly","aws-polly","bb","blackbox", + "af","airforce","api-airforce","llm7","llm-7","samba","sambanova","bm","bluesminds", + "bzl","bazaarlink","kgw","kilo-gateway","hunyuan","tencent","qianfan","baidu","ernie", + "dv","devin","devin-cli","morph","morphllm", ]; // Sort idToAlias by key — runtime accesses by key, order is irrelevant (content-based) From 72ec06a81dc92857dfa2b714fee65f5469566846 Mon Sep 17 00:00:00 2001 From: decolua Date: Sun, 26 Jul 2026 10:03:22 +0700 Subject: [PATCH 13/34] feat(cli-tools): add Devin CLI provider with ACP stdio executor Wire Devin CLI as a routed provider that spawns the local `devin acp` binary. Add the DevinCliExecutor, register it in the executor map, expose its status through the cli-tools batch endpoint and devin-settings route, and document setup in cliTools constants. Co-Authored-By: Claude Fable 5 --- open-sse/executors/devin-cli.js | 442 ++++++++++++++++++ open-sse/executors/index.js | 3 + open-sse/providers/registry/devin-cli.js | 62 +++ public/providers/devin-cli.png | Bin 0 -> 2525 bytes src/app/api/cli-tools/all-statuses/route.js | 2 + src/app/api/cli-tools/devin-settings/route.js | 78 ++++ src/shared/constants/cliTools.js | 26 ++ 7 files changed, 613 insertions(+) create mode 100644 open-sse/executors/devin-cli.js create mode 100644 open-sse/providers/registry/devin-cli.js create mode 100644 public/providers/devin-cli.png create mode 100644 src/app/api/cli-tools/devin-settings/route.js diff --git a/open-sse/executors/devin-cli.js b/open-sse/executors/devin-cli.js new file mode 100644 index 00000000..e470b5ac --- /dev/null +++ b/open-sse/executors/devin-cli.js @@ -0,0 +1,442 @@ +/** + * 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` (summarizer = no FS 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. + * + * Auth: credentials.apiKey / accessToken → WINDSURF_API_KEY env var passed to + * devin. If unset, devin falls back to credentials stored by `devin auth login`. + * + * Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths. + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { BaseExecutor } from "./base.js"; + +// ─── Binary discovery ──────────────────────────────────────────────────────── + +function resolveDevinBin() { + // 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 helper ──────────────────────────────────────────────────── + +function rpc(method, params, id) { + const msg = { jsonrpc: "2.0", method, params }; + if (id !== undefined) msg.id = id; + return JSON.stringify(msg) + "\n"; +} + +// ─── Multi-turn message → single prompt builder ───────────────────────────── + +function buildPromptText(messages) { + // Devin CLI (summarizer mode) receives a single text prompt. + // Inline the whole conversation so the model has full context. + const lines = []; + 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.type === "text") { + text += String(p.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: "devin://acp/stdio" }); + } + + buildUrl() { + return "devin://acp/stdio"; + } + + buildHeaders() { + return {}; + } + + transformRequest() { + return null; + } + + async execute({ model, body, credentials, signal, log }) { + const b = body ?? {}; + const messages = Array.isArray(b.messages) ? b.messages : []; + const promptText = buildPromptText(messages); + const apiKey = + credentials.apiKey || credentials.accessToken || process.env.WINDSURF_API_KEY || ""; + const devinBin = resolveDevinBin(); + + log?.info?.("DEVIN", `devin acp → model=${model}, bin=${devinBin}`); + + const sseStream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const emit = (data) => controller.enqueue(enc.encode(data)); + + const env = { ...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 = 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 = null; + let initDone = false; + let sessionCreated = false; + let promptSent = false; + const responseId = `chatcmpl-devin-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let roleEmitted = false; + let totalText = ""; + let finished = false; + + const sendRpc = (method, params) => { + 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) => { + 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 += chunk.toString("utf8"); + let nl; + // 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; + 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 || {}; + sessionId = res.sessionId || 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; + if (!params) continue; + + const type = params.type; + + if (type === "message_delta" || type === "text_delta" || type === "content_delta") { + const delta = + params.content || params.delta || params.text || ""; + 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 || 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 && res.stopReason) || ""; + 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) => { + 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: "9router", 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?.messages }, + }; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Extract text from a final ACP session/prompt result object across common shapes. +function extractResultText(result) { + // { 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; + if (msg && typeof msg.content === "string") return msg.content; + const msgs = result.messages; + if (Array.isArray(msgs)) { + return msgs + .filter((m) => m.role === "assistant") + .map((m) => String(m.content || "")) + .join("\n"); + } + return ""; +} + +export default DevinCliExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 7191facd..92e10464 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -25,6 +25,7 @@ import TraeExecutor from "./trae.js"; import ZedExecutor from "./zed.js"; import WindsurfExecutor from "./windsurf.js"; import { DefaultExecutor } from "./default.js"; +import { DevinCliExecutor } from "./devin-cli.js"; const executors = { antigravity: new AntigravityExecutor(), @@ -58,6 +59,7 @@ const executors = { trae: new TraeExecutor(), zed: new ZedExecutor(), windsurf: new WindsurfExecutor(), + "devin-cli": new DevinCliExecutor(), }; const defaultCache = new Map(); @@ -100,3 +102,4 @@ export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; export { default as TraeExecutor } from "./trae.js"; export { default as ZedExecutor } from "./zed.js"; export { default as WindsurfExecutor } from "./windsurf.js"; +export { DevinCliExecutor } from "./devin-cli.js"; diff --git a/open-sse/providers/registry/devin-cli.js b/open-sse/providers/registry/devin-cli.js new file mode 100644 index 00000000..173b559b --- /dev/null +++ b/open-sse/providers/registry/devin-cli.js @@ -0,0 +1,62 @@ +export default { + id: "devin-cli", + alias: "dv", + aliases: ["devin"], + uiAlias: "dv", + display: { + name: "Devin CLI", + icon: "smart_toy", + color: "#6366F1", + textIcon: "DV", + website: "https://devin.ai", + notice: { + signupUrl: "https://cli.devin.ai", + text: "Install the Devin CLI and run `devin auth login` first. Spawns the `devin` binary via ACP/stdio — no API key field.", + }, + }, + category: "free", + authType: "none", + noAuth: true, + authModes: ["none"], + transport: { + baseUrl: "devin://acp/stdio", + format: "openai", + }, + models: [ + { 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: "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 }, + { 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 }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { 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 }, + { 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 }, + { 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 }, + { 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: "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-flash-high", name: "Gemini 3 Flash High", contextLength: 1000000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 }, + { 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 }, + ], +}; diff --git a/public/providers/devin-cli.png b/public/providers/devin-cli.png new file mode 100644 index 0000000000000000000000000000000000000000..1fe62d2374323b529977da2c8dd1aa868725542b GIT binary patch literal 2525 zcmV<32_p81P)!d0sVwDiVq?+0LwqjRj$f|r zYoBv`pPv*dcJ4hsH_!h*=ef^$&aneUz*XP4$?$RlKyxuZ#|8uebg>s|Bs^#J2RN*3 z%vImm51<3Uc97AnCJ5jP0KXaud#!5J5&^jC8*jybC8L*yAONV5a7&>LTEZ*8s$A?l5Hi$OG7mAb>9B0m%6FqZyze(DpcJ%GcHe9hCgBH#7v; z91?)dApzJN5`fJq3jwInMc!SI7HI{bMi+I%dpw?m*Mxbb6o49CgvaB-$cPuw#sA{+ z#21+MU&Fw_J`zHXNR8!D=?a{GuiVu3m6$4!_lL!kO%-&R#mV46Yov{4~>7;`vIPK;_;$= zPHpWv{PM{saqr$eO#A()saC9&WU^ryGhANLw$!jOnUt$T%(ScDJLgtqr|B z-PrlevkB*kj%_^v=I7_}?{B{auo5J;wzlS7h@vO~T%DZ4(W9>b5Z4*+Lh-v?|qSXTAil)U179%T5JNMBAA?p)`sRsan<>e)O=sk<7s%rdX<3`lhuH)LD zHEY%s+(u<(B|M%T=-j>y|G9G)bF(*uszDIS12EW~^3L0Dp|_`-cXuo!6k5RV`hPD} zNt#d|fWhvN^XJba5cn51Zu~JGee_3pwG|32;Nwp|!Ni0QUw!4r&6_t7(_(qIR|vJW z>+snf*@VDW2le$m+!#BRyJcq*yA{ZSTM_*q*4FS1ORb8Y1T)OlnZp_Z% z!S@gF^R~8x;}j);H~)GX$BrF`tFA#mCtaJ%%S-s~{(WrQw#|}&&kQp&*M%Aagh>RM zg$)aRef`*f;2;zwfKX@w`wtw%&S##*@bDSacbBWK0mH**Ff%j5`?9zpbPSLVX5iuB zGkO}Ay`PV3y*fE1Ozxr7Naq2JFoWl7c*f-96f`Zy`*OG-wE&31k#Y!NSXG$O+G^iL zBGL-LxQKMah9-T{Cy=`k*xbGykxl^Vb_v^!9Y)o4Mhi=F86Vl2n%3j} z_ugH#X;ktL6OvsY{}@6K0ogW%%7n2@>+0kbCMTx=tOR^+cY8s*j+7HZ&IPbB-0BG% zD+)Xc{ro??@M2Ec%3>p&2Poz)Av}$5+^|X%;{$>TK#eY9XlT&#y@7c1`WtWR)6yat z9}s8=V0S*RqEJnXAsUThetuqmudS^u@4m2UY4%tOh*=malm`HSXf#@K0VqmfWe2f% z;U@5~XvFPq&$%p(wI{>=gi<^P7L(Djact?>nqVyBY-3+vKX!Kg8j2Fg+7_FZX6+R^ zNmUAj5de>Wl%fRm>YP-c&))a>eAv>l75fhyL@2bd>Nrh{VRUR9k8k>!G=^sgGX#`+ z6^*9FaPrh^rj9jwg!%bfc>3vI>4B=E1f<@DRG1(Tfa%Sktw2!%*wV2zXM-gNbRq|M zX0WXXU`xPqCE3uj8lGX>7~t;R5bsMC0$~Io!%Et6L>K|c?n-4v7y*z6;Day%AP>L? zVFW-PfDgh5fII*ngb@IF06qw_IJBy|26MAF@WV$QDf;dpqhsSZbm%a79S9@<09jZ6 z7K)+-FgWzOzIvDtY%YLd{7zsiO2dp_6a`ScVMIs)Fbb&(2I#9~%^=0(9+UE0bZU+ zD*%S<6*;^SrXna`$nb?BOe&b*0|~(9kN|8B3Bcx500{E#1i_791%NKzy&$;JMMHqi zVFI9Gz2{x`YzF1z;zOJfXdmF%W zyn9I$g}rJdoZtZ*sg9yppgb{-{fu-^0h>YD!Ez#q6`QWYk?ssved8tov%GswSiMctc>}lz6{Zn z2VhWOC|FLrt00000NkvXXu0mjftr4XB literal 0 HcmV?d00001 diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index c3ac832b..5925e526 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -14,6 +14,7 @@ import { GET as kiloGet } from "../kilo-settings/route"; import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; import { GET as jcodeGet } from "../jcode-settings/route"; import { GET as grokBuildGet } from "../grok-build-settings/route"; +import { GET as devinGet } from "../devin-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -29,6 +30,7 @@ const STATUS_GETTERS = { "deepseek-tui": deepseekTuiGet, jcode: jcodeGet, "grok-build": grokBuildGet, + devin: devinGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/devin-settings/route.js b/src/app/api/cli-tools/devin-settings/route.js new file mode 100644 index 00000000..1679ef92 --- /dev/null +++ b/src/app/api/cli-tools/devin-settings/route.js @@ -0,0 +1,78 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +// Mirror the executor's resolveDevinBin discovery so the dashboard's status +// matches what the runtime actually spawns. +const candidateDevinPaths = () => { + const home = os.homedir(); + const paths = [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + ]; + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.exe")); + } + return paths; +}; + +const checkDevinInstalled = async () => { + // 1. PATH lookup + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where devin" : "which devin"; + await execAsync(command, { windowsHide: true }); + return { installed: true, source: "path" }; + } catch { + // fall through to filesystem probes + } + // 2. Known installer paths + for (const candidate of candidateDevinPaths()) { + try { + await fs.access(candidate); + return { installed: true, source: candidate }; + } catch { /* keep probing */ } + } + return { installed: false, source: null }; +}; + +const readDevinVersion = async () => { + try { + const { stdout } = await execAsync("devin --version", { windowsHide: true }); + return stdout.trim().split("\n")[0] || null; + } catch { + return null; + } +}; + +// GET — install detection only. No config to write: the binary handles its own auth. +export async function GET() { + try { + const { installed, source } = await checkDevinInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + message: "Devin CLI is not installed. Install it from https://cli.devin.ai and run `devin auth login`.", + installUrl: "https://cli.devin.ai", + }); + } + const version = await readDevinVersion(); + return NextResponse.json({ + installed: true, + source, + version, + message: "Devin CLI detected. Make sure `devin auth login` has been run.", + }); + } catch (error) { + console.log("Error checking devin settings:", error); + return NextResponse.json({ error: "Failed to check devin settings" }, { status: 500 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index fc49d36a..78ce98d0 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -390,6 +390,32 @@ amp --model "{{model}}" }, ], }, + devin: { + id: "devin", + name: "Devin CLI", + image: "/providers/devin-cli.png", + color: "#6366F1", + description: "Cognition Devin CLI — local binary called by the Devin CLI provider via ACP/stdio", + configType: "guide", + installUrl: "https://cli.devin.ai", + notes: [ + { type: "info", text: "This is a local dependency, not a routed CLI. The Devin CLI provider spawns `devin acp --agent-type summarizer` and relays its output." }, + { type: "warning", text: "Install the Devin CLI and run `devin auth login` — without it, the provider returns a spawn error on first request." }, + ], + guideSteps: [ + { step: 1, title: "Install Devin CLI", desc: "Install via the official installer at cli.devin.ai.", docsUrl: "https://cli.devin.ai" }, + { step: 2, title: "Authenticate", desc: "Log in once so the binary stores its own credentials." }, + { step: 3, title: "Use the provider", desc: "Pick any Devin CLI model under the Providers tab — no API key field needed." }, + ], + codeBlock: { + language: "bash", + code: `# Install Devin CLI (see https://cli.devin.ai for options) +devin auth login + +# Verify detection (optional) +devin --version`, + }, + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli", From 65ac9b3cecffd8e201aad50ebcbf8b6b0170b0b8 Mon Sep 17 00:00:00 2001 From: decolua Date: Sun, 26 Jul 2026 10:03:31 +0700 Subject: [PATCH 14/34] fix(ui): prevent hidden quota row from overflowing Use w-full instead of min-w-0/flex-1 + overflow-x-auto so the hidden quota chips wrap cleanly instead of stretching the row. Co-Authored-By: Claude Fable 5 --- .../dashboard/usage/components/ProviderLimits/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index a0396897..b573272e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1270,7 +1270,7 @@ export default function ProviderLimits() { visibility_off Hidden: -
+
{hiddenQuotaRows.map((quotaRow) => (
))} + {/* Context Window */} +
+ Context window + arrow_forward + +
+ {/* CC Filter Naming */}
Filter naming diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index b831e395..76ba9232 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -123,7 +123,7 @@ export async function GET() { // POST - Backup old fields and write new settings export async function POST(request) { try { - const { env, exaMcpEnabled } = await request.json(); + const { env, exaMcpEnabled, maxContextTokens } = await request.json(); if (!env || typeof env !== "object") { return NextResponse.json( @@ -166,6 +166,14 @@ export async function POST(request) { }, }; + // CLAUDE_CODE_MAX_CONTEXT_TOKENS — only set when a concrete value is chosen; + // "Default" removes the key so Claude Code falls back to the model's window. + if (maxContextTokens) { + newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(maxContextTokens); + } else { + delete newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS; + } + // Write new settings await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); @@ -195,6 +203,7 @@ const RESET_ENV_KEYS = [ "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "API_TIMEOUT_MS", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", ]; // DELETE - Reset settings (remove env fields) From 15dfd864168db2c65b51895e7b7c4f210bb585b6 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 29 Jul 2026 18:10:33 +0700 Subject: [PATCH 21/34] fix(ui): flex quota rows and thin global scrollbars Replace the fixed-table quota layout with flex rows that shrink cleanly, keep the hidden-quota chip row from overflowing, and use thin mac-like scrollbars app-wide. Co-Authored-By: Claude Fable 5 --- .../components/ProviderLimits/QuotaTable.js | 187 +++++++++--------- .../usage/components/ProviderLimits/index.js | 2 +- src/app/globals.css | 31 +++ 3 files changed, 124 insertions(+), 96 deletions(-) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 299445ef..9f18e5d1 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -148,103 +148,100 @@ export default function QuotaTable({ )}
-
- - - {currentPageRows.map((quota) => { - const colors = getColorClasses(quota.remaining); - const countdown = formatResetTime(quota.resetAt); - const resetDisplay = formatResetTimeDisplay(quota.resetAt); - // recurring defaults true: a missing flag means the quota - // refreshes at resetAt. Bonus/one-shot packs set recurring:false - // and their resetAt is a hard expiry, so word it as "expires". - const recurring = quota.recurring !== false; - const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`; +
+ {currentPageRows.map((quota) => { + const colors = getColorClasses(quota.remaining); + const countdown = formatResetTime(quota.resetAt); + const resetDisplay = formatResetTimeDisplay(quota.resetAt); + // recurring defaults true: a missing flag means the quota + // refreshes at resetAt. Bonus/one-shot packs set recurring:false + // and their resetAt is a hard expiry, so word it as "expires". + const recurring = quota.recurring !== false; + const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`; - return ( -
+ {/* Name */} +
+ {colors.emoji} + + {quota.name} + +
+ + {/* Progress + used/total */} +
+
+
+
+ +
+ 0 ? quota.total.toLocaleString() : "∞"}`} + > + {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} + + + {quota.remaining}% + +
+
+ + {/* Reset time */} +
+ {countdown !== "-" || resetDisplay ? ( + compact ? ( +
+ {countdown !== "-" ? countdownLabel : resetDisplay} +
+ ) : ( +
+ {countdown !== "-" && ( +
+ {countdownLabel} +
+ )} + {resetDisplay && ( +
+ {resetDisplay} +
+ )} +
+ ) + ) : ( +
N/A
+ )} +
+ + {/* Hide action */} + {hasHideAction && ( +
- - - - - - {hasHideAction && ( - - )} - - ); - })} - -
-
- {colors.emoji} - - {quota.name} - -
-
-
-
-
-
- -
- - {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} - - - {quota.remaining}% - -
-
-
- {countdown !== "-" || resetDisplay ? ( - compact ? ( -
- {countdown !== "-" ? countdownLabel : resetDisplay} -
- ) : ( -
- {countdown !== "-" && ( -
- {countdownLabel} -
- )} - {resetDisplay && ( -
- {resetDisplay} -
- )} -
- ) - ) : ( -
N/A
- )} -
- -
+ + visibility_off + + + )} +
+ ); + })}
{totalPages > 1 && ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index b573272e..eb486cc0 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1270,7 +1270,7 @@ export default function ProviderLimits() { visibility_off Hidden: -
+
{hiddenQuotaRows.map((quotaRow) => (
- {oauthEntries.map(([key, info]) => ( - handleToggleProvider(key, "oauth", active)} - /> - ))} + {oauthEntries.map(([key, info]) => { + const authTypes = dualAuthTypes(info, key); + return ( + handleToggleProvider(key, authTypes, active)} + /> + ); + })}
)} @@ -466,12 +479,9 @@ export default function ProvidersPage() {
{freeEntries.map(([key, info]) => { - // Kiro accepts both OAuth and api-key connections; count/toggle both - // so the card total matches the provider detail page (#kiro-apikey). - // Kiro's headless api-key flow persists authType "api_key" (underscore), - // while generic apikey providers use "apikey" — include both spellings. - const freeAuthTypes = - key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth"; + // Dual-auth (e.g. kiro): count/toggle oauth + apikey/api_key so the + // card total matches the provider detail page. + const freeAuthTypes = dualAuthTypes(info, key); return ( Date: Wed, 29 Jul 2026 19:25:45 +0700 Subject: [PATCH 23/34] fix(kiro): canonicalize tool history and route API keys correctly Route API-key inference through Amazon Q first, enforce adjacent one-to-one tool use/result pairs after session replay, and treat payload-invalid HTTP 400 as terminal. --- open-sse/config/kiroConstants.js | 6 + open-sse/executors/base.js | 2 +- open-sse/executors/kiro.js | 47 +- open-sse/providers/registry/kiro.js | 1 - .../translator/concerns/kiroConversation.js | 435 ++++++++++++++++++ open-sse/translator/index.js | 9 +- open-sse/translator/request/claude-to-kiro.js | 217 +-------- open-sse/translator/request/openai-to-kiro.js | 243 +--------- open-sse/utils/kiroSessionReplay.js | 23 +- src/app/api/oauth/kiro/api-key/route.js | 8 +- src/lib/oauth/services/kiro.js | 52 ++- .../kiro-api-key-endpoint-routing.test.js | 67 +++ ...kiro-conversation-canonicalization.test.js | 372 +++++++++++++++ tests/unit/kiro-profile-arn.test.js | 33 +- 14 files changed, 1050 insertions(+), 465 deletions(-) create mode 100644 open-sse/translator/concerns/kiroConversation.js create mode 100644 tests/unit/kiro-api-key-endpoint-routing.test.js create mode 100644 tests/unit/kiro-conversation-canonicalization.test.js diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 2f97256d..e6408da1 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -20,6 +20,12 @@ import { effortToBudget } from "../translator/concerns/thinking.js"; export const KIRO_AGENTIC_SUFFIX = "-agentic"; export const KIRO_THINKING_SUFFIX = "-thinking"; +export const KIRO_TOOL_NAME_MAX_LENGTH = 64; +export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237; +export const KIRO_TOOL_ID_MAX_LENGTH = 64; +export const KIRO_CODEWHISPERER_TARGET = + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; +export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]); // Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method. // Used when an account cannot resolve its own profileArn. Builder ID and social diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 71418deb..5a3c4467 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -126,7 +126,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream); + const headers = this.buildHeaders(credentials, stream, url); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 90ec713e..1205522b 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -1,6 +1,10 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; -import { resolveKiroModel } from "../config/kiroConstants.js"; +import { + KIRO_CODEWHISPERER_TARGET, + KIRO_ENDPOINT_FALLBACK_STATUSES, + resolveKiroModel, +} from "../config/kiroConstants.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; @@ -216,12 +220,17 @@ export class KiroExecutor extends BaseExecutor { super("kiro", PROVIDERS.kiro); } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url = "") { const headers = { ...this.config.headers, "Amz-Sdk-Request": "attempt=1; max=3", "Amz-Sdk-Invocation-Id": uuidv4() }; + if (url.includes("://codewhisperer.")) { + headers["X-Amz-Target"] = KIRO_CODEWHISPERER_TARGET; + } else { + delete headers["X-Amz-Target"]; + } // API-key auth: the key is stored as accessToken and sent as a bearer token // exactly like an OAuth access token, but with an extra `tokentype: API_KEY` @@ -236,8 +245,8 @@ export class KiroExecutor extends BaseExecutor { const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null); if (isApiKey && apiKey) { headers["Authorization"] = `Bearer ${apiKey}`; - headers["tokentype"] = "API_KEY"; - } else if (credentials.accessToken) { + headers["TokenType"] = "API_KEY"; + } else if (credentials?.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; if (isExternalIdp) { headers["TokenType"] = "EXTERNAL_IDP"; @@ -250,14 +259,14 @@ export class KiroExecutor extends BaseExecutor { /** * Auth-aware endpoint ordering. * - * API-key Kiro connections store a raw CodeWhisperer credential (validated - * against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles). + * API-key Kiro connections use the Amazon Q surface. The legacy + * codewhisperer.* GenerateAssistantResponse endpoint can authenticate the key + * but rejects the same valid payload with REQUEST_BODY_INVALID. Since a 400 + * is terminal in BaseExecutor, putting CodeWhisperer first prevents the working + * q.* endpoint from ever being tried. Keep q.* first only for api_key accounts. + * * The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens - * and rejects an `tokentype: API_KEY` token with 401/403 — which - * BaseExecutor.execute() returns immediately (only 429 / network errors fall - * through to the next host). So for api-key auth we must try the *.amazonaws.com - * CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never - * routes api-key traffic through kiro.dev. External IdP enterprise tokens also + * and rejects TokenType=API_KEY. External IdP enterprise tokens instead * use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header. * Other OAuth methods keep the default order (kiro.dev first) since their * tokens are what that gateway accepts. @@ -282,6 +291,14 @@ export class KiroExecutor extends BaseExecutor { const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize); const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); + if (authMethod === "api_key") { + const q = amazon.filter((u) => u.includes("://q.")); + const remaining = amazon.filter((u) => !u.includes("://q.")); + return q.length > 0 + ? [...q, ...remaining, ...others] + : [...amazon, ...others]; + } + return amazon.length > 0 ? [...amazon, ...others] : baseUrls; } @@ -290,6 +307,14 @@ export class KiroExecutor extends BaseExecutor { return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; } + // Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be + // terminal: sending the same malformed body to every surface cannot repair it. + shouldRetry(status, urlIndex) { + const hasFallback = urlIndex + 1 < this.getFallbackCount(); + return super.shouldRetry(status, urlIndex) + || (hasFallback && KIRO_ENDPOINT_FALLBACK_STATUSES.has(status)); + } + transformRequest(model, body, stream, credentials) { return body; } diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index 1a47adaa..325745a4 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -29,7 +29,6 @@ export default { headers: { "Content-Type": "application/json", Accept: "application/vnd.amazon.eventstream", - "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0", }, diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js new file mode 100644 index 00000000..11d49dc7 --- /dev/null +++ b/open-sse/translator/concerns/kiroConversation.js @@ -0,0 +1,435 @@ +import { + KIRO_TOOL_DESCRIPTION_MAX_LENGTH, + KIRO_TOOL_ID_MAX_LENGTH, + KIRO_TOOL_NAME_MAX_LENGTH, +} from "../../config/kiroConstants.js"; + +const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g; + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function text(value) { + if (typeof value === "string") return value; + if (value == null) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function appendText(target, extra) { + if (!extra) return; + target.content = target.content ? `${target.content}\n\n${extra}` : extra; +} + +function trimCodePoints(value, limit) { + return [...String(value || "")].slice(0, limit).join(""); +} + +function uniqueName(rawName, index, usedNames) { + const cleaned = String(rawName || "") + .trim() + .replace(TOOL_NAME_PATTERN, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH); + let candidate = base; + let suffix = 2; + while (usedNames.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`; + } + usedNames.add(candidate); + return candidate; +} + +function cleanSchemaValue(value) { + if (Array.isArray(value)) return value.map(cleanSchemaValue); + if (!value || typeof value !== "object") return value; + + const cleaned = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "additionalProperties") continue; + if (key === "required" && Array.isArray(child) && child.length === 0) continue; + cleaned[key] = cleanSchemaValue(child); + } + return cleaned; +} + +function normalizeRootSchema(schema) { + const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {}); + cleaned.type = "object"; + if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) { + cleaned.properties = {}; + } + if (Array.isArray(cleaned.required)) { + cleaned.required = [...new Set(cleaned.required.filter( + (name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name) + ))]; + if (cleaned.required.length === 0) delete cleaned.required; + } + return cleaned; +} + +/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */ +export function normalizeKiroToolSpecs(tools) { + const specs = []; + const nameMap = new Map(); + const usedNames = new Set(); + + for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) { + if (!tool || typeof tool !== "object") continue; + const rawName = tool.function?.name ?? tool.name; + if (typeof rawName !== "string" || !rawName.trim()) continue; + + // A repeated definition with the same source name describes the same tool. + if (nameMap.has(rawName)) continue; + const name = uniqueName(rawName, index, usedNames); + nameMap.set(rawName, name); + + const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`; + const description = trimCodePoints( + String(rawDescription || `Tool: ${rawName}`), + KIRO_TOOL_DESCRIPTION_MAX_LENGTH + ); + const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {}; + specs.push({ + toolSpecification: { + name, + description, + inputSchema: { json: normalizeRootSchema(schema) }, + }, + }); + } + + return { specs, nameMap }; +} + +function toolCallText(toolUse) { + return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`; +} + +function toolResultText(toolResult) { + const content = Array.isArray(toolResult?.content) + ? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n") + : text(toolResult?.content); + return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`; +} + +function mergeUser(target, source) { + appendText(target, source.content); + if (Array.isArray(source.images) && source.images.length > 0) { + target.images = [...(target.images || []), ...source.images]; + } + const results = source.userInputMessageContext?.toolResults; + if (Array.isArray(results) && results.length > 0) { + target.userInputMessageContext ||= {}; + target.userInputMessageContext.toolResults = [ + ...(target.userInputMessageContext.toolResults || []), + ...results, + ]; + } +} + +function mergeAssistant(target, source) { + appendText(target, source.content); + if (Array.isArray(source.toolUses) && source.toolUses.length > 0) { + target.toolUses = [...(target.toolUses || []), ...source.toolUses]; + } +} + +function normalizeTurns(history, currentMessage, modelId) { + const rawTurns = [...(Array.isArray(history) ? history : [])]; + if (currentMessage) rawTurns.push(currentMessage); + const turns = []; + + for (const raw of rawTurns) { + const isUser = !!raw?.userInputMessage; + const isAssistant = !!raw?.assistantResponseMessage; + if (isUser === isAssistant) continue; + + const turn = isUser + ? { userInputMessage: clone(raw.userInputMessage) } + : { assistantResponseMessage: clone(raw.assistantResponseMessage) }; + const previous = turns[turns.length - 1]; + if (turn.userInputMessage && previous?.userInputMessage) { + mergeUser(previous.userInputMessage, turn.userInputMessage); + } else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) { + mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage); + } else { + turns.push(turn); + } + } + + if (turns[0]?.assistantResponseMessage) { + turns.unshift({ userInputMessage: { content: "continue", modelId } }); + } + if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) { + turns.push({ userInputMessage: { content: "continue", modelId } }); + } + + for (const turn of turns) { + if (turn.userInputMessage) { + turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue"; + turn.userInputMessage.modelId ||= modelId; + if (turn.userInputMessage.userInputMessageContext?.tools) { + delete turn.userInputMessage.userInputMessageContext.tools; + } + } else { + turn.assistantResponseMessage.content = + text(turn.assistantResponseMessage.content).trim() || "..."; + } + } + return turns; +} + +function rawId(value) { + return typeof value === "string" ? value : ""; +} + +function reserveToolId(value, turnIndex, callIndex, name, usedIds) { + const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, ""); + const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`; + const base = trimCodePoints( + TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated, + KIRO_TOOL_ID_MAX_LENGTH + ); + let candidate = base; + let suffix = 2; + while (usedIds.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`; + } + usedIds.add(candidate); + return candidate; +} + +function normalizeToolInput(input) { + if (input && typeof input === "object" && !Array.isArray(input)) return clone(input); + if (typeof input === "string") { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + return null; + } + } + return input == null ? {} : null; +} + +function normalizeToolResult(result) { + const content = Array.isArray(result?.content) + ? result.content.map((part) => ({ text: text(part?.text ?? part) })) + : [{ text: text(result?.content) }]; + return { + toolUseId: rawId(result?.toolUseId), + status: result?.status === "error" ? "error" : "success", + content: content.length > 0 ? content : [{ text: "" }], + }; +} + +function flattenResults(userMessage, results) { + for (const result of results) appendText(userMessage, toolResultText(result)); +} + +function cleanUserContext(userMessage) { + const context = userMessage.userInputMessageContext; + if (!context) return; + if (!context.toolResults?.length) delete context.toolResults; + if (!context.tools?.length) delete context.tools; + if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext; +} + +function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) { + const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : []; + const results = Array.isArray(user.userInputMessageContext?.toolResults) + ? user.userInputMessageContext.toolResults.map(normalizeToolResult) + : []; + if (calls.length === 0) { + if (results.length > 0) { + flattenResults(user, results); + repairs.orphanResults += results.length; + } + if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + return; + } + + const callQueues = new Map(); + const callRecords = calls.map((call, callIndex) => { + const key = rawId(call?.toolUseId); + const mappedName = nameMap.get(call?.name) || call?.name; + const input = normalizeToolInput(call?.input); + const record = { call, callIndex, key, mappedName, input, result: null }; + const queue = callQueues.get(key) || []; + queue.push(record); + callQueues.set(key, queue); + return record; + }); + + const orphanResults = []; + for (const result of results) { + const queue = callQueues.get(rawId(result.toolUseId)); + const record = queue?.find((candidate) => !candidate.result); + if (record) record.result = result; + else orphanResults.push(result); + } + + const keptCalls = []; + const keptResults = []; + for (const record of callRecords) { + const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName); + const valid = !!record.result && hasSpec && record.input !== null; + if (!valid) { + appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input })); + repairs.missingResults += record.result ? 0 : 1; + repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1; + if (record.result) { + flattenResults(user, [record.result]); + repairs.orphanResults++; + } + continue; + } + + const toolUseId = reserveToolId( + record.key, + turnIndex, + record.callIndex, + record.mappedName, + usedIds + ); + keptCalls.push({ + toolUseId, + name: record.mappedName, + input: record.input, + }); + keptResults.push({ ...record.result, toolUseId }); + } + + if (orphanResults.length > 0) { + flattenResults(user, orphanResults); + repairs.orphanResults += orphanResults.length; + } + + if (keptCalls.length > 0) assistant.toolUses = keptCalls; + else delete assistant.toolUses; + user.userInputMessageContext ||= {}; + if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults; + else delete user.userInputMessageContext.toolResults; + cleanUserContext(user); +} + +/** Validate the final Kiro wire conversation without mutating it. */ +export function validateKiroConversation(history, currentMessage, toolSpecs = []) { + const errors = []; + const turns = [...(history || []), currentMessage].filter(Boolean); + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index++) { + const expectedUser = index % 2 === 0; + const isUser = !!turns[index]?.userInputMessage; + if (isUser !== expectedUser) errors.push(`role:${index}`); + if (!isUser) { + const calls = turns[index].assistantResponseMessage?.toolUses || []; + const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || []; + const callIds = calls.map((call) => call.toolUseId); + const resultIds = results.map((result) => result.toolUseId); + if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) { + errors.push(`pair:${index}`); + } + for (const call of calls) { + if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`); + usedIds.add(call.toolUseId); + if (!specNames.has(call.name)) errors.push(`spec:${index}`); + } + } else if (index === 0) { + const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults; + if (results?.length) errors.push("orphan:0"); + } + } + if (!currentMessage?.userInputMessage?.content) errors.push("current"); + return { valid: errors.length === 0, errors }; +} + +function flattenAllStructuredTools(turns, repairs) { + for (const turn of turns) { + if (turn.assistantResponseMessage?.toolUses?.length) { + for (const call of turn.assistantResponseMessage.toolUses) { + appendText(turn.assistantResponseMessage, toolCallText(call)); + } + repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length; + delete turn.assistantResponseMessage.toolUses; + } + const user = turn.userInputMessage; + const results = user?.userInputMessageContext?.toolResults; + if (results?.length) { + flattenResults(user, results); + repairs.orphanResults += results.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } +} + +/** + * Produce a strict Kiro conversation: alternating turns, current user message, + * adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage. + */ +export function canonicalizeKiroConversation({ + history, + currentMessage, + modelId, + toolSpecs = [], + nameMap = new Map(), +} = {}) { + const turns = normalizeTurns(history, currentMessage, modelId); + const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 }; + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index += 2) { + const user = turns[index].userInputMessage; + if (index === 0) { + const leadingResults = user.userInputMessageContext?.toolResults || []; + if (leadingResults.length > 0) { + flattenResults(user, leadingResults); + repairs.orphanResults += leadingResults.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } + const assistant = turns[index + 1]?.assistantResponseMessage; + const nextUser = turns[index + 2]?.userInputMessage; + if (assistant && nextUser) { + reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs); + } + } + + const finalCurrent = turns[turns.length - 1]; + finalCurrent.userInputMessage.userInputMessageContext ||= {}; + if (toolSpecs.length > 0) { + finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs); + } + cleanUserContext(finalCurrent.userInputMessage); + + let finalHistory = turns.slice(0, -1); + let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + if (!validation.valid) { + flattenAllStructuredTools(turns, repairs); + finalHistory = turns.slice(0, -1); + validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + } + + return { + history: finalHistory, + currentMessage: finalCurrent, + repairs, + valid: validation.valid, + errors: validation.errors, + }; +} diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 37e5bde6..48bd1530 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -62,8 +62,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Always ensure tool_calls have id (some providers require it) ensureToolCallIds(result); - // Fix missing tool responses (insert empty tool_result if needed) - fixMissingToolResponses(result); + // Kiro performs stricter source-aware reconciliation after session replay. + // The generic helper inserts OpenAI `role: tool` messages, which a direct + // Claude→Kiro translator cannot consume and which cannot repair partial + // parallel tool results. + if (targetFormat !== FORMATS.KIRO) { + fixMissingToolResponses(result); + } // Capture thinking intent from the original (pre-translation) body, before any // format conversion strips/renames the fields. Applied after translation. diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 51b9c7f8..8651e819 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -6,17 +6,10 @@ * direct `claude:kiro` route in ../index.js uses; it is NOT reached through the * claude→openai→kiro pivot. * - * It reproduces the two 400-guards that live in openai-to-kiro.js so that a - * Claude client which omits the `tools` array on a follow-up turn (typical - * after client-side compaction) does not trip Kiro's schema validator and get - * "Improperly formed request" (HTTP 400): - * - * 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse - * every tool_use / tool_result block to plain text so no structured tool - * reference survives to trigger the "tools required" rule. - * 2. reconcileOrphanedToolResults — when tools ARE present, fold any - * tool_result whose tool_use_id has no matching tool_use back into the - * user text instead of leaving a dangling structured reference. + * After session replay it delegates to the shared Kiro conversation + * canonicalizer. That layer enforces adjacent one-to-one tool use/results, + * repairs partial parallel calls, and flattens compacted structured references + * that can no longer be represented safely. * * It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and * the `enabled` reasoning trigger, matching @@ -38,82 +31,17 @@ import { } from "../../config/kiroConstants.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; - -/** Stringify a tool_use input as a readable line. */ -function toolUseToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a Claude tool_result block's content as a readable line. */ -function toolResultBlockToText(content) { - let text = ""; - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - text = content - .map((c) => (typeof c === "string" ? c : c?.text || "")) - .filter(Boolean) - .join("\n"); - } else if (content) { - try { - text = JSON.stringify(content); - } catch { - text = ""; - } - } - return `[Tool result: ${text}]`; -} - -/** - * When the client sent no tools, rewrite every tool_use (assistant) and - * tool_result (user) content block into plain text. Keeps text + images. - * Returns a new messages array; never mutates the input. - */ -function flattenClaudeToolInteractions(messages) { - const out = []; - for (const msg of messages) { - if (!msg) continue; - - if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { - const parts = []; - for (const block of msg.content) { - if (block.type === CLAUDE_BLOCK.TEXT && block.text) { - parts.push(block.text); - } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolUseToText(block.name, block.input)); - } - } - out.push({ ...msg, content: parts.join("\n") }); - continue; - } - - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map((block) => - block.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) } - : block - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - return out; -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Convert Claude messages to Kiro history + currentMessage. * Kiro requires alternating user/assistant turns; consecutive same-role * messages are merged. */ -function convertClaudeMessagesToKiro(messages, tools, model) { +function convertClaudeMessagesToKiro(messages, model) { const history = []; let currentMessage = null; @@ -122,27 +50,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjected = false; - - const clientProvidedTools = Array.isArray(tools) && tools.length > 0; - - const buildToolSpecs = () => - tools.map((t) => { - const name = t.name; - const description = t.description || `Tool: ${name}`; - const schema = t.input_schema || {}; - const normalizedSchema = - Object.keys(schema).length === 0 - ? { type: "object", properties: {}, required: [] } - : { ...schema, required: schema.required ?? [] }; - return { - toolSpecification: { - name, - description, - inputSchema: { json: normalizedSchema }, - }, - }; - }); const flushPending = () => { if (currentRole === ROLE.USER) { @@ -157,15 +64,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { toolResults: pendingToolResults, }; } - // Attach tools to the first user turn only. - if (clientProvidedTools && !toolsInjected) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs(); - toolsInjected = true; - } - history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -209,7 +107,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) { } pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: resultContent }], }); } @@ -256,14 +154,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) { } } - // Grab tools from the first history user turn before cleanup strips them. - const firstHistoryTools = - history[0]?.userInputMessage?.userInputMessageContext?.tools; - history.forEach((item) => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if ( item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0 @@ -307,66 +198,9 @@ function convertClaudeMessagesToKiro(messages, tools, model) { currentMessage = { userInputMessage: { content: "", modelId: model } }; } - // Inject tools into currentMessage after cleanup if not already present. - if ( - firstHistoryTools?.length > 0 && - !currentMessage.userInputMessage.userInputMessageContext?.tools - ) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = - firstHistoryTools; - } - return { history: mergedHistory, currentMessage }; } -/** - * Fold orphaned toolResults (those whose toolUseId has no matching toolUse in - * any assistant turn) back into the user text, removing the dangling - * structured reference that makes Kiro 400. - */ -function reconcileOrphanedToolResults(history, currentMessage) { - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - const text = Array.isArray(tr.content) - ? tr.content.map((c) => c?.text || "").join("\n") - : ""; - salvaged.push(`[Tool result: ${text}]`); - } - } - - if (salvaged.length === 0) continue; - - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } - } -} - function extractClaudeSystemText(system) { if (!system) return ""; if (typeof system === "string") return system; @@ -383,9 +217,8 @@ function extractClaudeSystemText(system) { * Build a Kiro payload directly from a Claude Messages API request body. */ export function claudeToKiroRequest(model, body, stream, credentials) { - let messages = Array.isArray(body.messages) ? body.messages : []; + const messages = Array.isArray(body.messages) ? body.messages : []; const tools = Array.isArray(body.tools) ? body.tools : []; - const clientProvidedTools = tools.length > 0; const maxTokens = body.max_tokens || 32000; const temperature = body.temperature; const topP = body.top_p; @@ -397,21 +230,8 @@ export function claudeToKiroRequest(model, body, stream, credentials) { const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - // Guard 1: no client tools → flatten all tool interactions to text. - if (!clientProvidedTools) { - messages = flattenClaudeToolInteractions(messages); - } - - const { history, currentMessage } = convertClaudeMessagesToKiro( - messages, - tools, - upstreamModel - ); - - // Guard 2: tools present → reconcile dangling tool_results. - if (clientProvidedTools) { - reconcileOrphanedToolResults(history, currentMessage); - } + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel); // api_key / idc / external_idp must never use the shared default ARN (belongs // to another account → 403 "bearer token invalid"); OAuth/social fall back to it. @@ -460,7 +280,14 @@ export function claudeToKiroRequest(model, body, stream, credentials) { history, currentMessage, }); - const replayCurrent = replay.currentMessage?.userInputMessage || {}; + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + const replayCurrent = canonical.currentMessage.userInputMessage; const userInputMessage = { content: replayCurrent.content || "", modelId: upstreamModel, @@ -482,7 +309,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) { currentMessage: { userInputMessage, }, - history: replay.history, + history: canonical.history, }, agentMode: "vibe", }; diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 798c827f..aa776949 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -20,148 +20,10 @@ import { import { parseDataUri } from "../concerns/image.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; - -/** Render a single tool call as a readable text line. */ -function toolCallToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a tool result (string or content-block array) as a text line. */ -function toolResultToText(content) { - const text = Array.isArray(content) - ? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n") - : (typeof content === "string" ? content : ""); - return `[Tool result: ${text}]`; -} - -/** - * Flatten all tool calls/results in a conversation into plain text. - * - * Kiro's schema validator requires a non-empty - * currentMessage.userInputMessageContext.tools array whenever the history - * references any tool use; otherwise it returns "Improperly formed request" - * (HTTP 400). A client can hit this by omitting the `tools` array on a - * follow-up request — typically after client-side compaction (e.g. OpenCode). - * - * Rather than fabricate stub tool specs — which would advertise tool-calling - * capability the client never requested and may not handle, risking a phantom - * tool call on an otherwise plain turn — we collapse the tool interaction into - * text. The request stays honest, and since no structured tool content - * remains, the validator's "tools required" rule never fires. - * - * Only invoked when the client did NOT send tools; when tools are present the - * structured form is preserved. - */ -function flattenToolInteractions(messages) { - const out = []; - - for (const msg of messages) { - // OpenAI tool-result message → user text line - if (msg.role === ROLE.TOOL) { - out.push({ role: ROLE.USER, content: toolResultToText(msg.content) }); - continue; - } - - if (msg.role === ROLE.ASSISTANT) { - const parts = []; - if (Array.isArray(msg.content)) { - for (const c of msg.content) { - if (c.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolCallToText(c.name, c.input)); - } else if (c.type === OPENAI_BLOCK.TEXT || c.text) { - parts.push(c.text || ""); - } - } - } else if (typeof msg.content === "string") { - parts.push(msg.content); - } - for (const tc of msg.tool_calls || []) { - parts.push(toolCallToText(tc.function?.name, tc.function?.arguments)); - } - out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") }); - continue; - } - - // User messages: replace tool_result blocks with text, keep text + images. - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map(c => - c.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) } - : c - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - - return out; -} - -/** - * Reconcile orphaned toolResults — those whose toolUseId has no matching - * toolUse in any assistant message. This happens when client-side compaction - * truncates the conversation and removes the assistant message containing the - * tool_use, but keeps the user message with the corresponding tool_result. - * - * A dangling structured reference makes Kiro return 400, so it must be removed. - * But the client deliberately kept the result content through compaction, so - * rather than discard it we fold it back into the user message as text — the - * same shape flattenToolInteractions() produces. The 400 trigger (the - * structured reference) is gone; the content survives. - * - * `messages` is every carrier that can hold toolResults — both history items - * and the popped-out currentMessage (orphans can land on either). - */ -function reconcileOrphanedToolResults(history, currentMessage) { - // Phase 1: collect all valid toolUseIds from assistant messages in history. - // (currentMessage is always a user turn, so it carries no toolUses.) - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - // Phase 2: across history + currentMessage, keep results with a matching - // toolUse and salvage the rest as text. - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - salvaged.push(toolResultToText(tr.content)); - } - } - - if (salvaged.length === 0) continue; // no orphans — leave untouched - - // Fold orphaned result content into the user text so it is not lost - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } - } -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Safely parse JSON string, returning fallback on failure. @@ -177,26 +39,15 @@ function safeJSONParse(str, fallback) { * * Returns { history, currentMessage }. */ -function convertMessages(messages, tools, model) { +function convertMessages(messages, model) { let history = []; let currentMessage = null; - const clientProvidedTools = tools && tools.length > 0; - - // When the client did not send tools, flatten any tool calls/results in the - // history into plain text (see flattenToolInteractions). This keeps the - // request honest and sidesteps Kiro's "tools required" 400, since no - // structured tool content survives to trigger it. - if (!clientProvidedTools) { - messages = flattenToolInteractions(messages); - } - let pendingUserContent = []; let pendingAssistantContent = []; let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjectedToFirstUserMsg = false; const flushPending = () => { if (currentRole === "user") { @@ -219,39 +70,6 @@ function convertMessages(messages, tools, model) { }; } - // Add tools to the user message that has no preceding assistant messages, - // OR the first user message (whichever comes first after any opening - // assistant messages). We track whether any user message has already - // received tools via a flag on the history array. - if (clientProvidedTools && !toolsInjectedToFirstUserMsg) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => { - const name = t.function?.name || t.name; - let description = t.function?.description || t.description || ""; - - if (!description.trim()) { - description = `Tool: ${name}`; - } - - const schema = t.function?.parameters || t.parameters || t.input_schema || {}; - // Normalize schema: Kiro requires required[] and proper type/properties - const normalizedSchema = Object.keys(schema).length === 0 - ? { type: "object", properties: {}, required: [] } - : { ...schema, required: schema.required ?? [] }; - - return { - toolSpecification: { - name, - description, - inputSchema: { json: normalizedSchema } - } - }; - }); - toolsInjectedToFirstUserMsg = true; - } - history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -327,7 +145,7 @@ function convertMessages(messages, tools, model) { pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: text }] }); }); @@ -339,7 +157,7 @@ function convertMessages(messages, tools, model) { const toolContent = typeof msg.content === "string" ? msg.content : ""; pendingToolResults.push({ toolUseId: msg.tool_call_id, - status: "success", + status: msg.is_error || msg.status === "error" ? "error" : "success", content: [{ text: toolContent }] }); } else if (content) { @@ -413,14 +231,8 @@ function convertMessages(messages, tools, model) { } } - // Grab tools from first history item BEFORE cleanup removes them - const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools; - // Clean up history for Kiro API compatibility history.forEach(item => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if (item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { delete item.userInputMessage.userInputMessageContext; @@ -473,33 +285,6 @@ function convertMessages(messages, tools, model) { }; } - // Reconcile orphaned toolResults across history AND currentMessage — when - // client-side compaction removes assistant messages containing tool_use but - // keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the - // content back into the user text instead of discarding it. Run after - // currentMessage is finalized (an orphan can be merged into it) and before - // tool injection (which may re-add userInputMessageContext). - // - // Only needed on the tools-present path: when the client sent no tools, - // flattenToolInteractions already collapsed every toolResult to text, so - // there is nothing structured left to orphan. - if (clientProvidedTools) { - reconcileOrphanedToolResults(mergedHistory, currentMessage); - } - - // Inject tools into currentMessage AFTER cleanup. Tools only exist here when - // the client explicitly sent them (otherwise flattenToolInteractions already - // collapsed all tool content to text upstream, so there is nothing to carry). - const resolvedTools = firstHistoryTools; - - if (resolvedTools?.length > 0 && - !currentMessage.userInputMessage.userInputMessageContext?.tools) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools; - } - return { history: mergedHistory, currentMessage }; } @@ -532,7 +317,8 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertMessages(messages, upstreamModel); // API-key (headless) auth uses a raw CodeWhisperer credential whose profile is // account-specific. Injecting the shared builder-id/social *default* placeholder @@ -586,7 +372,14 @@ export function openaiToKiroRequest(model, body, stream, credentials) { history, currentMessage, }); - const replayCurrent = replay.currentMessage?.userInputMessage || {}; + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + const replayCurrent = canonical.currentMessage.userInputMessage; const payload = { conversationState: { @@ -607,7 +400,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) { }) } }, - history: replay.history + history: canonical.history }, agentMode: "vibe", }; diff --git a/open-sse/utils/kiroSessionReplay.js b/open-sse/utils/kiroSessionReplay.js index 11cae9cd..d758eed6 100644 --- a/open-sse/utils/kiroSessionReplay.js +++ b/open-sse/utils/kiroSessionReplay.js @@ -42,6 +42,14 @@ function findFirstUserIndex(history) { return history.findIndex((item) => item?.userInputMessage); } +function hasToolResults(message) { + return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length; +} + +function canReplaceSessionStart(history, firstUserIndex) { + return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]); +} + function rememberSessionStart(key, entry) { if (sessionStartStore.size >= MAX_SESSION_STARTS) { sessionStartStore.delete(sessionStartStore.keys().next().value); @@ -73,10 +81,13 @@ export function applyKiroSessionReplay({ existing.lastUsed = Date.now(); const firstUserIndex = findFirstUserIndex(baseHistory); const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId); - if (firstUserIndex >= 0) { + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { baseHistory[firstUserIndex] = sessionStart; } else { baseHistory.unshift(sessionStart); + if (baseHistory.length === 1) { + baseHistory.push({ assistantResponseMessage: { content: "..." } }); + } } return { history: ensureHistoryModelIds(baseHistory, modelId), @@ -88,10 +99,18 @@ export function applyKiroSessionReplay({ const firstUserIndex = findFirstUserIndex(baseHistory); let sessionStart; let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId); - if (firstUserIndex >= 0) { + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId); baseHistory[firstUserIndex] = clone(sessionStart); nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); + } else if (firstUserIndex >= 0) { + sessionStart = prefixUserMessage( + { userInputMessage: { content: "", modelId } }, + contentPrefix, + modelId + ); + baseHistory.unshift(clone(sessionStart)); + nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); } else { sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId); nextCurrent = clone(sessionStart); diff --git a/src/app/api/oauth/kiro/api-key/route.js b/src/app/api/oauth/kiro/api-key/route.js index 139df9b5..bd2140b3 100644 --- a/src/app/api/oauth/kiro/api-key/route.js +++ b/src/app/api/oauth/kiro/api-key/route.js @@ -5,8 +5,8 @@ import { createProviderConnection } from "@/models"; /** * POST /api/oauth/kiro/api-key * Import a Kiro API key (headless auth). The key is a long-lived bearer - * credential — there is no refresh token. It is validated by listing - * CodeWhisperer profiles, then stored with authMethod="api_key". + * credential — there is no refresh token. It is validated against the Amazon + * Q model catalog, then stored with authMethod="api_key". */ export async function POST(request) { try { @@ -21,7 +21,7 @@ export async function POST(request) { const kiroService = new KiroService(); - // Validate the key and resolve its profileArn via ListAvailableProfiles + // Validate the key against the same Amazon Q surface used for inference. const credential = await kiroService.validateApiKey( apiKey, region || "us-east-1" @@ -40,7 +40,7 @@ export async function POST(request) { expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), email: email || null, providerSpecificData: { - profileArn: credential.profileArn, + ...(credential.profileArn ? { profileArn: credential.profileArn } : {}), region: credential.region, authMethod: "api_key", provider: "API Key", diff --git a/src/lib/oauth/services/kiro.js b/src/lib/oauth/services/kiro.js index a739661e..f82089d8 100644 --- a/src/lib/oauth/services/kiro.js +++ b/src/lib/oauth/services/kiro.js @@ -260,11 +260,9 @@ export class KiroService { } /** - * List available CodeWhisperer profiles for a token (or API key) and return - * the best-matching profileArn. AWS SSO OIDC logins return no profileArn, so - * it must be fetched separately — the same call works for API-key auth. - * Accepts both `arn` and `profileArn` response field names (the API-key - * JSON-1.0 surface returns `arn`). + * List available CodeWhisperer profiles for OAuth/IDC tokens and return the + * best-matching profileArn. API keys use the Amazon Q model catalog instead; + * ListAvailableProfiles does not support TokenType=API_KEY. */ async listAvailableProfiles(accessToken, region = "us-east-1") { assertValidAwsRegion(region); @@ -294,10 +292,41 @@ export class KiroService { } /** - * Validate an API-key credential by listing profiles with it. API keys are - * long-lived bearer tokens (no refresh), so the only way to validate one is - * to make an authenticated CodeWhisperer call. Returns a credential object - * ready to persist as a "kiro" connection with authMethod="api_key". + * Validate an API key against the Amazon Q model catalog. A bearer-only call + * to ListAvailableProfiles can return HTTP 200 with an empty list for an + * arbitrary key, so it is not proof that the key can run inference. + */ + async listAvailableApiKeyModels(apiKey, region = "us-east-1") { + assertValidAwsRegion(region); + const params = new URLSearchParams({ origin: "AI_EDITOR" }); + const endpoint = `https://q.${region}.amazonaws.com/ListAvailableModels?${params}`; + const response = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": `Bearer ${apiKey}`, + "TokenType": "API_KEY", + "Accept": "application/json", + "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", + "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0", + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to list API-key models: ${error}`); + } + + const data = await response.json(); + const models = Array.isArray(data?.models) ? data.models : []; + if (models.length === 0) { + throw new Error("API key returned no available models"); + } + return models; + } + + /** + * Validate an API-key credential through the same Amazon Q surface used for + * inference. API keys are account-bound but do not require a profileArn. */ async validateApiKey(apiKey, region = "us-east-1") { if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { @@ -305,9 +334,8 @@ export class KiroService { } const trimmed = apiKey.trim(); - let profileArn = null; try { - profileArn = await this.listAvailableProfiles(trimmed, region); + await this.listAvailableApiKeyModels(trimmed, region); } catch (error) { throw new Error(`API key validation failed: ${error.message}`); } @@ -315,7 +343,7 @@ export class KiroService { return { accessToken: trimmed, refreshToken: null, - profileArn, + profileArn: null, region, authMethod: "api_key", }; diff --git a/tests/unit/kiro-api-key-endpoint-routing.test.js b/tests/unit/kiro-api-key-endpoint-routing.test.js new file mode 100644 index 00000000..a0750adc --- /dev/null +++ b/tests/unit/kiro-api-key-endpoint-routing.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { KiroExecutor } from "../../open-sse/executors/kiro.js"; + +const RUNTIME = "https://runtime.us-east-1.kiro.dev/generateAssistantResponse"; +const CODEWHISPERER = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse"; +const Q = "https://q.us-east-1.amazonaws.com/generateAssistantResponse"; + +function credentials(authMethod, region = "us-east-1") { + return { providerSpecificData: { authMethod, region } }; +} + +describe("Kiro auth-aware endpoint routing", () => { + const executor = new KiroExecutor(); + + it("routes API-key inference through Amazon Q before other surfaces", () => { + expect(executor.getOrderedBaseUrls(credentials("api_key"))).toEqual([ + Q, + CODEWHISPERER, + RUNTIME, + ]); + }); + + it("keeps Builder ID OAuth on the Kiro runtime surface", () => { + expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([ + RUNTIME, + CODEWHISPERER, + Q, + ]); + }); + + it("keeps external IdP on CodeWhisperer before Amazon Q", () => { + expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([ + CODEWHISPERER, + Q, + RUNTIME, + ]); + }); + + it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => { + expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([ + "https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse", + "https://q.eu-west-1.amazonaws.com/generateAssistantResponse", + RUNTIME, + ]); + }); + + it("retries only endpoint/auth-surface failures, not payload-invalid 400s", () => { + expect(executor.shouldRetry(400, 0)).toBe(false); + expect(executor.shouldRetry(401, 1)).toBe(true); + expect(executor.shouldRetry(403, 2)).toBe(false); + expect(executor.shouldRetry(422, 0)).toBe(false); + }); + + it("builds endpoint-specific headers", () => { + const auth = { accessToken: "test-key", providerSpecificData: { authMethod: "api_key" } }; + const qHeaders = executor.buildHeaders(auth, true, Q); + const codeWhispererHeaders = executor.buildHeaders(auth, true, CODEWHISPERER); + const runtimeHeaders = executor.buildHeaders(auth, true, RUNTIME); + + expect(qHeaders.TokenType).toBe("API_KEY"); + expect(qHeaders["X-Amz-Target"]).toBeUndefined(); + expect(codeWhispererHeaders["X-Amz-Target"]).toBe( + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse" + ); + expect(runtimeHeaders["X-Amz-Target"]).toBeUndefined(); + }); +}); diff --git a/tests/unit/kiro-conversation-canonicalization.test.js b/tests/unit/kiro-conversation-canonicalization.test.js new file mode 100644 index 00000000..e19984d1 --- /dev/null +++ b/tests/unit/kiro-conversation-canonicalization.test.js @@ -0,0 +1,372 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, + validateKiroConversation, +} from "../../open-sse/translator/concerns/kiroConversation.js"; +import { clearKiroSessionReplayStore } from "../../open-sse/utils/kiroSessionReplay.js"; +import { clearSessionStore } from "../../open-sse/utils/sessionManager.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; + +const modelId = "claude-opus-5"; + +function tool(name, schema = { type: "object", properties: {} }) { + return { name, description: `Tool ${name}`, input_schema: schema }; +} + +function specState(names = ["first", "second"]) { + const source = names.map((name) => tool(name)); + return normalizeKiroToolSpecs(source); +} + +function user(content, toolResults = []) { + return { + userInputMessage: { + content, + modelId, + ...(toolResults.length > 0 && { userInputMessageContext: { toolResults } }), + }, + }; +} + +function assistant(content, toolUses = []) { + return { + assistantResponseMessage: { + content, + ...(toolUses.length > 0 && { toolUses }), + }, + }; +} + +function result(toolUseId, value, status = "success") { + return { toolUseId, status, content: [{ text: value }] }; +} + +describe("Kiro conversation canonicalizer", () => { + beforeEach(() => { + clearKiroSessionReplayStore(); + clearSessionStore(); + }); + + it("keeps complete parallel tool pairs structured", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: { n: 1 } }, + { toolUseId: "t2", name: "second", input: { n: 2 } }, + ]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const calls = canonical.history[1].assistantResponseMessage.toolUses; + const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults; + expect(calls.map((call) => call.toolUseId)).toEqual(["t1", "t2"]); + expect(results.map((item) => item.toolUseId)).toEqual(["t1", "t2"]); + expect(canonical.valid).toBe(true); + }); + + it("keeps the answered parallel call and flattens only the missing one", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: {} }, + { toolUseId: "t2", name: "second", input: {} }, + ]), + ], + currentMessage: user("continue", [result("t1", "one")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const assistantMessage = canonical.history[1].assistantResponseMessage; + expect(assistantMessage.toolUses).toHaveLength(1); + expect(assistantMessage.toolUses[0].toolUseId).toBe("t1"); + expect(assistantMessage.content).toContain("[Tool call: second("); + expect(canonical.repairs.missingResults).toBe(1); + expect(canonical.valid).toBe(true); + }); + + it("flattens non-adjacent and orphaned tool results", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + user("result missing here"), + assistant("later"), + ], + currentMessage: user("late result", [result("t1", "too late")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(JSON.stringify(canonical)).not.toContain('"toolUseId":"t1"'); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.currentMessage.userInputMessage.content).toContain("too late"); + expect(canonical.valid).toBe(true); + }); + + it("remaps duplicate tool IDs together with their adjacent results", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "duplicate", name: "first", input: {} }, + { toolUseId: "duplicate", name: "second", input: {} }, + ]), + ], + currentMessage: user("continue", [ + result("duplicate", "one"), + result("duplicate", "two"), + ]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const calls = canonical.history[1].assistantResponseMessage.toolUses; + const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults; + expect(new Set(calls.map((call) => call.toolUseId)).size).toBe(2); + expect(results.map((item) => item.toolUseId)).toEqual(calls.map((call) => call.toolUseId)); + expect(canonical.valid).toBe(true); + }); + + it("deduplicates extra results without losing their text", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t1", "duplicate")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const current = canonical.currentMessage.userInputMessage; + expect(current.userInputMessageContext.toolResults).toHaveLength(1); + expect(current.content).toContain("duplicate"); + expect(canonical.valid).toBe(true); + }); + + it("flattens a trailing unanswered assistant tool call and creates a current user turn", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [user("start")], + currentMessage: assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.currentMessage.userInputMessage.content).toBe("continue"); + expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined(); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.valid).toBe(true); + }); + + it("flattens malformed input and tool uses missing from the current specs", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: "{bad json" }, + { toolUseId: "t2", name: "removed_tool", input: {} }, + ]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined(); + expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toBeUndefined(); + expect(canonical.currentMessage.userInputMessage.content).toContain("one"); + expect(canonical.currentMessage.userInputMessage.content).toContain("two"); + expect(canonical.valid).toBe(true); + }); + + it("repairs a 30-call parallel turn with one missing result", () => { + const names = Array.from({ length: 30 }, (_, index) => `tool_${index}`); + const { specs, nameMap } = specState(names); + const calls = names.map((name, index) => ({ + toolUseId: `t${index}`, + name, + input: { index }, + })); + const results = names.slice(0, -1).map((_, index) => result(`t${index}`, `r${index}`)); + const canonical = canonicalizeKiroConversation({ + history: [user("start"), assistant("run", calls)], + currentMessage: user("continue", results), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.history[1].assistantResponseMessage.toolUses).toHaveLength(29); + expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toHaveLength(29); + expect(canonical.repairs.missingResults).toBe(1); + expect(canonical.valid).toBe(true); + }); + + it("flattens structured history when the client sent no tool specs", () => { + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ], + currentMessage: user("continue", [result("t1", "one")]), + modelId, + }); + + expect(JSON.stringify(canonical)).not.toContain("toolUses"); + expect(JSON.stringify(canonical)).not.toContain("toolResults"); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.currentMessage.userInputMessage.content).toContain("[Tool result:"); + }); + + it("normalizes names and recursively removes unsupported schema fields", () => { + const longDescription = "x".repeat(11000); + const { specs, nameMap } = normalizeKiroToolSpecs([{ + name: "bad tool/name", + description: longDescription, + input_schema: { + additionalProperties: false, + properties: { + nested: { + type: "object", + additionalProperties: true, + properties: {}, + required: [], + }, + }, + required: [], + }, + }]); + + const specification = specs[0].toolSpecification; + expect(nameMap.get("bad tool/name")).toBe("bad_tool_name"); + expect(specification.name.length).toBeLessThanOrEqual(64); + expect(specification.description.length).toBe(10237); + expect(JSON.stringify(specification.inputSchema.json)).not.toContain("additionalProperties"); + expect(JSON.stringify(specification.inputSchema.json)).not.toContain('"required":[]'); + }); + + it("does not mutate the source conversation or tool definitions", () => { + const sourceTools = [tool("first")]; + const sourceHistory = [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ]; + const sourceCurrent = user("continue", [result("t1", "one")]); + const before = JSON.stringify({ sourceTools, sourceHistory, sourceCurrent }); + const { specs, nameMap } = normalizeKiroToolSpecs(sourceTools); + + canonicalizeKiroConversation({ + history: sourceHistory, + currentMessage: sourceCurrent, + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(JSON.stringify({ sourceTools, sourceHistory, sourceCurrent })).toBe(before); + }); + + it("preserves Claude tool_result errors", () => { + const output = claudeToKiroRequest(modelId, { + tools: [tool("first")], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", is_error: true, content: "failed" }] }, + ], + }, true, {}); + + const item = output.conversationState.currentMessage.userInputMessage + .userInputMessageContext.toolResults[0]; + expect(item.status).toBe("error"); + }); + + it("repairs partial parallel results in both direct translators", () => { + const claude = claudeToKiroRequest(modelId, { + tools: [tool("first"), tool("second")], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: [ + { type: "tool_use", id: "t1", name: "first", input: {} }, + { type: "tool_use", id: "t2", name: "second", input: {} }, + ] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "one" }] }, + ], + }, true, {}); + const openai = openaiToKiroRequest(modelId, { + tools: [ + { type: "function", function: { name: "first", parameters: { type: "object", properties: {} } } }, + { type: "function", function: { name: "second", parameters: { type: "object", properties: {} } } }, + ], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: "", tool_calls: [ + { id: "t1", type: "function", function: { name: "first", arguments: "{}" } }, + { id: "t2", type: "function", function: { name: "second", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: "t1", content: "one" }, + ], + }, true, {}); + + for (const payload of [claude, openai]) { + const state = payload.conversationState; + const validation = validateKiroConversation( + state.history, + state.currentMessage, + state.currentMessage.userInputMessage.userInputMessageContext.tools + ); + expect(validation.valid).toBe(true); + expect(state.history[1].assistantResponseMessage.toolUses).toHaveLength(1); + } + }); + + it("does not let session replay replace a tool-result turn", () => { + const credentials = { + rawHeaders: { "x-session-id": "kiro-replay-tool-result-regression" }, + connectionId: "kiro-account", + }; + claudeToKiroRequest(modelId, { + messages: [{ role: "user", content: "frozen session start" }], + }, true, credentials); + + const output = claudeToKiroRequest(modelId, { + tools: [tool("first")], + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "kept" }] }, + ], + }, true, credentials); + const state = output.conversationState; + const allText = JSON.stringify(state); + + expect(allText).toContain("frozen session start"); + expect(allText).toContain("kept"); + expect(validateKiroConversation( + state.history, + state.currentMessage, + state.currentMessage.userInputMessage.userInputMessageContext.tools + ).valid).toBe(true); + }); +}); diff --git a/tests/unit/kiro-profile-arn.test.js b/tests/unit/kiro-profile-arn.test.js index 1925582b..bfa3906c 100644 --- a/tests/unit/kiro-profile-arn.test.js +++ b/tests/unit/kiro-profile-arn.test.js @@ -4,10 +4,8 @@ import { KiroService } from "../../src/lib/oauth/services/kiro.js"; /** * Regression tests for Kiro API-key auth. * - * KiroService.validateApiKey resolves a profileArn with the key (via - * CodeWhisperer ListAvailableProfiles) and returns a credential shaped for - * persistence with authMethod="api_key". The response profile field name - * varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles. + * KiroService.validateApiKey validates against the Amazon Q model catalog and + * returns an account-bound credential without inventing a profileArn. * * Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by * fetchKiroProfileArn in providers.js and is covered there — not here. @@ -16,11 +14,10 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { beforeEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks()); - it("validates an API key and resolves a credential with profileArn", async () => { - const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY"; + it("validates an API key against Amazon Q without inventing profileArn", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, - json: async () => ({ profiles: [{ arn: expectedArn }] }), + json: async () => ({ models: [{ modelId: "claude-opus-5" }] }), }); const svc = new KiroService(); @@ -29,17 +26,18 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { expect(cred).toEqual({ accessToken: "my-secret-key", refreshToken: null, - profileArn: expectedArn, + profileArn: null, region: "us-east-1", authMethod: "api_key", }); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com"); - expect(init.headers.Authorization).toBe("Bearer my-secret-key"); - expect(init.headers["x-amz-target"]).toBe( - "AmazonCodeWhispererService.ListAvailableProfiles" + expect(url).toBe( + "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR" ); + expect(init.method).toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer my-secret-key"); + expect(init.headers.TokenType).toBe("API_KEY"); }); it("rejects an empty API key without a network call", async () => { @@ -60,4 +58,15 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { /API key validation failed/ ); }); + + it("rejects a 200 response with an empty model catalog", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ models: [] }), + }); + const svc = new KiroService(); + await expect(svc.validateApiKey("empty-key")).rejects.toThrow( + /returned no available models/ + ); + }); }); From 5e597908246b95de4ed3e91a492163ccd4ec2721 Mon Sep 17 00:00:00 2001 From: Kyle Welsworth Date: Wed, 29 Jul 2026 19:29:06 +0700 Subject: [PATCH 24/34] fix(cursor): stop leaking agent tool errors as text Emit SSE error frame for unsupported Cursor AgentService IDE tools instead of assistant content, and drop frames after the turn finishes to avoid double-closing the stream controller. --- open-sse/executors/cursor.js | 17 ++- tests/unit/cursor-agent-exec-request.test.js | 114 +++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 tests/unit/cursor-agent-exec-request.test.js diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 5023aa45..0aefc623 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -12,7 +12,7 @@ import { import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { estimateUsage } from "../utils/usageTracking.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; -import { chatChunkSse } from "../utils/sse.js"; +import { chatChunkSse, sseChunk } from "../utils/sse.js"; import { FORMATS } from "../translator/formats.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import zlib from "zlib"; @@ -543,6 +543,9 @@ export class CursorExecutor extends BaseExecutor { if (done) break; pending = Buffer.concat([pending, Buffer.from(value)]); pending = decodeAgentFrames(pending, (payload) => { + // A single read can carry several frames; once the turn is over the + // rest of the batch must not reach the already-closed controller. + if (finished) return; const serverMessage = decodeMessage(payload); // agent.v1.AgentServerMessage.interaction_update @@ -570,9 +573,12 @@ export class CursorExecutor extends BaseExecutor { if (execRequest.has(10)) { session.write(createRequestContextResponse()); } else { + // Every other ExecServerMessage variant is an editor-backed tool + // (shell, read, write, …) that 9router cannot service. Fail the + // turn rather than narrating protocol state as assistant text. + debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`); finished = true; onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" }); - onEvent({ type: "done" }); } } }); @@ -630,7 +636,12 @@ export class CursorExecutor extends BaseExecutor { } else if (event.type === "thinking") { controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } }))); } else if (event.type === "error") { - controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } }))); + // An SSE error frame, not a content delta: a protocol failure must not + // be rendered to the user as the assistant's reply, and downstream + // usage tracking must not record the turn as a success. + controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } }))); + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); } else if (event.type === "done") { controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" }))); controller.enqueue(encoder.encode(SSE_DONE)); diff --git a/tests/unit/cursor-agent-exec-request.test.js b/tests/unit/cursor-agent-exec-request.test.js new file mode 100644 index 00000000..347e159f --- /dev/null +++ b/tests/unit/cursor-agent-exec-request.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; + +import { CursorExecutor } from "../../open-sse/executors/cursor.js"; +import { encodeField, wrapConnectRPCFrame } from "../../open-sse/utils/cursorProtobuf.js"; + +const LEN = 2; + +// agent.v1.AgentServerMessage.exec_request (field 2) carrying one ExecServerMessage variant. +function execRequestFrame(execField) { + const execServerMessage = Buffer.from(encodeField(execField, LEN, new Uint8Array())); + return Buffer.from(wrapConnectRPCFrame(encodeField(2, LEN, execServerMessage))); +} + +// agent.v1.AgentServerMessage.interaction_update (field 1) → text delta. +function textFrame(text) { + const textPart = Buffer.from(encodeField(1, LEN, text)); + const update = Buffer.from(encodeField(1, LEN, textPart)); + return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update))); +} + +function stubAgentSession(executor, frames) { + const written = []; + const queue = [...frames]; + executor.openAgentHttp2Stream = () => ({ + responseHeaders: Promise.resolve({ ":status": 200 }), + write: (frame) => written.push(Buffer.from(frame)), + end() {}, + close() {}, + async read() { + if (!queue.length) return { value: undefined, done: true }; + return { value: queue.shift(), done: false }; + }, + }); + return written; +} + +const credentials = { + accessToken: "test-token", + providerSpecificData: { machineId: "a".repeat(64) }, +}; + +function parseSSE(text) { + return text + .split("\n\n") + .filter((chunk) => chunk.startsWith("data: ")) + .map((chunk) => chunk.slice("data: ".length)) + .filter((data) => data !== "[DONE]") + .map((data) => JSON.parse(data)); +} + +async function runAgent({ frames, stream }) { + const executor = new CursorExecutor(); + const written = stubAgentSession(executor, frames); + const result = await executor.executeAgent({ + model: "gpt-5.2", + body: { messages: [{ role: "user", content: "hi" }] }, + stream, + credentials, + }); + return { result, written }; +} + +describe("CursorExecutor AgentService exec_request handling", () => { + it("acknowledges a request-context exec request without ending the turn", async () => { + const { result, written } = await runAgent({ + frames: [execRequestFrame(10), textFrame("hello")], + stream: true, + }); + + expect(written.length).toBe(2); // run frame + request-context reply + const events = parseSSE(await result.response.text()); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("hello"); + }); + + it("does not render an unsupported exec request as assistant content", async () => { + const { result } = await runAgent({ + frames: [textFrame("partial answer"), execRequestFrame(2)], + stream: true, + }); + + const body = await result.response.text(); + expect(body).not.toContain("unsupported IDE tool\\n"); + const events = parseSSE(body); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("partial answer"); + + const errorEvent = events.find((e) => e.error); + expect(errorEvent?.error?.message).toContain("unsupported IDE tool"); + expect(events.some((e) => e.choices?.[0]?.finish_reason === "stop")).toBe(false); + }); + + it("drops frames batched behind an unsupported exec request in the same read", async () => { + const { result } = await runAgent({ + frames: [Buffer.concat([execRequestFrame(2), textFrame("late")])], + stream: true, + }); + + const body = await result.response.text(); + expect(body).toContain("unsupported IDE tool"); + expect(body).not.toContain("late"); + }); + + it("returns a non-200 error body for an unsupported exec request when not streaming", async () => { + const { result } = await runAgent({ + frames: [execRequestFrame(11)], + stream: false, + }); + + expect(result.response.status).not.toBe(200); + const payload = await result.response.json(); + expect(payload.error.message).toContain("unsupported IDE tool"); + }); +}); From 0afe9493878826bb3ddbd2f8b303b7c9ea8ee433 Mon Sep 17 00:00:00 2001 From: Nurwanda Romadhon Date: Wed, 29 Jul 2026 19:30:32 +0700 Subject: [PATCH 25/34] fix(antigravity): strip stream_options from non-stream requests OpenAI clients may send stream_options with stream=false; Google generateContent rejects that combination. Drop it when not streaming. --- open-sse/executors/antigravity.js | 4 ++ tests/unit/antigravity-stream-options.test.js | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/unit/antigravity-stream-options.test.js diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 9ca61853..7f24fd85 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -136,6 +136,10 @@ export class AntigravityExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { const projectId = credentials?.projectId || this.generateProjectId(); + // OpenAI clients may include stream_options even for non-streaming calls. + // Google generateContent rejects that combination before processing the request. + if (stream !== true) delete body.stream_options; + // ─── Image generation: completely different request structure ─── if (isImageModel(model)) { const imageConfig = parseImageConfig(model); diff --git a/tests/unit/antigravity-stream-options.test.js b/tests/unit/antigravity-stream-options.test.js new file mode 100644 index 00000000..47da8769 --- /dev/null +++ b/tests/unit/antigravity-stream-options.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; + +const credentials = { + projectId: "synthetic-project", + connectionId: "synthetic-connection", +}; + +function requestBody(stream) { + return { + stream, + stream_options: { include_usage: true }, + request: { + contents: [{ role: "user", parts: [{ text: "Reply only OK" }] }], + }, + }; +} + +describe("AntigravityExecutor stream_options normalization", () => { + it("removes stream_options from a non-streaming request", () => { + const executor = new AntigravityExecutor(); + const output = executor.transformRequest( + "gpt-oss-120b-medium", + requestBody(false), + false, + credentials, + ); + + expect(output.stream).toBe(false); + expect(output.stream_options).toBeUndefined(); + }); + + it("preserves stream_options for a streaming request", () => { + const executor = new AntigravityExecutor(); + const output = executor.transformRequest( + "gpt-oss-120b-medium", + requestBody(true), + true, + credentials, + ); + + expect(output.stream).toBe(true); + expect(output.stream_options).toEqual({ include_usage: true }); + }); +}); From e3e3e235f6a0ece39ca40834420f451b39696203 Mon Sep 17 00:00:00 2001 From: Cokky Turnip Date: Wed, 29 Jul 2026 19:29:35 +0700 Subject: [PATCH 26/34] fix(gemini): fill empty tool schemas after $ref strip Vertex rejects orphan {} left when $ref/$defs are removed from function declarations. Promote empty nodes to object+reason placeholder in addPlaceholders. --- open-sse/translator/formats/gemini.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index bf6c4586..b1d4db34 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -353,6 +353,19 @@ export function cleanJSONSchemaForAntigravity(schema) { function addPlaceholders(obj) { if (!obj || typeof obj !== "object") return; + // Empty schema {} (no type, no properties) after $ref removal — treat as object with placeholder + if (Object.keys(obj).length === 0) { + obj.type = "object"; + obj.properties = { + reason: { + type: "string", + description: "Brief explanation of why you are calling this tool" + } + }; + obj.required = ["reason"]; + return; + } + if (obj.type === "object") { if (!obj.properties || Object.keys(obj.properties).length === 0) { obj.properties = { From f8e80394465da12b4ddb877e761fe09a2b2d6948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20A=2E?= Date: Wed, 29 Jul 2026 19:30:55 +0700 Subject: [PATCH 27/34] i18n(pt-BR): expand partial translation to 986 terms Add ~793 new pt-BR UI strings for the dashboard and settings. --- public/i18n/literals/pt-BR.json | 1149 ++++++++++++++++++++++++++----- 1 file changed, 971 insertions(+), 178 deletions(-) diff --git a/public/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json index 6edba2e7..f2b5f6d2 100644 --- a/public/i18n/literals/pt-BR.json +++ b/public/i18n/literals/pt-BR.json @@ -1,195 +1,988 @@ { - "Cancel": "Cancelar", - "Delete": "Excluir", - "Edit": "Editar", - "Save": "Salvar", - "Close": "Fechar", - "Add": "Adicionar", - "Remove": "Remover", - "Settings": "Configurações", - "Profile": "Perfil", - "Dashboard": "Painel de controle", - "Logout": "Sair", - "Login": "Conectar", - "Providers": "Provedores", - "Usage": "Estatísticas", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(PXPIPE)": "(PXPIPE)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "9Router (Entry)": "9Router (Inicial)", + "API": "API", + "API Endpoint": "Endpoint da API", "API Key": "Chave API", - "Connected": "Conectado", - "Disconnected": "Desconectado", - "Active": "Ativo", - "Inactive": "Inativo", - "Success": "Sucesso", - "Failed": "Falha", - "Error": "Erro", - "Warning": "Aviso", - "Info": "Informações", - "Loading": "Carregando", - "Search": "Pesquisar", - "Filter": "Filtrar", - "Sort": "Classificar", - "Export": "Exportar", - "Import": "Importar", - "Refresh": "Atualizar", - "Back": "Voltar", - "Next": "Próximo", - "Previous": "Anterior", - "Submit": "Enviar", - "Confirm": "Confirmar", - "Yes": "Sim", - "No": "Não", - "OK": "OK", - "Apply": "Aplicar", - "Reset": "Redefinir", - "Clear": "Limpar", - "Select": "Selecionar", - "Upload": "Enviar", - "Download": "Baixar", - "Copy": "Copiar", - "Paste": "Colar", - "Cut": "Cortar", - "Undo": "Desfazer", - "Redo": "Refazer", - "Name": "Nome", - "Description": "Descrição", - "Status": "Status", - "Type": "Tipo", - "Date": "Data", - "Time": "Hora", - "Created": "Criado", - "Updated": "Atualizado", - "Actions": "Ações", - "Details": "Detalhes", - "View": "Visualizar", - "New": "Novo", - "Total": "Total", - "Count": "Contagem", - "Price": "Preço", - "Cost": "Custo", - "Free": "Gratuito", - "Paid": "Pago", - "Enable": "Ativar", - "Disable": "Desativar", - "Enabled": "Ativado", - "Disabled": "Desativado", - "Online": "Online", - "Offline": "Offline", - "Available": "Disponível", - "Unavailable": "Indisponível", - "Required": "Obrigatório", - "Optional": "Opcional", - "Default": "Padrão", - "Custom": "Personalizado", - "Advanced": "Avançado", - "Basic": "Básico", - "Help": "Ajuda", - "Support": "Suporte", - "Documentation": "Documentação", - "Version": "Versão", - "Language": "Idioma", - "Theme": "Tema", - "Light": "Claro", - "Dark": "Escuro", - "Auto": "Automático", - "Endpoint": "Ponto de extremidade", - "Combos": "Combinações", - "Quota Tracker": "Rastreador de cota", - "MITM": "MITM", - "CLI Tools": "Ferramentas CLI", - "Console Log": "Log do console", - "System": "Sistema", - "Debug": "Depuração", - "Shutdown": "Desligar", - "Close Proxy": "Fechar proxy", - "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", - "Server Disconnected": "Servidor desconectado", - "The proxy server has been stopped.": "O servidor proxy foi parado.", - "Reload Page": "Recarregar página", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", - "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", - "Model combos with fallback": "Combinações de modelos com fallback", - "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", - "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", - "Configure CLI tools": "Configurar ferramentas CLI", + "API Key (for Check)": "Chave API (para Verificação)", + "API Key Created": "Chave API Criada", + "API Keys": "Chaves de API", + "API Token": "Token de API", + "API Tokens": "Tokens de API", + "API Type": "Tipo de API", + "API Version": "Versão da API", "API endpoint configuration": "Configuração do ponto de extremidade da API", - "Manage your preferences": "Gerenciar suas preferências", - "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", - "Live server console output": "Saída do console do servidor ao vivo", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "AWS IAM Identity Center", + "AWS Region": "Região AWS", + "AWS region for the key (default: us-east-1)": "Região AWS para a chave (padrão: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "Região AWS para seu Identity Center (padrão: us-east-1)", + "About": "Sobre", + "Access token will be auto-filled...": "O token de acesso será preenchido automaticamente...", + "Account": "Conta", + "Account ID": "ID da Conta", + "Account Resources": "Recursos da Conta", + "Accounts per page": "Contas por página", + "Action": "Ação", + "Actions": "Ações", + "Activate": "Ativar", + "Active": "Ativo", + "Active All": "Ativar Todos", + "Active:": "Ativo:", + "Add": "Adicionar", + "Add API Key": "Adicionar Chave de API", + "Add Anthropic Compatible": "Adicionar Compatível com Anthropic", + "Add Connection": "Adicionar Conexão", + "Add Custom Embedding": "Adicionar Embedding Personalizado", + "Add Custom MCP": "Adicionar MCP Personalizado", + "Add Custom Model": "Adicionar Modelo Personalizado", + "Add Model": "Adicionar Modelo", + "Add Model for GitHub Copilot": "Adicionar Modelo para GitHub Copilot", + "Add Model for OpenCode": "Adicionar Modelo para OpenCode", + "Add Model to Combo": "Adicionar Modelo ao Combo", + "Add New Provider": "Adicionar Novo Provedor", + "Add OpenAI Compatible": "Adicionar Compatível com OpenAI", + "Add Provider": "Adicionar Provedor", + "Add Proxy Pool": "Adicionar Pool de Proxy", + "Add a connection to enable importing models.": "Adicione uma conexão para ativar a importação de modelos.", + "Add connection using browser cookie": "Adicionar conexão usando cookie do navegador", + "Add model": "Adicionar modelo", + "Advanced": "Avançado", + "After PXPIPE": "Após PXPIPE", + "After authorization, copy the full URL from your browser address bar.": "Após a autorização, copie a URL completa da barra de endereço do navegador.", + "After installation, run": "Após a instalação, execute", + "All": "Todos", + "All AI Providers": "Todos os Provedores de IA", + "All Providers": "Todos os Provedores", + "All data stored on your machine": "Todos os dados armazenados em sua máquina", + "All models are responding normally.": "Todos os modelos estão respondendo normalmente.", + "All providers": "Todos os provedores", + "Allow dashboard access via tunnel": "Permitir acesso ao painel via túnel", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "App Name": "Nome do App", + "Appearance": "Aparência", + "Appends (level) suffix to copied model names": "Adiciona sufixo (nível) aos nomes de modelo copiados", + "Apply": "Aplicar", + "Apply Proxy": "Aplicar Proxy", + "Applying...": "Aplicando...", + "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", + "Audio File": "Arquivo de Áudio", + "Auth Mode": "Modo de Autenticação", + "Authenticate": "Autenticar", + "Authentication Successful": "Autenticação Bem-sucedida", + "Authentication Successful!": "Autenticação Bem-sucedida!", + "Authless": "Sem Autenticação", + "Authorization Successful!": "Autorização Bem-sucedida!", + "Authorize": "Autorizar", + "Auto": "Automático", + "Auto (by priority)": "Auto (por prioridade)", + "Auto Refresh (3s)": "Atualização Automática (3s)", + "Auto-detect": "Detecção Automática", + "Auto-detecting token...": "Detectando token automaticamente...", + "Auto-detecting tokens...": "Detectando tokens automaticamente...", + "Auto-ping": "Ping Automático", + "Auto-refresh": "Atualização automática", + "Available": "Disponível", + "Azure Endpoint": "Endpoint Azure", + "Azure OpenAI Configuration": "Configuração Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "Voltar", + "Back to CLI Tools": "Voltar para Ferramentas CLI", + "Back to Providers": "Voltar para Provedores", + "Base URL": "URL Base", + "Basic": "Básico", + "Batch Import": "Importar em Lote", + "Batch Import Proxies": "Importar Proxies em Lote", + "Batch Size": "Tamanho do lote", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "Tendenciar o modelo para código mínimo: YAGNI, reutilizar stdlib, deletar ao invés de adicionar", + "Binary File": "Arquivo Binário", + "Browse MCP Marketplace": "Explorar Marketplace MCP", + "Browse source, README, and examples.": "Navegue pelo código fonte, README e exemplos.", + "Browser Control (Browser MCP)": "Controle do Navegador (Browser MCP)", + "Bulk Add": "Adição em Massa", + "Bypassed": "Ignorado", + "CLI Tools": "Ferramentas CLI", + "Cache Creation": "Criação de Cache", + "Cache Creation:": "Criação de Cache:", + "Cached": "Em Cache", + "Cached Tokens": "Tokens em Cache", + "Cached Tokens:": "Tokens em Cache:", + "Cached:": "Em Cache:", + "Calls per account before switching": "Chamadas por conta antes de alternar", + "Calls per combo model before switching": "Chamadas por modelo de combo antes de alternar", + "Cancel": "Cancelar", + "Capacity auto-switch": "Troca automática de capacidade", + "Cert": "Certificado", + "Change Log": "Registro de Alterações", + "Changelog": "Registro de Alterações", + "Chat": "Chat", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "Chat / geração de código via formato OpenAI ou Anthropic com streaming.", + "Check again": "Verificar novamente", + "Checking Claude CLI...": "Verificando Claude CLI...", + "Checking Claude Cowork...": "Verificando Claude Cowork...", + "Checking Cline...": "Verificando Cline...", + "Checking Codex CLI...": "Verificando Codex CLI...", + "Checking Copilot config...": "Verificando configuração do Copilot...", + "Checking DeepSeek TUI...": "Verificando DeepSeek TUI...", + "Checking Factory Droid CLI...": "Verificando Factory Droid CLI...", + "Checking Grok Build...": "Verificando Grok Build...", + "Checking Hermes Agent...": "Verificando Hermes Agent...", + "Checking Kilo Code...": "Verificando Kilo Code...", + "Checking Open Claw CLI...": "Verificando Open Claw CLI...", + "Checking OpenCode CLI...": "Verificando OpenCode CLI...", + "Checking jcode CLI...": "Verificando jcode CLI...", + "Checking...": "Verificando...", + "Checking…": "Verificando…", + "Choose how to authenticate with GitLab Duo:": "Escolha como autenticar com GitLab Duo:", + "Choose your authentication method:": "Escolha seu método de autenticação:", + "Claude CLI - Manual Configuration": "Claude CLI - Configuração Manual", + "Claude CLI not detected locally": "Claude CLI não detectado localmente", + "Claude Cowork - Manual Configuration": "Claude Cowork - Configuração Manual", + "Claude Desktop (Cowork mode) not detected": "Claude Desktop (modo Cowork) não detectado", + "Clear": "Limpar", + "Clear (inherit main model for subagents)": "Limpar (herdar modelo principal para subagentes)", + "Clear (will use main model)": "Limpar (usará o modelo principal)", + "Clear Filters": "Limpar Filtros", + "Clear search": "Limpar pesquisa", + "Click": "Clique", + "Click a model to set/clear active": "Clique em um modelo para ativar/desativar", + "Click to add, click again to remove.": "Clique para adicionar, clique novamente para remover.", + "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", + "Click to edit": "Clique para editar", + "Client ID": "ID do Cliente", + "Client Secret": "Segredo do Cliente", + "Client Secret (optional for PKCE)": "Segredo do Cliente (opcional para PKCE)", + "Cline - Manual Configuration": "Cline - Configuração Manual", + "Cline not detected locally": "Cline não detectado localmente", + "Clone and run locally": "Clone e execute localmente", + "Close": "Fechar", + "Close Proxy": "Fechar proxy", + "Close menu": "Fechar menu", + "Close provider filter": "Fechar filtro de provedor", + "Close reset credit expiry modal": "Fechar redefinição de crédito", + "Close test results": "Fechar resultados de teste", + "Closing in": "Fechando em", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "Túnel Cloudflare", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - Configuração Manual", + "Codex CLI not detected locally": "Codex CLI não detectado localmente", + "Codex Reset Credit Expiry": "Redefinir Expiração de Crédito do Codex", + "Combo Name": "Nome do Combo", + "Combo Round Robin": "Combo Round Robin", + "Combo Sticky Limit": "Limite Fixo do Combo", + "Combos": "Combinações", + "Coming soon...": "Em breve...", + "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", + "Company": "Empresa", + "Compress LLM output": "Comprimir saída do LLM", + "Compress context": "Comprimir contexto", + "Compress prompts as images": "Comprimir prompts como imagens", + "Compress prompts via /v1/compress before routing to the model": "Comprimir prompts via /v1/compress antes de rotear para o modelo", + "Compress tool output": "Comprimir saída da ferramenta", + "Compressed": "Comprimido", + "Compressed (est.)": "Comprimido (est.)", + "Compression extras": "Extras de compressão", + "Configure CLI tools": "Configurar ferramentas CLI", + "Configure a new AI provider to use with your applications.": "Configure um novo provedor de IA para usar com suas aplicações.", + "Configure pricing rates for cost tracking and calculations": "Configure taxas de preço para rastreamento e cálculo de custos", + "Configure providers and API keys via web interface": "Configure provedores e chaves de API via interface web", + "Confirm": "Confirmar", + "Confirm New Password": "Confirmar nova senha", + "Confirm Password": "Confirmar Senha", + "Confirm new password": "Confirme a nova senha", + "Connect": "Conectar", + "Connect Cursor IDE": "Conectar Cursor IDE", + "Connect GitLab Duo": "Conectar GitLab Duo", + "Connect Kiro": "Conectar Kiro", + "Connect with OAuth2": "Conectar com OAuth2", + "Connect your account using OAuth2 authentication.": "Conecte sua conta usando autenticação OAuth2.", + "Connected": "Conectado", + "Connected Successfully!": "Conectado com Sucesso!", + "Connection": "Conexão", + "Connection Failed": "Falha na Conexão", + "Connections": "Conexões", + "Console Log": "Log do console", + "Content": "Conteúdo", + "Continue": "Continuar", + "Continue to summary": "Continuar para resumo", + "Continue with GitHub": "Continuar com GitHub", + "Continue with Google": "Continuar com Google", + "Cookie": "Cookie", + "Cookie String": "String de Cookie", + "Copied": "Copiado", + "Copied!": "Copiado!", + "Copy": "Copiar", + "Copy This URL": "Copiar Esta URL", + "Copy combo name": "Copiar nome do combo", + "Copy install command": "Copiar comando de instalação", + "Copy link": "Copiar link", + "Copy the entire cookie string (must include BXAuth)": "Copie a string completa do cookie (deve incluir BXAuth)", + "Cost": "Custo", + "Cost Calculation:": "Cálculo de Custo:", + "Costs": "Custos", + "Could not read Cursor database automatically.": "Não foi possível ler o banco de dados do Cursor automaticamente.", + "Count": "Contagem", + "Create": "Criar", + "Create API Key": "Criar Chave API", + "Create Combo": "Criar Combo", + "Create Cowork Combo": "Criar Combo Cowork", + "Create Key": "Criar Chave", + "Create Provider": "Criar Provedor", + "Create Token": "Criar Token", + "Create a": "Criar um(a)", + "Create a proxy pool entry, then assign it to connections.": "Crie uma entrada de pool de proxy e atribua-a às conexões.", "Create model combos with fallback support": "Crie combinações de modelos com suporte a fallback", - "Local Mode": "Modo local", - "Running on your machine": "Executando em sua máquina", + "Create your first API key to get started": "Crie sua primeira chave de API para começar", + "Created": "Criado", + "Current": "Atual", + "Current Password": "Senha atual", + "Current Pricing Overview": "Visão Geral de Preços Atual", + "Current password": "Senha atual", + "Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE não detectado. Cole seus tokens manualmente.", + "Custom": "Personalizado", + "Custom Pricing:": "Preço Personalizado:", + "Custom Token": "Token Personalizado", + "Custom accounts per page": "Contas personalizadas por página", + "Custom...": "Personalizado...", + "Cut": "Cortar", + "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", + "Cycle through providers in combos instead of always starting with first": "Percorrer provedores em combos ao invés de sempre começar pelo primeiro", + "DNS off": "DNS desligado", + "Dark": "Escuro", + "Dashboard": "Painel", + "Data Location:": "Localização dos Dados:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "Os dados fluem perfeitamente da sua aplicação através de nossa camada de roteamento inteligente para o melhor provedor.", "Database Location": "Localização do banco de dados", - "Download Backup": "Baixar backup", - "Import Backup": "Importar backup", "Database backup downloaded": "Backup do banco de dados baixado", "Database imported successfully": "Banco de dados importado com sucesso", - "Security": "Segurança", - "Require login": "Exigir login", - "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", - "Current Password": "Senha atual", - "Enter current password": "Digite a senha atual", - "New Password": "Nova senha", - "Enter new password": "Digite a nova senha", - "Confirm New Password": "Confirmar nova senha", - "Confirm new password": "Confirme a nova senha", - "Update Password": "Atualizar senha", - "Set Password": "Definir senha", - "Password updated successfully": "Senha atualizada com sucesso", - "Passwords do not match": "As senhas não correspondem", - "Routing Strategy": "Estratégia de roteamento", - "Round Robin": "Round Robin", - "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", - "Sticky Limit": "Limite pegajoso", - "Calls per account before switching": "Chamadas por conta antes de alternar", - "Network": "Rede", - "Outbound Proxy": "Proxy de saída", - "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", - "Proxy URL": "URL do proxy", - "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", - "No Proxy": "Sem proxy", - "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", - "Test proxy URL": "Testar URL do proxy", - "Proxy settings applied": "Configurações de proxy aplicadas", - "Proxy enabled": "Proxy ativado", - "Proxy disabled": "Proxy desativado", - "Proxy test OK": "Teste de proxy OK", - "Proxy test failed": "Falha no teste de proxy", - "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", - "Observability": "Observabilidade", + "Date": "Data", + "DateTime": "Data e Hora", + "Deactivate": "Desativar", + "Debug": "Depuração", + "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - Configuração Manual", + "DeepSeek TUI not detected locally": "DeepSeek TUI não detectado localmente", + "Default": "Padrão", + "Default Model": "Modelo Padrão", + "Delete": "Excluir", + "Delete connection": "Excluir conexão", + "Delete saved endpoint": "Excluir endpoint salvo", + "Delete selected preset": "Excluir predefinição selecionada", + "Deno Deploy API Token": "Token de API Deno Deploy", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 roda em uma rede edge global de alto desempenho", + "Deno Relay": "Deno Relay", + "Deploy Cloudflare Relay": "Implantar Relay Cloudflare", + "Deploy Deno Relay": "Implantar Relay Deno", + "Deploy Relay": "Implantar Relay", + "Deploy Vercel Relay": "Implantar Relay Vercel", + "Deploy multiple relays for maximum IP diversity": "Implantar múltiplos relays para máxima diversidade de IP", + "Deploy multiple relays on different accounts for more IP diversity": "Implantar múltiplos relays em contas diferentes para mais diversidade de IP", + "Deployment Name": "Nome da Implantação", + "Description": "Descrição", + "Detail": "Detalhe", + "Details": "Detalhes", + "Dimensions": "Dimensões", + "Disable": "Desativar", + "Disable All": "Desativar Todos", + "Disable Tailscale": "Desativar Tailscale", + "Disable Tunnel": "Desativar Túnel", + "Disable connections with depleted quota on the current page": "Desativar conexões com cota esgotada na página atual", + "Disable this model": "Desativar este modelo", + "Disabled": "Desativado", + "Disconnected": "Desconectado", + "Dismiss notification": "Dispensar notificação", + "Display Name": "Nome de Exibição", + "Display language": "Idioma de exibição", + "Docs": "Documentação", + "Documentation": "Documentação", + "Donate": "Doar", + "Done": "Concluído", + "Download": "Baixar", + "Download Backup": "Baixar backup", + "Drag to reorder": "Arraste para reordenar", + "Duration": "Duração", + "Edit": "Editar", + "Edit Connection": "Editar Conexão", + "Edit Pricing": "Editar Preços", + "Edit connection": "Editar conexão", + "Edit hosts file manually to add the following entries:": "Edite o arquivo hosts manualmente para adicionar as seguintes entradas:", + "Email": "E-mail", + "Embeddings": "Embeddings", + "Enable": "Ativar", + "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", "Enable Observability": "Ativar observabilidade", - "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Enable Tunnel": "Ativar Túnel", + "Enable connections that still have quota on the current page": "Ativar conexões que ainda têm cota na página atual", + "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", + "Enabled": "Ativado", + "End Date": "Data Final", + "Endpoint": "Ponto de extremidade", + "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API.", + "Enter current password": "Digite a senha atual", + "Enter model id": "Digite o ID do modelo", + "Enter model id (provider-specific)": "Digite o ID do modelo (específico do provedor)", + "Enter new API key": "Digite a nova chave de API", + "Enter new password": "Digite a nova senha", + "Enter password": "Digite a senha", + "Enter sudo password": "Digite a senha sudo", + "Enter the model ID exactly as your compatible endpoint expects it.": "Digite o ID do modelo exatamente como seu endpoint compatível espera.", + "Enter your API key": "Digite sua chave de API", + "Enter your password to access the dashboard": "Digite sua senha para acessar o painel", + "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "EnvironmentVariables": "Variáveis de Ambiente", + "Error": "Erro", + "Error updating setting:": "Erro ao atualizar configuração:", + "Est. Cost": "Custo Est.", + "Estimated, not actual billing": "Estimado, não é a cobrança real", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "Tudo que você precisa para gerenciar sua infraestrutura de IA em um só lugar, projetado para escala.", + "Exa MCP": "Exa MCP", + "Example": "Exemplo", + "Expires At": "Expira Em", + "Expiring first": "Expirando primeiro", + "Export": "Exportar", + "External": "Externo", + "FREE": "GRATUITO", + "Factory Droid - Manual Configuration": "Factory Droid - Configuração Manual", + "Factory Droid CLI not detected locally": "Factory Droid CLI não detectado localmente", + "Fail request if proxy is unreachable instead of falling back to direct.": "Falhar requisição se o proxy estiver inacessível ao invés de cair para direto.", + "Failed": "Falha", + "Failed to load usage statistics.": "Falha ao carregar estatísticas de uso.", + "Failed to start proxy": "Falha ao iniciar proxy", + "Failed to start server": "Falha ao iniciar o servidor", + "Failed to stop server": "Falha ao parar o servidor", + "Fallback": "Fallback", + "Features": "Recursos", + "Filter": "Filtrar", + "Filter accounts by status": "Filtrar contas por status", + "Filter naming": "Filtrar nomenclatura", + "Filter naming requests": "Solicitações de filtro de nomenclatura", + "Filter quota providers": "Filtrar provedores de cota", + "Filters": "Filtros", + "Find MCPs →": "Encontrar MCPs →", + "First Page": "Primeira Página", + "Flush Interval (ms)": "Intervalo de liberação (ms)", + "For enterprise users with custom AWS IAM Identity Center.": "Para usuários empresariais com AWS IAM Identity Center personalizado.", + "Format": "Formato", + "Found on the right side of the Cloudflare dashboard overview page.": "Encontrado no lado direito da página de visão geral do painel Cloudflare.", + "Free": "Gratuito", + "Free Tier Providers": "Provedores Gratuitos", + "Free tier: 100,000 requests per day": "Camada gratuita: 100.000 requisições por dia", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Camada gratuita: 100GB largura de banda/mês, 500K invocações edge", + "Fresh API key obtained": "Nova chave de API obtida", + "Fusion": "Fusão", + "General": "Geral", + "Get 9Remote": "Obter 9Remote", + "Get API Key": "Obter Chave de API", + "Get API Key →": "Obter Chave de API →", + "Get Started": "Começar", + "Get Started in 30 Seconds": "Comece em 30 Segundos", + "Get started": "Começar", + "Get token →": "Obter token →", + "GitHub": "GitHub", + "GitHub Account": "Conta GitHub", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - Configuração Manual", + "GitLab Access Tokens": "Tokens de Acesso GitLab", + "GitLab Applications": "Aplicativos GitLab", + "GitLab Base URL": "URL Base do GitLab", + "Go to": "Ir para", + "Google Account": "Conta Google", + "Granted At": "Concedido Em", + "Grok Build - Manual Configuration": "Grok Build - Configuração Manual", + "Grok Build not detected locally": "Grok Build não detectado localmente", + "Group models under one name, then pick a strategy per combo:": "Agrupe modelos sob um nome e escolha uma estratégia por combo:", + "Headroom": "Headroom", + "Headroom proxy is reachable. You can enable the token saver.": "Proxy Headroom está acessível. Você pode ativar o economizador de tokens.", + "Health check": "Verificação de integridade", + "Healthy": "Saudável", + "Help": "Ajuda", + "Hermes Agent - Manual Configuration": "Hermes Agent - Configuração Manual", + "Hermes Agent not detected locally": "Hermes Agent não detectado localmente", + "Hidden:": "Oculto:", + "Hide this quota row": "Ocultar esta linha de cota", + "High performance global routing and IP masking via Cloudflare Workers": "Roteamento global de alto desempenho e mascaramento de IP via Cloudflare Workers", + "History": "Histórico", + "How 9Router Works": "Como o 9Router Funciona", + "How Pricing Works": "Como Funcionam os Preços", + "How it Works": "Como Funciona", + "How it works:": "Como funciona:", + "How to generate API token:": "Como gerar o token de API:", + "How to generate your API Token:": "Como gerar seu Token de API:", + "How to get cookie:": "Como obter o cookie:", + "ID:": "ID:", + "Image Generation": "Geração de Imagens", + "Images": "Imagens", + "Import": "Importar", + "Import Backup": "Importar backup", + "Import CLIProxyAPI JSON": "Importar JSON CLIProxyAPI", + "Import Token": "Importar Token", + "In / Out": "Entrada / Saída", + "Inactive": "Inativo", + "Inactive pools are ignored by runtime resolution.": "Pools inativos são ignorados pela resolução em tempo de execução.", + "Info": "Informações", + "Initializing...": "Inicializando...", + "Input": "Entrada", + "Input Tokens": "Tokens de Entrada", + "Input Tokens:": "Tokens de Entrada:", + "Input:": "Entrada:", + "Install": "Instalar", + "Install 9Router": "Instalar 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "Instale o 9Router, configure seus provedores via painel web e comece a rotear requisições de IA.", + "Install Chrome extension": "Instalar extensão Chrome", + "Install Cline VS Code extension or CLI from": "Instalar extensão Cline VS Code ou CLI de", + "Install Kilo Code from": "Instalar Kilo Code de", + "Install Tailscale": "Instalar Tailscale", + "Install [ml]": "Instalar [ml]", + "Install command:": "Comando de instalação:", + "Install failed": "Falha na instalação", + "Install jcode to enable automatic configuration:": "Instale jcode para ativar a configuração automática:", + "Install then click Start:": "Instale e clique em Iniciar:", + "Install via npm:": "Instalar via npm:", + "Installation Guide": "Guia de Instalação", + "Installing Tailscale...": "Instalando Tailscale...", + "Installing…": "Instalando…", + "Interactive diagram visible on desktop": "Diagrama interativo visível no desktop", + "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", + "Invalid": "Inválido", + "Issuer URL": "URL do Emissor", + "JSON Response": "Resposta JSON", + "Join developers who are streamlining their AI integrations with 9Router.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router.", + "Judge": "Julgador", + "KeepAlive": "KeepAlive", + "Key Name": "Nome da Chave", + "Kill this process to start MITM Server?": "Encerrar este processo para iniciar o Servidor MITM?", + "Kilo Code - Manual Configuration": "Kilo Code - Configuração Manual", + "Kilo Code not detected locally": "Kilo Code não detectado localmente", + "Kiro IDE not detected. Please paste your refresh token manually.": "Kiro IDE não detectado. Cole seu token de atualização manualmente.", + "Kompress-v2 HF model for prose/agentic traces (~+1GB)": "Modelo Kompress-v2 HF para texto/rastros agênticos (~+1GB)", + "Label": "Rótulo", + "Language": "Idioma", + "Last Page": "Última Página", + "Latency": "Latência", + "Latency:": "Latência:", + "Lazy senior dev": "Dev sênior preguiçoso", + "Leave blank to inherit Main Model. Each override keeps its own context window.": "Deixe em branco para herdar o Modelo Principal. Cada substituição mantém sua própria janela de contexto.", + "Leave blank to keep existing secret": "Deixe em branco para manter o segredo existente", + "Leave empty for public PKCE app": "Deixe vazio para app PKCE público", + "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", + "Legal": "Legal", + "Light": "Claro", + "Live server console output": "Saída do console do servidor ao vivo", + "Load": "Carregar", + "Loading": "Carregando", + "Loading logs...": "Carregando logs...", + "Loading pricing data...": "Carregando dados de preço...", + "Loading registry...": "Carregando registro...", + "Loading reset credits...": "Carregando créditos de redefinição...", + "Loading...": "Carregando...", + "Local": "Local", + "Local Mode": "Modo local", + "Local Mode - All data stored on your machine": "Modo Local - Todos os dados armazenados em sua máquina", + "Local Plugins": "Plugins Locais", + "Login": "Conectar", + "Login Button Label": "Texto do Botão de Login", + "Login URL": "URL de Login", + "Login to your account": "Conecte-se à sua conta", + "Login with your GitHub account (manual callback).": "Faça login com sua conta GitHub (callback manual).", + "Login with your Google account (manual callback).": "Faça login com sua conta Google (callback manual).", + "Logout": "Sair", + "Logs": "Logs", + "Logs are loaded from the request history database.": "Logs são carregados do banco de dados de histórico de requisições.", + "MCP": "MCP", + "MIT License": "Licença MIT", + "MITM": "MITM", + "MITM Server": "Servidor MITM", + "MITM Tools": "Ferramentas MITM", + "Machine ID will be auto-filled...": "O ID da máquina será preenchido automaticamente...", + "Main Model": "Modelo Principal", + "Manage": "Gerenciar", + "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", + "Manage your preferences": "Gerenciar suas preferências", + "Manual / current endpoint": "Endpoint manual / atual", + "Manual Callback Required": "Callback Manual Necessário", + "Manual Config": "Configuração Manual", + "Manual configuration is still available if 9router is deployed on a remote server.": "A configuração manual ainda está disponível se o 9Router estiver implantado em um servidor remoto.", + "Mask (URL)": "Máscara (URL)", + "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Max Records": "Número máximo de registros", "Maximum request detail records to keep (older records are auto-deleted)": "Número máximo de registros de detalhes de solicitação a manter (registros antigos são excluídos automaticamente)", - "Batch Size": "Tamanho do lote", - "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", - "Flush Interval (ms)": "Intervalo de liberação (ms)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", - "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Maximum size for each JSON field (request/response) before truncation": "Tamanho máximo para cada campo JSON (solicitação/resposta) antes do truncamento", - "All data stored on your machine": "Todos os dados armazenados em sua máquina", - "MITM Server": "Servidor MITM", - "Running": "Executando", - "Stopped": "Parado", - "Cert": "Certificado", - "Server": "Servidor", - "Purpose:": "Propósito:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", - "How it works:": "Como funciona:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", + "Media Providers": "Provedores de Mídia", + "Menu": "Menu", + "Message AI": "IA de Mensagens", + "Messages": "Mensagens", + "Minimum prompt size (chars)": "Tamanho mínimo do prompt (caracteres)", + "Model": "Modelo", + "Model ID": "ID do Modelo", + "Model ID (for Check)": "ID do Modelo (para Verificação)", + "Model ID (from OpenRouter)": "ID do Modelo (do OpenRouter)", + "Model ID (optional)": "ID do Modelo (opcional)", + "Model Status": "Status do Modelo", + "Model combos with fallback": "Combinações de modelos com fallback", + "Model is reachable": "Modelo está acessível", + "Model list is filtered from connected providers.": "Lista de modelos é filtrada dos provedores conectados.", + "Model mappings will be available soon.": "Mapeamentos de modelo estarão disponíveis em breve.", + "Model:": "Modelo:", + "Models": "Modelos", + "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", + "More on GitHub": "Mais no GitHub", + "Move down": "Mover para baixo", + "Move up": "Mover para cima", + "My Profile": "Meu Perfil", + "NPM": "NPM", + "Name": "Nome", + "Navigate to home": "Navegar para início", + "Network": "Rede", + "New": "Novo", + "New Password": "Nova senha", + "New password": "Nova senha", + "Next": "Próximo", + "Next accounts page": "Próxima página de contas", + "No": "Não", + "No API keys yet": "Nenhuma chave de API ainda", "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", - "sk_9router (default)": "sk_9router (padrão)", + "No MCPs added": "Nenhum MCP adicionado", + "No PXPIPE activity yet": "Nenhuma atividade PXPIPE ainda", + "No Providers Connected": "Nenhum Provedor Conectado", + "No Proxy": "Sem proxy", + "No active connections found for this group.": "Nenhuma conexão ativa encontrada para este grupo.", + "No active proxy pools available.": "Nenhum pool de proxy ativo disponível.", + "No authentication required": "Nenhuma autenticação necessária", + "No combos yet": "Nenhum combo ainda", + "No combos yet.": "Nenhum combo ainda.", + "No connections": "Nenhuma conexão", + "No connections yet": "Nenhuma conexão ainda", + "No console logs yet.": "Nenhum log de console ainda.", + "No conversations yet.": "Nenhuma conversa ainda.", + "No data for this period": "Nenhum dado para este período", + "No install log yet.": "Nenhum log de instalação ainda.", + "No key configured": "Nenhuma chave configurada", + "No language selected": "Nenhum idioma selecionado", + "No languages found.": "Nenhum idioma encontrado.", + "No logs recorded yet.": "Nenhum log registrado ainda.", + "No models": "Nenhum modelo", + "No models added yet": "Nenhum modelo adicionado ainda", + "No models found": "Nenhum modelo encontrado", + "No models selected": "Nenhum modelo selecionado", + "No per-request CPU time limits (unlike Vercel/Cloudflare)": "Sem limites de tempo de CPU por requisição (diferente de Vercel/Cloudflare)", + "No pricing data available": "Nenhum dado de preço disponível", + "No providers connected": "Nenhum provedor conectado", + "No providers match your search": "Nenhum provedor corresponde à sua pesquisa", + "No providers yet.": "Nenhum provedor ainda.", + "No providers.": "Nenhum provedor.", + "No proxy pool entries yet": "Nenhuma entrada no pool de proxy ainda", + "No quota data available": "Nenhum dado de cota disponível", + "No request details found": "Nenhum detalhe de requisição encontrado", + "No requests yet.": "Nenhuma requisição ainda.", + "No reset credit details returned for this account.": "Nenhum detalhe de crédito de redefinição retornado para esta conta.", + "No servers match filter": "Nenhum servidor corresponde ao filtro", + "No tools advertised by server.": "Nenhuma ferramenta anunciada pelo servidor.", + "No usage yet.": "Nenhum uso ainda.", + "None": "Nenhum", + "None (unbind all)": "Nenhum (desvincular todos)", + "Not configured": "Não configurado", + "Not installed": "Não instalado", + "Notice": "Aviso", + "Notifications": "Notificações", + "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", + "OAuth": "OAuth", + "OAuth App": "App OAuth", + "OAuth Providers": "Provedores OAuth", + "OIDC": "OIDC", + "OIDC Dashboard Login": "Login no Painel via OIDC", + "OK": "OK", + "Observability": "Observabilidade", + "Office Proxy": "Proxy de Escritório", + "Offline": "Offline", + "Ollama Host URL": "URL do Host Ollama", + "One key per line. Format:": "Uma chave por linha. Formato:", + "One-to-one (rotate)": "Um-para-um (rotacionar)", + "Online": "Online", + "Only from connected providers": "Apenas de provedores conectados", + "Only letters, numbers, -, _ and .": "Apenas letras, números, -, _ e .", + "Open": "Abrir", + "Open Claw - Manual Configuration": "Open Claw - Configuração Manual", + "Open Claw CLI not detected locally": "Open Claw CLI não detectado localmente", + "Open Dashboard": "Abrir Painel", + "Open DevTools (F12) → Application/Storage → Cookies": "Abra DevTools (F12) → Application/Storage → Cookies", + "Open Headroom Dashboard": "Abrir Painel Headroom", + "Open Logs": "Abrir Logs", + "Open menu": "Abrir menu", + "Open source": "Código aberto", + "Open source and free to start.": "Código aberto e gratuito para começar.", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "Vozes OpenAI / ElevenLabs / Edge / Google / Deepgram.", + "OpenCode - Manual Configuration": "OpenCode - Configuração Manual", + "OpenCode CLI not detected locally": "OpenCode CLI não detectado localmente", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter suporta qualquer modelo. Adicione modelos e crie aliases para acesso rápido.", + "Optional": "Opcional", + "Or paste callback URL manually": "Ou cole a URL de callback manualmente", + "Organization": "Organização", + "Organization Domain": "Domínio da Organização", + "Organization ID": "ID da Organização", + "Organization Token": "Token da Organização", + "Organization Tokens": "Tokens da Organização", + "Original": "Original", + "Original (est.)": "Original (est.)", + "Original tokens": "Tokens originais", + "Other": "Outro", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "Nosso mecanismo analisa o prompt, verifica a integridade do provedor e roteia para menor latência ou custo.", + "Out": "Saída", + "Outbound Proxy": "Proxy de saída", + "Output": "Saída", + "Output Format": "Formato de Saída", + "Output Tokens": "Tokens de Saída", + "Output Tokens:": "Tokens de Saída:", + "Output:": "Saída:", + "PATH": "PATH", + "PXPIPE": "PXPIPE", + "PXPIPE Dashboard": "Painel PXPIPE", + "PXPIPE Logs": "Logs PXPIPE", + "PXPIPE install failed": "Falha na instalação do PXPIPE", + "PXPIPE is not installed.": "PXPIPE não está instalado.", + "PXPIPE restart failed": "Falha ao reiniciar PXPIPE", + "PXPIPE start failed": "Falha ao iniciar PXPIPE", + "PXPIPE stop failed": "Falha ao parar PXPIPE", + "Paid": "Pago", + "Partial preview": "Visualização parcial", + "Password": "Senha", + "Password updated successfully": "Senha atualizada com sucesso", + "Passwords do not match": "As senhas não correspondem", + "Paste": "Colar", + "Paste Proxy List (One per line)": "Cole a Lista de Proxy (um por linha)", + "Paste it below": "Cole abaixo", + "Paste refresh token from Kiro IDE.": "Cole o token de atualização do Kiro IDE.", + "Paste the command into your terminal and press Enter.": "Cole o comando no terminal e pressione Enter.", + "Paste this to your AI:": "Cole isto em sua IA:", + "Paste your Kiro API key...": "Cole sua chave de API Kiro...", + "Paused": "Pausado", + "Permissions": "Permissões", + "Personal Access Token": "Token de Acesso Pessoal", + "Pick the model that fuses panel answers": "Escolha o modelo que funde as respostas do painel", + "Please copy the URL from the address bar and paste it in the application.": "Copie a URL da barra de endereço e cole no aplicativo.", + "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", + "Please wait while we complete the authorization.": "Aguarde enquanto concluímos a autorização.", + "Point your CLI tools to http://localhost:20128": "Aponte suas ferramentas CLI para http://localhost:20128", + "Port 443 Already In Use": "Porta 443 já está em uso", + "Port 443 is currently used by another process:": "A porta 443 está sendo usada por outro processo:", + "Powerful Features": "Recursos Poderosos", + "Prefix": "Prefixo", + "Preset": "Predefinição", + "Previous": "Anterior", + "Previous accounts page": "Página anterior de contas", + "Price": "Preço", + "Pricing Configuration": "Configuração de Preços", + "Pricing Format:": "Formato de Preço:", + "Pricing Rates Format": "Formato das Taxas de Preço", + "Pricing Settings": "Configurações de Preço", + "Priority": "Prioridade", + "Privacy Policy": "Política de Privacidade", + "Probing server for tools...": "Verificando servidor por ferramentas...", + "Processing...": "Processando...", + "Product": "Produto", + "Production Key": "Chave de Produção", + "Profile": "Perfil", + "ProgramArguments": "Argumentos do Programa", + "Project Name": "Nome do Projeto", + "Prompt": "Prompt", + "Provider": "Provedor", + "Provider not found": "Provedor não encontrado", + "Provider:": "Provedor:", + "Providers": "Provedores", + "Proxy": "Proxy", + "Proxy Pool": "Pool de Proxy", + "Proxy Pools": "Pools de Proxy", + "Proxy URL": "URL do Proxy", + "Proxy disabled": "Proxy desativado", + "Proxy enabled": "Proxy ativado", + "Proxy settings applied": "Configurações de proxy aplicadas", + "Proxy test OK": "Teste de proxy OK", + "Proxy test failed": "Falha no teste de proxy", + "Purpose:": "Propósito:", + "Python >= 3.10 required for local managed mode.": "Python >= 3.10 necessário para modo gerenciado local.", + "Quota Tracker": "Rastreador de cota", + "Read Documentation": "Ler Documentação", + "Read this skill and use it:": "Leia esta skill e use-a:", + "Ready": "Pronto", + "Ready to Simplify Your AI Infrastructure?": "Pronto para Simplificar Sua Infraestrutura de IA?", + "Reasoning": "Raciocínio", + "Reasoning:": "Raciocínio:", + "Recent Requests": "Requisições Recentes", + "Recent chats": "Chats recentes", + "Recheck": "Verificar novamente", + "Record request details for inspection in the logs view": "Gravar detalhes da requisição para inspeção na visualização de logs", + "Redirect URI": "URI de Redirecionamento", + "Redo": "Refazer", + "Reduction": "Redução", + "Ref Image (URL)": "URL da Imagem de Referência", + "Refresh": "Atualizar", + "Refresh all": "Atualizar tudo", + "Refresh quota": "Atualizar cota", + "Region": "Região", + "Reload Page": "Recarregar página", + "Reload VS Code after applying for changes to take effect.": "Recarregue o VS Code após aplicar para que as alterações entrem em vigor.", + "Remaining": "Restante", + "Remote": "Remoto", + "Remove": "Remover", + "Remove [code] and its packages?": "Remover [code] e seus pacotes?", + "Remove [ml] and its packages?": "Remover [ml] e seus pacotes?", + "Remove attachment": "Remover anexo", + "Remove custom model": "Remover modelo personalizado", + "Remove failed": "Falha ao remover", + "Remove model": "Remover modelo", + "Repair": "Reparar", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "Substitui WebSearch/WebFetch nativos. Remove automaticamente duplicatas da lista de ferramentas.", + "Request": "Requisição", + "Request Details": "Detalhes da Requisição", + "Request Logs": "Logs de Requisições", + "Requests": "Requisições", + "Requests smaller than this bypass PXPIPE and are sent as-is.": "Requisições menores que isso ignoram PXPIPE e são enviadas como estão.", + "Requests without a valid key will be rejected": "Requisições sem uma chave válida serão rejeitadas", + "Require API key": "Exigir chave de API", + "Require login": "Exigir login", + "Required": "Obrigatório", + "Required for SSL certificate and DNS configuration": "Necessário para certificado SSL e configuração de DNS", + "Required for SSL certificate and server startup": "Necessário para certificado SSL e inicialização do servidor", + "Required to modify /etc/hosts and flush DNS cache": "Necessário para modificar /etc/hosts e limpar cache DNS", + "Requires Cloudflare Account ID and a Workers API Token": "Requer ID da Conta Cloudflare e um Token de API Workers", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "Requer porta de saída 7844 (TCP/UDP). Conexão pode levar 10-30s.", + "Reset": "Redefinir", + "Reset Codex limit?": "Redefinir limite do Codex?", + "Reset Password to Default": "Redefinir Senha para Padrão", + "Reset judge to Auto": "Redefinir julgador para Automático", + "Reset to Defaults": "Redefinir para Padrões", + "Resources": "Recursos", + "Response": "Resposta", + "Response Format": "Formato da Resposta", + "Restart": "Reiniciar", + "Restart failed": "Falha ao reiniciar", + "Restarting proxy…": "Reiniciando proxy…", + "Restore model": "Restaurar modelo", + "Retry": "Tentar novamente", + "Risk Notice": "Aviso de Risco", + "Rotation Strategy": "Estratégia de Rotação", + "Round Robin": "Round Robin", + "Route Requests": "Rotear Requisições", + "Routing Strategy": "Estratégia de roteamento", + "Rows:": "Linhas:", + "Run": "Executar", + "Run npx command to start the server instantly": "Execute o comando npx para iniciar o servidor instantaneamente", + "RunAtLoad": "Executar ao Carregar", + "Running": "Executando", + "Running on your machine": "Executando em sua máquina", + "SSE URL": "URL SSE", + "START HERE": "COMECE AQUI", + "Save": "Salvar", + "Save Mappings": "Salvar Mapeamentos", + "Save auth mode": "Salvar modo de autenticação", + "Save current Base URL and API key as a browser-local preset": "Salvar URL Base e chave de API atuais como predefinição local do navegador", + "Save this key now!": "Salve esta chave agora!", + "Saved": "Salvo", + "Scopes": "Escopos", + "Scroll down to": "Role para baixo até", + "Search": "Pesquisar", + "Search by name or description...": "Pesquisar por nome ou descrição...", + "Search language...": "Pesquisar idioma...", + "Search models...": "Pesquisar modelos...", + "Search providers...": "Pesquisar provedores...", + "Search...": "Pesquisar...", + "Security": "Segurança", + "Security risk: no password set.": "Risco de segurança: nenhuma senha definida.", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "Risco de segurança: nenhuma senha definida. Será solicitado que você defina uma ao fazer login remotamente.", + "Select": "Selecionar", + "Select All": "Selecionar Todos", + "Select Cowork Model": "Selecionar Modelo Cowork", + "Select Endpoint": "Selecionar Endpoint", + "Select Judge Model": "Selecionar Modelo Julgador", + "Select Language": "Selecionar Idioma", + "Select Model": "Selecionar Modelo", + "Select Model for Cline": "Selecionar Modelo para Cline", + "Select Model for Codex": "Selecionar Modelo para Codex", + "Select Model for DeepSeek TUI": "Selecionar Modelo para DeepSeek TUI", + "Select Model for Factory Droid": "Selecionar Modelo para Factory Droid", + "Select Model for Hermes Agent": "Selecionar Modelo para Hermes Agent", + "Select Model for Kilo Code": "Selecionar Modelo para Kilo Code", + "Select Model for Open Claw": "Selecionar Modelo para Open Claw", + "Select Model for jcode": "Selecionar Modelo para jcode", + "Select Subagent Model for Codex": "Selecionar Modelo de Subagente para Codex", + "Select Subagent Model for OpenCode": "Selecionar Modelo de Subagente para OpenCode", + "Select a provider": "Selecionar um provedor", + "Select language": "Selecionar idioma", + "Select your": "Selecione seu(sua)", + "Selected provider": "Provedor selecionado", + "Send": "Enviar", + "Server": "Servidor", + "Server Disconnected": "Servidor desconectado", + "Server off": "Servidor desligado", "Server started": "Servidor iniciado", - "Failed to start server": "Falha ao iniciar o servidor", "Server stopped — all DNS cleared": "Servidor parado — todo DNS foi limpo", - "Failed to stop server": "Falha ao parar o servidor", - "Sudo password is required": "Senha sudo é necessária", - "Stop Server": "Parar servidor", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", + "Set Password": "Definir senha", + "Set a new password before accessing the dashboard remotely.": "Defina uma nova senha antes de acessar o painel remotamente.", + "Set password": "Definir senha", + "Settings": "Configurações", + "Setup": "Configurar", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "Configuração + índice de todas as capacidades. Comece aqui — cobre URL base, autenticação, descoberta de modelos e links para todas as skills.", + "Setup Headroom": "Configurar Headroom", + "Setup PXPIPE": "Configurar PXPIPE", + "Show this quota row": "Mostrar esta linha de cota", + "Shutdown": "Desligar", + "Sign in with OIDC": "Entrar com OIDC", + "Single": "Único", + "Sort": "Classificar", + "Sort Codex quotas by remaining": "Ordenar cotas do Codex por saldo restante", + "Sort accounts by earliest quota reset time": "Ordenar contas pelo horário de redefinição de cota", + "Speech-to-Text": "Fala-para-Texto", + "StandardErrorPath": "Caminho do Erro Padrão", + "StandardOutPath": "Caminho da Saída Padrão", + "Start": "Iniciar", + "Start DNS": "Iniciar DNS", + "Start Date": "Data de Início", + "Start Free": "Começar Gratuito", + "Start Headroom": "Iniciar Headroom", + "Start Headroom separately at the configured URL, then recheck.": "Inicie o Headroom separadamente na URL configurada e verifique novamente.", + "Start MITM": "Iniciar MITM", "Start Server": "Iniciar servidor", - "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", - "Sudo Password Required": "Senha Sudo necessária", - "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "Start Tunnel": "Iniciar Túnel", + "Start a conversation": "Iniciar uma conversa", + "Starting…": "Iniciando…", + "Status": "Status", + "Status:": "Status:", + "Step 1: Open this URL in your browser": "Passo 1: Abra esta URL no seu navegador", + "Step 2: Paste the callback URL here": "Passo 2: Cole a URL de callback aqui", + "Sticky Limit": "Limite pegajoso", + "Sticky:": "Fixo:", + "Stop": "Parar", + "Stop DNS": "Parar DNS", + "Stop Headroom": "Parar Headroom", + "Stop MITM": "Parar MITM", + "Stop Server": "Parar servidor", + "Stopped": "Parado", + "Stopping…": "Parando…", + "Strict Proxy": "Proxy Estrito", + "Subagent Model": "Modelo de Subagente", + "Subagent model overrides": "Substituições de modelo de subagente", + "Submit": "Enviar", + "Success": "Sucesso", "Sudo Password": "Senha sudo", - "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", - "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco.", + "Sudo Password Required": "Senha Sudo necessária", + "Sudo password is required": "Senha sudo é necessária", + "Support": "Suporte", + "Switch language": "Trocar idioma", + "System": "Sistema", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped.": "O Tailscale Funnel será parado.", + "Tailscale installed": "Tailscale instalado", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale não está instalado. Instale para ativar o Funnel.", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", + "Temperature": "Temperatura", + "Terms of Service": "Termos de Serviço", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Prompt de sistema conciso → ~65% menos tokens de saída (até 87%)", + "Test Example": "Exemplo de Teste", + "Test Results": "Resultados do Teste", + "Test all API Key connections": "Testar todas as conexões de Chave API", + "Test all Free connections": "Testar todas as conexões Gratuitas", + "Test all Free provider connections": "Testar todas as conexões de provedores Gratuitos", + "Test all OAuth connections": "Testar todas as conexões OAuth", + "Test connection": "Testar conexão", + "Test proxy": "Testar proxy", + "Test proxy URL": "Testar URL do proxy", + "Text-to-Speech": "Texto-para-Fala", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Texto-para-imagem via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…", + "The Cloudflare tunnel will be disconnected.": "O túnel Cloudflare será desconectado.", + "The proxy server has been stopped.": "O servidor proxy foi parado.", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "O endpoint unificado para geração de IA. Conecte, roteie e gerencie seus provedores de IA com facilidade.", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "A interface unificada para infraestrutura de IA moderna. Segura, observável e escalável.", + "Theme": "Tema", + "Thinking Process": "Processo de Raciocínio", + "This is the only time you will see this key. Store it securely.": "Esta é a única vez que você verá esta chave. Armazene-a com segurança.", + "Time": "Hora", + "Timestamp": "Timestamp", + "Timestamp:": "Timestamp:", + "Toggle auto-ping": "Alternar ping automático", + "Token Saver": "Economizador de Tokens", + "Token Saver settings": "Configurações do Economizador de Tokens", + "Token Types:": "Tipos de Token:", + "Tokens": "Tokens", + "Tools": "Ferramentas", + "Total": "Total", + "Total Input Tokens": "Total de Tokens de Entrada", + "Total Models": "Total de Modelos", + "Total Requests": "Total de Requisições", + "Total:": "Total:", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcreva áudio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…", + "Translator Debug": "Depuração do Tradutor", + "Tried in order (top-down) or rotated when round-robin is on.": "Tentado em ordem (de cima para baixo) ou rotacionado quando round-robin está ativo.", + "Trust Cert": "Confiar Certificado", + "Try Again": "Tentar Novamente", + "Tunnel": "Túnel", + "Turn off Empty": "Desligar Vazio", + "Turn on Available": "Ligar Disponível", + "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Twitter": "Twitter", + "Type": "Tipo", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / texto / HTML via Firecrawl, Jina, Tavily, Exa.", + "Unavailable": "Indisponível", + "Under": "Abaixo", + "Undo": "Desfazer", + "Uninstall": "Desinstalar", + "Uninstalling…": "Desinstalando…", + "Update": "Atualizar", + "Update 9Router": "Atualizar 9Router", + "Update Password": "Atualizar senha", + "Update now": "Atualizar agora", + "Updated": "Atualizado", + "Upload": "Enviar", + "Uptime": "Tempo de atividade", + "Usage": "Uso", + "Usage Logs": "Logs de Uso", + "Usage:": "Uso:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", + "Use a GitLab OAuth application": "Usar um aplicativo OAuth GitLab", + "Use a GitLab PAT with api scope": "Usar um PAT GitLab com escopo de API", + "Valid": "Válido", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vetores para RAG / busca semântica via OpenAI, Gemini, Mistral…", + "Vercel API Token": "Token de API Vercel", + "Vercel Relay": "Vercel Relay", + "Version": "Versão", + "View": "Visualizar", + "View Codex reset credit expiry": "Ver expiração de crédito do Codex", + "View Full Details": "Ver Detalhes Completos", + "View on GitHub": "Ver no GitHub", + "Visit the login URL below and authorize:": "Visite a URL de login abaixo e autorize:", + "Voice": "Voz", + "Voice ID": "ID de Voz", + "Voyage AI": "Voyage AI", + "Waiting for authorization...": "Aguardando autorização...", + "Warning": "Aviso", + "Web Fetch": "Fetch Web", + "Web Search": "Busca Web", + "What is Cloudflare Relay?": "O que é Cloudflare Relay?", + "What is Deno Relay?": "O que é Deno Relay?", + "What is Vercel Relay?": "O que é Vercel Relay?", + "When": "Quando", + "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: Execute o terminal (9Router) como Administrador para ativar MITM", + "Worker Name": "Nome do Worker", + "Writes to": "Grava em", + "Yes": "Sim", + "You will be asked to set one when logging in remotely.": "Será solicitado que você defina uma ao fazer login remotamente.", + "Your Account Name": "Nome da Sua Conta", + "Your Code": "Seu Código", + "Your OAuth application client ID": "ID do cliente do seu aplicativo OAuth", + "Your requests start from your favorite tools or our unified SDK.": "Suas requisições começam de suas ferramentas favoritas ou do nosso SDK unificado.", + "[ml] downloads ~1 GB (torch + huggingface-hub). Continue?": "[ml] baixa ~1 GB (torch + huggingface-hub). Continuar?", + "extras status failed": "falha no status dos extras", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → 60-90% menos tokens de entrada", + "not installed": "não instalado", + "sk_9router (default)": "sk_9router (padrão)", + "tree-sitter AST compression for code responses": "Compressão AST tree-sitter para respostas de código", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar solicitações aos seus provedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.", - "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API." -} + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco." +} \ No newline at end of file From a8313cd3227019720f33a41bc49588437f2b9ead Mon Sep 17 00:00:00 2001 From: Sutarto Jordan Chrisfivo Date: Wed, 29 Jul 2026 19:32:31 +0700 Subject: [PATCH 28/34] feat(kiro): add Claude Opus 5 models Register Opus 5 and its thinking/agentic variants with 1M context and adaptive-thinking capabilities. --- open-sse/providers/capabilities.js | 6 +++++- open-sse/providers/registry/kiro.js | 4 ++++ tests/unit/capabilities-opus-context.test.js | 6 +++++- tests/unit/capabilities.test.js | 12 ++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 2c456da4..e113b4cd 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -71,8 +71,11 @@ export function capabilitiesFromServiceKind(kind) { * otherwise mis-match. Only declare deltas vs DEFAULT. */ export const MODEL_CAPABILITIES = { - // Claude 4.6/4.7/4.8/5 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + // Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) "claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, @@ -181,6 +184,7 @@ export const PROVIDER_CAPABILITIES = { */ export const PATTERN_CAPABILITIES = [ // ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ────── + { pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } }, { pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index 325745a4..f506b8d4 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -42,6 +42,10 @@ export default { }, models: [ // Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models) + { id: "claude-opus-5", name: "Claude Opus 5" }, + { id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" }, + { id: "claude-opus-5-agentic", name: "Claude Opus 5 (Agentic)" }, + { id: "claude-opus-5-thinking-agentic", name: "Claude Opus 5 (Thinking + Agentic)" }, { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, { id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" }, { id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" }, diff --git a/tests/unit/capabilities-opus-context.test.js b/tests/unit/capabilities-opus-context.test.js index 9bb7518f..2ef9f956 100644 --- a/tests/unit/capabilities-opus-context.test.js +++ b/tests/unit/capabilities-opus-context.test.js @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; // Claude Opus 4.6+ ships a 1M-token context window (GA, standard pricing). -// The registry exposes dashed ids (claude-opus-4-8, claude-opus-4-7), which +// The registry exposes dashed ids (claude-opus-5, claude-opus-4-8, claude-opus-4-7), which // must resolve to the 1M context + adaptive thinking caps rather than falling // through to the generic *claude*opus* pattern (200k / budget thinking). describe("Claude Opus 1M context capabilities", () => { @@ -17,6 +17,10 @@ describe("Claude Opus 1M context capabilities", () => { }; for (const model of [ + "claude-opus-5", + "claude-opus-5-thinking", + "claude-opus-5-agentic", + "claude-opus-5-thinking-agentic", "claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-7", diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index ccfddfa5..a5c7b03d 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -20,6 +20,18 @@ describe("getCapabilitiesForModel", () => { search: true, }; + it("reports Kiro Claude Opus 5 variants as 1M adaptive-thinking models", () => { + for (const model of [ + "claude-opus-5", + "anthropic/claude-opus-5", + "claude-opus-5-thinking", + "claude-opus-5-agentic", + "claude-opus-5-thinking-agentic", + ]) { + expect(getCapabilitiesForModel("kiro", model)).toMatchObject(claudeSonnet5Expected); + } + }); + it("reports Kiro Claude Opus 4.8 as a 1M context model", () => { expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000); expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000); From 24fd165b0d2a571c8932ae7758093c2fc039149c Mon Sep 17 00:00:00 2001 From: ridwan kulu Date: Wed, 29 Jul 2026 19:36:52 +0700 Subject: [PATCH 29/34] docs(readme): add Indonesian translation Add an Indonesian README and link it from the root README language switcher. --- README.md | 2 +- i18n/README.id-ID.md | 951 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 952 insertions(+), 1 deletion(-) create mode 100644 i18n/README.id-ID.md diff --git a/README.md b/README.md index 916298bf..4458ba78 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) -[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) +[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md)
diff --git a/i18n/README.id-ID.md b/i18n/README.id-ID.md new file mode 100644 index 00000000..dbfbd8f5 --- /dev/null +++ b/i18n/README.id-ID.md @@ -0,0 +1,951 @@ +
+ 9Router Dashboard + + # 9Router - Router AI Gratis + + **Jangan berhenti ngoding. Otomatis dialihkan ke model AI gratis & murah dengan smart fallback.** + + **Hubungkan semua tool AI coding (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) ke 40+ provider AI dan 100+ model.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + [🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com) + + [🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md) +
+ +--- + +## 🤔 Kenapa 9Router? + +**Berhenti buang-buang uang dan terhambat limit:** + +- ❌ Kuota langganan hangus tiap bulan tanpa terpakai +- ❌ Rate limit bikin ngoding berhenti di tengah jalan +- ❌ API mahal ($20–50/bulan per provider) +- ❌ Harus gonta-ganti provider secara manual + +**9Router menyelesaikan itu semua:** + +- ✅ **Maksimalkan langganan** - lacak kuota dan habiskan sebelum reset +- ✅ **Fallback otomatis** - langganan → murah → gratis, tanpa downtime +- ✅ **Multi-akun** - round-robin antar akun untuk tiap provider +- ✅ **Universal** - mendukung Claude Code, Codex, Gemini CLI, Cursor, Cline, dan tool CLI apa pun + +--- + +## 🔄 Cara Kerja + +``` +┌─────────────┐ +│ Tool CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ kamu │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • Konversi format (OpenAI ↔ Claude) │ +│ • Pelacakan kuota │ +│ • Refresh token otomatis │ +└──────┬──────────────────────────────────┘ + │ + ├─→ [Tier 1: Langganan] Claude Code, Codex, Gemini CLI + │ ↓ kuota habis + ├─→ [Tier 2: Murah] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ batas budget tercapai + └─→ [Tier 3: Gratis] iFlow, Qwen, Kiro (unlimited) + +Hasil: ngoding tanpa berhenti, biaya minimum +``` + +--- + +## ⚡ Mulai Cepat + +**1. Install secara global:** + +```bash +npm install -g 9router +9router +``` + +🎉 Dashboard terbuka di `http://localhost:20128` + +**2. Hubungkan provider gratis (tanpa perlu daftar):** + +Dashboard → Providers → hubungkan **Claude Code** atau **Antigravity** → login OAuth → selesai! + +**3. Pakai di tool CLI kamu:** + +``` +Konfigurasi Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [salin dari dashboard] + Model: if/kimi-k2-thinking +``` + +**Cuma itu!** Mulai ngoding dengan model AI gratis. + +**Alternatif: jalankan dari source (repo ini):** + +Paket repo ini bersifat privat (`9router-app`), jadi menjalankan dari source/Docker adalah jalur yang diharapkan untuk pengembangan lokal. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Mode produksi: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URL default: +- Dashboard: `http://localhost:20128/dashboard` +- API kompatibel OpenAI: `http://localhost:20128/v1` + +--- + +## 🎥 Video Tutorial + +
+ +### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis + +[![9Router + Claude Code Setup](https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg)](https://www.youtube.com/watch?v=raEyZPg5xE0) + +**🎬 Tonton tutorial langkah demi langkah:** +- ✅ Install dan setup 9Router +- ✅ Konfigurasi Claude Sonnet 4.5 gratis +- ✅ Integrasi dengan Claude Code +- ✅ Demo live coding + +**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community + +[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg) + +
+ +--- + +## 🛠️ Tool CLI yang Didukung + +9Router bekerja mulus dengan semua tool AI coding utama: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Provider yang Didukung + +### 🔐 Provider OAuth + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 Provider Gratis + +
+ + + + + + + +
+ iFlow
+ iFlow AI
+ 8+ model • unlimited +
+ Qwen
+ Qwen Code
+ 3+ model • unlimited +
+ Gemini CLI
+ Gemini CLI
+ 180 ribu request/bulan gratis +
+ Kiro
+ Kiro AI
+ Claude • unlimited +
+
+ +### 🔑 Provider API Key (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...dan 20+ provider lain seperti Nebius, Chutes, Hyperbolic, serta endpoint custom yang kompatibel dengan OpenAI/Anthropic

+
+ +--- + +## 💡 Fitur Utama + +| Fitur | Ringkasan | Manfaat | +|-------|-----------|---------| +| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime | +| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal | +| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun | +| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi | +| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual | +| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu | +| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah | +| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun | +| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran | +| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel | + +
+📖 Detail Fitur + +### 🎯 Smart Fallback 3 Tingkat + +Buat combo dengan fallback otomatis: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (langganan) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. if/kimi-k2-thinking (fallback gratis) + +→ Otomatis beralih saat kuota habis atau terjadi error +``` + +### 📊 Pelacakan Kuota Real-time + +- Konsumsi token per provider +- Hitung mundur reset (5 jam, harian, mingguan) +- Estimasi biaya untuk tier berbayar +- Laporan pengeluaran bulanan + +### 🔄 Konversi Format + +Konversi mulus antar format: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses** +- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya +- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint + +### 👥 Dukungan Multi-akun + +- Tambahkan beberapa akun per provider +- Round-robin otomatis atau routing berbasis prioritas +- Saat satu akun mencapai kuota, fallback ke akun berikutnya + +### 🔄 Auto Refresh Token + +- Token OAuth di-refresh otomatis sebelum kedaluwarsa +- Tidak perlu autentikasi ulang manual +- Pengalaman mulus di semua provider + +### 🎨 Combo Kustom + +- Buat kombinasi model tanpa batas +- Campur tier langganan, murah, dan gratis +- Beri nama combo agar mudah diakses +- Bagikan combo antar perangkat lewat cloud sync + +### 📝 Log Request + +- Log lengkap request/response dalam mode debug +- Lacak API call, header, dan payload +- Troubleshoot masalah integrasi +- Ekspor log untuk dianalisis + +### 💾 Cloud Sync + +- Sinkronkan provider, combo, dan pengaturan antar perangkat +- Sinkronisasi latar belakang otomatis +- Penyimpanan terenkripsi yang aman +- Akses setup dari mana saja + +#### Catatan tentang cloud runtime + +- Untuk produksi, disarankan memakai variabel cloud sisi server: + - `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi) + - `CLOUD_URL` (base URL endpoint cloud sync) +- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`. +- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia. + +### 📊 Analitik Penggunaan + +- Lacak pemakaian token per provider dan per model +- Estimasi biaya dan tren pengeluaran +- Laporan dan insight bulanan +- Optimalkan pengeluaran AI + +> **💡 PENTING - tentang biaya di dashboard:** +> +> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**. +> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar. +> +> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow, +> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas). +> +> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router! + +### 🌐 Deploy di Mana Saja + +- 💻 **Localhost** - default, jalan offline +- ☁️ **VPS/Cloud** - berbagi antar perangkat +- 🐳 **Docker** - deploy satu perintah +- 🚀 **Cloudflare Workers** - jaringan edge global + +
+ +--- + +## 💰 Ringkasan Harga + +| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk | +|------|----------|-------|-------------|-------------| +| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan | +| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI | +| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! | +| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub | +| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat | +| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah | +| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi | +| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis | +| | Qwen | $0 | Unlimited | 3 model gratis | +| | Kiro | $0 | Unlimited | Claude gratis | + +**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0! + +--- + +### 📊 Tentang Biaya dan Penagihan 9Router + +**Fakta soal penagihan 9Router:** + +✅ **Software 9Router = gratis selamanya** (open source, tanpa tagihan) +✅ **"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan) +✅ **Pembayaran langsung ke provider** (langganan atau biaya API) +✅ **Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited) +❌ **9Router tidak mengirim invoice** atau menagih kartumu + +**Cara kerja tampilan biaya:** + +Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu. + +**Contoh skenario:** +``` +Tampilan dashboard: +• Total request: 1.662 +• Total token: 47 juta +• Biaya tertampil: $290 + +Kenyataannya: +• Provider: iFlow (gratis unlimited) +• Yang benar-benar dibayar: $0.00 +• Arti $290: jumlah yang kamu hemat dengan memakai model gratis! +``` + +**Aturan pembayaran:** +- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing +- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing +- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi +- **9Router**: tidak menagih apa pun + +--- + +## 🎯 Studi Kasus + +### Kasus 1: "Saya punya langganan Claude Pro" + +**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat + +**Solusi:** +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin) + 2. glm/glm-4.7 (backup murah saat kuota habis) + 3. if/kimi-k2-thinking (fallback darurat gratis) + +Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25 +vs. $20 + kena limit = frustrasi +``` + +### Kasus 2: "Saya mau biaya nol" + +**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal + +**Solusi:** +``` +Combo: "free-forever" + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + +Biaya bulanan: $0 +Kualitas: model siap produksi +``` + +### Kasus 3: "Ngoding 24/7 tanpa terputus" + +**Masalah:** deadline mepet, downtime tidak dapat ditoleransi + +**Solusi:** +``` +Combo: "always-on" + 1. cc/claude-opus-4-6 (kualitas terbaik) + 2. cx/gpt-5.2-codex (langganan kedua) + 3. glm/glm-4.7 (murah, reset harian) + 4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam) + 5. if/kimi-k2-thinking (gratis unlimited) + +Hasil: 5 lapis fallback = zero downtime +Biaya bulanan: $20-200 (langganan) + $10-20 (backup) +``` + +### Kasus 4: "Saya mau pakai AI gratis di OpenClaw" + +**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis + +**Solusi:** +``` +Combo: "openclaw-free" + 1. if/glm-4.7 (gratis unlimited) + 2. if/minimax-m2.1 (gratis unlimited) + 3. if/kimi-k2-thinking (gratis unlimited) + +Biaya bulanan: $0 +Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ FAQ + +
+📊 Kenapa dashboard menampilkan biaya yang besar? + +Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router. + +**Contoh:** +- **Tampilan dashboard:** "Total biaya $290" +- **Kenyataan:** sedang memakai iFlow (gratis unlimited) +- **Biaya sebenarnya:** **$0.00** +- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar! + +Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi. + +
+ +
+💳 Apakah 9Router menagih saya? + +**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali. + +**Kamu membayar ke:** +- ✅ **Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing +- ✅ **Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request +- ❌ **9Router sendiri** → **tidak menagih apa pun** + +9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis. + +
+ +
+🆓 Apakah provider gratis benar-benar unlimited? + +**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**. + +Ini adalah layanan gratis yang disediakan masing-masing perusahaan: +- **iFlow**: akses gratis unlimited ke 8+ model via OAuth +- **Kiro**: model Claude gratis unlimited via AWS Builder ID +- **Qwen**: akses gratis unlimited ke model Qwen via device authentication + +9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback. + +**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router. + +
+ +
+💰 Bagaimana cara menekan biaya AI seminimal mungkin? + +**Strategi free-first:** + +1. **Mulai dari combo 100% gratis:** + ``` + 1. gc/gemini-3-flash (180rb/bulan gratis dari Google) + 2. if/kimi-k2-thinking (gratis unlimited dari iFlow) + 3. qw/qwen3-coder-plus (gratis unlimited dari Qwen) + ``` + **Biaya: $0/bulan** + +2. **Tambahkan backup murah hanya bila perlu:** + ``` + 4. glm/glm-4.7 ($0.6 per 1 juta token) + ``` + **Tambahan biaya: bayar sesuai pemakaian saja** + +3. **Gunakan provider langganan paling akhir:** + - Hanya jika kamu memang sudah punya + - 9Router memaksimalkan nilainya lewat pelacakan kuota + +**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis! + +
+ +
+📈 Bagaimana kalau pemakaian tiba-tiba melonjak? + +Smart fallback 9Router mencegah tagihan tak terduga: + +**Skenario:** kuota habis di tengah sprint coding + +**Tanpa 9Router:** +- ❌ Kena rate limit → kerja berhenti → frustrasi +- ❌ Atau: tagihan API mahal tanpa disengaja + +**Dengan 9Router:** +- ✅ Langganan mencapai batas → otomatis fallback ke tier murah +- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis +- ✅ Ngoding tidak berhenti → biaya tetap terprediksi + +**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya. + +
+ +--- + +## 📖 Panduan Setup + +
+🔐 Provider Langganan (maksimalkan nilainya) + +### Claude Code (Pro/Max) + +```bash +Dashboard → Providers → hubungkan Claude Code +→ login OAuth → refresh token otomatis +→ pelacakan kuota 5 jam + mingguan + +Model: + cc/claude-opus-4-6 + cc/claude-sonnet-4-5-20250929 + cc/claude-haiku-4-5-20251001 +``` + +**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model! + +### OpenAI Codex (Plus/Pro) + +```bash +Dashboard → Providers → hubungkan Codex +→ login OAuth (port 1455) +→ reset 5 jam + mingguan + +Model: + cx/gpt-5.2-codex + cx/gpt-5.1-codex-max +``` + +### Gemini CLI (180rb request/bulan gratis!) + +```bash +Dashboard → Providers → hubungkan Gemini CLI +→ Google OAuth +→ 180rb/bulan + 1rb/hari + +Model: + gc/gemini-3-flash-preview + gc/gemini-2.5-pro +``` + +**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar. + +### GitHub Copilot + +```bash +Dashboard → Providers → hubungkan GitHub +→ OAuth via GitHub +→ reset bulanan (tanggal 1 tiap bulan) + +Model: + gh/gpt-5 + gh/claude-4.5-sonnet + gh/gemini-3-pro +``` + +
+ +
+💰 Provider Murah (backup) + +### GLM-4.7 (reset harian, $0.6/1M) + +1. Daftar: [Zhipu AI](https://open.bigmodel.cn/) +2. Ambil API key dari Coding Plan +3. Dashboard → tambahkan API key: + - Provider: `glm` + - API Key: `your-key` + +**Pemakaian:** `glm/glm-4.7` + +**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00. + +### MiniMax M2.1 (reset 5 jam, $0.20/1M) + +1. Daftar: [MiniMax](https://www.minimax.io/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `minimax/MiniMax-M2.1` + +**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)! + +### Kimi K2 ($9/bulan flat) + +1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `kimi/kimi-latest` + +**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M! + +
+ +
+🆓 Provider Gratis (backup darurat) + +### iFlow (8 model gratis) + +```bash +Dashboard → hubungkan iFlow +→ login OAuth iFlow +→ pemakaian unlimited + +Model: + if/kimi-k2-thinking + if/qwen3-coder-plus + if/glm-4.7 + if/minimax-m2 + if/deepseek-r1 +``` + +### Qwen (3 model gratis) + +```bash +Dashboard → hubungkan Qwen +→ autentikasi device code +→ pemakaian unlimited + +Model: + qw/qwen3-coder-plus + qw/qwen3-coder-flash +``` + +### Kiro (Claude gratis) + +```bash +Dashboard → hubungkan Kiro +→ AWS Builder ID atau Google/GitHub +→ pemakaian unlimited + +Model: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 +``` + +
+ +
+🎨 Membuat Combo + +### Contoh 1: maksimalkan langganan → backup murah + +``` +Dashboard → Combos → buat baru + +Nama: premium-coding +Model: + 1. cc/claude-opus-4-6 (langganan, utama) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M) + +Pemakaian di CLI: premium-coding + +Contoh biaya bulanan (100 juta token): + 80 juta lewat Claude (langganan): tambahan $0 + 15 juta lewat GLM: $9 + 5 juta lewat MiniMax: $1 + Total: $10 +``` + +### Contoh 2: combo 100% gratis + +``` +Nama: free-forever +Model: + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + 4. kr/claude-sonnet-4.5 (gratis unlimited) + +Biaya bulanan: $0 +``` + +### Tips membuat combo + +- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah +- Selalu taruh minimal satu provider gratis di posisi terakhir +- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI +- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain + +
+ +--- + +## 🐳 Deployment + +
+Docker + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v 9router-data:/app/data \ + -e PORT=20128 \ + -e BASE_URL=http://localhost:20128 \ + ghcr.io/decolua/9router:latest +``` + +Dashboard: `http://localhost:20128/dashboard` + +
+ +
+VPS / Cloud + +```bash +npm install -g 9router +PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router +``` + +Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri. + +
+ +
+Cloudflare Workers + +```bash +npm run build +npx wrangler deploy +``` + +Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare. + +
+ +--- + +## 🧪 Troubleshooting + +| Masalah | Kemungkinan Penyebab | Solusi | +|---------|----------------------|--------| +| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` | +| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard | +| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` | +| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard | +| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) | +| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast | + +Aktifkan mode debug di dashboard untuk melihat log lengkap request/response. + +--- + +## 🤝 Kontribusi + +Kontribusi sangat diterima! + +1. Fork repo ini +2. Buat branch fitur (`git checkout -b feature/nama-fitur`) +3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`) +4. Push ke branch (`git push origin feature/nama-fitur`) +5. Buka Pull Request + +--- + +## 📄 Lisensi + +MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya. + +--- + +
+ +**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!** + +[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues) + +
From baf335658326bba794ea44c3f2778b801c593ee9 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 29 Jul 2026 20:06:20 +0700 Subject: [PATCH 30/34] fix(ui): count free-tier oauth connections on providers list Free-tier cards (e.g. kimchi, oauth-only) hardcoded "apikey" for stats and toggle, so oauth connections were invisible on /dashboard/providers despite showing on the detail page. Use dualAuthTypes per provider instead. Co-Authored-By: Claude Fable 5 --- .../(dashboard)/dashboard/providers/page.js | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 2fa34f66..eab63c7a 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -294,15 +294,27 @@ export default function ProvidersPage() { const freeEntries = Object.entries(FREE_PROVIDERS) .filter(([, info]) => !info.hidden && matchSearch(info.name)) .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); - const freeTierEntries = sortByPriority( - Object.entries(FREE_TIER_PROVIDERS).filter( + // Free Tier cards may be oauth-only (e.g. kimchi) or dual-auth, so count via + // dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth + // connections are invisible here (mismatch with the detail page). + const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS) + .filter( ([, info]) => !info.hidden && matchSearch(info.name) && (info.serviceKinds ?? ["llm"]).includes("llm"), - ), - "freeTier", - ).sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); + ) + .sort(([ka, a], [kb, b]) => { + const pa = a.priority ?? 999; + const pb = b.priority ?? 999; + if (pa !== pb) return pa - pb; + const noAuthDiff = (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0); + if (noAuthDiff !== 0) return noAuthDiff; + const ca = getProviderStats(ka, dualAuthTypes(a, ka)).connected > 0 ? 0 : 1; + const cb = getProviderStats(kb, dualAuthTypes(b, kb)).connected > 0 ? 0 : 1; + if (ca !== cb) return ca - cb; + return (a.name || "").localeCompare(b.name || ""); + }); // API Key: connected providers first, then alphabetical by name const apikeyEntries = Object.entries(APIKEY_PROVIDERS) .filter( @@ -495,16 +507,19 @@ export default function ProvidersPage() { /> ); })} - {freeTierEntries.map(([key, info]) => ( - handleToggleProvider(key, "apikey", active)} - /> - ))} + {freeTierEntries.map(([key, info]) => { + const freeAuthTypes = dualAuthTypes(info, key); + return ( + handleToggleProvider(key, freeAuthTypes, active)} + /> + ); + })} )} From 31df0635aa1bf6bd552dc8f74fc1357434f14ace Mon Sep 17 00:00:00 2001 From: whale9820 Date: Wed, 29 Jul 2026 20:04:17 +0700 Subject: [PATCH 31/34] feat(providers): add Poolside provider (OpenAI-compatible) Adds Poolside (inference.poolside.ai) as an API-key provider using the default OpenAI transport. Registers three Laguna models with reasoning capabilities (262K context, 32K max output). --- open-sse/providers/capabilities.js | 6 +++++ open-sse/providers/registry/index.js | 2 ++ open-sse/providers/registry/poolside.js | 31 ++++++++++++++++++++++++ public/providers/poolside.png | Bin 0 -> 11703 bytes 4 files changed, 39 insertions(+) create mode 100644 open-sse/providers/registry/poolside.js create mode 100644 public/providers/poolside.png diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index e113b4cd..d999278d 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -174,6 +174,12 @@ export const PROVIDER_CAPABILITIES = { "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, }, + // Poolside Laguna — OpenAI-compatible, all reasoning-capable (262K context, 32K max output). + "poolside": { + "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 262000, maxOutput: 32000 }, + "laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 262000, maxOutput: 32000 }, + "laguna-m.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 262000, maxOutput: 32000 }, + }, }; /** diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 3afa797d..8d29e2cb 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -115,6 +115,7 @@ import p112 from "./tencent.js"; import p113 from "./morph.js"; import p114 from "./devin-cli.js"; // import p104 from "./windsurf.js"; +import p115 from "./poolside.js"; export default [ p0, @@ -231,4 +232,5 @@ export default [ p113, p114, // p104, // windsurf — hidden, no tool calling + p115, ]; diff --git a/open-sse/providers/registry/poolside.js b/open-sse/providers/registry/poolside.js new file mode 100644 index 00000000..462bd3f4 --- /dev/null +++ b/open-sse/providers/registry/poolside.js @@ -0,0 +1,31 @@ +export default { + id: "poolside", + priority: 60, + alias: "poolside", + aliases: [ + "ps", + ], + uiAlias: "ps", + display: { + name: "Poolside", + icon: "water_drop", + color: "#0EA5E9", + textIcon: "PS", + website: "https://poolside.ai", + notice: { + apiKeyUrl: "https://platform.poolside.ai/api-keys", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://inference.poolside.ai/v1/chat/completions", + validateUrl: "https://inference.poolside.ai/v1/models", + }, + models: [ + { id: "poolside/laguna-s-2.1", name: "Laguna S 2.1" }, + { id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" }, + { id: "poolside/laguna-m.1", name: "Laguna M.1" }, + ], +}; diff --git a/public/providers/poolside.png b/public/providers/poolside.png new file mode 100644 index 0000000000000000000000000000000000000000..592d604e32249c82f144cca6636fc3daf6329bef GIT binary patch literal 11703 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 Date: Wed, 29 Jul 2026 20:21:27 +0700 Subject: [PATCH 32/34] fix(providers): count apikey connections for freeTier providers openrouter, nvidia, gemini lack authModes, so dualAuthTypes on the providers page defaulted to "oauth" and their apikey connections showed as "No connections" on the freeTier card. Co-Authored-By: Claude Fable 5 --- open-sse/providers/registry/gemini.js | 2 ++ open-sse/providers/registry/nvidia.js | 2 ++ open-sse/providers/registry/openrouter.js | 2 ++ 3 files changed, 6 insertions(+) diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index d8e0b3c0..c9b0de9b 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -16,6 +16,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], mediaPriority: 1, transport: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", diff --git a/open-sse/providers/registry/nvidia.js b/open-sse/providers/registry/nvidia.js index 9522611a..4d375a17 100644 --- a/open-sse/providers/registry/nvidia.js +++ b/open-sse/providers/registry/nvidia.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", validateUrl: "https://integrate.api.nvidia.com/v1/models", diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index 4ac03641..a0df2a52 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://openrouter.ai/api/v1/chat/completions", thinkingFormat: "openai", From 9be6588cc8780329e64e200ee7c9c5de02b2b531 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 29 Jul 2026 21:05:23 +0700 Subject: [PATCH 33/34] chore: drop source-attribution comments from provider code Remove "Ported from OmniRoute" and cockpit-tools attribution comments. User-Agent strings and README/landing credits are left intact. Co-Authored-By: Claude Fable 5 --- open-sse/executors/zed.js | 3 +-- open-sse/handlers/search/callers.js | 1 - open-sse/handlers/search/normalizers.js | 1 - open-sse/providers/registry/codebuddy-intl.js | 3 +-- open-sse/providers/registry/zed.js | 8 +++----- open-sse/services/usage/grok-cli.js | 2 +- open-sse/services/usage/kimi.js | 2 +- open-sse/shared/zedAuth.js | 1 - tests/unit/kimi-usage.test.js | 2 +- 9 files changed, 8 insertions(+), 15 deletions(-) diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js index d7ffb401..e6233fcb 100644 --- a/open-sse/executors/zed.js +++ b/open-sse/executors/zed.js @@ -10,8 +10,7 @@ // 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 +// 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. diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index 64f045c3..e32d93ed 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -1,7 +1,6 @@ /** * Search Provider Request Builders * - * Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610). * Builds HTTP request `{ url, init }` for 10 search providers. * * @typedef {Object} SearchProviderConfig diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js index da008bf3..898b271f 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.js @@ -1,7 +1,6 @@ /** * Search Response Normalizers * - * Ported from OmniRoute open-sse/handlers/search.ts. * Each normalizer maps a provider-specific response into the unified SearchResult shape. */ diff --git a/open-sse/providers/registry/codebuddy-intl.js b/open-sse/providers/registry/codebuddy-intl.js index 4faaf804..7e69836c 100644 --- a/open-sse/providers/registry/codebuddy-intl.js +++ b/open-sse/providers/registry/codebuddy-intl.js @@ -1,6 +1,5 @@ // 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 +// swapping the Tencent CN domain for the .ai endpoint set. All OAuth/plugin URLs // use the /v2/plugin prefix with platform=ide (CN uses platform=CLI). export default { id: "codebuddy-intl", diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js index 5941b1e4..9224cf95 100644 --- a/open-sse/providers/registry/zed.js +++ b/open-sse/providers/registry/zed.js @@ -1,5 +1,4 @@ // 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, @@ -20,11 +19,10 @@ export default { hasOAuth: true, transport: { - // Zed hosted LLM aggregator (OmniRoute-verified): cloud.zed.dev/completions is a + // Zed hosted LLM aggregator: 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. + // token exchanged from the RSA-decrypted access_token (see open-sse/shared/zedAuth). baseUrl: "https://cloud.zed.dev/completions", format: "openai", forceStream: true, @@ -43,7 +41,7 @@ export default { 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). + // per-connection rather than hardcoded. modelsUrl: "https://cloud.zed.dev/models", }, diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index 80d89677..768192ad 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -35,7 +35,7 @@ const USAGE = U("grok-cli"); const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; -// SuperGrok weekly pool — same endpoint OmniRoute #6844 / steipete CodexBar docs. +// SuperGrok weekly pool. const GRPC_CREDITS_URL = "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; // Empty gRPC-web request frame (flag 0 + length 0). Without it upstream returns diff --git a/open-sse/services/usage/kimi.js b/open-sse/services/usage/kimi.js index 9fa92eda..4400965b 100644 --- a/open-sse/services/usage/kimi.js +++ b/open-sse/services/usage/kimi.js @@ -3,7 +3,7 @@ * * Dual auth (single provider id `kimi`): * - apiKey present → x-api-key only (platform / coding API key) - * - else accessToken → Bearer + X-Msh-* (device-code OAuth; OmniRoute parity) + * - else accessToken → Bearer + X-Msh-* (device-code OAuth) * * Note: chat messages use combined x-api-key; /usages OAuth is Bearer. * 403 permission_denied is NOT auth-expired — account lacks usage feature / sub. diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js index 5f38298b..aa3337d7 100644 --- a/open-sse/shared/zedAuth.js +++ b/open-sse/shared/zedAuth.js @@ -1,5 +1,4 @@ // 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: diff --git a/tests/unit/kimi-usage.test.js b/tests/unit/kimi-usage.test.js index 52d1e911..9d944a37 100644 --- a/tests/unit/kimi-usage.test.js +++ b/tests/unit/kimi-usage.test.js @@ -58,7 +58,7 @@ describe("getUsageForProvider(kimi) auth selection", () => { vi.clearAllMocks(); }); - it("OAuth path: Bearer + X-Msh-* (OmniRoute /usages parity; not chat x-api-key)", async () => { + it("OAuth path: Bearer + X-Msh-* (not chat x-api-key)", async () => { proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); const usage = await getUsageForProvider({ From 6fcd27337a7893642c7fe630840d0a641743f28f Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 30 Jul 2026 09:43:55 +0700 Subject: [PATCH 34/34] # v0.5.45 (2026-07-30) ## Features - **Providers**: add Poolside (OpenAI-compatible) - **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent - **OAuth**: zed / trae / windsurf providers + harden callback proxies - **CLI tools**: set Claude Code max context tokens - **Qoder**: PAT auth + refresh model list - **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite - **Claude**: bump default Opus to `claude-opus-5` - **Kiro**: add Claude Opus 5 models - **Usage**: Kimi and DeepSeek usage handlers - **Usage**: SuperGrok weekly pool via gRPC-web ## Fixes - **Refresh**: rotate `refresh_token` between retry attempts - **Kiro**: canonicalize tool history and route API keys correctly - **Kiro**: normalize dashboard thinking intensity models - **Cursor**: stop leaking agent tool errors as text - **Gemini**: fill empty tool schemas after `$ref` strip - **Antigravity**: strip `stream_options` from non-stream requests - **Jina-reader**: recover after transient errors, use JSON POST API - **Usage**: record exact embedding tokens - **Tunnel**: preserve successor cloudflared PID - **Console-log**: initialize capture at server boot + prevent SSE proxy buffering - **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly - **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow ## Docs - **i18n**: expand pt-BR translation to 986 terms - README: Indonesian translation --- CHANGELOG.md | 32 ++++++++++++++++ cli/package.json | 2 +- open-sse/executors/devin-cli.js | 38 +++++++++++-------- open-sse/providers/capabilities.js | 14 +++++-- open-sse/providers/registry/devin-cli.js | 3 +- open-sse/providers/registry/index.js | 4 +- open-sse/providers/registry/poolside.js | 1 - package.json | 2 +- src/app/api/cli-tools/devin-settings/route.js | 28 +++++++++----- src/shared/components/Sidebar.js | 19 +++++++++- 10 files changed, 108 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd3b81f..b970869d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +# v0.5.45 (2026-07-30) + +## Features +- **Providers**: add Poolside (OpenAI-compatible) +- **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent +- **OAuth**: zed / trae / windsurf providers + harden callback proxies +- **CLI tools**: set Claude Code max context tokens +- **Qoder**: PAT auth + refresh model list +- **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite +- **Claude**: bump default Opus to `claude-opus-5` +- **Kiro**: add Claude Opus 5 models +- **Usage**: Kimi and DeepSeek usage handlers +- **Usage**: SuperGrok weekly pool via gRPC-web + +## Fixes +- **Refresh**: rotate `refresh_token` between retry attempts +- **Kiro**: canonicalize tool history and route API keys correctly +- **Kiro**: normalize dashboard thinking intensity models +- **Cursor**: stop leaking agent tool errors as text +- **Gemini**: fill empty tool schemas after `$ref` strip +- **Antigravity**: strip `stream_options` from non-stream requests +- **Jina-reader**: recover after transient errors, use JSON POST API +- **Usage**: record exact embedding tokens +- **Tunnel**: preserve successor cloudflared PID +- **Console-log**: initialize capture at server boot + prevent SSE proxy buffering +- **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly +- **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow + +## Docs +- **i18n**: expand pt-BR translation to 986 terms +- README: Indonesian translation + # v0.5.40 (2026-07-20) ## Features diff --git a/cli/package.json b/cli/package.json index cb24c4f0..efb786e4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.40", + "version": "0.5.45", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/open-sse/executors/devin-cli.js b/open-sse/executors/devin-cli.js index dd452f9b..7cb35ff1 100644 --- a/open-sse/executors/devin-cli.js +++ b/open-sse/executors/devin-cli.js @@ -32,26 +32,34 @@ function resolveDevinBin() { 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"), - ]) { + + // 2. Known installer / package-manager locations. spawn uses shell:false on + // macOS/Linux, so process.env.PATH alone may miss ~/.local/bin, Homebrew, + // Scoop, etc. when the server runs detached (tray/daemon/launchd) without + // a login shell — probe these explicitly before falling back to PATH. + const candidates = isWin + ? [ + // Official installer: %LOCALAPPDATA%\devin\cli\bin\devin.exe + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "devin", "cli", "bin", "devin.exe"), + path.join(home, ".local", "bin", "devin.exe"), + path.join(home, "scoop", "shims", "devin.exe"), + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "devin", "devin.exe"), + ] + : [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + path.join(home, ".local", "bin", "devin"), // pipx / user install + "/opt/homebrew/bin/devin", // Homebrew (Apple Silicon) + "/usr/local/bin/devin", // Homebrew (Intel) / manual + "/usr/bin/devin", + ]; + for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } - // Fallback — rely on PATH + // 3. Fallback — rely on process.env.PATH return isWin ? "devin.exe" : "devin"; } diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index d999278d..526ceb9b 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -174,11 +174,10 @@ export const PROVIDER_CAPABILITIES = { "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, }, - // Poolside Laguna — OpenAI-compatible, all reasoning-capable (262K context, 32K max output). + // Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output). "poolside": { - "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 262000, maxOutput: 32000 }, - "laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 262000, maxOutput: 32000 }, - "laguna-m.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 262000, maxOutput: 32000 }, + "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 }, + "laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 }, }, }; @@ -302,6 +301,13 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } }, { pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } }, + // ── Poolside Laguna (resellers: openrouter/nvidia/kilocode/vercel/...) ── + // Free tiers cap S 2.1 well below the paid 1M window → match the free suffix + // (":free" or "-free", depending on reseller) before the plain id. + { pattern: "*laguna-s-2.1*free*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + { pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } }, + { pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + // ── Others ─────────────────────────────────────────────────────── { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, diff --git a/open-sse/providers/registry/devin-cli.js b/open-sse/providers/registry/devin-cli.js index 3ff062fe..09391649 100644 --- a/open-sse/providers/registry/devin-cli.js +++ b/open-sse/providers/registry/devin-cli.js @@ -3,6 +3,7 @@ export default { alias: "dv", aliases: ["devin"], uiAlias: "dv", + hidden: true, display: { name: "Devin CLI", icon: "smart_toy", @@ -11,7 +12,7 @@ export default { website: "https://devin.ai", notice: { signupUrl: "https://cli.devin.ai", - text: "Install the Devin CLI and run `devin auth login` first. Spawns the `devin` binary via ACP/stdio — no API key field. Uses the default agent with built-in fs/shell tools (DEVIN_PERMISSION_MODE=bypass). Local use only. Set CLI_DEVIN_AGENT_TYPE=summarizer for a tool-less mode.", + text: "Install: `curl -fsSL https://cli.devin.ai/install.sh | bash` (macOS: `brew install --cask devin-cli`, Windows PowerShell: `irm https://static.devin.ai/cli/setup.ps1 | iex`). Then run `devin auth login`. No API key needed.", }, }, category: "free", diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 8d29e2cb..7bfdd0d7 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -113,7 +113,7 @@ import p110 from "./llm7.js"; import p111 from "./sambanova.js"; import p112 from "./tencent.js"; import p113 from "./morph.js"; -import p114 from "./devin-cli.js"; +// import p114 from "./devin-cli.js"; // import p104 from "./windsurf.js"; import p115 from "./poolside.js"; @@ -230,7 +230,7 @@ export default [ p111, p112, p113, - p114, + // p114, // devin-cli — hidden, spawns local agent with shell/fs access // p104, // windsurf — hidden, no tool calling p115, ]; diff --git a/open-sse/providers/registry/poolside.js b/open-sse/providers/registry/poolside.js index 462bd3f4..02882ad9 100644 --- a/open-sse/providers/registry/poolside.js +++ b/open-sse/providers/registry/poolside.js @@ -26,6 +26,5 @@ export default { models: [ { id: "poolside/laguna-s-2.1", name: "Laguna S 2.1" }, { id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" }, - { id: "poolside/laguna-m.1", name: "Laguna M.1" }, ], }; diff --git a/package.json b/package.json index dc89873d..fbedad57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.40", + "version": "0.5.45", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/api/cli-tools/devin-settings/route.js b/src/app/api/cli-tools/devin-settings/route.js index 1679ef92..240df0a8 100644 --- a/src/app/api/cli-tools/devin-settings/route.js +++ b/src/app/api/cli-tools/devin-settings/route.js @@ -13,15 +13,25 @@ const execAsync = promisify(exec); // matches what the runtime actually spawns. const candidateDevinPaths = () => { const home = os.homedir(); - const paths = [ - path.join(home, ".local", "share", "devin", "bin", "devin"), - path.join(home, ".devin", "bin", "devin"), - ]; - if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); - paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.exe")); - } - return paths; + const isWin = os.platform() === "win32"; + const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + // Mirror resolveDevinBin in the executor — cover installer + common + // package-manager locations so detection matches runtime resolution. + return isWin + ? [ + path.join(localAppData, "devin", "cli", "bin", "devin.exe"), + path.join(home, ".local", "bin", "devin.exe"), + path.join(home, "scoop", "shims", "devin.exe"), + path.join(localAppData, "Programs", "devin", "devin.exe"), + ] + : [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + path.join(home, ".local", "bin", "devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/usr/bin/devin", + ]; }; const checkDevinInstalled = async () => { diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index ef4fcba8..cded1824 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -302,9 +302,26 @@ export default function Sidebar({ onClose }) { computer - Remote + 9Remote + {/* 9English */} +
+ + translate + + 9English + + {/* Settings */}