From 5c399b64062f09f359668bfc036c2aeedf1e4bb7 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 11 Sep 2026 22:03:48 +0700 Subject: [PATCH 01/31] feat(codebuddy-intl,ollama): add DeepSeek-V4.1-Flash codebuddy-intl: deepseek-v4-flash replaced by deepseek-v4.1-flash (same gateway catalog as CN) and a capability override so the model keeps the openai-style reasoning_effort path instead of the vendor-native "deepseek" thinking shape the gateway rejects. Thinking levels low/high/xhigh. ollama: add deepseek-v4.1-flash:cloud (verified on ollama.com/api/tags) with vision + 1M context caps. Co-Authored-By: Claude Code --- open-sse/providers/capabilities.js | 17 +++++++++++++++++ open-sse/providers/registry/codebuddy-intl.js | 4 +++- open-sse/providers/registry/ollama.js | 1 + open-sse/providers/thinkingLevels.js | 2 ++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index e011b788..8ad55314 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -214,6 +214,13 @@ export const PROVIDER_CAPABILITIES = { // contract). maxOutput 128000 per the server's product-config payload. "deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 128000 }, }, + // CodeBuddy intl — same gateway catalog as CN, so deepseek-v4.1-flash mirrors + // the codebuddy-cn entry (the openai-style reasoning_effort format matters: + // the generic *deepseek-v4* pattern would otherwise pick the vendor-native + // "deepseek" thinking shape, which the CodeBuddy gateway does not accept). + "codebuddy-intl": { + "deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 128000 }, + }, // Qoder — upstream exposes opaque internal ids (dfmodel, kmodel, …); the // registry `name` is display-only and capability lookup matches on the raw // id, so every qoder model would fall through to DEFAULT_CAPABILITIES @@ -251,6 +258,16 @@ export const PROVIDER_CAPABILITIES = { "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 }, "laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 }, }, + // Ollama Cloud — the generic *deepseek-v4* pattern misses the vision badge + // the library page publishes for this model (text+image in, 1M context). + // ponytail: thinkingFormat stays "deepseek" to preserve today's body shape; + // Ollama's native toggle is the top-level `think` field (bool or + // low/medium/high/max), which no format in thinkingUnified.js emits yet — + // openai-to-ollama.js drops it. Wire a "think" format when thinking on + // Ollama Cloud is actually needed. + "ollama": { + "deepseek-v4.1-flash:cloud": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }, + }, }; /** diff --git a/open-sse/providers/registry/codebuddy-intl.js b/open-sse/providers/registry/codebuddy-intl.js index eab1ce93..65e787d1 100644 --- a/open-sse/providers/registry/codebuddy-intl.js +++ b/open-sse/providers/registry/codebuddy-intl.js @@ -58,7 +58,9 @@ export default { { 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" }, + // deepseek-v4-flash replaced server-side by deepseek-v4.1-flash (same + // catalog as CN; the old endpoint still answers 200 but the list is the contract). + { id: "deepseek-v4.1-flash", name: "DeepSeek-V4.1-Flash" }, { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" }, ], oauth: { diff --git a/open-sse/providers/registry/ollama.js b/open-sse/providers/registry/ollama.js index 6965484a..605c4b89 100644 --- a/open-sse/providers/registry/ollama.js +++ b/open-sse/providers/registry/ollama.js @@ -30,6 +30,7 @@ export default { { id: "glm-4.7-flash", name: "GLM 4.7 Flash" }, { id: "qwen3.5", name: "Qwen3.5" }, { id: "minimax-m3", name: "MiniMax M3" }, + { id: "deepseek-v4.1-flash:cloud", name: "DeepSeek V4.1 Flash" }, ], serviceKinds: ["llm", "webFetch"], fetchConfig: { diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 7cc1b28f..404fa75e 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -52,6 +52,8 @@ const PATTERN_THINKING = [ { provider: "codebuddy-cn", pattern: "deepseek-v4*", levels: ["low", "high", "xhigh"] }, { provider: "codebuddy-cn", pattern: "hy3*", levels: ["low", "high"] }, { provider: "codebuddy-cn", pattern: "hy4*", levels: ["high"] }, + // codebuddy-intl rides the same gateway catalog, so its deepseek levels match. + { provider: "codebuddy-intl", pattern: "deepseek-v4*", levels: ["low", "high", "xhigh"] }, ]; // Returns valid thinking levels for a model, or null when the model has no reasoning. From 9300121366c8baf41df57ff588d81703400a234b Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 16 Sep 2026 20:04:10 +0700 Subject: [PATCH 02/31] fix(stream): report aborts after HTTP 200 in-band instead of closing silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stream that stalled or lost its upstream was closed with no terminal frame at all, so clients saw "200 OK, a few chunks, then nothing" and could not tell a truncated reply from a finished one. The Responses passthrough path already synthesized response.failed; every other client format got nothing. The watchdog now hands its reason ("stream stall timeout" or "upstream connection lost") to onAbortTerminal, and buildStreamErrorBytes frames it per client format: OpenAI-compatible clients get data: {"error":{...}} followed by data: [DONE], Anthropic clients get `event: error`. The error frame always precedes [DONE] (openai-python raises APIError on any data payload carrying an error key), and no synthetic finish_reason is ever emitted — a truncated stream must not look like a clean stop. Co-Authored-By: Claude Code --- .../handlers/chatCore/streamingHandler.js | 12 +++- open-sse/utils/streamHandler.js | 7 +- open-sse/utils/streamHelpers.js | 25 +++++++ tests/unit/responses-abort-terminal.test.js | 72 ++++++++++++++++++- 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 7b7634e2..6751256b 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -3,8 +3,9 @@ import { needsTranslation } from "../../translator/index.js"; import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js"; import { pipeWithDisconnect } from "../../utils/streamHandler.js"; import { PROVIDERS } from "../../config/providers.js"; -import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; +import { HTTP_STATUS, STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; +import { buildStreamErrorBytes } from "../../utils/streamHelpers.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { saveRequestDetail } from "@/lib/usageDb.js"; import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; @@ -81,9 +82,14 @@ export async function handleStreamingResponse({ providerResponse, provider, mode const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, credentials }); - // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event + // Terminal bytes when the stream aborts after HTTP 200 was already sent, so the + // client sees a real error instead of a silently truncated stream. + // Responses passthrough keeps its own response.failed shape; every other client + // format gets the OpenAI error frame + [DONE], or `event: error` for Claude. const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES; - const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null; + const onAbortTerminal = isResponsesPassthrough + ? buildAbortedResponsesTerminalBytes + : (message) => buildStreamErrorBytes(HTTP_STATUS.GATEWAY_TIMEOUT, message, sourceFormat); const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 27a623fc..fce01706 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -95,6 +95,9 @@ export function createStreamController({ onDisconnect, onError, log, provider, m * activity), not here — output of the transform stream may be silent * for long periods while raw bytes still flow (e.g. Kiro EventStream * binary frames buffering, Claude reasoning streams). + * + * @param {function} [onAbortTerminal] - Receives a human-readable abort + * message and returns terminal SSE bytes to emit downstream. */ export function createDisconnectAwareStream(transformStream, streamController, onAbortTerminal = null) { const reader = transformStream.readable.getReader(); @@ -194,6 +197,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont let chunkCount = 0; let totalBytes = 0; let lastChunkAt = Date.now(); + let abortMessage = "upstream connection lost"; const t0 = Date.now(); const tag = "STREAM"; const clearStall = () => { @@ -203,6 +207,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont clearStall(); stallTimer = setTimeout(() => { stallTimer = null; + abortMessage = "stream stall timeout"; dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`); streamController.handleError?.(new Error("stream stall timeout")); streamController.abort?.(); @@ -249,7 +254,7 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont return createDisconnectAwareStream( { readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } }, wrappedController, - onAbortTerminal + onAbortTerminal ? () => onAbortTerminal(abortMessage) : null ); } diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.js index a7a19180..ee4e720a 100644 --- a/open-sse/utils/streamHelpers.js +++ b/open-sse/utils/streamHelpers.js @@ -1,4 +1,8 @@ import { FORMATS } from "../translator/formats.js"; +import { buildErrorBody } from "./error.js"; +import { SSE_DONE } from "./sseConstants.js"; + +const sharedEncoder = new TextEncoder(); // Parse SSE data line export function parseSSELine(line, format = null) { @@ -120,3 +124,24 @@ export function formatSSE(data, sourceFormat) { return `data: ${JSON.stringify(data)}\n\n`; } + +// Terminal frames for a stream that aborted after HTTP 200 was already sent, so +// the status code can no longer change. OpenAI-compatible clients (openai-python +// raises APIError on any `data:` payload carrying an `error` key, checked before +// [DONE]) need the error frame first, then [DONE]; Anthropic clients need +// `event: error`. Never fabricate a successful finish_reason instead. +// +// Returns encoded bytes: onAbortTerminal callbacks are enqueued verbatim, same +// as buildAbortedResponsesTerminalBytes. +// +// NOTE: non-SSE client formats (Ollama NDJSON) get an SSE frame here — dead in +// practice because detectFormatByEndpoint never resolves to OLLAMA. +export function buildStreamErrorBytes(statusCode, message, clientFormat) { + const { error } = buildErrorBody(statusCode, message); + + const sse = clientFormat === FORMATS.CLAUDE + ? formatSSE({ type: "error", error }, FORMATS.CLAUDE) + : formatSSE({ error }, clientFormat) + SSE_DONE; + + return sharedEncoder.encode(sse); +} diff --git a/tests/unit/responses-abort-terminal.test.js b/tests/unit/responses-abort-terminal.test.js index 10af5848..e05c9d14 100644 --- a/tests/unit/responses-abort-terminal.test.js +++ b/tests/unit/responses-abort-terminal.test.js @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createDisconnectAwareStream } from "../../open-sse/utils/streamHandler.js"; +import { createDisconnectAwareStream, pipeWithDisconnect, createStreamController } from "../../open-sse/utils/streamHandler.js"; import { buildAbortedResponsesTerminalBytes } from "../../open-sse/utils/responsesStreamHelpers.js"; +import { buildStreamErrorBytes } from "../../open-sse/utils/streamHelpers.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; // Minimal stream controller stub function makeController() { @@ -70,3 +72,71 @@ describe("Responses abort terminal synthesis", () => { expect(text).not.toContain("[DONE]"); }); }); + +// A stream that aborts after HTTP 200 cannot change status, so the failure must +// travel in-band: structured error frame first, then [DONE]. openai-python raises +// APIError on any `data:` payload carrying an `error` key (checked before [DONE]); +// Anthropic clients need `event: error`. Never a fabricated finish_reason. +describe("buildStreamErrorBytes", () => { + const jsonOf = (sse) => JSON.parse(sse.match(/\{.*\}/s)[0]); + const textOf = (bytes) => new TextDecoder().decode(bytes); + + // onAbortTerminal callbacks are enqueued verbatim, so a string here is a + // silent no-op at runtime (createDisconnectAwareStream swallows the throw). + it("returns bytes, not a string", () => { + expect(buildStreamErrorBytes(504, "x", FORMATS.OPENAI)).toBeInstanceOf(Uint8Array); + }); + + it("emits error frame then [DONE] for OpenAI clients", () => { + const out = textOf(buildStreamErrorBytes(504, "stream stall timeout", FORMATS.OPENAI)); + + expect(out).toContain('data: {"error"'); + expect(out.indexOf("data: [DONE]")).toBeGreaterThan(out.indexOf('data: {"error"')); + + expect(jsonOf(out).error).toEqual({ + message: "stream stall timeout", + type: "server_error", + code: "gateway_timeout", + }); + }); + + it("emits event: error (no [DONE]) for Claude clients", () => { + const out = textOf(buildStreamErrorBytes(504, "stream stall timeout", FORMATS.CLAUDE)); + + expect(out).toContain("event: error\n"); + expect(out).not.toContain("[DONE]"); + expect(jsonOf(out)).toMatchObject({ type: "error", error: { message: "stream stall timeout" } }); + }); +}); + +// The wiring, not just the frame builder: the watchdog must hand its reason to +// onAbortTerminal and the bytes must reach a real consumer. +describe("stall abort through pipeWithDisconnect", () => { + it("delivers the error frame and closes the stream", async () => { + // Real controller: the stub above never fires its signal, and the abort + // must reach the upstream body for the pipe to end. + const ctrl = createStreamController({ provider: "ollama", model: "test" }); + + // Emits one chunk then goes silent; errors on abort like a real fetch body. + const upstream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: hi\n\n")); + ctrl.signal.addEventListener("abort", () => controller.error(new Error("aborted")), { once: true }); + }, + }); + + let seen = null; + const out = pipeWithDisconnect( + { body: upstream }, + new TransformStream(), + ctrl, + (message) => { seen = message; return buildStreamErrorBytes(504, message, FORMATS.OPENAI); }, + 50 + ); + + const text = await readAll(out); + expect(seen).toBe("stream stall timeout"); + expect(text).toContain('"stream stall timeout"'); + expect(text).toContain("data: [DONE]"); + }); +}); From 13b468b88996155d32cc3308f077352227896fb2 Mon Sep 17 00:00:00 2001 From: KhuatHieu Date: Wed, 16 Sep 2026 20:16:32 +0700 Subject: [PATCH 03/31] fix(commandcode): preserve images and reasoning_effort on /alpha/generate Command Code dropped vision and ignored client effort through the router: image blocks became "[image omitted]", HTTP image URLs were never inlined, and reasoning_effort landed on the envelope wrapper instead of params (so the DeepSeek family mapping remapped low -> high). The catalog also treated deepseek/deepseek-v4.1-flash as text-only, so the vision adapter stole those requests to another provider. - Map OpenAI image_url / Claude image blocks (base64 or data-URI) to the native {type:"image", image:"data:...;base64,...", mimeType} generate block. - Add FORMATS.COMMANDCODE to TARGETS_NEED_BASE64 so remote http(s) images are inlined by the existing SSRF-safe fetcher before translation. - Write reasoning_effort inside params for targetFormat commandcode and pass low|medium|high|xhigh|max through unmapped; allow it in thinkingLevels. - Provider-scoped capabilities for commandcode/cmc: vision except the CLI text-only denylist, thinkingFormat commandcode, so family patterns (deepseek-v4 -> thinkingFormat deepseek, vision false) no longer win. - Quota Tracker: whoami + billing credits/subscriptions (credits vs plan cap, 5h and weekly windows), labels from AI_PROVIDERS[].name. --- open-sse/providers/capabilities.js | 54 +++++++ open-sse/providers/registry/commandcode.js | 4 + open-sse/providers/thinkingLevels.js | 1 + open-sse/services/usage.js | 2 + open-sse/services/usage/commandcode.js | 134 +++++++++++++++++ open-sse/translator/concerns/prefetch.js | 1 + .../translator/concerns/thinkingUnified.js | 17 +++ .../request/openai-to-commandcode.js | 51 ++++++- .../usage/components/ProviderLimits/index.js | 18 ++- .../bugs-gemini-cursor-commandcode.test.js | 11 +- tests/translator/thinking-unified.test.js | 23 +++ tests/unit/capabilities.test.js | 22 +++ tests/unit/commandcode-usage.test.js | 135 ++++++++++++++++++ tests/unit/openai-to-commandcode.test.js | 54 +++++++ tests/unit/prefetch-images.test.js | 17 +++ tests/unit/usage-dispatch.test.js | 2 +- 16 files changed, 529 insertions(+), 17 deletions(-) create mode 100644 open-sse/services/usage/commandcode.js create mode 100644 tests/unit/commandcode-usage.test.js diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 8ad55314..177db4a7 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -467,12 +467,66 @@ function refine(base, provider, model) { return result; } +// Mirrors Command Code CLI `isKnownTextOnlyModel` (no image input). New models +// default to vision; only this denylist stays text-only. +const COMMANDCODE_TEXT_ONLY = new Set([ + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash-fast", + "zai-org/glm-5.3", + "zai-org/glm-5.2", + "zai-org/glm-5.2-fast", + "zai-org/glm-5.1", + "zai-org/glm-5", + "minimaxai/minimax-m2.7", + "minimax/minimax-m2.7-free", + "minimaxai/minimax-m2.5", + "xiaomi/mimo-v2.5-pro", + "qwen/qwen3.6-max-preview", + "qwen/qwen3.7-max", + "meituan/longcat-2.0:free", + "stepfun/step-3.5-flash", + "tencent/hy4-preview", + "tencent/hy3", + "tencent/hy3-paid", + "nvidia/nemotron-3-ultra-550b-a55b", + "poolside/laguna-s-2.1-free", + "inclusionai/ling-3.0-flash-free", + "inclusionai/ling-3.0-flash-sante:free", +]); + +function isCommandCodeTextOnly(model) { + const key = String(model || "").toLowerCase(); + if (COMMANDCODE_TEXT_ONLY.has(key)) return true; + for (const id of COMMANDCODE_TEXT_ONLY) { + const base = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id; + if (key === base || key.endsWith("/" + base)) return true; + } + return false; +} export function getCapabilitiesForModel(provider, model) { if (!model) return { ...DEFAULT_CAPABILITIES }; // Canonical exact lookup strips vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7". const baseModel = model.includes("/") ? model.split("/").pop() : model; + // CommandCode wire is /alpha/generate for every model. Family patterns + // (deepseek-v4 → thinkingFormat:deepseek, vision:false) must not win here. + if (provider === "commandcode" || provider === "cmc") { + const providerCaps = PROVIDER_CAPABILITIES.commandcode; + if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] }; + if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] }; + return { + ...DEFAULT_CAPABILITIES, + reasoning: true, + thinkingFormat: "commandcode", + thinkingEffortSupported: true, + vision: !isCommandCodeTextOnly(model), + contextWindow: 1000000, + maxOutput: 384000, + }; + } + // 1. Provider-specific override if (provider) { const providerCaps = PROVIDER_CAPABILITIES[provider]; diff --git a/open-sse/providers/registry/commandcode.js b/open-sse/providers/registry/commandcode.js index 3b21fbbc..f59aac04 100644 --- a/open-sse/providers/registry/commandcode.js +++ b/open-sse/providers/registry/commandcode.js @@ -40,4 +40,8 @@ export default { { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }, { id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" }, ], + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 404fa75e..0b77e4b4 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -26,6 +26,7 @@ const FORMAT_LEVELS = { qwen: L.base, kimi: L.levelMax, deepseek: L.hiMax, + commandcode: ["none", "low", "medium", "high", "xhigh", "max"], minimax: L.onOff, hunyuan: L.base, step: L.base, diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index d7868760..cca5d861 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -20,6 +20,7 @@ import { getZedUsage } from "./usage/zed.js"; import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.js"; import { resolveQoderCredentials } from "./qoderModels.js"; import { getGlmUsage } from "./usage/glm.js"; +import { getCommandCodeUsage } from "./usage/commandcode.js"; import { getIflowUsage, getOllamaUsage, @@ -62,6 +63,7 @@ const USAGE_HANDLERS = { groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions), zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), "xiaomi-mimo": (c) => getXiaomiMimoUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null, options = {}) { diff --git a/open-sse/services/usage/commandcode.js b/open-sse/services/usage/commandcode.js new file mode 100644 index 00000000..7c62480c --- /dev/null +++ b/open-sse/services/usage/commandcode.js @@ -0,0 +1,134 @@ +/** + * Command Code usage — billing credits + 5h/weekly rate windows. + * Mirrors ~/cc-usage.mjs: whoami → credits + subscriptions. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseResetTime, toFiniteNumber } from "./shared.js"; + +const BASE = (process.env.COMMAND_CODE_API_BASE_URL || "https://api.commandcode.ai").replace(/\/$/, ""); + +const PLAN_NAMES = { + "individual-go": "Go", + "individual-goat": "GOAT", + "individual-pro": "Pro", + "individual-pro-v1": "Pro", + "individual-provider": "Provider", + "individual-max": "Max", + "individual-ultra": "Ultra", + "teams-pro": "Teams Pro", +}; + +const PLAN_CAPS = { + "individual-go": 10, + "individual-goat": 70, + "individual-pro": 30, + "individual-pro-v1": 80, + "individual-provider": 15, + "individual-max": 150, + "individual-ultra": 300, + "teams-pro": 40, +}; + +function qs(route, params) { + const s = new URLSearchParams( + Object.entries(params || {}).filter(([, v]) => v != null), + ).toString(); + return s ? `${route}?${s}` : route; +} + +function windowQuota(win) { + if (!win || typeof win !== "object") return null; + const used = toFiniteNumber(win.used, 0); + const total = toFiniteNumber(win.cap, 0); + if (total <= 0 && used <= 0) return null; + return { + used, + total, + remaining: Math.max(0, total - used), + unlimited: false, + resetAt: parseResetTime(win.resetAt), + }; +} + +/** + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + */ +export async function getCommandCodeUsage(apiKey, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { message: "Command Code API key not available. Add a key to view usage." }; + } + + const headers = { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: "application/json", + }; + + const get = async (route) => { + const response = await proxyAwareFetch( + BASE + route, + { method: "GET", headers }, + proxyOptions, + ); + return response; + }; + + try { + const whoamiRes = await get(qs("/alpha/whoami", { limits: "1" })); + if (whoamiRes.status === 401 || whoamiRes.status === 403) { + return { plan: "Command Code", message: "Command Code authentication failed. Check the API key." }; + } + if (!whoamiRes.ok) { + return { plan: "Command Code", message: `Command Code usage API error (${whoamiRes.status})` }; + } + const whoami = await whoamiRes.json().catch(() => ({})); + const orgId = whoami?.org?.id ?? null; + + const [creditsRes, subsRes] = await Promise.all([ + get(qs("/alpha/billing/credits", { orgId })), + get(qs("/alpha/billing/subscriptions", { orgId })), + ]); + + if (creditsRes.status === 401 || creditsRes.status === 403 || subsRes.status === 401 || subsRes.status === 403) { + return { plan: "Command Code", message: "Command Code authentication failed. Check the API key." }; + } + if (!creditsRes.ok) { + return { plan: "Command Code", message: `Command Code credits API error (${creditsRes.status})` }; + } + if (!subsRes.ok) { + return { plan: "Command Code", message: `Command Code subscriptions API error (${subsRes.status})` }; + } + + const creditsBody = await creditsRes.json().catch(() => ({})); + const subsBody = await subsRes.json().catch(() => ({})); + const planId = subsBody?.data?.planId ?? null; + const plan = (planId && PLAN_NAMES[planId]) || planId || "Command Code"; + const cap = planId ? (PLAN_CAPS[planId] || 0) : 0; + const c = creditsBody?.credits || {}; + const remaining = + toFiniteNumber(c.monthlyCredits, 0) + + toFiniteNumber(c.purchasedCredits, 0) + + toFiniteNumber(c.freeCredits, 0); + const used = cap > 0 ? Math.max(0, cap - remaining) : 0; + const total = cap > 0 ? cap : remaining; + + const quotas = {}; + quotas.Credits = { + used, + total, + remaining, + unlimited: cap <= 0, + resetAt: parseResetTime(subsBody?.data?.currentPeriodEnd), + }; + + const fiveHour = windowQuota(creditsBody?.windowLimits?.fiveHour); + if (fiveHour) quotas["Session (5h)"] = fiveHour; + const weekly = windowQuota(creditsBody?.windowLimits?.weekly); + if (weekly) quotas.Weekly = weekly; + + return { plan, quotas }; + } catch (error) { + return { message: `Command Code error: ${error.message}` }; + } +} diff --git a/open-sse/translator/concerns/prefetch.js b/open-sse/translator/concerns/prefetch.js index 5055be3c..4d606ebd 100644 --- a/open-sse/translator/concerns/prefetch.js +++ b/open-sse/translator/concerns/prefetch.js @@ -8,6 +8,7 @@ import { fetchImageAsBase64, parseDataUri } from "./image.js"; const TARGETS_NEED_BASE64 = new Set([ FORMATS.GEMINI, FORMATS.GEMINI_CLI, FORMATS.VERTEX, FORMATS.ANTIGRAVITY, FORMATS.OLLAMA, FORMATS.KIRO, + FORMATS.COMMANDCODE, ]); function isRemoteUrl(url) { diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index e86dbef7..001259e3 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -19,6 +19,7 @@ const FORMAT_TO_NATIVE = { vertex: "gemini-budget", antigravity: "gemini-budget", kiro: "kiro", + commandcode: "commandcode", }; // Strip a trailing thinking suffix "model(value)" → "model" (no-op when absent). @@ -108,6 +109,7 @@ export const captureThinking = extractThinking; const NATIVE_ONLY_FORMATS = new Set(["gemini-level", "gemini-budget", "claude-budget", "claude-adaptive", "kiro"]); function resolveFormat(targetFormat, model, provider) { + if (targetFormat === "commandcode") return "commandcode"; const providerFmt = provider ? PROVIDERS[provider]?.thinkingFormat : null; if (providerFmt) return providerFmt; const caps = getCapabilitiesForModel(provider, model); @@ -223,6 +225,10 @@ function stripAll(body) { delete body.output_config; if (body.generationConfig) delete body.generationConfig.thinkingConfig; if (body.request?.generationConfig) delete body.request.generationConfig.thinkingConfig; + if (body.params && typeof body.params === "object") { + delete body.params.reasoning_effort; + delete body.params.thinking; + } } // Apply unified thinking config to body in the resolved provider-native format. @@ -336,6 +342,17 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) { case "kiro": // Kiro thinking handled via system-tag injection in openai-to-kiro.js; no body field here. break; + case "commandcode": { + // Native CLI sends reasoning_effort inside params of the /alpha/generate envelope. + if (!body.params || typeof body.params !== "object") body.params = {}; + if (none && canDisable) { + delete body.params.reasoning_effort; + break; + } + const level = toLevel(eff); + if (level) body.params.reasoning_effort = level; + break; + } default: break; } diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js index 9825048b..194078ae 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -5,6 +5,7 @@ * - params.system: STRING at top level (Anthropic-style; system messages NOT allowed in messages[]) * - params.messages[*].role ∈ {"user","assistant","tool"} * - params.messages[*].content: Array of content blocks (NEVER a string) + * - image_url / image source → {type:"image", image:"data:...;base64,...", mimeType} * - tool_use blocks (assistant): {type:"tool-call", toolCallId, toolName, input} * - tool_result blocks (role=user): {type:"tool-result", toolCallId, toolName, output} * - tools[*]: Anthropic plain {name, description, input_schema} @@ -12,8 +13,9 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { randomUUID } from "crypto"; -import { ROLE, OPENAI_BLOCK } from "../schema/index.js"; +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; +import { parseDataUri, encodeDataUri } from "../concerns/image.js"; function flattenText(content) { if (content == null) return ""; @@ -29,6 +31,43 @@ function flattenText(content) { return String(content); } +function toNativeImageBlock(part) { + if (!part || typeof part !== "object") return null; + + if (part.type === OPENAI_BLOCK.IMAGE_URL) { + const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url; + const parsed = parseDataUri(url); + if (!parsed) return null; + return { + type: OPENAI_BLOCK.IMAGE, + image: encodeDataUri(parsed.mimeType, parsed.base64), + mimeType: parsed.mimeType, + }; + } + + if (part.type === OPENAI_BLOCK.IMAGE || part.type === CLAUDE_BLOCK.IMAGE) { + if (typeof part.image === "string" && part.image.startsWith("data:")) { + const parsed = parseDataUri(part.image); + return { + type: OPENAI_BLOCK.IMAGE, + image: part.image, + mimeType: part.mimeType || parsed?.mimeType || "image/png", + }; + } + const source = part.source; + if (source?.type === "base64" && typeof source.data === "string") { + const mime = source.media_type || "image/png"; + return { + type: OPENAI_BLOCK.IMAGE, + image: encodeDataUri(mime, source.data), + mimeType: mime, + }; + } + } + + return null; +} + function toContentBlocks(content) { if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }]; if (typeof content === "string") return [{ type: OPENAI_BLOCK.TEXT, text: content }]; @@ -40,10 +79,12 @@ function toContentBlocks(content) { } else if (part && typeof part === "object") { if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") { blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); - } else if (part.type === OPENAI_BLOCK.IMAGE_URL || part.type === OPENAI_BLOCK.IMAGE) { - blocks.push({ type: OPENAI_BLOCK.TEXT, text: "[image omitted]" }); - } else if (typeof part.text === "string") { - blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } else { + const image = toNativeImageBlock(part); + if (image) blocks.push(image); + else if (typeof part.text === "string") { + blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } } } } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 568c4808..0a508265 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -40,7 +40,7 @@ import { } from "./utils"; import Card from "@/shared/components/Card"; import { ConfirmModal, EditConnectionModal } from "@/shared/components"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { USAGE_SUPPORTED_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; // Maps the stored providerSpecificData.authMethod to a human label for Kiro. @@ -100,6 +100,10 @@ function getCodexResetCreditCount(quota) { return Number.isFinite(count) ? Math.max(0, count) : 0; } +function providerLabel(providerId) { + return AI_PROVIDERS[providerId]?.name || providerId; +} + function formatCreditDate(value) { if (!value) return "N/A"; const date = new Date(value); @@ -767,7 +771,7 @@ export default function ProviderLimits() { }; const selectedProviderLabel = - providerFilter === "all" ? "All providers" : providerFilter; + providerFilter === "all" ? "All providers" : providerLabel(providerFilter); const hasEligibleConnections = totals.eligibleConnections > 0; const hasVisibleConnections = sortedConnections.length > 0; const emptyState = getConnectionsEmptyMessage( @@ -844,7 +848,7 @@ export default function ProviderLimits() { fallbackText={providerFilter.slice(0, 2).toUpperCase()} /> )} - + {selectedProviderLabel} @@ -905,8 +909,8 @@ export default function ProviderLimits() { className="size-6 rounded-md object-contain" fallbackText={provider.slice(0, 2).toUpperCase()} /> - - {provider} + + {providerLabel(provider)} {providerFilter === provider && ( @@ -1079,8 +1083,8 @@ export default function ProviderLimits() { />
-

- {conn.provider} +

+ {providerLabel(conn.provider)}

{getConnectionLabel(conn) ? (

diff --git a/tests/translator/bugs-gemini-cursor-commandcode.test.js b/tests/translator/bugs-gemini-cursor-commandcode.test.js index 4b74c60e..8483bfc1 100644 --- a/tests/translator/bugs-gemini-cursor-commandcode.test.js +++ b/tests/translator/bugs-gemini-cursor-commandcode.test.js @@ -62,15 +62,18 @@ describe("OpenAI → CommandCode", () => { expect(Object.keys(call.input).length, "arguments silently dropped to {}").toBeGreaterThan(0); }); - // openai-to-commandcode.js:41-42 — image becomes "[image omitted]" - // KNOWN BUG - it.fails("image content is preserved", () => { + it("image content is preserved as native CommandCode image blocks", () => { const out = O2CC({ messages: [{ role: "user", content: [ { type: "text", text: "look" }, { type: "image_url", image_url: { url: "data:image/png;base64,BBBB" } }, ] }], }); - expect(JSON.stringify(out), "image omitted").toContain("BBBB"); + expect(JSON.stringify(out)).toContain("BBBB"); + expect(JSON.stringify(out)).not.toContain("[image omitted]"); + expect(out.params.messages[0].content).toEqual([ + { type: "text", text: "look" }, + { type: "image", image: "data:image/png;base64,BBBB", mimeType: "image/png" }, + ]); }); }); diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js index fd8f9d28..f04b18bd 100644 --- a/tests/translator/thinking-unified.test.js +++ b/tests/translator/thinking-unified.test.js @@ -250,6 +250,29 @@ describe("applyThinking per provider format", () => { const out = apply("gemini-cli", "gemini-3.5-flash-lite", { reasoning_effort: "medium" }, "gemini-cli"); expect(out.generationConfig.thinkingConfig.thinkingLevel).toBe("medium"); }); + it("commandcode envelope writes params.reasoning_effort, not wrapper fields", () => { + const out = apply("commandcode", "deepseek/deepseek-v4.1-flash", { + params: { model: "deepseek/deepseek-v4.1-flash", messages: [] }, + reasoning_effort: "high", + }, "commandcode"); + expect(out.params.reasoning_effort).toBe("high"); + expect(out.reasoning_effort).toBeUndefined(); + expect(out.thinking).toBeUndefined(); + }); + it("commandcode preserves low effort instead of remapping to high", () => { + const out = apply("commandcode", "deepseek/deepseek-v4.1-flash", { + params: { messages: [] }, + reasoning_effort: "low", + }, "commandcode"); + expect(out.params.reasoning_effort).toBe("low"); + }); + it("commandcode preserves max effort", () => { + const out = apply("commandcode", "deepseek/deepseek-v4.1-flash", { + params: { messages: [] }, + reasoning_effort: "max", + }, "commandcode"); + expect(out.params.reasoning_effort).toBe("max"); + }); }); describe("extractReasoningText (response shapes)", () => { diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index 3482a2c8..cde8e9b4 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -73,4 +73,26 @@ describe("getCapabilitiesForModel", () => { maxOutput: 128000, }); }); + + it("CommandCode v4.1-flash is vision + effort capable", () => { + expect(getCapabilitiesForModel("commandcode", "deepseek/deepseek-v4.1-flash")).toMatchObject({ + vision: true, + reasoning: true, + thinkingFormat: "commandcode", + thinkingEffortSupported: true, + }); + }); + + it("CommandCode MiniMax-M3 is vision capable", () => { + expect(getCapabilitiesForModel("commandcode", "MiniMaxAI/MiniMax-M3").vision).toBe(true); + }); + + it("CommandCode text-only DeepSeek V4 Flash stays non-vision", () => { + expect(getCapabilitiesForModel("commandcode", "deepseek/deepseek-v4-flash").vision).toBe(false); + expect(getCapabilitiesForModel("commandcode", "deepseek/deepseek-v4-flash")).toMatchObject({ + reasoning: true, + thinkingFormat: "commandcode", + thinkingEffortSupported: true, + }); + }); }); diff --git a/tests/unit/commandcode-usage.test.js b/tests/unit/commandcode-usage.test.js new file mode 100644 index 00000000..5f38dc47 --- /dev/null +++ b/tests/unit/commandcode-usage.test.js @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { + USAGE_SUPPORTED_PROVIDERS, + USAGE_APIKEY_PROVIDERS, +} from "../../src/shared/constants/providers.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const BASE = "https://api.commandcode.ai"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const WHOAMI = { + user: { name: "Hieu", email: "hieu@example.com" }, + org: { id: "org_1", name: "personal" }, +}; +const CREDITS = { + credits: { monthlyCredits: 12.5, purchasedCredits: 1, freeCredits: 0.5 }, + windowLimits: { + fiveHour: { used: 2, cap: 10, resetAt: Date.now() + 3_600_000, exceeded: false }, + weekly: { used: 20, cap: 70, resetAt: Date.now() + 86_400_000, exceeded: false }, + }, +}; +const SUBS = { + data: { + planId: "individual-goat", + currentPeriodStart: "2026-09-01T00:00:00.000Z", + currentPeriodEnd: "2026-10-01T00:00:00.000Z", + }, +}; + +function mockHappyPath() { + proxyAwareFetch.mockImplementation(async (url) => { + const u = String(url); + if (u.includes("/alpha/whoami")) return jsonResponse(WHOAMI); + if (u.includes("/alpha/billing/credits")) return jsonResponse(CREDITS); + if (u.includes("/alpha/billing/subscriptions")) return jsonResponse(SUBS); + return jsonResponse({ error: "unexpected " + u }, 404); + }); +} + +describe("commandcode registry usage flags", () => { + it("is listed for apikey quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("commandcode"); + expect(USAGE_APIKEY_PROVIDERS).toContain("commandcode"); + }); +}); + +describe("getUsageForProvider(commandcode)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a message when apiKey is missing", async () => { + const usage = await getUsageForProvider({ provider: "commandcode" }); + expect(usage.message).toMatch(/api key/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + }); + + it("GETs whoami, credits, and subscriptions with Bearer apiKey", async () => { + mockHappyPath(); + const usage = await getUsageForProvider({ + provider: "commandcode", + apiKey: "user_test", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("GOAT"); + const urls = proxyAwareFetch.mock.calls.map(([url]) => String(url)); + expect(urls.some((u) => u.startsWith(`${BASE}/alpha/whoami`))).toBe(true); + expect(urls.some((u) => u.includes("/alpha/billing/credits") && u.includes("orgId=org_1"))).toBe(true); + expect(urls.some((u) => u.includes("/alpha/billing/subscriptions") && u.includes("orgId=org_1"))).toBe(true); + expect(proxyAwareFetch.mock.calls[0][1].headers.Authorization).toBe("Bearer user_test"); + }); + + it("maps remaining credits vs plan cap and rate windows", async () => { + mockHappyPath(); + const usage = await getUsageForProvider({ + provider: "commandcode", + apiKey: "user_test", + }); + + // remaining = 12.5 + 1 + 0.5 = 14; cap GOAT = 70; used = 56 + expect(usage.quotas.Credits).toMatchObject({ + used: 56, + total: 70, + unlimited: false, + }); + expect(usage.quotas["Session (5h)"]).toMatchObject({ + used: 2, + total: 10, + unlimited: false, + }); + expect(usage.quotas.Weekly).toMatchObject({ + used: 20, + total: 70, + }); + expect(new Date(usage.quotas.Credits.resetAt).toISOString()).toBe("2026-10-01T00:00:00.000Z"); + }); + + it("returns an auth message on 401", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); + const usage = await getUsageForProvider({ + provider: "commandcode", + apiKey: "bad", + }); + expect(usage.message).toMatch(/auth|key|login/i); + }); +}); + +describe("parseQuotaData(commandcode)", () => { + it("forwards used/total/resetAt for the dashboard table", () => { + const rows = parseQuotaData("commandcode", { + plan: "GOAT", + quotas: { + Credits: { used: 56, total: 70, resetAt: "2026-10-01T00:00:00.000Z" }, + "Session (5h)": { used: 2, total: 10, resetAt: "2026-09-16T10:00:00.000Z" }, + }, + }); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ name: "Credits", used: 56, total: 70 }); + expect(rows[1]).toMatchObject({ name: "Session (5h)", used: 2, total: 10 }); + }); +}); diff --git a/tests/unit/openai-to-commandcode.test.js b/tests/unit/openai-to-commandcode.test.js index 7f12dc85..0a441dd7 100644 --- a/tests/unit/openai-to-commandcode.test.js +++ b/tests/unit/openai-to-commandcode.test.js @@ -179,3 +179,57 @@ describe("openaiToCommandCodeRequest — tools schema conversion", () => { expect(out.params.tools).toBeUndefined(); }); }); + +describe("openaiToCommandCodeRequest — native image blocks", () => { + const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const DATA_URI = `data:image/png;base64,${PNG_B64}`; + + it("maps OpenAI image_url data URI to CommandCode {type:image,image,mimeType}", () => { + const out = openaiToCommandCodeRequest(MODEL, { + messages: [{ + role: "user", + content: [ + { type: "text", text: "what color?" }, + { type: "image_url", image_url: { url: DATA_URI } }, + ], + }], + }, true); + + expect(out.params.messages[0].content).toEqual([ + { type: "text", text: "what color?" }, + { type: "image", image: DATA_URI, mimeType: "image/png" }, + ]); + }); + + it("maps Claude/OpenAI base64 image source to a data-URI image block", () => { + const out = openaiToCommandCodeRequest(MODEL, { + messages: [{ + role: "user", + content: [ + { type: "image", source: { type: "base64", media_type: "image/png", data: PNG_B64 } }, + ], + }], + }, true); + + expect(out.params.messages[0].content).toEqual([ + { type: "image", image: DATA_URI, mimeType: "image/png" }, + ]); + }); + + it("does not stub dropped images as [image omitted]", () => { + const out = openaiToCommandCodeRequest(MODEL, { + messages: [{ + role: "user", + content: [ + { type: "text", text: "see this" }, + { type: "image_url", image_url: { url: DATA_URI } }, + ], + }], + }, true); + + const texts = out.params.messages[0].content + .filter((b) => b.type === "text") + .map((b) => b.text); + expect(texts).not.toContain("[image omitted]"); + }); +}); diff --git a/tests/unit/prefetch-images.test.js b/tests/unit/prefetch-images.test.js index 2289697b..0d4c0db3 100644 --- a/tests/unit/prefetch-images.test.js +++ b/tests/unit/prefetch-images.test.js @@ -55,4 +55,21 @@ describe("prefetchRemoteImages", () => { expect(n).toBe(1); expect(body.messages[0].content[0].source.type).toBe("base64"); }); + + it("openai source -> commandcode target: converts remote URL to base64", async () => { + const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] }; + const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.COMMANDCODE); + expect(n).toBe(1); + expect(body.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")).toBe(true); + expect(fetchImageAsBase64).toHaveBeenCalled(); + }); + + it("claude source -> commandcode target: source.url -> base64", async () => { + const body = { messages: [{ role: "user", content: [ + { type: "image", source: { type: "url", url: "https://x/a.png" } }, + ] }] }; + const n = await prefetchRemoteImages(body, FORMATS.CLAUDE, FORMATS.COMMANDCODE); + expect(n).toBe(1); + expect(body.messages[0].content[0].source.type).toBe("base64"); + }); }); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index 84a5f469..ead63892 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -16,7 +16,7 @@ const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "iflow", "ollama", "glm", "glm-cn", "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", - "deepseek", "opencode-go", "zed", + "deepseek", "opencode-go", "zed", "commandcode", ]; describe("usage dispatch", () => { From 912ed295db3153f01f44f3e56f93fa4616b69607 Mon Sep 17 00:00:00 2001 From: izzzzzi Date: Thu, 17 Sep 2026 17:52:47 +0700 Subject: [PATCH 04/31] fix(deepseek,model-catalog): vision for V4.1-Flash ids, scope synced catalog to gateways - Declare deepseek-v4.1-flash and deepseek-flash as vision-capable in MODEL_CAPABILITIES - Share installed catalogSource across route chunks via globalThis.__9rCatalogSource - Scope catalog modality keys by provider:model to prevent cross-gateway collisions - Upgrade catalog format to v2 with automatic rebuild of older schemas --- open-sse/providers/capabilities.js | 32 ++++- open-sse/providers/catalogOverride.js | 22 +++- src/lib/modelCatalog/sync.js | 105 +++++++++------ tests/unit/capabilities.test.js | 18 +++ tests/unit/model-catalog-scope.test.js | 174 +++++++++++++++++++++++++ 5 files changed, 299 insertions(+), 52 deletions(-) create mode 100644 tests/unit/model-catalog-scope.test.js diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 177db4a7..eac9089e 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -116,6 +116,16 @@ export const MODEL_CAPABILITIES = { // DeepSeek's first V4 model with image input; text limits match V4-Flash. "deepseek-v4-flash-vision-exp": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }, + // DeepSeek V4.1-Flash is natively multimodal — models.dev lists + // opencode-go/deepseek-v4.1-flash with modalities.input ["text","image"] — and upstream + // the retired v4-flash / vision-exp ids route to it, so the live V4.1 ids carry the + // same image capability as the exp id above. "deepseek-flash" is the GA id on the + // DeepSeek API; it previously fell through to the generic *deepseek* pattern, whose + // 128K/64K limits are kept here. The repeated fields are deliberate: an exact entry + // short-circuits the pattern table, so a vision-only delta would drop them. + "deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }, + "deepseek-flash": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 128000, maxOutput: 64000 }, + // Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases "vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, "coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, @@ -431,14 +441,27 @@ const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; // Catalog lookups, installed by the server at startup. Left as no-ops in the // browser bundle, where there is no file to read. +// +// The server bundles this module into every route chunk that needs it, and each +// copy carries its own module state, so an install landing in the copy the +// startup hook imported stays invisible to the copy resolving requests. The slot +// lives on globalThis instead; the local binding is the fast path. let catalogSource = null; /** * Install the synced catalog reader (server only). - * @param {{ getModalities: Function, getLimits: Function } | null} source + * @param {{ getModalities: (provider: string, model: string) => object|null, + * getLimits: (provider: string, model: string) => object|null } | null} source */ export function setCatalogSource(source) { catalogSource = source; + if (typeof globalThis !== "undefined") globalThis.__9rCatalogSource = source; +} + +function getCatalogSource() { + if (catalogSource) return catalogSource; + if (typeof globalThis === "undefined") return null; + return (catalogSource = globalThis.__9rCatalogSource || null); } // Apply the synced catalog + name heuristic on top of a table-resolved result. @@ -447,15 +470,16 @@ export function setCatalogSource(source) { function refine(base, provider, model) { const result = { ...DEFAULT_CAPABILITIES, ...base }; - if (catalogSource) { - const modalities = catalogSource.getModalities(model); + const source = getCatalogSource(); + if (source) { + const modalities = source.getModalities(provider, model); if (modalities) { for (const key of MODALITY_KEYS) { if (modalities[key] === true) result[key] = true; } } - const limits = catalogSource.getLimits(provider, model); + const limits = source.getLimits(provider, model); if (limits) { if (limits.contextWindow > 0) result.contextWindow = limits.contextWindow; if (limits.maxOutput > 0) result.maxOutput = limits.maxOutput; diff --git a/open-sse/providers/catalogOverride.js b/open-sse/providers/catalogOverride.js index 12914b7d..c34576d9 100644 --- a/open-sse/providers/catalogOverride.js +++ b/open-sse/providers/catalogOverride.js @@ -13,6 +13,11 @@ export const CATALOG_FILE = path.join(DATA_DIR, "model-catalog.json"); // Trimmed upstream catalog, read by the add-models skill (not by the router). export const CATALOG_RAW_FILE = path.join(DATA_DIR, "model-catalog-raw.json"); +// Schema of the file this module reads. The writer stamps it; a file carrying an +// older value predates provider-scoped modality keys, and its flat keys are not +// looked up here, so the sync rebuilds it instead of asking upstream for a 304. +export const CATALOG_VERSION = 2; + const EMPTY = { models: {}, providers: {} }; let cache = EMPTY; let cachedMtime = -1; @@ -45,14 +50,19 @@ function load() { return cache; } -// Modality is a property of the model itself — any gateway serving it inherits -// the same image/video/pdf support, so this is keyed by model id alone. -export function getCatalogModalities(model) { - return load().models[baseId(model)] || null; +// Modalities are recorded per gateway upstream, and gateways disagree about the +// same weights — some do not proxy images at all — so the key is provider + +// model, in the local provider id space, exactly like the limits below. Keying +// by model id alone made short ids collide across vendors: "auto", "free" and +// "efficient" are router modes in one catalog and model names in another, and a +// request to the router mode inherited a stranger's vision. +export function getCatalogModalities(provider, model) { + if (!provider) return null; + return load().models[`${provider}:${baseId(model)}`] || null; } -// Context and output limits are a property of the gateway, not the model: each -// one truncates differently, so these stay keyed by provider + model. +// Context and output limits are a property of the gateway too: each one +// truncates differently, so these stay keyed by provider + model. export function getCatalogLimits(provider, model) { const byProvider = provider && load().providers[provider]; if (!byProvider) return null; diff --git a/src/lib/modelCatalog/sync.js b/src/lib/modelCatalog/sync.js index 0d49c48e..973c18b3 100644 --- a/src/lib/modelCatalog/sync.js +++ b/src/lib/modelCatalog/sync.js @@ -6,7 +6,7 @@ import fs from "node:fs"; import path from "node:path"; -import { CATALOG_FILE, CATALOG_RAW_FILE, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js"; +import { CATALOG_FILE, CATALOG_RAW_FILE, CATALOG_VERSION, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js"; const CATALOG_URL = "https://models.dev/api.json"; const FETCH_TIMEOUT_MS = 60000; @@ -16,16 +16,14 @@ const STARTUP_DELAY_MS = 60 * 1000; // let the server boot and serve first req const RETRY_DELAY_MS = 30 * 60 * 1000; const MODALITY_BY_INPUT = { image: "vision", pdf: "pdf", audio: "audioInput", video: "videoInput" }; -// Gateways disagree about the same model, so a modality needs a majority of -// them to declare it — one reseller mislabelling a text model must not win. -const MIN_SHARE = 0.5; // Ignore limit differences below this: gateways round 200000 vs 202752. const LIMIT_TOLERANCE = 0.1; -// 9router provider id -> models.dev provider id, for context/maxOutput only. -// Providers absent here keep whatever the local pattern table resolves; names -// that already match are resolved automatically. -const PROVIDER_ALIASES = { +// 9router provider id -> models.dev provider id: the same gateway under another +// name. Both halves of the catalog are stored against the local id, so this runs +// while building rather than on every lookup. Providers absent here keep whatever +// the local pattern table resolves; names that already match need no entry. +export const PROVIDER_ALIASES = { "glm": "zai", "glm-cn": "zhipuai", "claude": "anthropic", @@ -40,7 +38,7 @@ const PROVIDER_ALIASES = { "cloudflare-ai": "cloudflare-workers-ai", }; -let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null }; +let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null, fileVersion: null }; let timer = null; export function getSyncState() { @@ -78,40 +76,57 @@ function slim(catalog) { return out; } -function build(catalog, entries) { - // Index once: per provider for limits, and tallied across all of them for - // modalities. - const byProvider = {}; - const tally = {}; - for (const [providerId, provider] of Object.entries(catalog)) { - const models = {}; - const counted = new Set(); - for (const [modelId, model] of Object.entries(provider?.models || {})) { - const id = baseId(modelId); - models[id] = model; - // One vote per provider: several ids can normalize to the same model - // (claude-opus-4-thinking:1024, :8192, :32768 …) and must not stack. - if (counted.has(id)) continue; - counted.add(id); - const counts = tally[id] || (tally[id] = { total: 0 }); - counts.total++; - for (const input of model?.modalities?.input || []) { - const key = MODALITY_BY_INPUT[input]; - if (key) counts[key] = (counts[key] || 0) + 1; - } - } - byProvider[providerId] = models; +export function build(catalog, entries) { + // Upstream provider id -> the local ids it belongs to, taken from the registry + // snapshot so a gateway listed upstream under another name is still filed + // under the name requests arrive with. One upstream name can back more than one + // local id (glm-cn and zhipu are both zhipuai) and each has to resolve; the + // snapshot only covers the built-in registry, so an upstream provider it does + // not mention keeps its own name. + const localIds = new Map(); + for (const { provider } of entries) { + const upstreamId = PROVIDER_ALIASES[provider] || provider; + let locals = localIds.get(upstreamId); + if (!locals) localIds.set(upstreamId, (locals = [])); + if (!locals.includes(provider)) locals.push(provider); } - // Modalities belong to the model — every gateway serving it has the same - // weights — so they are keyed by model id and shared across providers. + // Index once: the raw upstream record per provider+model for limits, and the + // modalities each gateway declares for it. + const byProvider = {}; + // Modalities are recorded per gateway upstream and gateways disagree about the + // same weights — some do not proxy images at all — so the key is provider + + // model. Keying by model id alone let short ids collide across vendors: "auto", + // "free" and "efficient" are router modes in one catalog and model names in + // another, so a router mode inherited a stranger's vision. const models = {}; - for (const [id, counts] of Object.entries(tally)) { - const declared = {}; - for (const key of Object.values(MODALITY_BY_INPUT)) { - if ((counts[key] || 0) / counts.total >= MIN_SHARE) declared[key] = true; + for (const [providerId, provider] of Object.entries(catalog)) { + const locals = localIds.get(providerId) || [providerId]; + const modelsById = {}; + const seen = new Set(); + for (const [modelId, model] of Object.entries(provider?.models || {})) { + const id = baseId(modelId); + modelsById[id] = model; + // One entry per provider+model: several upstream ids can normalize to the + // same model (claude-opus-4-thinking:1024, :8192, :32768 …) and must not + // stack their modalities. + if (seen.has(id)) continue; + seen.add(id); + const declared = {}; + for (const input of model?.modalities?.input || []) { + const key = MODALITY_BY_INPUT[input]; + if (key) declared[key] = true; + } + if (Object.keys(declared).length) { + // Filed under every local id requests arrive with, and under the upstream + // id too: a custom provider node can carry the upstream name without + // appearing in the registry snapshot, and nothing else would resolve for + // it. The reader takes whichever key it is handed. + for (const local of locals) models[`${local}:${id}`] = declared; + if (!locals.includes(providerId)) models[`${providerId}:${id}`] = declared; + } } - if (Object.keys(declared).length) models[id] = declared; + byProvider[providerId] = modelsById; } // Limits belong to the gateway — each truncates differently — so only the @@ -172,7 +187,9 @@ export async function syncModelCatalog() { state.running = true; try { const headers = { accept: "application/json" }; - if (state.etag) headers["if-none-match"] = state.etag; + // A file written by an older schema has to be rebuilt even when upstream is + // unchanged, so only ask upstream for a 304 when the file is current. + if (state.etag && state.fileVersion === CATALOG_VERSION) headers["if-none-match"] = state.etag; const response = await fetch(CATALOG_URL, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); let result; @@ -187,12 +204,13 @@ export async function syncModelCatalog() { const etag = response.headers.get("etag") || null; const entries = await collectEntries(); const { models, providers } = build(catalog, entries); - const serialized = JSON.stringify({ v: 1, etag, syncedAt: Date.now(), models, providers }); + const serialized = JSON.stringify({ v: CATALOG_VERSION, etag, syncedAt: Date.now(), models, providers }); writeAtomic(CATALOG_FILE, serialized); writeAtomic(CATALOG_RAW_FILE, JSON.stringify(slim(catalog))); state.etag = etag; + state.fileVersion = CATALOG_VERSION; invalidateCatalog(); result = { status: "updated", @@ -223,10 +241,13 @@ export async function syncModelCatalog() { // of re-downloading 4.3MB to be told nothing changed. function restoreEtag() { try { - state.etag = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8")).etag || null; + const parsed = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8")); + state.etag = parsed.etag || null; + state.fileVersion = parsed.v || 1; state.lastSync = fs.statSync(CATALOG_FILE).mtimeMs; } catch { state.etag = null; + state.fileVersion = null; } } diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index cde8e9b4..84a8643c 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; describe("getCapabilitiesForModel", () => { + + it("reports DeepSeek V4.1-Flash ids as vision-capable without dropping their thinking/context", () => { + const v41 = { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 }; + expect(getCapabilitiesForModel(undefined, "deepseek-v4.1-flash")).toMatchObject(v41); + expect(getCapabilitiesForModel("opencode-go", "deepseek-v4.1-flash")).toMatchObject(v41); + expect(getCapabilitiesForModel("openrouter", "deepseek/deepseek-v4.1-flash")).toMatchObject(v41); + // "deepseek-flash" is the GA id for V4.1-Flash on the DeepSeek API; the pattern it + // used to fall through to gives it 128K/64K, which the exact entry keeps. + expect(getCapabilitiesForModel("opencode-go", "deepseek-flash")).toMatchObject({ + vision: true, + reasoning: true, + thinkingFormat: "deepseek", + contextWindow: 128000, + maxOutput: 64000, + }); + // the superseded text-only Flash id stays text-only + expect(getCapabilitiesForModel("opencode-go", "deepseek-v4-flash").vision).toBe(false); + }); const claudeSonnet5Expected = { contextWindow: 1000000, maxOutput: 128000, diff --git a/tests/unit/model-catalog-scope.test.js b/tests/unit/model-catalog-scope.test.js new file mode 100644 index 00000000..c582d28f --- /dev/null +++ b/tests/unit/model-catalog-scope.test.js @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Both modules read their file path from DATA_DIR at import time, so the temp +// data dir has to be in place before the first import. +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9r-catalog-")); +process.env.DATA_DIR = dataDir; +const catalogFile = path.join(dataDir, "model-catalog.json"); + +// One upstream record per gateway: the same short id means different things to +// different vendors, which is what used to leak capabilities across providers. +const upstream = { + zai: { models: { "glm-4.6v": { modalities: { input: ["text", "image"] } } } }, + // two local ids alias this one upstream provider + zhipuai: { models: { "glm-5-canary": { modalities: { input: ["text", "image", "pdf"] } } } }, + moonshotai: { models: { "kimi-k3": { modalities: { input: ["text"] } } } }, + kilo: { models: { "kilo-auto/efficient": { modalities: { input: ["text", "image"] } } } }, +}; +// The registry snapshot the sync feeds build(): local ids, with the capabilities +// the tables resolve on their own. +const entries = [ + { provider: "glm", model: "glm-4.6v", current: { contextWindow: 200000, maxOutput: 128000 } }, + { provider: "glm-cn", model: "glm-5-canary", current: { contextWindow: 200000, maxOutput: 128000 } }, + { provider: "zhipu", model: "glm-5-canary", current: { contextWindow: 200000, maxOutput: 128000 } }, + { provider: "kimi", model: "kimi-k3", current: { contextWindow: 128000, maxOutput: 32000 } }, +]; + +let build, getCatalogModalities, invalidateCatalog, syncModelCatalog, startModelCatalogSync, capabilities; + +beforeAll(async () => { + ({ build, syncModelCatalog, startModelCatalogSync } = await import("../../src/lib/modelCatalog/sync.js")); + // the builder is exercised directly; a missing export must fail loudly here + // rather than skip every case below + expect(typeof build).toBe("function"); + const { models, providers } = build(upstream, entries); + fs.writeFileSync(catalogFile, JSON.stringify({ v: 2, models, providers })); + ({ getCatalogModalities, invalidateCatalog } = await import("../../open-sse/providers/catalogOverride.js")); + capabilities = await import("../../open-sse/providers/capabilities.js"); +}); + +afterAll(() => { + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("model catalog", () => { + it("keys modalities by gateway and writes no model-only key", () => { + const { models } = build(upstream, entries); + // upstream "zai" is filed under the local id requests arrive with... + expect(models["glm:glm-4.6v"]).toEqual({ vision: true }); + // ...and under its upstream name, because a custom provider node can carry + // that name without being in the registry snapshot + expect(models["zai:glm-4.6v"]).toEqual({ vision: true }); + expect(models["kilo:efficient"]).toEqual({ vision: true }); + // the vendor-stripped key is what used to be shared with every other gateway + expect(models["glm-4.6v"]).toBeUndefined(); + expect(models["efficient"]).toBeUndefined(); + // a gateway that only declares text has nothing to contribute + expect(models["kimi:kimi-k3"]).toBeUndefined(); + }); + + it("files an upstream provider under every local id that aliases it", () => { + const { models } = build(upstream, entries); + // glm-cn and zhipu are both zhipuai upstream; neither may be dropped + expect(models["glm-cn:glm-5-canary"]).toEqual({ vision: true, pdf: true }); + expect(models["zhipu:glm-5-canary"]).toEqual({ vision: true, pdf: true }); + expect(models["zhipuai:glm-5-canary"]).toEqual({ vision: true, pdf: true }); + expect(getCatalogModalities("glm-cn", "glm-5-canary")).toEqual({ vision: true, pdf: true }); + expect(getCatalogModalities("zhipu", "glm-5-canary")).toEqual({ vision: true, pdf: true }); + }); + + it("does not hand a router mode another vendor's modalities", () => { + // kilo's "efficient" is a real model; another gateway's "efficient" is a mode + expect(getCatalogModalities("kilo", "kilo-auto/efficient")).toEqual({ vision: true }); + expect(getCatalogModalities("kilo-gateway", "kilo-auto/efficient")).toBeNull(); + expect(getCatalogModalities("qoder", "efficient")).toBeNull(); + }); + + it("resolves a gateway the file was written for, and nobody else", () => { + expect(getCatalogModalities("glm", "glm-4.6v")).toEqual({ vision: true }); + expect(getCatalogModalities("zai", "glm-4.6v")).toEqual({ vision: true }); + expect(getCatalogModalities("unrelated", "glm-4.6v")).toBeNull(); + expect(getCatalogModalities(undefined, "glm-4.6v")).toBeNull(); + }); + + it("passes the gateway to the catalog reader when refining", () => { + const seen = []; + capabilities.setCatalogSource({ + getModalities: (provider) => { + seen.push(provider); + return provider === "gateway-a" ? { vision: true } : null; + }, + getLimits: () => null, + }); + try { + // "*laguna*" resolves from the pattern table, so refine() runs + expect(capabilities.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true); + expect(capabilities.getCapabilitiesForModel("gateway-b", "laguna-9-preview").vision).toBe(false); + expect(seen).toContain("gateway-a"); + } finally { + capabilities.setCatalogSource(null); + } + }); + + it("shares the installed source with every copy of the module", async () => { + const source = { + getModalities: (provider) => (provider === "gateway-a" ? { vision: true } : null), + getLimits: () => null, + }; + capabilities.setCatalogSource(source); + try { + // The server bundles this module into more than one chunk and the startup + // hook only runs in one of them, so the slot has to be process-wide. + expect(globalThis.__9rCatalogSource).toBe(source); + const other = await import("../../open-sse/providers/capabilities.js?copy=2"); + expect(other.getCapabilitiesForModel).not.toBe(capabilities.getCapabilitiesForModel); + // ...and that second copy resolves through the source it never installed + expect(other.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true); + } finally { + capabilities.setCatalogSource(null); + } + expect(globalThis.__9rCatalogSource).toBeNull(); + }); +}); + +describe("catalog schema", () => { + it("ignores a file written before the keys were scoped", () => { + const scoped = fs.readFileSync(catalogFile); + // v1: flat model keys, which is exactly the shape that collided + fs.writeFileSync(catalogFile, JSON.stringify({ v: 1, models: { "kimi-k3": { vision: true } }, providers: {} })); + invalidateCatalog(); + expect(getCatalogModalities("kimi", "kimi-k3")).toBeNull(); + fs.writeFileSync(catalogFile, scoped); + invalidateCatalog(); + }); + + it("rebuilds an older-schema file instead of trusting its etag", async () => { + fs.writeFileSync(catalogFile, JSON.stringify({ v: 1, etag: 'W/"old"', models: {}, providers: {} })); + invalidateCatalog(); + startModelCatalogSync(); // picks the file's etag + schema version back up + + const sent = []; + const realFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + sent.push(options?.headers || {}); + return { ok: true, status: 200, headers: new Map([["etag", 'W/"new"']]), json: async () => upstream }; + }; + try { + expect((await syncModelCatalog()).status).toBe("updated"); + } finally { + globalThis.fetch = realFetch; + } + expect(sent[0]["if-none-match"]).toBeUndefined(); + const written = JSON.parse(fs.readFileSync(catalogFile, "utf8")); + expect(written.v).toBe(2); + expect(written.models["glm:glm-4.6v"]).toEqual({ vision: true }); + }); + + it("asks upstream for a 304 once the file is current", async () => { + const sent = []; + const realFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + sent.push(options?.headers || {}); + return { ok: false, status: 304, headers: new Map(), json: async () => ({}) }; + }; + try { + expect((await syncModelCatalog()).status).toBe("unchanged"); + } finally { + globalThis.fetch = realFetch; + } + expect(sent[0]["if-none-match"]).toBe('W/"new"'); + }); +}); From 702b57c30d0e4f3cae2d188f342710d3dd10cf82 Mon Sep 17 00:00:00 2001 From: Ridho Perdana Date: Thu, 17 Sep 2026 17:59:45 +0700 Subject: [PATCH 05/31] fix(opencode-go): route every responses-only model (incl. thinking variants) to /responses Derive responses-only routing from the model registry's targetFormat instead of hardcoding model checks, and strip thinking suffixes when looking up models in providerModels so variants like gpt-5.6-luna(high) are routed correctly to /responses. --- open-sse/config/providerModels.js | 9 ++++++--- open-sse/executors/opencode-go.js | 8 ++++++-- tests/unit/opencode-go-models.test.js | 14 +++++++++++++- .../opencode-go-muse-spark-responses.test.js | 16 ++++++++++++++++ 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 065def24..afa98fa7 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -27,11 +27,14 @@ const DOT_VERSION_PROVIDERS = new Set(["kr", "kiro"]); // ("claude-sonnet-4-5" ~= "claude-sonnet-4.5"). Other providers use exact match only. function findModel(models, modelId, aliasOrId) { if (!models) return undefined; - const found = models.find(m => m.id === modelId); + const baseModelId = typeof modelId === "string" + ? modelId.replace(/\([^()]+\)\s*$/, "").trim() + : modelId; + const found = models.find(m => m.id === modelId || m.id === baseModelId); if (found) return found; if (!DOT_VERSION_PROVIDERS.has(aliasOrId)) return undefined; - const normalized = normalizeModelId(modelId); - if (normalized === modelId) return undefined; + const normalized = normalizeModelId(baseModelId); + if (normalized === baseModelId) return undefined; return models.find(m => m.id === normalized); } diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js index efb656ac..c7065ea7 100644 --- a/open-sse/executors/opencode-go.js +++ b/open-sse/executors/opencode-go.js @@ -1,7 +1,8 @@ import crypto from "node:crypto"; import { DefaultExecutor } from "./default.js"; import { resolveSessionId } from "../utils/sessionManager.js"; -import { isMuseSparkModel } from "../providers/models/helpers.js"; +import { modelTargetFormat } from "../providers/models/schema.js"; +import { getProviderModels } from "../config/providerModels.js"; import { normalizeResponsesInput, clampResponsesCallId, @@ -45,8 +46,11 @@ function baseModelId(model) { return String(model || "").replace(/\([^()]+\)\s*$/, "").trim(); } +// Responses-only per the provider registry (grok-4.6, gpt-5.6-luna, muse-spark, …). +// Reading the registry keeps this in sync with config — never hardcode model ids here. function isResponsesModel(model) { - return isMuseSparkModel(baseModelId(model)); + const entry = getProviderModels("opencode-go").find((m) => m.id === baseModelId(model)); + return modelTargetFormat(entry) === "openai-responses"; } // Flatten Chat Completions tool declarations into the Responses flat shape and diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 4ee2042a..92e7a059 100644 --- a/tests/unit/opencode-go-models.test.js +++ b/tests/unit/opencode-go-models.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js"; +import { PROVIDER_MODELS, getModelSupportedFormats, getModelTargetFormat } from "../../open-sse/config/providerModels.js"; import { PROVIDERS } from "../../open-sse/config/providers.js"; import { resolveTransport } from "../../open-sse/services/provider.js"; @@ -37,6 +37,18 @@ describe("OpenCode Go model catalog", () => { }); }); +describe("OpenCode Go thinking-suffix model lookup", () => { + it("preserves Responses routing for gpt-5.6-luna thinking variants", () => { + expect(getModelSupportedFormats("opencode-go", "gpt-5.6-luna(high)")).toEqual(["openai-responses"]); + expect(getModelTargetFormat("opencode-go", "gpt-5.6-luna(high)")).toBe("openai-responses"); + }); + + it("preserves Responses routing for grok-4.6 thinking variants", () => { + expect(getModelSupportedFormats("opencode-go", "grok-4.6(high)")).toEqual(["openai-responses"]); + expect(getModelTargetFormat("opencode-go", "grok-4.6(high)")).toBe("openai-responses"); + }); +}); + describe("OpenCode Go per-model supportedFormats", () => { it("declares [openai, claude] for MiniMax + Qwen models", () => { for (const m of CLAUDE_CAPABLE) { diff --git a/tests/unit/opencode-go-muse-spark-responses.test.js b/tests/unit/opencode-go-muse-spark-responses.test.js index 4f7dcb3d..cbe5c349 100644 --- a/tests/unit/opencode-go-muse-spark-responses.test.js +++ b/tests/unit/opencode-go-muse-spark-responses.test.js @@ -48,6 +48,22 @@ describe("ocg/muse-spark-1.3-contributor catalog", () => { }); describe("OpenCodeGoExecutor routing + sanitization", () => { + it("routes gpt-5.6-luna to /responses", () => { + const ex = new OpenCodeGoExecutor(); + expect(ex.buildUrl("gpt-5.6-luna")).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(ex.buildUrl("gpt-5.6-luna(high)", true, 0, { + runtimeTransport: { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions" }, + })).toBe("https://opencode.ai/zen/go/v1/responses"); + }); + + it("routes every responses-only registry model (grok-4.6) to /responses", () => { + const ex = new OpenCodeGoExecutor(); + expect(ex.buildUrl("grok-4.6")).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(ex.buildUrl("grok-4.6(high)", true, 0, { + runtimeTransport: { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions" }, + })).toBe("https://opencode.ai/zen/go/v1/responses"); + }); + it("is wired for opencode-go and routes muse-spark to /responses", () => { expect(getExecutor("opencode-go")).toBeInstanceOf(OpenCodeGoExecutor); const ex = new OpenCodeGoExecutor(); From 2b65c49ff5a9aa96c20287bf02ec13047d4bea35 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 17 Sep 2026 18:01:33 +0700 Subject: [PATCH 06/31] fix(opencode): route Union Alpha through Messages API Route union-alpha to /zen/v1/messages with targetFormat claude, add anthropic-version header, and register model capabilities (vision, 262K context, 131K max output). --- open-sse/executors/opencode.js | 19 +++++++---- open-sse/providers/capabilities.js | 2 ++ open-sse/providers/registry/opencode.js | 4 +-- .../unit/opencode-muse-spark-thinking.test.js | 33 +++++++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index e2e36449..fe77137c 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -5,13 +5,14 @@ import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { isMuseSparkModel } from "../providers/models/helpers.js"; +import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; const OPENCODE_UA = "opencode"; -// Models served by /zen/v1/responses; every other model stays on /chat/completions. const RESPONSES_MODELS = new Set([ "muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free", ]); +const MESSAGES_MODELS = new Set(["union-alpha"]); function generateRequestId() { return `msg_${crypto.randomUUID().replace(/-/g, "")}`; @@ -31,6 +32,10 @@ function isResponsesModel(model) { return RESPONSES_MODELS.has(base) || isMuseSparkModel(base); } +function isMessagesModel(model) { + return MESSAGES_MODELS.has(baseModelId(model)); +} + function resolveOpencodeSession(body, credentials) { const headers = credentials?.rawHeaders || {}; return resolveSessionId({ @@ -89,12 +94,12 @@ export class OpenCodeExecutor extends BaseExecutor { buildUrl(model) { const base = this.config.baseUrl; - return isResponsesModel(model) - ? `${base}/zen/v1/responses` - : `${base}/zen/v1/chat/completions`; + if (isResponsesModel(model)) return `${base}/zen/v1/responses`; + if (isMessagesModel(model)) return `${base}/zen/v1/messages`; + return `${base}/zen/v1/chat/completions`; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url = "") { const raw = credentials?.rawHeaders || {}; const lower = {}; for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v; @@ -102,7 +107,7 @@ export class OpenCodeExecutor extends BaseExecutor { const downstreamUa = lower["user-agent"] || ""; const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode"); - return { + const headers = { "Content-Type": "application/json", "Authorization": "Bearer public", "User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA, @@ -112,5 +117,7 @@ export class OpenCodeExecutor extends BaseExecutor { "x-opencode-project": lower["x-opencode-project"] || "global", "Accept": stream ? "text/event-stream" : "*/*", }; + if (url.endsWith("/messages")) headers["anthropic-version"] = ANTHROPIC_API_VERSION; + return headers; } } diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index eac9089e..12c9d82f 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -141,6 +141,8 @@ export const MODEL_CAPABILITIES = { // via OpenAI Responses input_image; reasoning supports up to xhigh. "muse-spark-1.2-contributor-free": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 }, "muse-spark-1.3-contributor-free": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 }, + // OpenCode Free Union Alpha — multimodal (text+vision), 262K context, 131K max output + "union-alpha": { vision: true, contextWindow: 262144, maxOutput: 131072 }, }; const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index 03e72eef..fc2ab8c6 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -20,10 +20,10 @@ export default { noAuth: true, }, models: [ - // Muse Spark models are served by /zen/v1/responses; the rest stay on - // /chat/completions, so the format is declared per-model, not per-provider. + // Endpoint formats differ per model, so declare non-chat models explicitly. { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses" }, { id: "muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Contributor Free", targetFormat: "openai-responses" }, + { id: "union-alpha", name: "Union Alpha Free", targetFormat: "claude" }, ], modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" }, passthroughModels: true, diff --git a/tests/unit/opencode-muse-spark-thinking.test.js b/tests/unit/opencode-muse-spark-thinking.test.js index 800f7d4e..9ffc29b1 100644 --- a/tests/unit/opencode-muse-spark-thinking.test.js +++ b/tests/unit/opencode-muse-spark-thinking.test.js @@ -63,6 +63,39 @@ describe("OpenCode Free Muse Spark thinking", () => { expect(out.max_tokens).toBeUndefined(); }); + it("routes Union Alpha through Anthropic Messages", () => { + const caps = getCapabilitiesForModel(PROVIDER, "union-alpha"); + expect(caps.vision).toBe(true); + expect(caps.contextWindow).toBe(262144); + expect(caps.maxOutput).toBe(131072); + + const executor = new OpenCodeExecutor(); + + expect(getModelTargetFormat("oc", "union-alpha")).toBe(FORMATS.CLAUDE); + const url = executor.buildUrl("union-alpha"); + expect(url).toBe("https://opencode.ai/zen/v1/messages"); + expect(executor.buildHeaders({}, true, url)).toMatchObject({ + "anthropic-version": "2023-06-01", + }); + expect(executor.buildHeaders({}, true, executor.buildUrl("big-pickle"))) + .not.toHaveProperty("anthropic-version"); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "union-alpha", + { messages: [{ role: "user", content: "ping" }], max_tokens: 1 }, + false, + {}, + PROVIDER, + ); + expect(translated).toMatchObject({ + model: "union-alpha", + messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }], + max_tokens: 1, + }); + }); + it("leaves the other free models on Chat Completions", () => { const executor = new OpenCodeExecutor(); const body = { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 }; From 6091ff597e61e83c7c06e7dbc25968809a78c891 Mon Sep 17 00:00:00 2001 From: anojndr Date: Thu, 17 Sep 2026 18:02:31 +0700 Subject: [PATCH 07/31] fix(opencode): resolve 403 FreeTierError with canonical session format and valid User-Agent OpenCode upstream validates free-tier requests: User-Agent must be opencode/ (>= 1.17.0) and x-opencode-session must match canonical ses_ format. Default OPENCODE_UA to opencode/1.18.31, generate canonical descending session IDs, provide deterministic foreign session translation, and isolate credentials per-request. --- open-sse/executors/opencode.js | 134 ++++++++++++++++-- tests/unit/opencode-session.test.js | 202 ++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 tests/unit/opencode-session.test.js diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index fe77137c..ba6a16a0 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -7,19 +7,100 @@ import { resolveSessionId } from "../utils/sessionManager.js"; import { isMuseSparkModel } from "../providers/models/helpers.js"; import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; -const OPENCODE_UA = "opencode"; +const OPENCODE_UA = "opencode/1.18.31"; +const MAX_SESSION_LENGTH = 256; +const SESSION_HEADER = "x-opencode-session"; +const SESSION_FIELD = "_opencodeSession"; +export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; +const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +function hasValidOpencodeVersion(ua) { + const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i); + if (!m) return false; + const major = parseInt(m[1], 10); + const minor = parseInt(m[2], 10); + return major > 1 || (major === 1 && minor >= 17); +} + +// Models served by /zen/v1/responses; every other model stays on /chat/completions. const RESPONSES_MODELS = new Set([ "muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free", ]); const MESSAGES_MODELS = new Set(["union-alpha"]); -function generateRequestId() { - return `msg_${crypto.randomUUID().replace(/-/g, "")}`; +let lastTimestamp = 0; +let counter = 0; + +function unstableRandom() { + const bytes = crypto.randomBytes(14); + let randomPart = ""; + for (let i = 0; i < 14; i++) { + randomPart += BASE62_CHARS[bytes[i] % 62]; + } + return randomPart; } -function generateSessionId() { - return `ses_${crypto.randomUUID().replace(/-/g, "")}`; +export function generateSessionId(timestamp = Date.now()) { + if (timestamp !== lastTimestamp) { + lastTimestamp = timestamp; + counter = 0; + } + counter++; + + const current = BigInt(timestamp) * 0x1000n + BigInt(counter); + const value = ~current; + const time = Array.from({ length: 6 }, (_, index) => + Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0") + ).join(""); + return `ses_${time}${unstableRandom()}`; +} + +export function generateRequestId(timestamp = Date.now()) { + const current = BigInt(timestamp) * 0x1000n + 1n; + const value = current; + const time = Array.from({ length: 6 }, (_, index) => + Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0") + ).join(""); + return `msg_${time}${unstableRandom()}`; +} + +export function translateSessionId(sessionId, clientTool = "") { + if (typeof sessionId === "string" && OPENCODE_SESSION_RE.test(sessionId.trim())) { + return sessionId.trim(); + } + const digest = crypto + .createHash("sha256") + .update(`opencode\0${clientTool || "generic"}\0${sessionId || ""}`) + .digest(); + const timeHex = digest.subarray(0, 6).toString("hex"); + let randomPart = ""; + for (let i = 6; i < 20; i++) { + randomPart += BASE62_CHARS[digest[i] % 62]; + } + return `ses_${timeHex}${randomPart}`; +} + +function normalizeSession(value) { + if (typeof value !== "string") return null; + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_SESSION_LENGTH) return null; + return normalized; +} + +function nativeSession(headers) { + if (!headers || typeof headers !== "object") return null; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === SESSION_HEADER) { + const normalized = normalizeSession(value); + if (normalized && OPENCODE_SESSION_RE.test(normalized)) return normalized; + } + } + return null; } // Strip the thinking suffix "model(level)" so registry lookups hit the base id. @@ -36,15 +117,27 @@ function isMessagesModel(model) { return MESSAGES_MODELS.has(baseModelId(model)); } -function resolveOpencodeSession(body, credentials) { +function resolveOpencodeSession(body, credentials, providerSessionId, clientTool) { const headers = credentials?.rawHeaders || {}; - return resolveSessionId({ + const native = nativeSession(headers); + if (native) return native; + + let incoming = null; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === SESSION_HEADER) { + incoming = normalizeSession(value); + break; + } + } + + const resolved = incoming || normalizeSession(providerSessionId) || resolveSessionId({ headers, body, connectionId: credentials?.connectionId, scope: "opencode", - generate: generateSessionId, }); + + return resolved ? translateSessionId(resolved, clientTool) : generateSessionId(); } function normalizeOpencodeReasoning(model, body) { @@ -73,12 +166,21 @@ function normalizeOpencodeReasoning(model, body) { export class OpenCodeExecutor extends BaseExecutor { constructor() { super("opencode", PROVIDERS.opencode); - this._currentSessionId = null; + } + + prepareRequestCredentials({ body, credentials, providerSessionId, clientTool } = {}) { + const sourceCredentials = credentials || {}; + const resolved = resolveOpencodeSession(body, sourceCredentials, providerSessionId, clientTool); + + return { + ...sourceCredentials, + [SESSION_FIELD]: resolved, + }; } transformRequest(model, body, stream, credentials) { - this._currentSessionId = resolveOpencodeSession(body, credentials); - if (isResponsesModel(model)) { + if (body && typeof body === "object" && model && !body.model) body.model = model; + if (isResponsesModel(model) && body && typeof body === "object") { // Responses API names the output cap max_output_tokens and takes thinking // as reasoning:{effort,summary} — normalize the Chat fields at this boundary. if (body.max_output_tokens === undefined) { @@ -92,6 +194,10 @@ export class OpenCodeExecutor extends BaseExecutor { return injectReasoningContent({ provider: this.provider, model, body }); } + async execute(args) { + return super.execute({ ...args, credentials: this.prepareRequestCredentials(args) }); + } + buildUrl(model) { const base = this.config.baseUrl; if (isResponsesModel(model)) return `${base}/zen/v1/responses`; @@ -105,14 +211,16 @@ export class OpenCodeExecutor extends BaseExecutor { for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v; const downstreamUa = lower["user-agent"] || ""; - const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode"); + const isOpencodeDownstream = hasValidOpencodeVersion(downstreamUa); + + const session = credentials?.[SESSION_FIELD] || this.prepareRequestCredentials({ credentials })[SESSION_FIELD]; const headers = { "Content-Type": "application/json", "Authorization": "Bearer public", "User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA, "x-opencode-client": lower["x-opencode-client"] || "desktop", - "x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(), + "x-opencode-session": session, "x-opencode-request": lower["x-opencode-request"] || generateRequestId(), "x-opencode-project": lower["x-opencode-project"] || "global", "Accept": stream ? "text/event-stream" : "*/*", diff --git a/tests/unit/opencode-session.test.js b/tests/unit/opencode-session.test.js new file mode 100644 index 00000000..4fef80fe --- /dev/null +++ b/tests/unit/opencode-session.test.js @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})); + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: fetchMock, +})); + +import { getExecutor } from "../../open-sse/executors/index.js"; +import { + OPENCODE_SESSION_RE, + generateSessionId, + generateRequestId, + translateSessionId, +} from "../../open-sse/executors/opencode.js"; + +function makeCredentials(overrides = {}) { + return { + connectionId: "conn_test", + rawHeaders: {}, + ...overrides, + }; +} + +function prepare(executor, overrides = {}) { + const credentials = overrides.credentials || makeCredentials(); + const prepared = executor.prepareRequestCredentials({ + body: overrides.body || { input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }] }, + credentials, + providerSessionId: overrides.providerSessionId ?? "conversation-a", + clientTool: overrides.clientTool ?? "claude", + }); + return { credentials, prepared }; +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + })); +}); + +describe("OpenCode Free Session ID Format", () => { + it("generates session IDs matching OpenCode canonical format (ses_ + 12 hex + 14 base62)", () => { + for (let i = 0; i < 20; i++) { + const id = generateSessionId(); + expect(id).toMatch(OPENCODE_SESSION_RE); + expect(id).toHaveLength(30); + } + }); + + it("generates request IDs matching OpenCode canonical format (msg_ + 12 hex + 14 base62)", () => { + for (let i = 0; i < 20; i++) { + const id = generateRequestId(); + expect(id).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + expect(id).toHaveLength(30); + } + }); + + it("translates arbitrary sessions into valid OpenCode session format", () => { + const inputs = [ + "claude:550e8400-e29b-41d4-a716-446655440000", + "antigravity:conv-abc-123", + "session-from-codex", + "12345", + "", + ]; + for (const raw of inputs) { + const translated = translateSessionId(raw, "claude"); + expect(translated).toMatch(OPENCODE_SESSION_RE); + expect(translated).toHaveLength(30); + } + }); + + it("preserves already-valid OpenCode sessions without re-hashing", () => { + const valid = "ses_f534dfae8ffeCy4Ee4tLWNygDc"; + expect(translateSessionId(valid)).toBe(valid); + expect(translateSessionId(` ${valid} `)).toBe(valid); + }); +}); + +describe("OpenCode Free Executor Session Resolution", () => { + it("uses request-local session credentials without mutating source credentials", () => { + const executor = getExecutor("opencode"); + const { credentials, prepared } = prepare(executor); + + expect(executor.constructor.name).toBe("OpenCodeExecutor"); + expect(prepared).not.toBe(credentials); + expect(prepared._opencodeSession).toMatch(OPENCODE_SESSION_RE); + expect(credentials).not.toHaveProperty("_opencodeSession"); + expect(executor).not.toHaveProperty("_currentSessionId"); + }); + + it("preserves valid native x-opencode-session header case-insensitively", () => { + const executor = getExecutor("opencode"); + const valid = "ses_f534dfae8ffeCy4Ee4tLWNygDc"; + const { prepared } = prepare(executor, { + credentials: makeCredentials({ rawHeaders: { "X-OpenCode-Session": ` ${valid} ` } }), + }); + + expect(prepared._opencodeSession).toBe(valid); + }); + + it("translates invalid native x-opencode-session header into a valid session", () => { + const executor = getExecutor("opencode"); + const { prepared } = prepare(executor, { + credentials: makeCredentials({ rawHeaders: { "x-opencode-session": "invalid-session-uuid" } }), + }); + + expect(prepared._opencodeSession).toMatch(OPENCODE_SESSION_RE); + expect(prepared._opencodeSession).not.toBe("invalid-session-uuid"); + }); + + it("translates conversation session deterministically", () => { + const executor = getExecutor("opencode"); + const first = prepare(executor, { providerSessionId: "conversation-a", clientTool: "claude" }).prepared._opencodeSession; + const second = prepare(executor, { providerSessionId: "conversation-a", clientTool: "claude" }).prepared._opencodeSession; + + expect(first).toBe(second); + expect(first).toMatch(OPENCODE_SESSION_RE); + }); + + it("isolates different conversations and tools", () => { + const executor = getExecutor("opencode"); + const convA = prepare(executor, { providerSessionId: "conversation-a" }).prepared._opencodeSession; + const convB = prepare(executor, { providerSessionId: "conversation-b" }).prepared._opencodeSession; + const toolClaude = prepare(executor, { providerSessionId: "same", clientTool: "claude" }).prepared._opencodeSession; + const toolCodex = prepare(executor, { providerSessionId: "same", clientTool: "codex" }).prepared._opencodeSession; + + expect(convA).not.toBe(convB); + expect(toolClaude).not.toBe(toolCodex); + }); + + it("adds the valid session header to fetch requests", async () => { + const executor = getExecutor("opencode"); + const credentials = makeCredentials(); + const result = await executor.execute({ + model: "muse-spark-1.3-contributor-free", + body: { input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }] }, + stream: false, + credentials, + providerSessionId: "conversation-fetch-test", + clientTool: "claude", + }); + + expect(result.headers["x-opencode-session"]).toMatch(OPENCODE_SESSION_RE); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][1].headers["x-opencode-session"]).toBe(result.headers["x-opencode-session"]); + expect(fetchMock.mock.calls[0][1].headers["Authorization"]).toBe("Bearer public"); + expect(credentials).not.toHaveProperty("_opencodeSession"); + }); + + it("falls back to a valid generated session in buildHeaders when called standalone", () => { + const executor = getExecutor("opencode"); + const headers = executor.buildHeaders({}); + + expect(headers["x-opencode-session"]).toMatch(OPENCODE_SESSION_RE); + expect(headers["Authorization"]).toBe("Bearer public"); + }); + it("handles null or undefined body gracefully in transformRequest", () => { + const executor = getExecutor("opencode"); + expect(() => executor.transformRequest("muse-spark-1.3-contributor-free", null, false, {})).not.toThrow(); + expect(() => executor.transformRequest("big-pickle", undefined, false, {})).not.toThrow(); + }); +}); + +describe("OpenCode Free User-Agent Validation", () => { + it("defaults User-Agent to opencode/1.18.31 for non-opencode downstream clients", () => { + const executor = getExecutor("opencode"); + const headersNoUa = executor.buildHeaders({}); + expect(headersNoUa["User-Agent"]).toBe("opencode/1.18.31"); + + const headersClaude = executor.buildHeaders({ rawHeaders: { "user-agent": "Claude-Code/1.0" } }); + expect(headersClaude["User-Agent"]).toBe("opencode/1.18.31"); + }); + + it("replaces bare opencode with versioned opencode/1.18.31 to prevent 403 FreeTierError", () => { + const executor = getExecutor("opencode"); + const headers = executor.buildHeaders({ rawHeaders: { "user-agent": "opencode" } }); + expect(headers["User-Agent"]).toBe("opencode/1.18.31"); + }); + + it("upgrades outdated opencode versions (< 1.17) to prevent 426 Upgrade Required", () => { + const executor = getExecutor("opencode"); + const headers = executor.buildHeaders({ rawHeaders: { "user-agent": "opencode/1.15.0" } }); + expect(headers["User-Agent"]).toBe("opencode/1.18.31"); + }); + + it("preserves valid opencode versions (>= 1.17)", () => { + const executor = getExecutor("opencode"); + const headers118 = executor.buildHeaders({ + rawHeaders: { "user-agent": "opencode/1.18.31 ai-sdk/provider-utils/4.0.40 runtime/bun/1.3.14" }, + }); + expect(headers118["User-Agent"]).toBe("opencode/1.18.31 ai-sdk/provider-utils/4.0.40 runtime/bun/1.3.14"); + + const headersFuture = executor.buildHeaders({ rawHeaders: { "user-agent": "opencode/1.19.0" } }); + expect(headersFuture["User-Agent"]).toBe("opencode/1.19.0"); + }); +}); From 0c6ab4f99b69ecfadcd255f6f2bd793919ab6ff2 Mon Sep 17 00:00:00 2001 From: ErfanBagheri404 Date: Thu, 17 Sep 2026 13:47:12 +0330 Subject: [PATCH 08/31] fix(opencode): reuse one stable upstream session per identity to stop 429s Follow-up to the canonical-session fix: with no explicit session, every request minted a fresh x-opencode-session, and upstream free-tier quota is accounted per session. That burns through quota and surfaces as 429 FreeUsageLimitError with growing reset-after delays, while the real CLI reuses one long-lived session per conversation. - Stable canonical session per downstream identity (connectionId, else auth-header hash, else shared default), evicted after MEMORY_CONFIG.sessionTtlMs like the other session stores. - Deterministic x-opencode-request per message (stable across retries, like the CLI user message id); valid downstream ids preserved. - 6 more unit tests (22 total). --- open-sse/executors/opencode.js | 183 ++++++++++++++++++++++++++-- tests/unit/opencode-session.test.js | 77 ++++++++++++ 2 files changed, 250 insertions(+), 10 deletions(-) diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index ba6a16a0..c5d0caa7 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -1,6 +1,7 @@ import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; @@ -11,7 +12,9 @@ const OPENCODE_UA = "opencode/1.18.31"; const MAX_SESSION_LENGTH = 256; const SESSION_HEADER = "x-opencode-session"; const SESSION_FIELD = "_opencodeSession"; +const REQ_FIELD = "_opencodeRequest"; export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; +export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/; const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; function hasValidOpencodeVersion(ua) { @@ -103,6 +106,140 @@ function nativeSession(headers) { return null; } +// Upstream free-tier quota is accounted per session. Minting a fresh +// x-opencode-session on every request burns through it and surfaces as +// 429 FreeUsageLimitError with growing reset-after delays, while the real +// CLI reuses one long-lived canonical session per conversation. Mirror +// that: one stable canonical session per downstream identity, evicted +// after MEMORY_CONFIG.sessionTtlMs like the other session stores. +const stableOpencodeSessions = new Map(); +const MAX_STABLE_SESSIONS = 1000; +const stableSessionCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of stableOpencodeSessions) { + if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) { + stableOpencodeSessions.delete(key); + } + } +}, MEMORY_CONFIG.sessionCleanupIntervalMs); +if (stableSessionCleanup.unref) stableSessionCleanup.unref(); + +function identityKey(credentials) { + const connectionId = credentials?.connectionId || credentials?.id; + if (connectionId) return `opencode:conn:${String(connectionId).slice(0, 128)}`; + const raw = credentials?.rawHeaders || {}; + const auth = raw.authorization || raw.Authorization || raw["x-api-key"] || raw["X-Api-Key"] || ""; + if (auth) { + const digest = crypto.createHash("sha256").update(String(auth)).digest("hex").slice(0, 32); + return `opencode:auth:${digest}`; + } + return "opencode:default"; +} + +export function stableSessionId(credentials) { + const key = identityKey(credentials); + const existing = stableOpencodeSessions.get(key); + if (existing) { + existing.lastUsed = Date.now(); + stableOpencodeSessions.delete(key); + stableOpencodeSessions.set(key, existing); + return existing.sessionId; + } + const sessionId = generateSessionId(); + if (stableOpencodeSessions.size >= MAX_STABLE_SESSIONS) { + stableOpencodeSessions.delete(stableOpencodeSessions.keys().next().value); + } + stableOpencodeSessions.set(key, { sessionId, lastUsed: Date.now() }); + return sessionId; +} + +function lastUserText(body) { + try { + if (!body || typeof body !== "object") return ""; + const arr = Array.isArray(body.messages) + ? body.messages + : Array.isArray(body.input) + ? body.input + : null; + if (!arr) return typeof body.input === "string" ? body.input.slice(-600) : ""; + for (let i = arr.length - 1; i >= 0; i--) { + const msg = arr[i]; + if (!msg) continue; + if (msg.role && msg.role !== "user") continue; + const content = msg.content; + if (typeof content === "string" && content.trim()) return content.trim().slice(-600); + if (Array.isArray(content)) { + const text = content + .map((part) => (typeof part === "string" ? part : part?.text || part?.input_text || "")) + .join(" ") + .trim(); + if (text) return text.slice(-600); + } + } + } catch { + return ""; + } + return ""; +} + +// The real CLI sends the current user message id (stable per turn, same on +// retries) as x-opencode-request. Derive it deterministically from the +// session plus the last user message so retries share the id. +export function deriveRequestId(sessionId, body) { + const text = lastUserText(body); + if (!text) return generateRequestId(); + const digest = crypto + .createHash("sha256") + .update(`opencode-req\0${sessionId || ""}\0${text}`) + .digest(); + const timeHex = digest.subarray(0, 6).toString("hex"); + let randomPart = ""; + for (let i = 6; i < 20; i++) { + randomPart += BASE62_CHARS[digest[i] % 62]; + } + const id = `msg_${timeHex}${randomPart}`; + return OPENCODE_REQUEST_RE.test(id) ? id : generateRequestId(); +} + +function normalizeRequestId(value) { + if (typeof value !== "string") return null; + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_SESSION_LENGTH) return null; + return OPENCODE_REQUEST_RE.test(normalized) ? normalized : null; +} + +function bodyHasSessionHints(body) { + try { + if (!body || typeof body !== "object") return false; + if (typeof body.session_id === "string" && body.session_id.trim()) return true; + if (typeof body.conversation_id === "string" && body.conversation_id.trim()) return true; + if (typeof body.prompt_cache_key === "string" && body.prompt_cache_key.trim()) return true; + if (body.metadata && typeof body.metadata.user_id === "string" && body.metadata.user_id.trim()) return true; + if (body.request && body.request.sessionId != null && String(body.request.sessionId) !== "") return true; + const arr = Array.isArray(body.messages) + ? body.messages + : Array.isArray(body.input) + ? body.input + : null; + if (arr) { + let assistantText = ""; + for (const msg of arr) { + if (msg?.role === "assistant") { + const content = msg.content; + if (typeof content === "string") assistantText += content; + else if (Array.isArray(content)) { + for (const part of content) assistantText += part?.text || part?.output || ""; + } + if (assistantText.length >= 50) return true; + } + } + } + return false; + } catch { + return false; + } +} + // Strip the thinking suffix "model(level)" so registry lookups hit the base id. function baseModelId(model) { return String(model || "").replace(/\([^()]+\)\s*$/, "").trim(); @@ -130,14 +267,37 @@ function resolveOpencodeSession(body, credentials, providerSessionId, clientTool } } - const resolved = incoming || normalizeSession(providerSessionId) || resolveSessionId({ - headers, - body, - connectionId: credentials?.connectionId, - scope: "opencode", - }); + const hinted = incoming || normalizeSession(providerSessionId); + if (hinted) return translateSessionId(hinted, clientTool); - return resolved ? translateSessionId(resolved, clientTool) : generateSessionId(); + if (credentials?.connectionId || bodyHasSessionHints(body)) { + let viaManager = null; + try { + viaManager = resolveSessionId({ + headers, + body, + connectionId: credentials?.connectionId, + scope: "opencode", + }); + } catch { + viaManager = null; + } + if (viaManager) return translateSessionId(viaManager, clientTool); + } + + return stableSessionId(credentials); +} + +function resolveOpencodeRequestId(body, credentials, sessionId) { + const raw = credentials?.rawHeaders || {}; + for (const [key, value] of Object.entries(raw)) { + if (key.toLowerCase() === "x-opencode-request") { + const normalized = normalizeRequestId(value); + if (normalized) return normalized; + break; + } + } + return deriveRequestId(sessionId, body); } function normalizeOpencodeReasoning(model, body) { @@ -170,11 +330,12 @@ export class OpenCodeExecutor extends BaseExecutor { prepareRequestCredentials({ body, credentials, providerSessionId, clientTool } = {}) { const sourceCredentials = credentials || {}; - const resolved = resolveOpencodeSession(body, sourceCredentials, providerSessionId, clientTool); + const session = resolveOpencodeSession(body, sourceCredentials, providerSessionId, clientTool); return { ...sourceCredentials, - [SESSION_FIELD]: resolved, + [SESSION_FIELD]: session, + [REQ_FIELD]: resolveOpencodeRequestId(body, sourceCredentials, session), }; } @@ -214,6 +375,8 @@ export class OpenCodeExecutor extends BaseExecutor { const isOpencodeDownstream = hasValidOpencodeVersion(downstreamUa); const session = credentials?.[SESSION_FIELD] || this.prepareRequestCredentials({ credentials })[SESSION_FIELD]; + const downstreamReq = normalizeRequestId(lower["x-opencode-request"]); + const requestId = credentials?.[REQ_FIELD] || downstreamReq || generateRequestId(); const headers = { "Content-Type": "application/json", @@ -221,7 +384,7 @@ export class OpenCodeExecutor extends BaseExecutor { "User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA, "x-opencode-client": lower["x-opencode-client"] || "desktop", "x-opencode-session": session, - "x-opencode-request": lower["x-opencode-request"] || generateRequestId(), + "x-opencode-request": requestId, "x-opencode-project": lower["x-opencode-project"] || "global", "Accept": stream ? "text/event-stream" : "*/*", }; diff --git a/tests/unit/opencode-session.test.js b/tests/unit/opencode-session.test.js index 4fef80fe..b7651f16 100644 --- a/tests/unit/opencode-session.test.js +++ b/tests/unit/opencode-session.test.js @@ -11,9 +11,12 @@ vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ import { getExecutor } from "../../open-sse/executors/index.js"; import { OPENCODE_SESSION_RE, + OPENCODE_REQUEST_RE, generateSessionId, generateRequestId, translateSessionId, + stableSessionId, + deriveRequestId, } from "../../open-sse/executors/opencode.js"; function makeCredentials(overrides = {}) { @@ -200,3 +203,77 @@ describe("OpenCode Free User-Agent Validation", () => { expect(headersFuture["User-Agent"]).toBe("opencode/1.19.0"); }); }); + +describe("OpenCode Stable Session Reuse (429 follow-up)", () => { + function anonymousCredentials(auth) { + return makeCredentials({ connectionId: undefined, rawHeaders: { authorization: `Bearer ${auth}` } }); + } + + it("reuses one stable upstream session instead of minting a new one per request", () => { + const executor = getExecutor("opencode"); + const body = { messages: [{ role: "user", content: "hello" }] }; + const first = executor.prepareRequestCredentials({ + body, + credentials: anonymousCredentials("stable-key-1"), + providerSessionId: null, + clientTool: "claude", + }); + const second = executor.prepareRequestCredentials({ + body, + credentials: anonymousCredentials("stable-key-1"), + providerSessionId: null, + clientTool: "claude", + }); + + expect(first._opencodeSession).toMatch(OPENCODE_SESSION_RE); + expect(second._opencodeSession).toBe(first._opencodeSession); + }); + + it("isolates stable sessions by downstream identity", () => { + const executor = getExecutor("opencode"); + const body = { messages: [{ role: "user", content: "hello" }] }; + const forKey = (auth) => executor.prepareRequestCredentials({ + body, + credentials: anonymousCredentials(auth), + providerSessionId: null, + clientTool: "claude", + })._opencodeSession; + + expect(forKey("user-A")).not.toBe(forKey("user-B")); + expect(forKey("user-A")).toMatch(OPENCODE_SESSION_RE); + }); + + it("exposes the stable session helper directly", () => { + const first = stableSessionId({ connectionId: "direct-conn" }); + expect(stableSessionId({ connectionId: "direct-conn" })).toBe(first); + expect(first).toMatch(OPENCODE_SESSION_RE); + }); + + it("derives deterministic, canonical request ids per message", () => { + const session = stableSessionId({ connectionId: "req-conn" }); + const body = { messages: [{ role: "user", content: "ping" }] }; + const first = deriveRequestId(session, body); + expect(first).toMatch(OPENCODE_REQUEST_RE); + expect(deriveRequestId(session, body)).toBe(first); + expect( + deriveRequestId(session, { messages: [{ role: "user", content: "a different question" }] }), + ).not.toBe(first); + }); + + it("preserves a valid downstream x-opencode-request header", () => { + const executor = getExecutor("opencode"); + const validReq = "msg_0ae8d9cd3001swxaFbM248jcIF"; + const { prepared } = prepare(executor, { + credentials: makeCredentials({ rawHeaders: { "x-opencode-request": validReq } }), + }); + expect(prepared._opencodeRequest).toBe(validReq); + }); + + it("keeps the standalone buildHeaders session stable across calls", () => { + const executor = getExecutor("opencode"); + const first = executor.buildHeaders({})["x-opencode-session"]; + const second = executor.buildHeaders({})["x-opencode-session"]; + expect(first).toMatch(OPENCODE_SESSION_RE); + expect(second).toBe(first); + }); +}); From f4f06f290c054f55c3cdb598e8ef5d58f5b8043d Mon Sep 17 00:00:00 2001 From: Manan Santoki Date: Thu, 17 Sep 2026 18:11:20 +0700 Subject: [PATCH 09/31] fix(translator): keep tool-result images, restore Kiro tool names, preserve thinking display Forward images inside tool_result to OpenAI and Kiro upstreams via following user messages, restore original client tool names on Kiro responses via _toolNameMap, and preserve thinking display settings across translations. --- open-sse/executors/base.js | 2 +- open-sse/executors/default.js | 4 +- open-sse/providers/shared.js | 13 +- .../translator/concerns/thinkingUnified.js | 11 +- open-sse/translator/formats/claude.js | 30 +++ open-sse/translator/request/claude-to-kiro.js | 19 +- .../translator/request/claude-to-openai.js | 28 ++- .../request/openai-to-commandcode.js | 6 +- open-sse/translator/request/openai-to-kiro.js | 7 + .../translator/response/kiro-to-claude.js | 14 +- .../translator/response/kiro-to-openai.js | 31 ++- tests/translator/agent-client-fixes.test.js | 186 ++++++++++++++++++ 12 files changed, 328 insertions(+), 23 deletions(-) create mode 100644 tests/translator/agent-client-fixes.test.js diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index a4c017e4..18a62229 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -127,7 +127,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, url, model); + const headers = this.buildHeaders(credentials, stream, url, model, transformedBody); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 00f3e3de..64ad46d0 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -146,7 +146,7 @@ export class DefaultExecutor extends BaseExecutor { return BEARER; } - buildHeaders(credentials, stream = true, url, model) { + buildHeaders(credentials, stream = true, url, model, body = null) { const rt = credentials?.runtimeTransport; const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) }; const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor(); @@ -166,7 +166,7 @@ export class DefaultExecutor extends BaseExecutor { const isClaudeModel = typeof model === "string" && /^claude-/.test(model); if (model && (this.provider === "claude" || (this.provider?.startsWith?.("anthropic-compatible-") && isClaudeModel))) { - headers["Anthropic-Beta"] = selectAnthropicBeta(model); + headers["Anthropic-Beta"] = selectAnthropicBeta(model, body); } // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 88488499..c0699c6f 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -62,8 +62,17 @@ const ANTHROPIC_BETA_BASE = [ const ANTHROPIC_BETA_HEAVY_AGENT = ["advanced-tool-use-2025-11-20", "effort-2025-11-24"]; // Heavy-agent beta flags are gated to opus/sonnet — cheaper models don't need them. -export function selectAnthropicBeta(model = "") { - const flags = [...ANTHROPIC_BETA_BASE]; +// `redact-thinking` asks Anthropic to return signature-only thinking blocks, which +// is right for clients that never render thinking but blanks the summaries a +// client explicitly requested with `thinking.display: "summarized"`. +const ANTHROPIC_BETA_REDACT_THINKING = "redact-thinking-2026-02-12"; + +export function wantsThinkingSummaries(body) { + return body?.thinking?.display === "summarized"; +} + +export function selectAnthropicBeta(model = "", body = null) { + const flags = ANTHROPIC_BETA_BASE.filter((flag) => flag !== ANTHROPIC_BETA_REDACT_THINKING || !wantsThinkingSummaries(body)); if (/^claude-(opus|sonnet)/.test(model)) flags.push(...ANTHROPIC_BETA_HEAVY_AGENT); return flags.join(","); } diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 001259e3..4bc9601f 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -232,7 +232,7 @@ function stripAll(body) { } // Apply unified thinking config to body in the resolved provider-native format. -function applyFormat(fmt, body, cfg, caps, supportedLevels) { +function applyFormat(fmt, body, cfg, caps, supportedLevels, display) { const none = cfg.mode === "none"; const canDisable = caps.thinkingCanDisable !== false; // Model cannot disable thinking → clamp "none" to minimal effort instead. @@ -249,7 +249,7 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } // Models that can disable thinking need the explicit adaptive switch. // Permanently adaptive models such as Fable 5.1 accept effort directly. - if (canDisable) body.thinking = { type: "adaptive" }; + if (canDisable) body.thinking = { type: "adaptive", ...(display ? { display } : {}) }; else delete body.thinking; const level = toLevel(eff); body.output_config = { effort: level === "xhigh" || level === "auto" ? "high" : level }; @@ -258,7 +258,7 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) { case "claude-budget": { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } const budget = toBudget(eff, caps.thinkingRange); - body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 }; + body.thinking = budget === -1 ? { type: "enabled", ...(display ? { display } : {}) } : { type: "enabled", budget_tokens: budget || 8192, ...(display ? { display } : {}) }; break; } case "gemini-level": { @@ -378,7 +378,10 @@ export function applyThinking(targetFormat, model, body, provider = null, intent const fmt = resolveFormat(targetFormat, cleanModel, provider); const supportedLevels = getThinkingLevels(provider, cleanModel); + // Anthropic's `display` (summarized | omitted) decides whether thinking text + // comes back at all; keep what the client asked for instead of resetting it. + const display = typeof body.thinking?.display === "string" ? body.thinking.display : undefined; stripAll(body); - applyFormat(fmt, body, cfg, caps, supportedLevels); + applyFormat(fmt, body, cfg, caps, supportedLevels, display); return body; } diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index f57972da..14c9fc10 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -415,6 +415,28 @@ export function anchorClaudeCache(body) { // - Add thinking block for Anthropic endpoint (provider === "claude") // - Fix tool_use/tool_result ordering // - Apply cloaking (billing header + fake user ID) for OAuth tokens +export function hoistToolResultImages(body) { + if (!Array.isArray(body?.messages)) return body; + let touched = false; + const messages = body.messages.map((msg) => { + if (msg?.role !== ROLE.USER || !Array.isArray(msg.content)) return msg; + const hoisted = []; + const content = msg.content.map((block) => { + if (block?.type !== CLAUDE_BLOCK.TOOL_RESULT || !Array.isArray(block.content)) return block; + const images = block.content.filter((c) => c?.type === CLAUDE_BLOCK.IMAGE); + if (!images.length) return block; + const rest = block.content.filter((c) => c?.type !== CLAUDE_BLOCK.IMAGE); + hoisted.push({ type: CLAUDE_BLOCK.TEXT, text: `[Image from tool result ${block.tool_use_id}]` }, ...images); + return { ...block, content: rest.length ? rest : [{ type: CLAUDE_BLOCK.TEXT, text: "(image attached below)" }] }; + }); + if (!hoisted.length) return msg; + touched = true; + // tool_result blocks must lead a user message; the hoisted image follows them. + return { ...msg, content: [...content, ...hoisted] }; + }); + return touched ? { ...body, messages } : body; +} + export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null, rawHeaders = null, sessionId = null) { // quirk: MiniMax's Claude-compatible endpoint rejects Anthropic's output_config (400 invalid params) if (PROVIDERS[provider]?.quirks?.dropOutputConfig) { @@ -608,6 +630,14 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne } } + // Anthropic itself reads images inside tool_result; other Anthropic-compatible + // endpoints (OpenCode Go, Kimi, DeepSeek, GLM, MiniMax) accept image blocks + // only as user content and silently drop them inside a tool result. Move a + // tool's screenshot out of the result and into the same user turn. + if (provider !== "claude" && !provider?.startsWith("anthropic-compatible")) { + body = hoistToolResultImages(body); + } + // Apply cloaking for OAuth tokens (billing header + fake user ID) // session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) { diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index ef2dd6c5..ac7a704d 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -97,11 +97,21 @@ function convertClaudeMessagesToKiro(messages, model) { if (typeof block.content === "string") { resultContent = block.content; } else if (Array.isArray(block.content)) { + // Images a tool returned (screenshots) ride along as user images; + // Kiro tool results are text-only. + let hasImage = false; + for (const c of block.content) { + if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") { + hasImage = true; + const imageType = c.source.media_type || DEFAULT_IMAGE_MIME; + pendingImages.push({ format: imageType.split("/")[1] || imageType, source: { bytes: c.source.data } }); + } + } resultContent = block.content .filter((c) => c.type === CLAUDE_BLOCK.TEXT) .map((c) => c.text) - .join("\n") || JSON.stringify(block.content); + .join("\n") || (hasImage ? "(image attached)" : JSON.stringify(block.content)); } else if (block.content) { resultContent = JSON.stringify(block.content); } @@ -341,6 +351,13 @@ export function claudeToKiroRequest(model, body, stream, credentials) { enumerable: false, }); + // Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the + // reverse map so tool calls stream back under the client's own names. + const restoredToolNames = new Map(); + for (const [original, sanitized] of nameMap) { + if (original !== sanitized) restoredToolNames.set(sanitized, original); + } + if (restoredToolNames.size) payload._toolNameMap = restoredToolNames; return payload; } diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index 38976226..e75d89b8 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -196,25 +196,41 @@ function convertClaudeMessage(msg) { }); break; - case CLAUDE_BLOCK.TOOL_RESULT: + case CLAUDE_BLOCK.TOOL_RESULT: { let resultContent = ""; + const resultImages = []; if (typeof block.content === "string") { resultContent = block.content; } else if (Array.isArray(block.content)) { - resultContent = block.content - .filter(c => c.type === CLAUDE_BLOCK.TEXT) - .map(c => c.text) - .join("\n") || JSON.stringify(block.content); + for (const c of block.content) { + if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") { + resultImages.push({ + type: OPENAI_BLOCK.IMAGE_URL, + image_url: { url: encodeDataUri(c.source.media_type, c.source.data) } + }); + } + } + const textOnly = block.content.filter(c => c?.type === CLAUDE_BLOCK.TEXT); + resultContent = textOnly.map(c => c.text).join("\n") + || (resultImages.length ? "" : JSON.stringify(block.content)); } else if (block.content) { resultContent = JSON.stringify(block.content); } - + toolResults.push({ role: ROLE.TOOL, tool_call_id: block.tool_use_id, content: resultContent }); + // The OpenAI tool role is text-only, so a screenshot or any other image a + // tool returned would otherwise vanish. Hand it to the model in the user + // turn that follows the tool messages, tagged with the call it came from. + if (resultImages.length) { + parts.push({ type: OPENAI_BLOCK.TEXT, text: `[Image from tool result ${block.tool_use_id}]` }); + parts.push(...resultImages); + } break; + } } } diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js index 194078ae..d0a55e43 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -42,16 +42,19 @@ function toNativeImageBlock(part) { type: OPENAI_BLOCK.IMAGE, image: encodeDataUri(parsed.mimeType, parsed.base64), mimeType: parsed.mimeType, + mediaType: parsed.mimeType, }; } if (part.type === OPENAI_BLOCK.IMAGE || part.type === CLAUDE_BLOCK.IMAGE) { if (typeof part.image === "string" && part.image.startsWith("data:")) { const parsed = parseDataUri(part.image); + const mime = part.mimeType || parsed?.mimeType || "image/png"; return { type: OPENAI_BLOCK.IMAGE, image: part.image, - mimeType: part.mimeType || parsed?.mimeType || "image/png", + mimeType: mime, + mediaType: mime, }; } const source = part.source; @@ -61,6 +64,7 @@ function toNativeImageBlock(part) { type: OPENAI_BLOCK.IMAGE, image: encodeDataUri(mime, source.data), mimeType: mime, + mediaType: mime, }; } } diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index dbaefbae..0c9f04fd 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -434,6 +434,13 @@ export function openaiToKiroRequest(model, body, stream, credentials) { enumerable: false }); + // Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the + // reverse map so tool calls stream back under the client's own names. + const restoredToolNames = new Map(); + for (const [original, sanitized] of nameMap) { + if (original !== sanitized) restoredToolNames.set(sanitized, original); + } + if (restoredToolNames.size) payload._toolNameMap = restoredToolNames; return payload; } diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js index 455672b1..9118b9cb 100644 --- a/open-sse/translator/response/kiro-to-claude.js +++ b/open-sse/translator/response/kiro-to-claude.js @@ -46,6 +46,14 @@ function convertFinishReason(reason) { * Convert one OpenAI-format chunk (from KiroExecutor) into Claude SSE events. * Returns an array of Claude events, or null when the chunk yields nothing. */ +// Kiro only accepts sanitized tool names; the request translator leaves the +// reverse map on the stream state so calls come back under the client's names. +function restoreToolName(state, name) { + const raw = name || ""; + const map = state?.toolNameMap; + return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw; +} + export function kiroToClaudeResponse(chunk, state) { // KiroExecutor emits chat.completion.chunk objects; tolerate string chunks // by attempting a parse (defensive — the direct path is always objects). @@ -161,7 +169,7 @@ export function kiroToClaudeResponse(chunk, state) { const toolBlockIndex = state.nextBlockIndex++; state.toolCalls.set(idx, { id: tc.id, - name: tc.function?.name || "", + name: restoreToolName(state, tc.function?.name), blockIndex: toolBlockIndex, }); results.push({ @@ -170,7 +178,7 @@ export function kiroToClaudeResponse(chunk, state) { content_block: { type: "tool_use", id: tc.id, - name: tc.function?.name || "", + name: restoreToolName(state, tc.function?.name), input: {}, }, }); @@ -246,7 +254,7 @@ export function kiroToClaudeNonStreaming(data) { content.push({ type: "tool_use", id: tc.id || `toolu_${Date.now()}`, - name: tc.function?.name || "", + name: restoreToolName(state, tc.function?.name), input, }); } diff --git a/open-sse/translator/response/kiro-to-openai.js b/open-sse/translator/response/kiro-to-openai.js index 7059a851..8e713fd7 100644 --- a/open-sse/translator/response/kiro-to-openai.js +++ b/open-sse/translator/response/kiro-to-openai.js @@ -20,13 +20,38 @@ function chunkMeta(state) { * Parse Kiro SSE event and convert to OpenAI format * Kiro events: assistantResponseEvent, codeEvent, supplementaryWebLinksEvent, etc. */ +// Kiro only accepts sanitized tool names; the request translator leaves the +// reverse map on the stream state so calls come back under the client's names. +function restoreToolName(state, name) { + const raw = name || ""; + const map = state?.toolNameMap; + return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw; +} + export function kiroToOpenAIResponse(chunk, state) { if (!chunk) return null; - // If chunk is already in OpenAI format (from executor transform), return as-is + // If chunk is already in OpenAI format (from executor transform), return it + // with the client's tool names restored. if (chunk.object === "chat.completion.chunk" && chunk.choices) { - return chunk; + if (!state?.toolNameMap?.size) return chunk; + return { + ...chunk, + choices: chunk.choices.map((choice) => { + const calls = choice?.delta?.tool_calls; + if (!Array.isArray(calls)) return choice; + return { + ...choice, + delta: { + ...choice.delta, + tool_calls: calls.map((tc) => tc?.function?.name + ? { ...tc, function: { ...tc.function, name: restoreToolName(state, tc.function.name) } } + : tc), + }, + }; + }), + }; } // Handle string chunk (raw SSE data) @@ -109,7 +134,7 @@ export function kiroToOpenAIResponse(chunk, state) { state.hadToolUse = true; const toolUse = data.toolUseEvent || data; const toolCallId = toolUse.toolUseId || fallbackToolCallId(); - const toolName = toolUse.name || ""; + const toolName = restoreToolName(state, toolUse.name); const toolInput = toolUse.input || {}; const openaiChunk = buildChunk(chunkMeta(state), { diff --git a/tests/translator/agent-client-fixes.test.js b/tests/translator/agent-client-fixes.test.js new file mode 100644 index 00000000..3f4e7cd7 --- /dev/null +++ b/tests/translator/agent-client-fixes.test.js @@ -0,0 +1,186 @@ +// Fixes for agent clients (Claude Code) driving non-Anthropic upstreams: +// - tool-result images survive the Claude → OpenAI / Kiro request translation +// - Kiro tool calls stream back under the client's own (unsanitized) names +// - the client's thinking `display` is kept on Claude-format upstreams +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest, translateResponse, initState } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { applyThinking } from "../../open-sse/translator/concerns/thinkingUnified.js"; +import { kiroToClaudeResponse } from "../../open-sse/translator/response/kiro-to-claude.js"; +import { kiroToOpenAIResponse } from "../../open-sse/translator/response/kiro-to-openai.js"; +import { selectAnthropicBeta } from "../../open-sse/providers/shared.js"; +import { hoistToolResultImages } from "../../open-sse/translator/formats/claude.js"; +import { openaiToCommandCodeRequest } from "../../open-sse/translator/request/openai-to-commandcode.js"; + +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +const screenshotTurn = (extraTools = []) => ({ + tools: [ + { name: "mcp__browser__computer", description: "browser", input_schema: { type: "object", properties: {} } }, + ...extraTools, + ], + messages: [ + { role: "user", content: "take a screenshot" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "mcp__browser__computer", input: { action: "screenshot" } }] }, + { + role: "user", + content: [{ + type: "tool_result", + tool_use_id: "toolu_1", + content: [ + { type: "text", text: "Successfully captured screenshot (1x1, png)" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: PNG } }, + ], + }], + }, + ], +}); + +describe("tool-result images reach OpenAI-format upstreams", () => { + it("emits the tool message text and a follow-up user message carrying the image", () => { + const out = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "gpt-x", screenshotTurn(), true, null, "openai"); + const toolMsg = out.messages.find((m) => m.role === "tool"); + expect(toolMsg.tool_call_id).toBe("toolu_1"); + expect(toolMsg.content).toBe("Successfully captured screenshot (1x1, png)"); + expect(toolMsg.content).not.toContain(PNG); + const follow = out.messages[out.messages.indexOf(toolMsg) + 1]; + expect(follow.role).toBe("user"); + const image = follow.content.find((p) => p.type === "image_url"); + expect(image.image_url.url).toBe(`data:image/png;base64,${PNG}`); + expect(follow.content.find((p) => p.type === "text").text).toContain("toolu_1"); + }); + + it("does not dump base64 into a tool message that had no text", () => { + const body = screenshotTurn(); + body.messages[2].content[0].content = [{ type: "image", source: { type: "base64", media_type: "image/png", data: PNG } }]; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "gpt-x", body, true, null, "openai"); + const toolMsg = out.messages.find((m) => m.role === "tool"); + expect(toolMsg.content).toBe(""); + expect(out.messages.some((m) => Array.isArray(m.content) && m.content.some((p) => p.type === "image_url"))).toBe(true); + }); + + it("leaves text-only tool results exactly as before", () => { + const body = screenshotTurn(); + body.messages[2].content[0].content = "plain result"; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "gpt-x", body, true, null, "openai"); + const toolMsg = out.messages.find((m) => m.role === "tool"); + expect(toolMsg.content).toBe("plain result"); + expect(out.messages[out.messages.length - 1]).toBe(toolMsg); + }); + + it("forwards tool-result images to Kiro as user images", () => { + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", screenshotTurn(), true, null, "kiro"); + const json = JSON.stringify(out.conversationState); + expect(json).toContain(PNG); + expect(json).toContain("Successfully captured screenshot"); + }); +}); + +describe("Kiro tool names round-trip", () => { + it("returns the sanitized→original map on the translated body", () => { + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", screenshotTurn(), true, null, "kiro"); + expect(out._toolNameMap).toBeInstanceOf(Map); + expect(out._toolNameMap.get("mcp_browser_computer")).toBe("mcp__browser__computer"); + const wire = JSON.parse(JSON.stringify(out.conversationState)); + expect(JSON.stringify(wire)).toContain("mcp_browser_computer"); + expect(JSON.stringify(wire)).not.toContain("mcp__browser__computer"); + }); + + it("omits the map when no name changed", () => { + const body = screenshotTurn(); + body.tools = [{ name: "plain_tool", description: "x", input_schema: { type: "object", properties: {} } }]; + body.messages[1].content[0].name = "plain_tool"; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro"); + expect(out._toolNameMap).toBeUndefined(); + }); + + it("restores the client name on streamed Claude tool_use blocks", () => { + const state = { ...initState(FORMATS.CLAUDE), toolNameMap: new Map([["mcp_browser_computer", "mcp__browser__computer"]]) }; + const chunk = { + id: "c1", object: "chat.completion.chunk", created: 1, model: "claude-sonnet-4.5", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "mcp_browser_computer", arguments: "" } }] }, finish_reason: null }], + }; + const events = kiroToClaudeResponse(chunk, state); + const start = events.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"); + expect(start.content_block.name).toBe("mcp__browser__computer"); + }); + + it("passes unknown names through untouched", () => { + const state = { ...initState(FORMATS.CLAUDE), toolNameMap: new Map([["mcp_browser_computer", "mcp__browser__computer"]]) }; + const chunk = { + id: "c1", object: "chat.completion.chunk", created: 1, model: "claude-sonnet-4.5", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_2", type: "function", function: { name: "other_tool", arguments: "" } }] }, finish_reason: null }], + }; + const events = kiroToClaudeResponse(chunk, state); + const start = events.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"); + expect(start.content_block.name).toBe("other_tool"); + }); + + it("restores the client name on OpenAI chunks passed through kiro-to-openai", () => { + const state = { ...initState(FORMATS.OPENAI), toolNameMap: new Map([["mcp_browser_computer", "mcp__browser__computer"]]) }; + const chunk = { + id: "c1", object: "chat.completion.chunk", created: 1, model: "claude-sonnet-4.5", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "mcp_browser_computer", arguments: "{}" } }] }, finish_reason: null }], + }; + const out = kiroToOpenAIResponse(chunk, state); + expect(out.choices[0].delta.tool_calls[0].function.name).toBe("mcp__browser__computer"); + expect(kiroToOpenAIResponse(chunk, initState(FORMATS.OPENAI))).toBe(chunk); + }); +}); + +describe("thinking display is preserved for Claude-format upstreams", () => { + it("keeps display on adaptive thinking", () => { + const body = { model: "claude-sonnet-5", thinking: { type: "adaptive", display: "summarized" }, output_config: { effort: "high" }, messages: [] }; + applyThinking(FORMATS.CLAUDE, "claude-sonnet-5", body, "claude"); + expect(body.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(body.output_config).toEqual({ effort: "high" }); + }); + + it("keeps display on budget thinking and omits it when the client sent none", () => { + const withDisplay = { model: "claude-haiku-4-5-20251001", thinking: { type: "adaptive", display: "omitted" }, output_config: { effort: "low" }, messages: [] }; + applyThinking(FORMATS.CLAUDE, "claude-haiku-4-5-20251001", withDisplay, "claude"); + expect(withDisplay.thinking.type).toBe("enabled"); + expect(withDisplay.thinking.display).toBe("omitted"); + + const without = { model: "claude-sonnet-5", thinking: { type: "adaptive" }, output_config: { effort: "high" }, messages: [] }; + applyThinking(FORMATS.CLAUDE, "claude-sonnet-5", without, "claude"); + expect(without.thinking).toEqual({ type: "adaptive" }); + }); +}); + +describe("redact-thinking beta follows the client's display request", () => { + it("keeps redact-thinking by default and drops it for summarized display", () => { + expect(selectAnthropicBeta("claude-sonnet-5")).toContain("redact-thinking-2026-02-12"); + expect(selectAnthropicBeta("claude-sonnet-5", { thinking: { type: "adaptive", display: "omitted" } })).toContain("redact-thinking-2026-02-12"); + const summarized = selectAnthropicBeta("claude-sonnet-5", { thinking: { type: "adaptive", display: "summarized" } }); + expect(summarized).not.toContain("redact-thinking-2026-02-12"); + expect(summarized).toContain("interleaved-thinking-2025-05-14"); + expect(summarized).toContain("effort-2025-11-24"); + }); +}); + +describe("tool-result images reach Anthropic-compatible and Command Code upstreams", () => { + it("hoists a tool_result image into the same user turn after the results", () => { + const body = screenshotTurn(); + const out = hoistToolResultImages(body); + const user = out.messages[2]; + expect(user.content[0].type).toBe("tool_result"); + expect(user.content[0].content.every((c) => c.type !== "image")).toBe(true); + expect(user.content.some((c) => c.type === "image" && c.source?.data === PNG)).toBe(true); + expect(user.content.find((c) => c.type === "text" && /toolu_1/.test(c.text))).toBeTruthy(); + // No image: untouched object identity. + const plain = { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "x", content: "ok" }] }] }; + expect(hoistToolResultImages(plain)).toBe(plain); + }); + + it("sends an image block to Command Code instead of a placeholder", () => { + const openaiBody = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "muse-spark", screenshotTurn(), true, null, "commandcode"); + const out = openaiToCommandCodeRequest("muse-spark", openaiBody, true); + const json = JSON.stringify(out); + expect(json).not.toContain("[image omitted]"); + expect(json).toContain(`"type":"image"`); + expect(json).toContain(`data:image/png;base64,${PNG}`); + expect(json).toContain(`"mediaType":"image/png"`); + }); +}); From 82b1bca42a12fb50b19643d2ae73b2d99d80233e Mon Sep 17 00:00:00 2001 From: Qisthi Ramadhani Date: Thu, 17 Sep 2026 18:12:34 +0700 Subject: [PATCH 10/31] fix(kiro): use neutral placeholder for tool-result-only user turns Replace the literal 'continue' placeholder on tool-result-only user turns with 'Tool results provided.' to prevent models from treating it as a new user instruction. --- .../translator/concerns/kiroConversation.js | 17 ++- open-sse/translator/request/claude-to-kiro.js | 4 +- open-sse/translator/request/openai-to-kiro.js | 4 +- .../__snapshots__/golden-request.test.js.snap | 8 +- .../unit/kiro-tool-result-placeholder.test.js | 115 ++++++++++++++++++ 5 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/unit/kiro-tool-result-placeholder.test.js diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js index 11d49dc7..5ac1485b 100644 --- a/open-sse/translator/concerns/kiroConversation.js +++ b/open-sse/translator/concerns/kiroConversation.js @@ -5,6 +5,20 @@ import { } from "../../config/kiroConstants.js"; const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; + +/** + * Kiro rejects user turns with empty `content`, so a turn that only carries + * tool results needs placeholder text. It must not read like a user + * instruction: with "continue", models answer the word itself ("Nothing in + * progress to continue") and drop the task they were in the middle of. + */ +export const KIRO_TOOL_RESULTS_PLACEHOLDER = "Tool results provided."; +export const KIRO_EMPTY_USER_PLACEHOLDER = "continue"; + +/** Placeholder content for a user turn with no text of its own. */ +export function kiroEmptyUserContent(hasToolResults) { + return hasToolResults ? KIRO_TOOL_RESULTS_PLACEHOLDER : KIRO_EMPTY_USER_PLACEHOLDER; +} const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g; function clone(value) { @@ -174,7 +188,8 @@ function normalizeTurns(history, currentMessage, modelId) { for (const turn of turns) { if (turn.userInputMessage) { - turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue"; + turn.userInputMessage.content = text(turn.userInputMessage.content).trim() + || kiroEmptyUserContent(turn.userInputMessage.userInputMessageContext?.toolResults?.length > 0); turn.userInputMessage.modelId ||= modelId; if (turn.userInputMessage.userInputMessageContext?.tools) { delete turn.userInputMessage.userInputMessageContext.tools; diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index ac7a704d..cffe59ec 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -34,6 +34,7 @@ import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; import { canonicalizeKiroConversation, normalizeKiroToolSpecs, + kiroEmptyUserContent, } from "../concerns/kiroConversation.js"; /** @@ -53,7 +54,8 @@ function convertClaudeMessagesToKiro(messages, model) { const flushPending = () => { if (currentRole === ROLE.USER) { - const content = pendingUserContent.join("\n\n").trim() || "continue"; + const content = pendingUserContent.join("\n\n").trim() + || kiroEmptyUserContent(pendingToolResults.length > 0); const userMsg = { userInputMessage: { content, modelId: model } }; if (pendingImages.length > 0) { diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 0c9f04fd..b8846660 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -23,6 +23,7 @@ import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; import { canonicalizeKiroConversation, normalizeKiroToolSpecs, + kiroEmptyUserContent, } from "../concerns/kiroConversation.js"; /** @@ -51,7 +52,8 @@ function convertMessages(messages, model) { const flushPending = () => { if (currentRole === "user") { - const content = pendingUserContent.join("\n\n").trim() || "continue"; + const content = pendingUserContent.join("\n\n").trim() + || kiroEmptyUserContent(pendingToolResults.length > 0); const userMsg = { userInputMessage: { content: content, diff --git a/tests/translator/__snapshots__/golden-request.test.js.snap b/tests/translator/__snapshots__/golden-request.test.js.snap index a7a0c219..f5a5253b 100644 --- a/tests/translator/__snapshots__/golden-request.test.js.snap +++ b/tests/translator/__snapshots__/golden-request.test.js.snap @@ -239,7 +239,7 @@ exports[`GOLDEN request: OpenAI → Kiro > full body (image base64 + tool_result "userInputMessage": { "content": "[Context: Current time is -continue", +Tool results provided.", "modelId": "claude-sonnet-4.5", "origin": "AI_EDITOR", "userInputMessageContext": { @@ -281,7 +281,11 @@ continue", "history": [ { "userInputMessage": { - "content": "You are helpful. + "content": "[Context: Current time is + + +You are helpful. + What's in this image?", "images": [ diff --git a/tests/unit/kiro-tool-result-placeholder.test.js b/tests/unit/kiro-tool-result-placeholder.test.js new file mode 100644 index 00000000..ab910b42 --- /dev/null +++ b/tests/unit/kiro-tool-result-placeholder.test.js @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; +import { + canonicalizeKiroConversation, + KIRO_TOOL_RESULTS_PLACEHOLDER, + KIRO_EMPTY_USER_PLACEHOLDER, +} from "../../open-sse/translator/concerns/kiroConversation.js"; + +const TOOLS_OPENAI = [{ + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + }, +}]; + +const TOOLS_CLAUDE = [{ + name: "get_weather", + description: "Get weather", + input_schema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, +}]; + +function allUserContents(payload) { + const state = payload.conversationState; + return [ + ...state.history.filter((t) => t.userInputMessage).map((t) => t.userInputMessage.content), + state.currentMessage.userInputMessage.content, + ]; +} + +describe("Kiro tool-result-only turns", () => { + it("OpenAI → Kiro: tool message gets a neutral placeholder, not \"continue\"", () => { + const payload = openaiToKiroRequest("claude-sonnet-4.6", { + tools: TOOLS_OPENAI, + messages: [ + { role: "user", content: "The secret word is PINEAPPLE. Weather in Jakarta?" }, + { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather", arguments: "{\"city\":\"Jakarta\"}" } }] }, + { role: "tool", tool_call_id: "call_1", content: "32C, humid" }, + ], + }, true, {}); + + const current = payload.conversationState.currentMessage.userInputMessage; + expect(current.content).toContain(KIRO_TOOL_RESULTS_PLACEHOLDER); + expect(current.content).not.toMatch(/\bcontinue\b/); + expect(current.userInputMessageContext.toolResults).toHaveLength(1); + expect(allUserContents(payload).join("\n")).toContain("PINEAPPLE"); + }); + + it("Claude → Kiro: tool_result-only user message gets a neutral placeholder", () => { + const payload = claudeToKiroRequest("claude-sonnet-4.6", { + tools: TOOLS_CLAUDE, + messages: [ + { role: "user", content: "The secret word is PINEAPPLE. Weather in Jakarta?" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "Jakarta" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "32C, humid" }] }, + ], + }, true, {}); + + const current = payload.conversationState.currentMessage.userInputMessage; + expect(current.content).toContain(KIRO_TOOL_RESULTS_PLACEHOLDER); + expect(current.content).not.toMatch(/\bcontinue\b/); + expect(current.userInputMessageContext.toolResults).toHaveLength(1); + }); + + it("keeps real user text when a turn has both text and tool results", () => { + const payload = claudeToKiroRequest("claude-sonnet-4.6", { + tools: TOOLS_CLAUDE, + messages: [ + { role: "user", content: "Weather in Jakarta?" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "Jakarta" } }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_1", content: "32C" }, + { type: "text", text: "Now answer in one word." }, + ] }, + ], + }, true, {}); + + const current = payload.conversationState.currentMessage.userInputMessage; + expect(current.content).toContain("Now answer in one word."); + expect(current.content).not.toContain(KIRO_TOOL_RESULTS_PLACEHOLDER); + }); + + it("canonicalize: history turn with tool results and no text uses the placeholder", () => { + const result = canonicalizeKiroConversation({ + history: [ + { userInputMessage: { content: "Weather in Jakarta?", modelId: "m" } }, + { assistantResponseMessage: { content: "", toolUses: [{ toolUseId: "t1", name: "get_weather", input: { city: "Jakarta" } }] } }, + { userInputMessage: { content: "", modelId: "m", userInputMessageContext: { toolResults: [{ toolUseId: "t1", status: "success", content: [{ text: "32C" }] }] } } }, + { assistantResponseMessage: { content: "It is 32C." } }, + ], + currentMessage: { userInputMessage: { content: "Hot or cold?", modelId: "m" } }, + modelId: "m", + toolSpecs: [{ toolSpecification: { name: "get_weather", description: "Get weather", inputSchema: { json: { type: "object", properties: {} } } } }], + nameMap: new Map([["get_weather", "get_weather"]]), + }); + + expect(result.valid).toBe(true); + expect(result.history[2].userInputMessage.content).toBe(KIRO_TOOL_RESULTS_PLACEHOLDER); + }); + + it("canonicalize: an empty turn without tool results still falls back to \"continue\"", () => { + const result = canonicalizeKiroConversation({ + history: [{ assistantResponseMessage: { content: "Hello" } }], + currentMessage: { userInputMessage: { content: "", modelId: "m" } }, + modelId: "m", + toolSpecs: [], + nameMap: new Map(), + }); + + expect(result.history[0].userInputMessage.content).toBe(KIRO_EMPTY_USER_PLACEHOLDER); + expect(result.currentMessage.userInputMessage.content).toBe(KIRO_EMPTY_USER_PLACEHOLDER); + }); +}); From c49efdf5288200f19ed78136bdb0a6dcc3d5d7ff Mon Sep 17 00:00:00 2001 From: Qisthi Ramadhani Date: Thu, 17 Sep 2026 18:13:14 +0700 Subject: [PATCH 11/31] fix(kiro): preserve underscores in tool names and restore sanitized names in responses Do not collapse consecutive underscores in uniqueName so mcp__server__tool is sent intact to Kiro, attach reverse map on request translation, and restore client tool names in responses. --- .../translator/concerns/kiroConversation.js | 1 - .../translator/response/kiro-to-claude.js | 6 +- tests/translator/agent-client-fixes.test.js | 8 +- .../kiro-tool-name-roundtrip.test.js | 110 ++++++++++++++++++ 4 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 tests/translator/kiro-tool-name-roundtrip.test.js diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js index 5ac1485b..b19e9ba6 100644 --- a/open-sse/translator/concerns/kiroConversation.js +++ b/open-sse/translator/concerns/kiroConversation.js @@ -48,7 +48,6 @@ 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; diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js index 9118b9cb..d9fc0aab 100644 --- a/open-sse/translator/response/kiro-to-claude.js +++ b/open-sse/translator/response/kiro-to-claude.js @@ -48,9 +48,9 @@ function convertFinishReason(reason) { */ // Kiro only accepts sanitized tool names; the request translator leaves the // reverse map on the stream state so calls come back under the client's names. -function restoreToolName(state, name) { +function restoreToolName(stateOrData, name) { const raw = name || ""; - const map = state?.toolNameMap; + const map = stateOrData?.toolNameMap || stateOrData?._toolNameMap; return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw; } @@ -254,7 +254,7 @@ export function kiroToClaudeNonStreaming(data) { content.push({ type: "tool_use", id: tc.id || `toolu_${Date.now()}`, - name: restoreToolName(state, tc.function?.name), + name: restoreToolName(data, tc.function?.name), input, }); } diff --git a/tests/translator/agent-client-fixes.test.js b/tests/translator/agent-client-fixes.test.js index 3f4e7cd7..6004db80 100644 --- a/tests/translator/agent-client-fixes.test.js +++ b/tests/translator/agent-client-fixes.test.js @@ -79,12 +79,14 @@ describe("tool-result images reach OpenAI-format upstreams", () => { describe("Kiro tool names round-trip", () => { it("returns the sanitized→original map on the translated body", () => { - const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", screenshotTurn(), true, null, "kiro"); + const body = screenshotTurn(); + body.tools = [{ name: "mcp.browser.computer", description: "browser", input_schema: { type: "object", properties: {} } }]; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro"); expect(out._toolNameMap).toBeInstanceOf(Map); - expect(out._toolNameMap.get("mcp_browser_computer")).toBe("mcp__browser__computer"); + expect(out._toolNameMap.get("mcp_browser_computer")).toBe("mcp.browser.computer"); const wire = JSON.parse(JSON.stringify(out.conversationState)); expect(JSON.stringify(wire)).toContain("mcp_browser_computer"); - expect(JSON.stringify(wire)).not.toContain("mcp__browser__computer"); + expect(JSON.stringify(wire)).not.toContain("mcp.browser.computer"); }); it("omits the map when no name changed", () => { diff --git a/tests/translator/kiro-tool-name-roundtrip.test.js b/tests/translator/kiro-tool-name-roundtrip.test.js new file mode 100644 index 00000000..a8f151ad --- /dev/null +++ b/tests/translator/kiro-tool-name-roundtrip.test.js @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { normalizeKiroToolSpecs } from "../../open-sse/translator/concerns/kiroConversation.js"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; +import { kiroToOpenAIResponse } from "../../open-sse/translator/response/kiro-to-openai.js"; +import { kiroToClaudeResponse, kiroToClaudeNonStreaming } from "../../open-sse/translator/response/kiro-to-claude.js"; + +describe("Kiro tool name normalization and roundtrip", () => { + it("preserves consecutive underscores like mcp__gitea__search_repos without collapsing", () => { + const { specs, nameMap } = normalizeKiroToolSpecs([ + { name: "mcp__gitea__search_repos", description: "Search Gitea" }, + ]); + expect(specs).toHaveLength(1); + expect(specs[0].toolSpecification.name).toBe("mcp__gitea__search_repos"); + expect(nameMap.get("mcp__gitea__search_repos")).toBe("mcp__gitea__search_repos"); + }); + + it("builds _toolNameMap for illegal characters and deduplicates colliding names", () => { + const tools = [ + { name: "my.tool/search", description: "tool 1" }, + { name: "my_tool_search", description: "tool 2" }, + ]; + const openaiPayload = openaiToKiroRequest("claude-sonnet-4.6", { + tools: tools.map((t) => ({ type: "function", function: t })), + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(openaiPayload._toolNameMap).toBeInstanceOf(Map); + // my.tool/search cleaned to my_tool_search. Since my_tool_search comes next, it becomes my_tool_search_2 + expect(openaiPayload._toolNameMap.get("my_tool_search")).toBe("my.tool/search"); + + const claudePayload = claudeToKiroRequest("claude-sonnet-4.6", { + tools, + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(claudePayload._toolNameMap).toBeInstanceOf(Map); + expect(claudePayload._toolNameMap.get("my_tool_search")).toBe("my.tool/search"); + }); + + it("does not attach _toolNameMap when all tool names are legal and unchanged", () => { + const tools = [ + { name: "mcp__gitea__search_repos", description: "Search Gitea" }, + { name: "bash_exec", description: "Run bash" }, + ]; + const payload = openaiToKiroRequest("claude-sonnet-4.6", { + tools: tools.map((t) => ({ type: "function", function: t })), + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(payload._toolNameMap).toBeUndefined(); + }); + + it("restores original tool name in kiroToOpenAIResponse when state.toolNameMap is present", () => { + const state = { + toolNameMap: new Map([["my_tool_search", "my.tool/search"]]), + }; + const event = { + toolUseEvent: { + toolUseId: "call_123", + name: "my_tool_search", + input: { q: "test" }, + }, + }; + const chunk = kiroToOpenAIResponse(event, state); + expect(chunk).not.toBeNull(); + expect(chunk.choices[0].delta.tool_calls[0].function.name).toBe("my.tool/search"); + }); + + it("restores original tool name in kiroToClaudeResponse streaming when state.toolNameMap is present", () => { + const state = { + toolNameMap: new Map([["my_tool_search", "my.tool/search"]]), + toolCalls: new Map(), + nextBlockIndex: 0, + }; + const chunk = { + id: "chatcmpl-1", + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: "call_123", + type: "function", + function: { name: "my_tool_search", arguments: "" }, + }], + }, + }], + }; + const events = kiroToClaudeResponse(chunk, state); + const startEvent = events.find((e) => e.type === "content_block_start"); + expect(startEvent).toBeDefined(); + expect(startEvent.content_block.name).toBe("my.tool/search"); + }); + + it("restores original tool name in kiroToClaudeNonStreaming when toolNameMap is present", () => { + const data = { + choices: [{ + message: { + tool_calls: [{ + id: "call_123", + function: { name: "my_tool_search", arguments: "{}" }, + }], + }, + }], + toolNameMap: new Map([["my_tool_search", "my.tool/search"]]), + }; + const result = kiroToClaudeNonStreaming(data); + expect(result.content[0].name).toBe("my.tool/search"); + }); +}); From eafac37dcbd24d867b18bd70e6959eccbec500fe Mon Sep 17 00:00:00 2001 From: anojndr Date: Thu, 17 Sep 2026 18:17:18 +0700 Subject: [PATCH 12/31] fix(opencode): strip prior reasoning items on Muse Spark Responses models Strip prior-turn type: reasoning items and encrypted_content fields from body.input on Muse Spark Responses endpoints in OpenCode and OpenCode Go executors to avoid HTTP 400 errors across rotated proxy accounts. --- open-sse/executors/opencode-go.js | 6 ++ open-sse/executors/opencode.js | 82 ++++++++++++++++++- .../opencode-go-muse-spark-responses.test.js | 22 +++++ .../unit/opencode-muse-spark-thinking.test.js | 59 +++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js index c7065ea7..fe90c6eb 100644 --- a/open-sse/executors/opencode-go.js +++ b/open-sse/executors/opencode-go.js @@ -94,6 +94,12 @@ function sanitizeResponsesItems(body) { if (!Array.isArray(body.input)) return; body.input = body.input.filter((item) => { if (!item || typeof item !== "object" || Array.isArray(item)) return true; + // Strip prior-turn reasoning items: Muse Spark contributor models route to + // an upstream Console backend where encrypted_content cannot be validated across + // rotated accounts or sessions, causing 400 "reasoning encrypted_content was not issued to this caller". + if (item.type === "reasoning") return false; + delete item.encrypted_content; + delete item.reasoning_encrypted_content; if (item.type === "function_call") { if (!item.name || typeof item.name !== "string" || item.name.trim() === "") return false; item.name = item.name.trim().slice(0, MAX_TOOL_NAME_LEN); diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index c5d0caa7..c4c31720 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -7,9 +7,16 @@ import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { isMuseSparkModel } from "../providers/models/helpers.js"; import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; +import { + normalizeResponsesInput, + clampResponsesCallId, + coerceResponsesArguments, + coerceResponsesOutput, +} from "../translator/formats/responsesApi.js"; const OPENCODE_UA = "opencode/1.18.31"; const MAX_SESSION_LENGTH = 256; +const MAX_TOOL_NAME_LEN = 128; const SESSION_HEADER = "x-opencode-session"; const SESSION_FIELD = "_opencodeSession"; const REQ_FIELD = "_opencodeRequest"; @@ -24,7 +31,6 @@ function hasValidOpencodeVersion(ua) { const minor = parseInt(m[2], 10); return major > 1 || (major === 1 && minor >= 17); } - // Models served by /zen/v1/responses; every other model stays on /chat/completions. const RESPONSES_MODELS = new Set([ "muse-spark-1.2-contributor-free", @@ -300,6 +306,69 @@ function resolveOpencodeRequestId(body, credentials, sessionId) { return deriveRequestId(sessionId, body); } +function normalizeResponsesTools(body) { + if (!Array.isArray(body.tools)) return; + const validNames = new Set(); + body.tools = body.tools.filter((tool) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null; + const rawName = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : ""); + const name = rawName.trim(); + if (!name) return false; + const description = typeof tool.description === "string" ? tool.description : (typeof fn?.description === "string" ? fn.description : ""); + let parameters = (tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)) + ? tool.parameters + : (fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) ? fn.parameters : { type: "object", properties: {} }); + if (parameters.type === "object" && !parameters.properties) parameters = { ...parameters, properties: {} }; + for (const k of Object.keys(tool)) delete tool[k]; + tool.type = "function"; + tool.name = name.slice(0, MAX_TOOL_NAME_LEN); + if (description) tool.description = description; + tool.parameters = parameters; + validNames.add(tool.name); + return true; + }); + if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) { + if (body.tool_choice.type === "function") { + const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : ""; + if (!n || !validNames.has(n)) delete body.tool_choice; + } + } +} + +function sanitizeResponsesItems(body) { + if (!Array.isArray(body.input)) return; + body.input = body.input.filter((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return true; + // Strip prior-turn reasoning items: OpenCode Free uses public/pooled credentials + // (`Bearer public`) routing to an upstream OpenAI/Console account pool. + // OpenAI Responses API strictly enforces that reasoning `encrypted_content` + // can only be decrypted by the exact caller/account that issued it; sending it + // across different accounts or rotating proxy relays triggers: + // [invalid_request_error] reasoning `encrypted_content` was not issued to this caller (400). + // Furthermore, under stateless mode (store=false), omitting encrypted_content + // causes OpenAI to reject the referenced reasoning item as "not found or was deleted". + // Dropping prior reasoning items allows multi-turn conversations and tool-calling + // loops to succeed cleanly. + if (item.type === "reasoning") return false; + delete item.encrypted_content; + delete item.reasoning_encrypted_content; + if (item.type === "function_call") { + if (!item.name || typeof item.name !== "string" || item.name.trim() === "") return false; + item.name = item.name.trim().slice(0, MAX_TOOL_NAME_LEN); + item.call_id = clampResponsesCallId(item.call_id); + item.arguments = coerceResponsesArguments(item.arguments); + return true; + } + if (item.type === "function_call_output") { + item.call_id = clampResponsesCallId(item.call_id); + item.output = coerceResponsesOutput(item.output); + return true; + } + return true; + }); +} + function normalizeOpencodeReasoning(model, body) { const current = body.reasoning; const currentReasoning = current && typeof current === "object" && !Array.isArray(current) @@ -341,7 +410,12 @@ export class OpenCodeExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { if (body && typeof body === "object" && model && !body.model) body.model = model; - if (isResponsesModel(model) && body && typeof body === "object") { + if (isResponsesModel(model || body?.model) && body && typeof body === "object") { + const normalized = normalizeResponsesInput(body.input); + if (normalized) body.input = normalized; + if (!Array.isArray(body.input) || body.input.length === 0) { + body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }]; + } // Responses API names the output cap max_output_tokens and takes thinking // as reasoning:{effort,summary} — normalize the Chat fields at this boundary. if (body.max_output_tokens === undefined) { @@ -351,6 +425,10 @@ export class OpenCodeExecutor extends BaseExecutor { delete body.max_tokens; delete body.max_completion_tokens; normalizeOpencodeReasoning(model, body); + body.stream = true; + body.store = false; + normalizeResponsesTools(body); + sanitizeResponsesItems(body); } return injectReasoningContent({ provider: this.provider, model, body }); } diff --git a/tests/unit/opencode-go-muse-spark-responses.test.js b/tests/unit/opencode-go-muse-spark-responses.test.js index cbe5c349..98a780d7 100644 --- a/tests/unit/opencode-go-muse-spark-responses.test.js +++ b/tests/unit/opencode-go-muse-spark-responses.test.js @@ -136,6 +136,28 @@ describe("OpenCodeGoExecutor routing + sanitization", () => { expect(out.tools.find((t) => t.name === "bare").parameters).toEqual({ type: "object", properties: {} }); expect(out.tools.find((t) => t.name === "full").parameters).toEqual({ type: "object", properties: { a: { type: "string" } } }); }); + + it("strips prior-turn reasoning items carrying encrypted_content from input", () => { + const ex = new OpenCodeGoExecutor(); + const body = { + model: MODEL, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { + type: "reasoning", + id: "rs_123", + encrypted_content: "ENC_BLOB_TURN_1", + summary: [{ type: "summary_text", text: "thinking text" }], + }, + { type: "function_call", call_id: "c1", name: "read", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + ], + }; + const out = ex.transformRequest(MODEL, body, true, {}); + expect(out.input.some((i) => i.type === "reasoning")).toBe(false); + expect(JSON.stringify(out.input)).not.toContain("ENC_BLOB_TURN_1"); + expect(out.input.map((i) => i.type)).toEqual(["message", "function_call", "function_call_output"]); + }); }); describe("chat/claude clients translate to Responses without breaking tools", () => { diff --git a/tests/unit/opencode-muse-spark-thinking.test.js b/tests/unit/opencode-muse-spark-thinking.test.js index 9ffc29b1..94a44e8c 100644 --- a/tests/unit/opencode-muse-spark-thinking.test.js +++ b/tests/unit/opencode-muse-spark-thinking.test.js @@ -165,4 +165,63 @@ describe("OpenCode Free Muse Spark thinking", () => { expect(out.max_tokens).toBeUndefined(); } }); + + it("strips prior-turn reasoning items carrying encrypted_content from input", () => { + const executor = new OpenCodeExecutor(); + const model = "muse-spark-1.3-contributor-free"; + const body = { + model, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "say hi" }] }, + { + type: "reasoning", + id: "rs_123", + encrypted_content: "ENC_BLOB_TURN_1", + summary: [{ type: "summary_text", text: "thinking text" }], + }, + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "shell", + arguments: JSON.stringify({ command: "echo hi" }), + }, + { + type: "function_call_output", + call_id: "call_1", + output: "hi", + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now say bye" }] }, + ], + tools: [ + { + type: "function", + function: { + name: "shell", + description: "Run shell command", + parameters: { type: "object" }, + }, + }, + ], + }; + + const out = executor.transformRequest(model, body, true, {}); + expect(out.stream).toBe(true); + expect(out.store).toBe(false); + // Prior reasoning items stripped to prevent 400 "reasoning encrypted_content was not issued to this caller" + expect(out.input.some((item) => item.type === "reasoning")).toBe(false); + expect(JSON.stringify(out.input)).not.toContain("ENC_BLOB_TURN_1"); + // User message, function_call, function_call_output, and next user message survive + const types = out.input.map((item) => item.type); + expect(types).toEqual(["message", "function_call", "function_call_output", "message"]); + // Tools flattened and empty properties added + expect(out.tools).toEqual([ + { + type: "function", + name: "shell", + description: "Run shell command", + parameters: { type: "object", properties: {} }, + }, + ]); + }); }); From aa14ef72e2ea71431dc5963214a7615116af386f Mon Sep 17 00:00:00 2001 From: KunN-21 Date: Thu, 17 Sep 2026 18:19:38 +0700 Subject: [PATCH 13/31] fix(opencode): normalize Muse Free tool choice OpenCode Free returns HTTP 400 for muse-spark-1.3-contributor-free when tool_choice is non-auto. Declare forceAutoToolChoiceModels quirk and normalize explicit tool_choice to auto. --- open-sse/executors/opencode.js | 5 ++ open-sse/providers/registry/opencode.js | 3 + tests/unit/opencode-free-tool-choice.test.js | 90 ++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 tests/unit/opencode-free-tool-choice.test.js diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index c4c31720..531c5df5 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -411,6 +411,11 @@ export class OpenCodeExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { if (body && typeof body === "object" && model && !body.model) body.model = model; if (isResponsesModel(model || body?.model) && body && typeof body === "object") { + // ponytail: chỉ model đã xác nhận auto-only; mở allowlist khi có bằng chứng. + if ("tool_choice" in body && body.tool_choice !== "auto" + && this.config.quirks?.forceAutoToolChoiceModels?.includes(baseModelId(model))) { + body.tool_choice = "auto"; + } const normalized = normalizeResponsesInput(body.input); if (normalized) body.input = normalized; if (!Array.isArray(body.input) || body.input.length === 0) { diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index fc2ab8c6..0914f431 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -18,6 +18,9 @@ export default { "x-opencode-client": "desktop", }, noAuth: true, + quirks: { + forceAutoToolChoiceModels: ["muse-spark-1.3-contributor-free"], + }, }, models: [ // Endpoint formats differ per model, so declare non-chat models explicitly. diff --git a/tests/unit/opencode-free-tool-choice.test.js b/tests/unit/opencode-free-tool-choice.test.js new file mode 100644 index 00000000..4d3aca91 --- /dev/null +++ b/tests/unit/opencode-free-tool-choice.test.js @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js"; +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(async () => ({ ok: true, status: 200, headers: { get: () => "" } })), +})); + +// Break caught: opencode/muse-spark-1.3-contributor-free 400 vì upstream +// chỉ nhận tool_choice "auto"; named/required/none phải demote sang "auto". +const FREE_13 = "muse-spark-1.3-contributor-free"; +const CREDS = { connectionId: "opencode-free-tool-choice-test" }; +const INPUT = [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }]; +const TOOLS = [{ type: "function", name: "get_weather", description: "w", parameters: { type: "object", properties: {} } }]; + +function responsesBody(model, tool_choice) { + const body = { model, input: structuredClone(INPUT), tools: structuredClone(TOOLS) }; + if (tool_choice !== undefined) body.tool_choice = tool_choice; + return body; +} + +describe("opencode Free 1.3 tool_choice auto-only", () => { + it("khai quirk đúng model 1.3-Free trong registry", () => { + expect(PROVIDERS.opencode.quirks?.forceAutoToolChoiceModels).toEqual([FREE_13]); + }); + + it.each([ + ["Responses named", { type: "function", name: "get_weather" }], + ["Chat function named", { type: "function", function: { name: "get_weather" } }], + ["Claude tool named", { type: "tool", name: "get_weather" }], + ["required", "required"], + ["none", "none"], + ])("demote %s sang auto (plain và max)", (_label, choice) => { + for (const model of [FREE_13, `${FREE_13}(max)`]) { + const body = responsesBody(model, structuredClone(choice)); + const out = new OpenCodeExecutor().transformRequest(model, body, true, CREDS); + expect(out.tool_choice).toBe("auto"); + expect(out.tools).toEqual(TOOLS); + expect(out.input).toEqual(INPUT); + } + }); + + it("giữ auto và absent; tools/input nguyên vẹn", () => { + const autoOut = new OpenCodeExecutor().transformRequest( + FREE_13, responsesBody(FREE_13, "auto"), true, CREDS, + ); + expect(autoOut.tool_choice).toBe("auto"); + expect(autoOut.tools).toEqual(TOOLS); + expect(autoOut.input).toEqual(INPUT); + + const absentOut = new OpenCodeExecutor().transformRequest( + FREE_13, responsesBody(FREE_13, undefined), true, CREDS, + ); + expect("tool_choice" in absentOut).toBe(false); + expect(absentOut.tools).toEqual(TOOLS); + expect(absentOut.input).toEqual(INPUT); + }); + + it.each([ + ["1.2-Free", "muse-spark-1.2-contributor-free"], + ["future 1.4-Free", "muse-spark-1.4-contributor-free"], + ["Go id", "muse-spark-1.3-contributor"], + ["non-Muse", "big-pickle"], + ])("không đổi tool_choice của %s", (_label, model) => { + const choice = { type: "function", name: "get_weather" }; + const body = responsesBody(model, structuredClone(choice)); + const out = new OpenCodeExecutor().transformRequest(model, body, true, CREDS); + expect(out.tool_choice).toEqual(choice); + }); + + it("wire: execute gửi choice auto tới /zen/v1/responses", async () => { + proxyAwareFetch.mockClear(); + const ex = new OpenCodeExecutor(); + const body = responsesBody(FREE_13, { type: "function", name: "get_weather" }); + const { url, transformedBody } = await ex.execute({ + model: FREE_13, body, stream: true, credentials: CREDS, + }); + expect(url).toBe("https://opencode.ai/zen/v1/responses"); + expect(transformedBody.tool_choice).toBe("auto"); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [actualUrl, actualInit] = proxyAwareFetch.mock.calls[0]; + expect(actualUrl).toBe("https://opencode.ai/zen/v1/responses"); + const sent = JSON.parse(actualInit.body); + expect(sent.tool_choice).toBe("auto"); + expect(sent.model).toBe(FREE_13); + expect(sent.tools).toEqual(TOOLS); + expect(sent.input).toEqual(INPUT); + }); +}); From 20a43f5a2ca491335e623c4ceb7d36cac1f225d7 Mon Sep 17 00:00:00 2001 From: RaoYu <2425198313@qq.com> Date: Thu, 17 Sep 2026 18:26:05 +0700 Subject: [PATCH 14/31] fix(auth): don't cool down an account for a request-scoped 4xx Do not trigger account cooldown or fallback for request-scoped 4xx errors that match no account rules so healthy credentials are not locked out for context length or validation errors. --- open-sse/services/accountFallback.js | 14 +++++++++ tests/unit/account-fallback-4xx.test.js | 38 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/unit/account-fallback-4xx.test.js diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index 8d280da4..766b9981 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -45,6 +45,20 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) { } } + // Request-scoped client errors that matched no rule above: a 400 caused by the + // request itself (context overflow, malformed body, unsupported parameter) says + // nothing about the credential, so cooling the account down only removes a + // healthy connection from rotation. With a single connection it is worse: every + // later request in the window fails with a copy of this very error + // ("all 1 accounts locked for | lastError=[400]: ..."), which hides the + // real cause from the caller and makes unrelated sessions look like they hit the + // same limit. Hand the upstream error back for this request instead. + // Account-scoped statuses keep their rules above (401/402/403/404/429), and the + // text rules still win for rate-limit / quota / capacity wording. + if (status >= 400 && status < 500 && status !== 401 && status !== 402 && status !== 403 && status !== 429) { + return { shouldFallback: false, cooldownMs: 0 }; + } + // Default: transient cooldown for any unmatched error return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS }; } diff --git a/tests/unit/account-fallback-4xx.test.js b/tests/unit/account-fallback-4xx.test.js new file mode 100644 index 00000000..ba94c16c --- /dev/null +++ b/tests/unit/account-fallback-4xx.test.js @@ -0,0 +1,38 @@ +// Regression: an unmatched 4xx (a request-scoped failure) used to hit the +// transient-cooldown default, which locked the account for 30s and — with a +// single connection — answered every other request in that window with a copy of +// the first error. A 400 "maximum context length" from one session therefore +// looked like the same failure in unrelated sessions. +import { describe, expect, it } from "vitest"; +import { checkFallbackError } from "../../open-sse/services/accountFallback.js"; + +describe("checkFallbackError — request-scoped vs account-scoped failures", () => { + it("does not cool the account down for a 400 caused by the request", () => { + const result = checkFallbackError(400, JSON.stringify({ + error: { + message: "This model's maximum context length is 1048576 tokens. However, you requested 1186139 tokens", + type: "invalid_request_error", + }, + })); + + expect(result).toEqual({ shouldFallback: false, cooldownMs: 0 }); + }); + + it("still falls back for account-scoped statuses", () => { + for (const status of [401, 402, 403, 404, 429]) { + expect(checkFallbackError(status, "nope").shouldFallback).toBe(true); + } + }); + + it("still honours rate-limit / quota wording on any 4xx", () => { + expect(checkFallbackError(400, "rate limit reached").shouldFallback).toBe(true); + expect(checkFallbackError(422, "quota exceeded").shouldFallback).toBe(true); + }); + + it("keeps the transient cooldown for unmatched server errors", () => { + const result = checkFallbackError(503, "upstream exploded"); + + expect(result.shouldFallback).toBe(true); + expect(result.cooldownMs).toBeGreaterThan(0); + }); +}); From 367fc546d8c4a98d6c54e678af701f79b5b4ee8c Mon Sep 17 00:00:00 2001 From: Rafli Ahmad Zulfikar Date: Thu, 17 Sep 2026 18:38:10 +0700 Subject: [PATCH 15/31] feat(models): deepseek-v4.* accepts low..max effort; flag thinkingEffortSupported and vision --- open-sse/providers/capabilities.js | 6 +++++- open-sse/providers/thinkingLevels.js | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 12c9d82f..c02f255f 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -378,7 +378,11 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*glm*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } }, // ── DeepSeek (thinking.enabled + reasoning_effort; r1 = thinking-only) ─ - { pattern: "*deepseek-v4*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 } }, + // v4.1+ has real image input (probed live on Alibaba MaaS: correct color + // read from a PNG). v4-pro / v4-flash-0731 accept image blocks but ignore + // them (answered "Unknown"), so vision stays scoped to v4.* dotted releases. + { pattern: "*deepseek-v4.*", caps: { vision: true, reasoning: true, thinkingFormat: "deepseek", thinkingEffortSupported: true, contextWindow: 1000000, maxOutput: 128000 } }, + { pattern: "*deepseek-v4*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingEffortSupported: true, contextWindow: 1000000, maxOutput: 384000 } }, { pattern: "*reasoner*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } }, { pattern: "*deepseek-r*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } }, { pattern: "*deepseek-chat*", caps: { contextWindow: 128000 } }, diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 0b77e4b4..94897ec7 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -41,6 +41,10 @@ const PATTERN_THINKING = [ { provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking + // DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max + // all 200 via output_config.effort; "none" is a 400 on the anthropic route + // (disable thinking instead). none kept for the picker = disable. + { pattern: "*deepseek-v4.*", levels: ["none", "low", "medium", "high", "xhigh", "max"] }, // codebuddy-cn per-model effort sets — the server's product-config payload // publishes `reasoning.supportedEfforts` per model. NOTE: the chat endpoint // accepts any level you send (probed none/minimal/low/medium/high/xhigh/max From 725e2c1187f69f93310c1bbd0ecd8063b474d068 Mon Sep 17 00:00:00 2001 From: Ahmad Beyranvand Date: Thu, 17 Sep 2026 18:42:31 +0700 Subject: [PATCH 16/31] feat(i18n): integrate Persian (fa) translation --- public/i18n/literals/fa.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/public/i18n/literals/fa.json b/public/i18n/literals/fa.json index 0f28367f..bfd2af60 100644 --- a/public/i18n/literals/fa.json +++ b/public/i18n/literals/fa.json @@ -1389,5 +1389,21 @@ "⚠️ 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.": "⚠️ اطلاعیه ریسک: این ارائه‌دهنده از اشتراک/جلسه OAuth استفاده می‌کند که به طور رسمی برای استفاده پروکسی/روتر مجوز ندارد. حساب ممکن است محدود یا مسدود شود. با مسئولیت خود استفاده کنید.", "✓ Confirm Add": "✓ تأیید افزودن", "📝 Configure providers in dashboard or use environment variables": "📝 ارائه‌دهندگان را در داشبورد پیکربندی کنید یا از متغیرهای محیطی استفاده کنید", - "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 نیاز به OAuth. اکنون اضافه کنید و پس از اعمال، احراز هویت کنید؛ لیست ابزارها پس از اولین اتصال کشف می‌شود." + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 نیاز به OAuth. اکنون اضافه کنید و پس از اعمال، احراز هویت کنید؛ لیست ابزارها پس از اولین اتصال کشف می‌شود.", + "Combo & Vision Adapter":"آداپتور بینایی و ترکیبی", + "Vision Adapter": "آداپتور بینایی", + "Skills": "مهارت‌ها", + "Cached Cost":"هزینه‌ی کش‌شده", + "Compress prompts and outputs to save tokens":"فشرده‌سازی پرامپت‌ها و خروجی‌ها برای صرفه‌جویی در توکن‌ها", + "Manage your web providers": "مدیریت ارائه‌دهندگان خدمات وب خود را انجام دهید", + "providers":"ارائه‌دهندگان", + "combos": "ترکیبی", + "Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.":"پیکربندی ورود تک‌نشانه سازمانی (SSO) برای دسترسی به داشبورد با استفاده از SAML 2.0 یا OIDC.", + "Optional SSO via Okta, Entra ID, Keycloak, or OIDC":"SSO اختیاری از طریق Okta، Entra ID، Keycloak یا OIDC", + "Single Sign-On (SSO)":"ورود یک‌باره (SSO)", + "SSO Protocol":"پروتکل SSO", + "Keep legacy password login.":"حفظ ورود با نام کاربری و رمز عبور قدیمی", + "Require SSO for dashboard access.":"برای دسترسی به داشبورد نیاز به SSO دارد.", + "Allow password or SSO login.":"اجازه ورود با رمز عبور یا ورود یک‌بار مصرف (SSO)", + "Save OIDC settings":"ذخیره تنظیمات OIDC" } From ef18175226449e11481e984011ee5dc2dd03dea2 Mon Sep 17 00:00:00 2001 From: Mosabbir Maruf <96982516+mosabbir-maruf@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:46:13 +0700 Subject: [PATCH 17/31] fix(zed): harden OAuth lifecycle and live model support - executors/zed.js: use exact wire values (anthropic, open_ai, google, x_ai) and strip incompatible Vertex safetySettings on the Google path - shared/zedAuth.js: robust callback query parsing, reject garbage PKCS#1 v1.5 decryptions, and thread proxyOptions when fetching LLM tokens - oauth: preserve systemId across authorize/register/exchange lifecycle, renew proxy idle timeout on reuse, and ignore non-callback localhost requests - shared/OAuthModal.js: track owned proxy in flowRef and stop at most once - api/providers/[id]/models: add connection-scoped live Zed model resolver - registry: unhide provider in dashboard - tests: add unit coverage for wire format, native auth, and live models --- open-sse/executors/zed.js | 24 +- open-sse/providers/registry/zed.js | 1 - open-sse/shared/zedAuth.js | 28 +- .../dashboard/providers/[id]/page.js | 37 ++- .../api/oauth/[provider]/[action]/route.js | 13 +- src/app/api/providers/[id]/models/route.js | 41 ++- src/lib/oauth/providers/index.js | 5 + src/lib/oauth/providers/zed.js | 8 +- src/lib/oauth/utils/server.js | 44 ++- src/shared/components/OAuthModal.js | 218 +++++++++----- tests/unit/zed-completions-wire.test.js | 121 ++++++++ tests/unit/zed-live-models.test.js | 165 +++++++++++ tests/unit/zed-native-auth.test.js | 266 ++++++++++++++++++ 13 files changed, 866 insertions(+), 105 deletions(-) create mode 100644 tests/unit/zed-completions-wire.test.js create mode 100644 tests/unit/zed-live-models.test.js create mode 100644 tests/unit/zed-native-auth.test.js diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js index e6233fcb..b88d2762 100644 --- a/open-sse/executors/zed.js +++ b/open-sse/executors/zed.js @@ -29,11 +29,18 @@ import { zedLlmFetch, } from "../shared/zedAuth.js"; +// Wire values for the `provider` field of POST /completions. These are NOT +// display names: cloud.zed.dev matches them exactly, and an unrecognized value +// fails the whole request with `500 {"message":"An internal server error +// occurred."}` before the model is ever looked at. Spellings come from Zed's +// own GET /models catalog: `anthropic`, `open_ai`, `google` (note underscore), +// `x_ai` follows the same convention — so feeding a catalog value back through +// normalizeZedProvider is identity. const ZED_PROVIDER = { - anthropic: "Anthropic", - openai: "OpenAi", - google: "Google", - xai: "XAi", + anthropic: "anthropic", + openai: "open_ai", + google: "google", + xai: "x_ai", }; function normalizeZedProvider(value, model) { @@ -55,7 +62,14 @@ function buildProviderRequest(provider, model, body, stream, credentials) { return openaiToClaudeRequest(model, body, true); } if (provider === ZED_PROVIDER.google) { - return openaiToGeminiRequest(model, body, true); + const geminiRequest = openaiToGeminiRequest(model, body, true); + // Zed's hosted Gemini backend speaks the Vertex safety vocabulary, not the + // public Gemini API enum the shared translator emits (`OFF`, `CIVIC_INTEGRITY`, + // `DANGEROUS_CONTENT`). Drop client-side safetySettings for the Zed Google + // path so Zed applies its own defaults — scoped here so native Gemini/ + // Antigravity is untouched. + delete geminiRequest.safetySettings; + return geminiRequest; } if (provider === ZED_PROVIDER.openai) { return openaiToOpenAIResponsesRequest(model, body, true, credentials); diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js index 9224cf95..7e2e5dda 100644 --- a/open-sse/providers/registry/zed.js +++ b/open-sse/providers/registry/zed.js @@ -4,7 +4,6 @@ export default { priority: 10, alias: "zd", uiAlias: "zd", - hidden: true, display: { name: "Zed", icon: "code", diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js index e3d8371a..4bfc33bc 100644 --- a/open-sse/shared/zedAuth.js +++ b/open-sse/shared/zedAuth.js @@ -112,7 +112,14 @@ export function parseZedCallbackPayload(input) { url = new URL(raw); } catch { try { - url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + // Accept pathname+query (what the local proxy forwards, e.g. + // "/?user_id=..&access_token=.." or "/callback?.."), a bare query, + // or a lone query string. Only the query part is parsed — a leading + // path must never become part of the first parameter name. + const query = raw.includes("?") + ? raw.slice(raw.indexOf("?") + 1) + : raw.replace(/^\?/, ""); + url = new URL(`http://127.0.0.1/?${query}`); } catch { throw new Error("Invalid Zed callback URL"); } @@ -134,6 +141,10 @@ export function parseZedCallbackPayload(input) { export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) { const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier); const encrypted = Buffer.from(String(encryptedAccessToken), "base64url"); + const fail = (oaepError) => { + const message = oaepError instanceof Error ? oaepError.message : String(oaepError); + throw new Error(`Failed to decrypt Zed access token: ${message}`); + }; try { return crypto .privateDecrypt( @@ -143,15 +154,21 @@ export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) .toString("utf8"); } catch (oaepError) { try { - return crypto + const text = 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}`); + // PKCS#1 v1.5 unpadding is not integrity-checked: a wrong-key decrypt + // can "succeed" with garbage bytes instead of throwing. Replacement + // characters prove the output is not the real UTF-8 token — fail loudly + // rather than storing garbage as a credential. + if (text.includes("�")) fail(oaepError); + return text; + } catch (err) { + if (err.message.startsWith("Failed to decrypt Zed access token")) throw err; + fail(oaepError); } } } @@ -280,6 +297,7 @@ export async function fetchZedLlmToken(credentials, options = {}) { body: JSON.stringify({ organization_id: organizationId }), signal: options.signal ?? undefined, }, + options.proxyOptions ?? null, ); const token = typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 5ae217fb..437214e2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -71,6 +71,8 @@ export default function ProviderDetailPage() { const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [suggestedModels, setSuggestedModels] = useState([]); const [liveModels, setLiveModels] = useState([]); + // Live-catalog fetch warning/error (surfaced for zed only; cursor behavior unchanged). + const [liveModelsError, setLiveModelsError] = useState(null); const [kiloFreeModels, setKiloFreeModels] = useState([]); const [disabledModelIds, setDisabledModelIds] = useState([]); const [confirmState, setConfirmState] = useState(null); @@ -153,7 +155,7 @@ export default function ProviderDetailPage() { const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey"); const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; const staticModels = getModelsByProviderId(providerId); - const models = providerId === "cursor" && liveModels.length > 0 + const models = (providerId === "cursor" || providerId === "zed") && liveModels.length > 0 ? liveModels : staticModels; const providerAlias = getProviderAlias(providerId); @@ -467,11 +469,13 @@ export default function ProviderDetailPage() { fetchDisabledModels(); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); - // Cursor's model availability is account-specific and changes frequently. - // Load the active account's live catalog for the dashboard; the static - // registry remains the fallback while the request is pending or unavailable. + // Live per-connection catalogs (cursor, zed): the static registry carries + // no usable list, so resolve from the active connection. Fires only when + // the provider id or connection list changes — no polling, no loop. + // Cursor path is statement-identical to before; zed adds error surfacing. useEffect(() => { - if (providerId !== "cursor") { + const isLiveCatalog = providerId === "cursor" || providerId === "zed"; + if (!isLiveCatalog) { setLiveModels([]); return; } @@ -479,18 +483,32 @@ export default function ProviderDetailPage() { const connection = connections.find((item) => item.isActive !== false); if (!connection?.id) { setLiveModels([]); + if (providerId === "zed") setLiveModelsError(null); return; } let cancelled = false; + if (providerId === "zed") setLiveModelsError(null); fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }) - .then(async (res) => ({ ok: res.ok, data: await res.json() })) + .then(async (res) => ({ ok: res.ok, data: await res.json().catch(() => null) })) .then(({ ok, data }) => { - if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) { + if (cancelled) return; + if (ok && Array.isArray(data?.models) && data.models.length > 0) { setLiveModels(data.models); + if (providerId === "zed" && data?.warning) setLiveModelsError(data.warning); + return; + } + if (providerId === "zed") { + setLiveModels([]); + setLiveModelsError(data?.warning || data?.error || "Zed returned no live models."); } }) - .catch(() => {}); + .catch(() => { + if (!cancelled && providerId === "zed") { + setLiveModels([]); + setLiveModelsError("Failed to reach the Zed model catalog."); + } + }); return () => { cancelled = true; }; }, [providerId, connections]); @@ -1767,6 +1785,9 @@ export default function ProviderDetailPage() { {!!modelsTestError && (

{modelsTestError}

)} + {providerId === "zed" && !!liveModelsError && ( +

{liveModelsError}

+ )} {renderModelsSection()} diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 12fb9d66..520be970 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -309,13 +309,13 @@ export async function POST(request, { params }) { 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 if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier, systemId: body?.systemId }); 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; + const { code, redirectUri, codeVerifier, state, meta, systemId } = body; // Xiaomi MiMo: no token exchange needed — the callback already decrypted the sk. // Just read the session result and create the connection. @@ -459,8 +459,13 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } - // Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl) - const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, meta); + // Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl). + // systemId (Zed) is merged into meta so the login attempt's own id is + // used instead of a freshly prepared one. Ignored by other providers. + const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, { + ...(meta || {}), + ...(systemId ? { systemId } : {}), + }); // Save to database const connection = await createProviderConnection({ diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 605bc5ca..23f97380 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; -import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth"; +import { GEMINI_CONFIG, ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth"; import { refreshGoogleToken, refreshCodexToken, updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; import { getModelsByProviderId } from "open-sse/config/providerModels.js"; @@ -11,6 +11,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; +import { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { resolveClineModels, resolveClinepassModels } from "open-sse/services/clinepassModels.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -287,6 +288,44 @@ const PROVIDER_MODELS_CONFIG = { }; }, }, + // Zed has no static catalog by design (live /models only) — same cursor + // direct pattern: resolve with the connection's own credentials (never + // exposed to the browser), return rich metadata, drop disabled entries. + // Empty/failure yields an explicit warning, never a silent zero list. + zed: { + customResolver: async (connection) => { + try { + const result = await resolveZedModels({ + accessToken: connection.accessToken, + providerSpecificData: connection.providerSpecificData || {}, + }, { config: ZED_HOSTED_CONFIG, forceRefresh: true }); + const models = (result?.models || []) + .filter((m) => m && !m.isDisabled) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + provider: m.provider, + contextLength: m.contextLength, + contextLengthInMaxMode: m.contextLengthInMaxMode, + maxOutputTokens: m.maxOutputTokens, + supportsTools: m.supportsTools, + supportsImages: m.supportsImages, + supportsThinking: m.supportsThinking, + supportsDisablingThinking: m.supportsDisablingThinking, + supportsFastMode: m.supportsFastMode, + supportsServerSideCompaction: m.supportsServerSideCompaction, + supportedEffortLevels: m.supportedEffortLevels || [], + supportsStreamingTools: m.supportsStreamingTools, + supportsParallelToolCalls: m.supportsParallelToolCalls, + })); + if (models.length > 0) return { models }; + return { models: [], warning: "Zed returned no live models." }; + } catch (error) { + console.log("Failed to fetch Zed models dynamically:", error.message); + return { models: [], warning: `Failed to fetch Zed models: ${error.message}` }; + } + }, + }, // Cline/ClinePass share api.cline.bot/api/v1/models. The service layer already // handles Bearer-vs-`workos:` auth and swallows failures into null, so these follow diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index e505b01d..8ba6c8cb 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -112,6 +112,11 @@ export async function generateAuthData(providerName, redirectUri, meta) { flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", + // Zed: surface the system_id embedded in the sign-in URL so the frontend + // can thread it through register-session → exchange → stored connection + // (exchangeTokens re-runs prepareConfig, which would otherwise mint a + // different one). Absent for every other provider — purely additive. + ...(config.systemId ? { systemId: config.systemId } : {}), }; } diff --git a/src/lib/oauth/providers/zed.js b/src/lib/oauth/providers/zed.js index 3343976a..3f38bf87 100644 --- a/src/lib/oauth/providers/zed.js +++ b/src/lib/oauth/providers/zed.js @@ -21,11 +21,15 @@ const zed = { return { ...config, ...auth }; }, buildAuthUrl: (config, redirectUri, state) => config.authUrl, - exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta) => { // 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 }; + // Prefer the system_id registered for this login attempt (threaded via + // meta from register-session); fall back to the prepared config. Never + // mint a fresh one here — exchangeTokens re-runs prepareConfig, which + // would otherwise store a system_id unrelated to the zed.dev login. + return { accessToken, userId, systemId: meta?.systemId || config.systemId }; }, postExchange: async (tokens) => { const credentials = { diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index 80377752..83de1821 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -648,9 +648,15 @@ let zedProxyTimeout = null; let zedProxyPort = null; let zedSession = null; -export function registerZedSession({ state, codeVerifier }) { +export function registerZedSession({ state, codeVerifier, systemId }) { if (!state || !codeVerifier) return false; - zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() }; + zedSession = { + state, + codeVerifier, + systemId: systemId || null, + status: "pending", + createdAt: Date.now(), + }; return true; } export function getZedSessionStatus(state) { @@ -665,6 +671,10 @@ export function clearZedSession(state) { export function startZedProxy(preferredPort = 0) { return new Promise((resolve) => { if (zedProxyServer) { + // Reuse the live listener, but renew its idle timeout so a previous + // flow's deadline can never kill the flow that just adopted the port. + if (zedProxyTimeout) clearTimeout(zedProxyTimeout); + zedProxyTimeout = setTimeout(() => { console.log("[Zed proxy] timeout, stopping"); stopZedProxy(); }, ZED_HOSTED_CONFIG.oauthTimeoutMs); resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); return; } @@ -694,13 +704,34 @@ export function startZedProxy(preferredPort = 0) { res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); return; } + // A genuine Zed redirect always carries user_id + access_token. Anything + // else (probe, prefetch, stray navigation, favicon-style miss) is NOT + // the callback: answer without touching the session and WITHOUT + // stopping the server, so the real redirect can still land afterwards. + const qp = url.searchParams; + const hasZedParams = + qp.has("user_id") || qp.has("userId") || + qp.has("access_token") || qp.has("accessToken") || qp.has("token"); + if (!hasZedParams) { + console.log(`[Zed proxy] ignoring non-callback ${req.method} ${url.pathname} (session kept, server kept)`); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Waiting for Zed sign-in — this request carried no login data.")); + 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 tokenData = await exchangeTokens( + "zed", + rawCallback, + null, + session.codeVerifier, + session.state, + session.systemId ? { systemId: session.systemId } : undefined, + ); const connection = await createProviderConnection({ provider: "zed", authType: "oauth", @@ -712,13 +743,16 @@ export function startZedProxy(preferredPort = 0) { session.email = connection.email; res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(renderCodexResultPage(true, "You can close this window.")); + stopZedProxy(); } 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(); + // Intentionally NOT stopping here: the failure may belong to a + // superseded attempt (e.g. an older popup landing after "Try Again" + // registered a new keypair). The live attempt's genuine callback must + // still land. The idle timeout + modal close bound the listener. } }); const tryPort = Number(preferredPort) || 0; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 805301da..589a0415 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -50,6 +50,19 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const popupRef = useRef(null); const pollingAbortRef = useRef(false); const openedRef = useRef(false); + // Proxy-flow session ledger: which provider's proxy THIS modal session + // started, and whether its stop was already sent. Every stop-proxy call is + // gated on this — parent re-renders can never spam it, and a close stops + // the owned proxy exactly once. + const flowRef = useRef({ proxyStarted: false, proxyProvider: null, stopSent: false }); + // Parent callbacks are stored in refs so effect/callback identities stay + // stable across parent re-renders (the page passes fresh inline closures). + // Synced by the ref-sync effect below (placed after all callbacks are + // defined); the open effect then depends only on stable primitives. + const onSuccessRef = useRef(onSuccess); + const onCloseRef = useRef(onClose); + const isOpenRef = useRef(isOpen); + const startOAuthFlowRef = useRef(null); const { copied, copy } = useCopyToClipboard(); // State for client-only values to avoid hydration mismatch @@ -81,6 +94,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, redirectUri: authData.redirectUri, codeVerifier: authData.codeVerifier, state, + // Zed: thread the login attempt's system_id so the stored + // connection keeps the id sent to zed.dev (see register-session). + ...(authData.systemId ? { systemId: authData.systemId } : {}), ...(oauthMeta ? { meta: oauthMeta } : {}), }), }); @@ -89,12 +105,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (!res.ok) throw new Error(data.error); setStep("success"); - onSuccess?.(); + onSuccessRef.current?.(); } catch (err) { setError(err.message); setStep("error"); } - }, [authData, provider, onSuccess, oauthMeta]); + }, [authData, provider, oauthMeta]); const completeXaiManualCode = useCallback(async (code) => { if (!authData?.state) return; @@ -108,12 +124,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (!res.ok) throw new Error(data.error); setStep("success"); - onSuccess?.(); + onSuccessRef.current?.(); } catch (err) { setError(err.message); setStep("error"); } - }, [authData, onSuccess]); + }, [authData]); // Poll for device code token const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData, deadlineMs) => { @@ -155,7 +171,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, pollingAbortRef.current = true; // Stop polling immediately setStep("success"); setPolling(false); - onSuccess?.(); + onSuccessRef.current?.(); return; } @@ -177,9 +193,19 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setError("Authorization timeout"); setStep("error"); setPolling(false); - }, [provider, onSuccess]); + }, [provider]); - // Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange. + // Stop the proxy owned by THIS modal session, at most once. Re-renders, + // repeated closes, and post-completion calls are all no-ops by construction. + const stopOwnedProxy = useCallback(() => { + const flow = flowRef.current; + if (flow.proxyStarted && !flow.stopSent && flow.proxyProvider) { + flow.stopSent = true; + fetch(`/api/oauth/${flow.proxyProvider}/stop-proxy`).catch(() => {}); + } + }, []); + + // Trae/Windsurf/Zed 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`); @@ -187,31 +213,61 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (!startRes.ok || !startData.success || !startData.callbackUrl) { throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`); } + // Take ownership immediately so a close during the remaining flight still + // cleans this proxy up (via the close effect or the abort below). + flowRef.current.proxyStarted = true; + flowRef.current.proxyProvider = providerId; + flowRef.current.stopSent = false; + if (!isOpenRef.current) { + stopOwnedProxy(); + return; + } // 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); + if (!authRes.ok) { + stopOwnedProxy(); + throw new Error(authData.error); + } + if (!isOpenRef.current) { + stopOwnedProxy(); + return; + } // 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. + // Zed also passes code_verifier (encodes the RSA private key for decrypt) + // + systemId; sent via POST body so secrets never land in URL/query logs. const regBody = { state: authData.state }; if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier; - await fetch(`/api/oauth/${providerId}/register-session`, { + if (authData.systemId) regBody.systemId = authData.systemId; + const regRes = await fetch(`/api/oauth/${providerId}/register-session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(regBody), }); + let regData = null; + try { + regData = await regRes.json(); + } catch { + regData = null; + } + if (!regRes.ok || regData?.success === false) { + stopOwnedProxy(); + throw new Error(regData?.error || "Failed to register login session; please retry"); + } + if (!isOpenRef.current) return; // closed mid-flight: close effect owns cleanup now // 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 - }, []); + }, [stopOwnedProxy]); - // Start OAuth flow - const startOAuthFlow = useCallback(async () => { + // Start OAuth flow (plain function by design: it is only invoked from the + // open effect via ref and from user actions, so memoization would only add + // an identity that re-triggers effects on every parent re-render). + const startOAuthFlow = async () => { if (!provider) return; try { setError(null); @@ -356,6 +412,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide }); + // Take ownership of server-side proxies so close stops them exactly once + // (replaces the per-provider stop branches; same behavior, one ledger). + if ((provider === "codex" && codexProxyActive) || (provider === "xai" && xaiProxyActive)) { + flowRef.current.proxyStarted = true; + flowRef.current.proxyProvider = provider; + flowRef.current.stopSent = false; + } + // Guard: device_code providers return authUrl:null from /authorize. Never window.open(null) // (browsers coerce it to the relative path ".../null"). if (!data.authUrl) { @@ -396,49 +460,55 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setError(err.message); setStep("error"); } - }, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]); + }; - // Reset state and start OAuth when modal opens + // Sync latest props/flow into refs after every render (no dep array). + // The open effect below then depends only on stable primitives. useEffect(() => { - if (isOpen && provider) { - // Guard against StrictMode/effect re-runs auto-opening multiple tabs. - if (openedRef.current) return; - openedRef.current = true; - setAuthData(null); - setCallbackUrl(""); - setError(null); - 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 - pollingAbortRef.current = true; - openedRef.current = false; - if (provider === "codex") { - 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(() => {}); - } + onSuccessRef.current = onSuccess; + onCloseRef.current = onClose; + isOpenRef.current = isOpen; + startOAuthFlowRef.current = startOAuthFlow; + }); + + // Reset state and start OAuth when modal opens — exactly once per open. + // Guarded by openedRef so StrictMode/effect re-runs never open extra tabs. + useEffect(() => { + if (!isOpen || !provider) return; + if (openedRef.current) return; + openedRef.current = true; + setAuthData(null); + setCallbackUrl(""); + setError(null); + setIsDeviceCode(false); + setDeviceData(null); + setPolling(false); + setAuthMode("browser"); + setPasteToken(""); + setIdeStatus(null); + pollingAbortRef.current = false; + flowRef.current = { proxyStarted: false, proxyProvider: null, stopSent: 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 })); } - }, [isOpen, provider, startOAuthFlow]); + startOAuthFlowRef.current(); + }, [isOpen, provider]); + + // Cleanup when the modal closes: abort polling and stop the proxy THIS + // session started, exactly once. Deps are stable primitives, so unrelated + // parent re-renders cannot reach the stop call (previously every parent + // render re-fired stop-proxy while the modal was closed). + useEffect(() => { + if (isOpen) return; + pollingAbortRef.current = true; + openedRef.current = false; + stopOwnedProxy(); + flowRef.current = { proxyStarted: false, proxyProvider: null, stopSent: false }; + }, [isOpen, provider, stopOwnedProxy]); // Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port): // poll status until the proxy auto-exchanges and saves the connection. @@ -467,7 +537,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (data.status === "done") { callbackProcessedRef.current = true; setStep("success"); - onSuccess?.(); + onSuccessRef.current?.(); return; } if (data.status === "error") { @@ -489,7 +559,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, }; setTimeout(tick, POLL_INTERVAL_MS); return () => { cancelled = true; }; - }, [authData, onSuccess]); + }, [authData]); // Listen for OAuth callback via multiple methods useEffect(() => { @@ -589,23 +659,31 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const data = await res.json(); if (!res.ok) throw new Error(data.error); setStep("success"); - onSuccess?.(); + onSuccessRef.current?.(); return; } const input = callbackUrl.trim(); - // Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL + // Trae/Windsurf/Zed 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 }), + body: JSON.stringify({ + code: input, + state: authData?.state, + // Zed manual fallback needs the same attempt material as the + // automatic path (redirectUri + RSA verifier + system_id). + ...(authData?.redirectUri ? { redirectUri: authData.redirectUri } : {}), + ...(authData?.codeVerifier ? { codeVerifier: authData.codeVerifier } : {}), + ...(authData?.systemId ? { systemId: authData.systemId } : {}), + }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); setStep("success"); - onSuccess?.(); + onSuccessRef.current?.(); return; } @@ -652,21 +730,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } }; - // Clear session on modal close + cleanup proxy + // Clear session on modal close + cleanup proxy (idempotent: the owned + // proxy is stopped at most once across effect-close, button-close, and + // Escape/backdrop-close — all funnel through here or the close effect). const handleClose = useCallback(() => { - if (provider === "codex") { - 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]); + stopOwnedProxy(); + onCloseRef.current(); + }, [stopOwnedProxy]); if (!provider || !providerInfo) return null; const isXaiProvider = provider === "xai"; diff --git a/tests/unit/zed-completions-wire.test.js b/tests/unit/zed-completions-wire.test.js new file mode 100644 index 00000000..5be982ae --- /dev/null +++ b/tests/unit/zed-completions-wire.test.js @@ -0,0 +1,121 @@ +// Zed completions wire acceptance: the `provider` field of POST /completions +// must use cloud.zed.dev's exact wire values (anthropic/open_ai/google/x_ai), +// and the Zed Gemini path must not carry the shared translator's +// safetySettings (Zed's hosted Gemini backend speaks the Vertex safety +// vocabulary, not the public-Gemini enums). +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("open-sse/shared/zedAuth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveZedModels: vi.fn(), + zedLlmFetch: vi.fn(), + }; +}); + +import { + resolveZedModels, + zedLlmFetch, +} from "open-sse/shared/zedAuth.js"; +import ZedExecutor from "open-sse/executors/zed.js"; + +function catalogFor(entries) { + const rawById = new Map(entries); + return { rawById, models: [] }; +} + +function mockCatalogFetch(captured) { + zedLlmFetch.mockImplementation(async (credentials, path, options) => { + captured.body = JSON.parse(options.fetchOptions.body); + return new Response("upstream-error-stub", { status: 500 }); + }); +} + +function makeExecutor() { + const executor = new ZedExecutor(); + executor.config = {}; + return executor; +} + +const CHAT_BODY = { messages: [{ role: "user", content: "hi" }] }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("wire provider enum", () => { + it.each([ + ["Anthropic", "anthropic"], + ["anthropic", "anthropic"], + ["OpenAi", "open_ai"], + ["open_ai", "open_ai"], + ["Google", "google"], + ["gemini", "google"], + ["XAi", "x_ai"], + ["x_ai", "x_ai"], + ])("catalog provider %j normalizes to wire %j", async (catalogValue, wire) => { + resolveZedModels.mockResolvedValue(catalogFor([["m", { provider: catalogValue }]])); + const executor = makeExecutor(); + const { provider } = await executor.resolveModel("m", {}, null, null); + expect(provider).toBe(wire); + }); + + it("infers wire provider from the model id when the catalog is unavailable", async () => { + resolveZedModels.mockRejectedValue(new Error("catalog down")); + const executor = makeExecutor(); + const log = { warn: vi.fn() }; + expect((await executor.resolveModel("claude-opus-x", {}, null, log)).provider).toBe("anthropic"); + expect((await executor.resolveModel("gemini-3-x", {}, null, log)).provider).toBe("google"); + expect((await executor.resolveModel("grok-4-x", {}, null, log)).provider).toBe("x_ai"); + expect((await executor.resolveModel("gpt-5-x", {}, null, log)).provider).toBe("open_ai"); + }); +}); + +describe("completion payload shaping", () => { + it("sends wire provider values per model family", async () => { + resolveZedModels.mockImplementation(async () => catalogFor([ + ["claude-x", { provider: "anthropic" }], + ["gpt-x", { provider: "open_ai" }], + ["gemini-x", { provider: "google" }], + ["grok-x", { provider: "x_ai" }], + ])); + const captured = {}; + mockCatalogFetch(captured); + const executor = makeExecutor(); + + for (const [model, wire] of [ + ["claude-x", "anthropic"], + ["gpt-x", "open_ai"], + ["gemini-x", "google"], + ["grok-x", "x_ai"], + ]) { + await executor.execute({ model, body: { ...CHAT_BODY }, stream: false, credentials: {} }); + expect(captured.body.provider).toBe(wire); + expect(captured.body.model).toBe(model); + } + }); + + it("strips safetySettings on the Zed Gemini path only", async () => { + resolveZedModels.mockImplementation(async () => catalogFor([ + ["gemini-x", { provider: "google" }], + ["claude-x", { provider: "anthropic" }], + ])); + const captured = {}; + mockCatalogFetch(captured); + const executor = makeExecutor(); + + await executor.execute({ model: "gemini-x", body: { ...CHAT_BODY }, stream: false, credentials: {} }); + expect(captured.body.provider).toBe("google"); + expect(captured.body.provider_request).not.toHaveProperty("safetySettings"); + + // Sanity: the shared translator still emits safetySettings — the removal + // happens in the Zed executor, not in shared/native Gemini behavior. + const { openaiToGeminiRequest } = await import( + "open-sse/translator/request/openai-to-gemini.js" + ); + expect(openaiToGeminiRequest("gemini-x", { ...CHAT_BODY }, true)).toHaveProperty( + "safetySettings", + ); + }); +}); diff --git a/tests/unit/zed-live-models.test.js b/tests/unit/zed-live-models.test.js new file mode 100644 index 00000000..1b999044 --- /dev/null +++ b/tests/unit/zed-live-models.test.js @@ -0,0 +1,165 @@ +// Route-level acceptance for the Zed live-model wiring: +// GET /api/providers/[connectionId]/models → resolveZedModels → UI rows +// RUN WITH AN ISOLATED DB: DATA_DIR=$(mktemp -d) npx vitest run ... +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { GET } from "@/app/api/providers/[id]/models/route.js"; +import { createProviderConnection } from "@/models/index.js"; + +// Transport stub BELOW resolveZedModels: proxyAwareFetch captures the native +// fetch at import time, so stubbing globalThis.fetch cannot intercept it. +// Mock the module instead; untouched hosts pass through to native fetch. +const stub = vi.hoisted(() => { + const nativeFetch = globalThis.fetch.bind(globalThis); + return { mode: "ok", calls: [], nativeFetch }; +}); +vi.mock("open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: async (url, options) => { + const u = String(url); + stub.calls.push(u); + if (u.includes("cloud.zed.dev/client/users/me")) { + return Response.json({ default_organization_id: "org-1" }); + } + if (u.includes("cloud.zed.dev/client/llm_tokens")) { + return Response.json({ token: "llm-token" }); + } + if (u.includes("cloud.zed.dev/models")) { + if (stub.mode === "error") return new Response("boom", { status: 500 }); + if (stub.mode === "empty") return Response.json({ models: [] }); + return Response.json(stub.catalog); + } + return stub.nativeFetch(url, options); + }, + default: async (url, options) => stub.nativeFetch(url, options), +})); + +stub.catalog = { + models: [ + { + id: "claude-opus-4-live", + display_name: "Claude Opus Live", + provider: "anthropic", + max_token_count: 200000, + max_output_tokens: 32000, + supports_tools: true, + supports_images: true, + supports_thinking: true, + is_disabled: false, + }, + { + id: "gpt-live", + display_name: "GPT Live", + provider: "openai", + max_token_count: 128000, + max_output_tokens: 16384, + supports_tools: true, + is_disabled: false, + }, + { + id: "retired-model", + display_name: "Retired", + provider: "openai", + is_disabled: true, + }, + ], + default_model: "claude-opus-4-live", +}; + +beforeEach(() => { + stub.mode = "ok"; + stub.calls.length = 0; +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedZed(n) { + return createProviderConnection({ + provider: "zed", + authType: "oauth", + accessToken: `tok-live-${n}-${Date.now()}`, + email: `zed-live-${n}-${Date.now()}@example.com`, + providerSpecificData: { userId: `u-${n}`, systemId: `sys-${n}` }, + testStatus: "active", + }); +} + +async function getModels(connectionId) { + const req = new Request(`http://localhost/api/providers/${connectionId}/models`); + return GET(req, { params: Promise.resolve({ id: connectionId }) }); +} + +describe("criterion 1+2 — active connection + live catalog → models with metadata", () => { + it("returns enabled models with preserved metadata, no secrets", async () => { + const conn = await seedZed("m1"); + const res = await getModels(conn.id); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.models.map((m) => m.id).sort()).toEqual(["claude-opus-4-live", "gpt-live"]); + const opus = data.models.find((m) => m.id === "claude-opus-4-live"); + expect(opus.name).toBe("Claude Opus Live"); + expect(opus.contextLength).toBe(200000); + expect(opus.maxOutputTokens).toBe(32000); + expect(opus.supportsTools).toBe(true); + expect(opus.supportsImages).toBe(true); + expect(opus.supportsThinking).toBe(true); + // Credentials must never leak into the client response. + expect(JSON.stringify(data)).not.toContain(conn.accessToken); + expect(JSON.stringify(data)).not.toContain("tok-live"); + }); +}); + +describe("criterion 4 — disabled models excluded", () => { + it("is_disabled entries never reach the UI", async () => { + const conn = await seedZed("m2"); + const data = await (await getModels(conn.id)).json(); + expect(data.models.some((m) => m.id === "retired-model")).toBe(false); + }); +}); + +describe("criterion 4b — empty catalog → explicit warning", () => { + it("returns warning instead of silent zero", async () => { + stub.mode = "empty"; + const conn = await seedZed("m3"); + const res = await getModels(conn.id); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.models).toEqual([]); + expect(data.warning).toMatch(/no live models/i); + }); +}); + +describe("criterion 5 — resolver failure → useful warning, no crash", () => { + it("returns 200 with warning text", async () => { + stub.mode = "error"; + const conn = await seedZed("m4"); + const res = await getModels(conn.id); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.models).toEqual([]); + expect(data.warning).toMatch(/failed to fetch zed models/i); + }); +}); + +describe("criterion 6 (route) — unknown connection → 404", () => { + it("rejects missing connections", async () => { + const res = await getModels("00000000-0000-0000-0000-000000000000"); + expect(res.status).toBe(404); + }); +}); + +describe("criterion 5 (guard) — unsupported provider unchanged", () => { + it("still 400s for providers without a models config", async () => { + const conn = await createProviderConnection({ + provider: "kimchi-nope", + authType: "oauth", + accessToken: "x", + email: `guard-${Date.now()}@example.com`, + testStatus: "active", + }).catch(() => null); + // createProviderConnection may reject unknown providers; either way the + // route must not have gained a zed-shaped branch for others. + if (!conn) return; + const res = await getModels(conn.id); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/unit/zed-native-auth.test.js b/tests/unit/zed-native-auth.test.js new file mode 100644 index 00000000..66acc9cf --- /dev/null +++ b/tests/unit/zed-native-auth.test.js @@ -0,0 +1,266 @@ +// Acceptance suite for the Zed native-app auth fix. +// RUN WITH AN ISOLATED DB: DATA_DIR=$(mktemp -d) npx vitest run unit/zed-native-auth.test.js +// +// Covers criteria: +// 1. Zed proxy starts +// 2. Stray callback (no params) MUST NOT kill session / stop proxy +// 3. Real callback (user_id + access_token) MUST complete session + save connection +// 4. RSA decrypt works (round-trip) +// 5. systemId identical authorize → exchange → stored connection +// 6. register-session failure is distinguishable (backend contract) +// 8. (backend) reopen/re-register creates a fresh session +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import crypto from "node:crypto"; +import { + createZedNativeAuthData, + parseZedCallbackPayload, + decryptZedAccessToken, +} from "open-sse/shared/zedAuth.js"; +import { + startZedProxy, + stopZedProxy, + registerZedSession, + getZedSessionStatus, + clearZedSession, +} from "@/lib/oauth/utils/server.js"; +import { + generateAuthData, + exchangeTokens, +} from "@/lib/oauth/providers/index.js"; + +const realFetch = globalThis.fetch; + +// Never hit the real network in tests: cloud.zed.dev calls are best-effort +// (postExchange try/catch) — fail them fast and loud instead. +beforeEach(() => { + globalThis.fetch = async (url, init) => { + if (String(url).includes("cloud.zed.dev")) { + return new Response("test-stubbed", { status: 500 }); + } + return realFetch(url, init); + }; +}); +afterEach(async () => { + globalThis.fetch = realFetch; + stopZedProxy(); + vi.restoreAllMocks(); +}); + +async function startTestProxy() { + const started = await startZedProxy(0); // random loopback port — parallel-safe + expect(started.success).toBe(true); + return started; +} + +/** Simulate zed.dev: RSA-encrypt a plaintext token with the flow's public key. */ +function encryptForCallback(publicKeyB64Url, plaintext) { + const der = Buffer.from(String(publicKeyB64Url), "base64url"); + const key = crypto.createPublicKey({ key: der, format: "der", type: "pkcs1" }); + return crypto + .publicEncrypt( + { key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + Buffer.from(plaintext, "utf8"), + ) + .toString("base64url"); +} + +describe("criterion 1 — Zed proxy starts", () => { + it("binds 127.0.0.1 and reports a usable callback URL", async () => { + const started = await startTestProxy(); + expect(started.port).toBeGreaterThan(0); + expect(started.callbackUrl).toBe(`http://127.0.0.1:${started.port}/`); + }); +}); + +describe("criterion 4 — RSA decrypt works", () => { + it("round-trips OAEP-SHA256 through the verifier slot", async () => { + const auth = createZedNativeAuthData({}, { nativeAppPort: 1 }); + const encrypted = encryptForCallback(auth.publicKey, "plaintext-token-abc"); + expect(decryptZedAccessToken(encrypted, auth.privateKeyVerifier)).toBe( + "plaintext-token-abc", + ); + }); + + it("rejects a missing verifier instead of silently failing", () => { + const auth = createZedNativeAuthData({}, { nativeAppPort: 1 }); + const encrypted = encryptForCallback(auth.publicKey, "x"); + expect(() => decryptZedAccessToken(encrypted, null)).toThrow( + /private key verifier/i, + ); + }); + + it("parser keeps strict validation (no weakened acceptance)", () => { + expect(() => parseZedCallbackPayload("")).toThrow(); + expect(() => parseZedCallbackPayload("http://127.0.0.1:1/")).toThrow( + /user_id and access_token/, + ); + expect(() => + parseZedCallbackPayload("http://127.0.0.1:1/?user_id=only-user"), + ).toThrow(/user_id and access_token/); + }); +}); + +describe("criterion 2 — stray callback MUST NOT kill session", () => { + it("bare GET / leaves session pending and proxy listening", async () => { + const started = await startTestProxy(); + const auth = createZedNativeAuthData({}, { nativeAppPort: started.port }); + expect( + registerZedSession({ state: "stray-state-1", codeVerifier: auth.privateKeyVerifier }), + ).toBe(true); + + const res = await realFetch(`http://127.0.0.1:${started.port}/`); + expect(res.status).toBe(200); + + // Session must still be pending (not poisoned to error)… + const session = getZedSessionStatus("stray-state-1"); + expect(session).not.toBeNull(); + expect(session.status).toBe("pending"); + + // …and the SAME server must still own the port (no silent restart). + const again = await startZedProxy(0); + expect(again.port).toBe(started.port); + + clearZedSession("stray-state-1"); + }); + + it("GET /callback with unrelated params leaves session pending", async () => { + const started = await startTestProxy(); + const auth = createZedNativeAuthData({}, { nativeAppPort: started.port }); + registerZedSession({ state: "stray-state-2", codeVerifier: auth.privateKeyVerifier }); + + const res = await realFetch(`http://127.0.0.1:${started.port}/callback?foo=bar`); + expect(res.status).toBe(200); + + const session = getZedSessionStatus("stray-state-2"); + expect(session).not.toBeNull(); + expect(session.status).toBe("pending"); + clearZedSession("stray-state-2"); + }); +}); + +describe("criterion 3 — real callback completes session + saves connection", () => { + it("user_id + access_token → done, decrypted token persisted", async () => { + const started = await startTestProxy(); + const auth = createZedNativeAuthData({}, { nativeAppPort: started.port }); + const state = `real-state-${Date.now()}`; + registerZedSession({ state, codeVerifier: auth.privateKeyVerifier, systemId: auth.systemId }); + + const encrypted = encryptForCallback(auth.publicKey, "decrypted-token-xyz"); + const cb = new URL(`http://127.0.0.1:${started.port}/`); + cb.searchParams.set("user_id", "user-123"); + cb.searchParams.set("access_token", encrypted); + const res = await realFetch(cb.toString()); + expect(res.status).toBe(200); + + const session = getZedSessionStatus(state); + expect(session).not.toBeNull(); + expect(session.status).toBe("done"); + expect(session.connectionId).toBeTruthy(); + + const { getProviderConnectionById } = await import("@/models/index.js"); + const conn = await getProviderConnectionById(session.connectionId); + expect(conn).toBeTruthy(); + expect(conn.provider).toBe("zed"); + expect(conn.accessToken).toBe("decrypted-token-xyz"); + expect(conn.providerSpecificData?.userId).toBe("user-123"); + expect(conn.providerSpecificData?.systemId).toBe(auth.systemId); + + // Proxy stopped itself after the terminal outcome (no orphan listener). + const again = await startZedProxy(0); + expect(again.port).not.toBe(started.port); + stopZedProxy(); + }); +}); + +describe("criterion 5 — systemId stable authorize → exchange → stored", () => { + it("generateAuthData exposes the systemId sent to zed.dev", async () => { + const auth = await generateAuthData("zed", "http://127.0.0.1:59999/", { + nativeAppPort: 59999, + }); + const url = new URL(auth.authUrl); + expect(url.searchParams.get("native_app_port")).toBe("59999"); + // The system_id embedded in the sign-in URL must be observable downstream. + expect(auth.systemId).toBe(url.searchParams.get("system_id")); + expect(auth.systemId).toBeTruthy(); + }); + + it("exchange preserves the registered systemId (no regeneration)", async () => { + const auth = await generateAuthData("zed", "http://127.0.0.1:59998/", { + nativeAppPort: 59998, + }); + // Public key always rides in the authorize URL (mirrors the real flow). + const pubFromUrl = new URL(auth.authUrl).searchParams.get("native_app_public_key"); + expect(pubFromUrl).toBeTruthy(); + const enc2 = encryptForCallback(pubFromUrl, "tok2"); + const tokens = await exchangeTokens( + "zed", + `/?user_id=u1&access_token=${encodeURIComponent(enc2)}`, + null, + auth.codeVerifier, + auth.state, + { systemId: auth.systemId }, + ); + expect(tokens.providerSpecificData.systemId).toBe(auth.systemId); + }); +}); + +describe("criterion 6 — register-session failure is distinguishable", () => { + it("route reports { success: false } when the verifier is missing", async () => { + const { POST } = await import("@/app/api/oauth/[provider]/[action]/route.js"); + const req = new Request("http://localhost/api/oauth/zed/register-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "no-verifier-state" }), + }); + const res = await POST(req, { + params: Promise.resolve({ provider: "zed", action: "register-session" }), + }); + const data = await res.json(); + // Backend contract: failure must be explicit (modal is required to check it). + expect(data.success).toBe(false); + }); +}); + +describe("criterion 8 (backend) — re-register creates a fresh session", () => { + it("a new register supersedes the old state cleanly", async () => { + const a = createZedNativeAuthData({}, { nativeAppPort: 1 }); + const b = createZedNativeAuthData({}, { nativeAppPort: 1 }); + registerZedSession({ state: "old-state", codeVerifier: a.privateKeyVerifier }); + registerZedSession({ state: "new-state", codeVerifier: b.privateKeyVerifier }); + + expect(getZedSessionStatus("old-state")).toBeNull(); + const fresh = getZedSessionStatus("new-state"); + expect(fresh).not.toBeNull(); + expect(fresh.status).toBe("pending"); + expect(fresh.codeVerifier).toBe(b.privateKeyVerifier); + clearZedSession("new-state"); + }); +}); + +describe("criterion L — decrypt failure errors the session but keeps the server", () => { + it("wrong-key token → session error, listener survives for the live attempt", async () => { + const started = await startTestProxy(); + const live = createZedNativeAuthData({}, { nativeAppPort: started.port }); + const other = createZedNativeAuthData({}, { nativeAppPort: started.port }); + const state = `wrongkey-state-${Date.now()}`; + registerZedSession({ state, codeVerifier: live.privateKeyVerifier }); + + // Token encrypted for a DIFFERENT keypair (e.g. superseded popup). + const bad = encryptForCallback(other.publicKey, "not-for-this-key"); + const cb = new URL(`http://127.0.0.1:${started.port}/`); + cb.searchParams.set("user_id", "user-123"); + cb.searchParams.set("access_token", bad); + const res = await realFetch(cb.toString()); + expect(res.status).toBe(200); + + const session = getZedSessionStatus(state); + expect(session).not.toBeNull(); + expect(session.status).toBe("error"); + expect(session.error).toMatch(/decrypt/i); + + // Server must still be alive (same port) for the live attempt. + const again = await startZedProxy(0); + expect(again.port).toBe(started.port); + clearZedSession(state); + }); +}); From 3ac100d5248e49a6aaa3b7efa6c1fd9d3dd14571 Mon Sep 17 00:00:00 2001 From: Ali Shaikh Date: Thu, 17 Sep 2026 20:06:19 +0700 Subject: [PATCH 18/31] fix(usage): improve DeepSeek credit balance display Display DeepSeek prepaid balances as credit with currency instead of a 0/total quota bar, and mark balance items as isCreditBalance. --- open-sse/providers/registry/opencode-go.js | 2 +- open-sse/services/usage/deepseek.js | 7 ++++--- .../usage/components/ProviderLimits/QuotaTable.js | 15 +++++++++++---- .../usage/components/ProviderLimits/utils.js | 2 ++ tests/unit/deepseek-usage.test.js | 2 ++ 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index 1d1d12c0..d65d4c15 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -13,7 +13,7 @@ export default { textIcon: "OC", website: "https://opencode.ai/auth", notice: { - text: "OpenCode Go subscription: $5/mo (then 0/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", + text: "OpenCode Go subscription: $5/mo (then 10/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", apiKeyUrl: "https://opencode.ai/auth", }, }, diff --git a/open-sse/services/usage/deepseek.js b/open-sse/services/usage/deepseek.js index cb70a40d..9d2ed2ac 100644 --- a/open-sse/services/usage/deepseek.js +++ b/open-sse/services/usage/deepseek.js @@ -91,14 +91,15 @@ export async function getDeepseekUsage(apiKey = null, proxyOptions = null) { const quotas = {}; for (const b of balances) { const total = Math.max(0, b.totalBalance); - // Credit pot: show full remaining against current balance; never set absolute - // `remaining` — QuotaTable treats it as a 0–100 percentage. + // Credit balance: show as "Credit: $X.XX USD" not a usage quota quotas[`Balance (${b.currency})`] = { used: 0, total, remainingPercentage: total > 0 ? 100 : 0, resetAt: null, - unlimited: total > 0, + unlimited: false, + isCreditBalance: true, + currency: b.currency, }; } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 57a7a9d2..fef34f6c 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -151,7 +151,10 @@ export default function QuotaTable({
{currentPageRows.map((quota) => { const isUnlimited = quota.unlimited === true; - const colors = getColorClasses(quota.remaining); + const isCreditBalance = quota.isCreditBalance === true; + const colors = isCreditBalance + ? { text: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500", bgLight: "bg-blue-500/10", emoji: "💰" } + : getColorClasses(quota.remaining); const countdown = formatResetTime(quota.resetAt); const resetDisplay = formatResetTimeDisplay(quota.resetAt); // recurring defaults true: a missing flag means the quota @@ -175,7 +178,7 @@ export default function QuotaTable({ {/* Progress + used/total */}
- {!isUnlimited && ( + {!isUnlimited && !isCreditBalance && (
@@ -192,15 +195,19 @@ export default function QuotaTable({ title={ isUnlimited ? `${quota.used.toLocaleString()} used · Unlimited` + : isCreditBalance + ? `Credit balance: ${quota.total.toFixed(2)} ${quota.currency || ""}` : `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}` } > {isUnlimited ? `${quota.used.toLocaleString()} used · Unlimited` + : isCreditBalance + ? `Credit: ${quota.total.toFixed(2)} ${quota.currency || ""}` : `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`} - - {isUnlimited ? "Unlimited" : `${quota.remaining}%`} + + {isUnlimited ? "Unlimited" : isCreditBalance ? "" : `${quota.remaining}%`}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index ad321433..377bb02d 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -609,6 +609,8 @@ export function parseQuotaData(provider, data) { total: quota.total || 0, resetAt: quota.resetAt || null, remainingPercentage: quota.remainingPercentage, + isCreditBalance: quota.isCreditBalance ?? true, + currency: quota.currency || (name.includes("(") ? name.slice(name.indexOf("(") + 1, name.indexOf(")")) : "USD"), }); }); } diff --git a/tests/unit/deepseek-usage.test.js b/tests/unit/deepseek-usage.test.js index 0be26edc..d9048b09 100644 --- a/tests/unit/deepseek-usage.test.js +++ b/tests/unit/deepseek-usage.test.js @@ -80,6 +80,8 @@ describe("getUsageForProvider(deepseek)", () => { used: 0, total: 12.5, remainingPercentage: 100, + isCreditBalance: true, + currency: "USD", }); expect(usage.quotas["Balance (USD)"].remaining).toBeUndefined(); // Zero CNY still listed so user sees currency row From f64229530eb7cdd798e3f934a9af716c995fc0da Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 17 Sep 2026 20:18:37 +0700 Subject: [PATCH 19/31] fix(antigravity): sanitize Hermes system identity --- open-sse/config/appConstants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 4df1463e..106cba50 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -178,6 +178,7 @@ export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official C // makes the backend flag the request and answer 429 Quota Exhausted. export const ANTIGRAVITY_PROMPT_REWRITES = [ { from: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", to: "" }, + { from: /You are Hermes Agent,\s*(an intelligent AI assistant)(?: created by Nous Research)?\./gi, to: "You are Hermes Agent. You are $1." }, { from: /opencode/gi, to: (m) => (m === "OpenCode" ? "Antigravity" : m === "OPENCODE" ? "ANTIGRAVITY" : "antigravity") } ]; From 52917a6d49d290b8a3be315dafc1b3e86a425ee5 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 17 Sep 2026 20:18:56 +0700 Subject: [PATCH 20/31] Add NEW badge to 9Remote menu --- src/shared/components/Sidebar.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 812e319e..01dd1334 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -303,6 +303,9 @@ export default function Sidebar({ onClose }) { computer 9Remote + + New + {/* 9English */} From 93837af09fca065717784895d5c092421bdf1d1b Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 18 Sep 2026 16:57:06 +0700 Subject: [PATCH 21/31] fix(opencode): fix free tier 403 error and improve China region handling - Force stream:true and cloak decoy tools (bash, read) for OpenCode free tier - Support connection testing for opencode in testUtils - Expand error message slice limits in auth and ping to preserve workspace link - Add concise China region link chip in provider detail page Co-Authored-By: Claude Code --- open-sse/executors/opencode.js | 70 +++++++++++++++++++ .../dashboard/providers/[id]/page.js | 28 +++++++- src/app/api/models/test/ping.js | 2 +- src/app/api/providers/[id]/test/testUtils.js | 6 ++ src/sse/services/auth.js | 2 +- tests/unit/opencode-session.test.js | 35 ++++++++++ 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 531c5df5..507c3718 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -24,6 +24,68 @@ export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/; const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +// OpenCode free tier requires both 'bash' and 'read' in tools payload. +// Injected as cloaked decoy tools so external CLI tools (e.g. Claude Code's Bash/Read) +// take precedence while satisfying upstream verification. +const OPENCODE_DECOY_CHAT_TOOLS = [ + { + type: "function", + function: { + name: "bash", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "read", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, + }, +]; + +const OPENCODE_DECOY_RESPONSES_TOOLS = [ + { + type: "function", + name: "bash", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, + { + type: "function", + name: "read", + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, +]; + +function cloakOpencodeTools(body, isResponses) { + if (!body || typeof body !== "object") return; + if (isResponses) { + if (!Array.isArray(body.tools)) body.tools = []; + const names = new Set(body.tools.map((t) => t.name || t.function?.name)); + for (const tool of OPENCODE_DECOY_RESPONSES_TOOLS) { + if (!names.has(tool.name)) body.tools.push({ ...tool }); + } + if (!body.tool_choice) body.tool_choice = "auto"; + } else { + const hasTools = Array.isArray(body.tools) && body.tools.length > 0; + if (!hasTools) { + body.tools = OPENCODE_DECOY_CHAT_TOOLS.map((t) => ({ ...t, function: { ...t.function } })); + if (!body.tool_choice) body.tool_choice = "none"; + } else { + const names = new Set(body.tools.map((t) => t.function?.name || t.name)); + for (const tool of OPENCODE_DECOY_CHAT_TOOLS) { + if (!names.has(tool.function.name)) { + body.tools.push({ ...tool, function: { ...tool.function } }); + } + } + } + } +} + function hasValidOpencodeVersion(ua) { const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i); if (!m) return false; @@ -410,6 +472,9 @@ export class OpenCodeExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { if (body && typeof body === "object" && model && !body.model) body.model = model; + // Zen rejects non-streaming requests on free models with 403 FreeTierError; + // always stream upstream and let the handler layer aggregate for non-stream clients. + if (body && typeof body === "object") body.stream = true; if (isResponsesModel(model || body?.model) && body && typeof body === "object") { // ponytail: chỉ model đã xác nhận auto-only; mở allowlist khi có bằng chứng. if ("tool_choice" in body && body.tool_choice !== "auto" @@ -434,6 +499,11 @@ export class OpenCodeExecutor extends BaseExecutor { body.store = false; normalizeResponsesTools(body); sanitizeResponsesItems(body); + if (!Array.isArray(body.tools) || body.tools.length === 0) { + cloakOpencodeTools(body, true); + } + } else if (body && typeof body === "object") { + cloakOpencodeTools(body, false); } return injectReasoningContent({ provider: this.provider, model, body }); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 437214e2..bae6ff05 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -1783,7 +1783,33 @@ export default function ProviderDetailPage() { })()}
{!!modelsTestError && ( -

{modelsTestError}

+
+

{modelsTestError}

+ {/RegionError|hosted in China|regionNotAllowed/i.test(modelsTestError) && (() => { + const str = typeof modelsTestError === "string" ? modelsTestError : JSON.stringify(modelsTestError); + const linkMatch = str.match(/https:\/\/opencode\.ai\/workspace\/[^\s"')]+/); + const wrkMatch = str.match(/wrk_[0-9A-Za-z]+/); + const targetUrl = linkMatch + ? (linkMatch[0].endsWith("/go") ? linkMatch[0] : `${linkMatch[0]}/go`) + : wrkMatch + ? `https://opencode.ai/workspace/${wrkMatch[0]}/go` + : "https://opencode.ai"; + + return ( + + ); + })()} +
)} {providerId === "zed" && !!liveModelsError && (

{liveModelsError}

diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 273aff47..a5c882d5 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -160,7 +160,7 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$ if (!res.ok) { const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; - return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; + return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 500)}` : ""}`, status: res.status }; } const providerStatus = parsed?.status; diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 9572e69e..03500da2 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -751,6 +751,12 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { const valid = !!(data && data.user); return { valid, error: valid ? null : "Session expired — re-paste cookie" }; } + case "opencode": { + const res = await fetchWithConnectionProxy("https://opencode.ai/zen/v1/models", { + headers: { Authorization: "Bearer public", "User-Agent": "opencode/1.18.31" }, + }, effectiveProxy); + return { valid: res.ok, error: res.ok ? null : "OpenCode free tier unavailable" }; + } case "opencode-go": { const res = await fetchWithConnectionProxy("https://opencode.ai/zen/go/v1/chat/completions", { method: "POST", diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index d85c4901..eff83e7c 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -263,7 +263,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr } if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; - const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; + const reason = typeof errorText === "string" ? errorText.slice(0, 200) : "Provider error"; const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs); await updateProviderConnection(connectionId, { diff --git a/tests/unit/opencode-session.test.js b/tests/unit/opencode-session.test.js index b7651f16..89e06a8f 100644 --- a/tests/unit/opencode-session.test.js +++ b/tests/unit/opencode-session.test.js @@ -276,4 +276,39 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => { expect(first).toMatch(OPENCODE_SESSION_RE); expect(second).toBe(first); }); + + it("cloaks free-tier requests with bash and read decoy tools", () => { + const executor = getExecutor("opencode"); + + // Case 1: no tools sent by client -> injects bash + read with tool_choice none + const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + }); + expect(chatNoTools.stream).toBe(true); + expect(chatNoTools.tool_choice).toBe("none"); + expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual(["bash", "read"]); + + // Case 2: external CLI tools (e.g. Claude Code Bash) -> preserves Bash, appends read + const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "Bash", description: "Claude Code tool" } }], + tool_choice: "auto", + }); + expect(chatWithTools.tool_choice).toBe("auto"); + const names = chatWithTools.tools.map((t) => t.function?.name); + expect(names).toContain("Bash"); + expect(names).toContain("bash"); + expect(names).toContain("read"); + + // Case 3: already has both bash and read -> do not insert anything + const chatFull = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + tools: [ + { type: "function", function: { name: "bash", description: "existing" } }, + { type: "function", function: { name: "read", description: "existing" } }, + ], + }); + expect(chatFull.tools.length).toBe(2); + expect(chatFull.tools[0].function.description).toBe("existing"); + }); }); From 4641c2b76aa0e498c752145f0ad905e69b31e1f7 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 18 Sep 2026 16:59:45 +0700 Subject: [PATCH 22/31] fix(zed): lower display priority in oauth list Move zed provider to the bottom of oauth providers by setting priority to 999. Co-Authored-By: Claude Code --- open-sse/providers/registry/zed.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js index 7e2e5dda..a8ef14f1 100644 --- a/open-sse/providers/registry/zed.js +++ b/open-sse/providers/registry/zed.js @@ -1,7 +1,7 @@ // Zed provider — RSA keypair callback auth (NOT standard OAuth). export default { id: "zed", - priority: 10, + priority: 999, alias: "zd", uiAlias: "zd", display: { From efc80ba2e3d30e1a427798743c7b73fedbc52064 Mon Sep 17 00:00:00 2001 From: Amirsalar Sojoudi Date: Fri, 18 Sep 2026 17:03:57 +0700 Subject: [PATCH 23/31] fix(codex): route bare codex-auto-review to the Codex provider (#4135) --- open-sse/providers/registry/codex.js | 3 ++ open-sse/services/model.js | 2 + tests/unit/codex-auto-review-routing.test.js | 44 ++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 tests/unit/codex-auto-review-routing.test.js diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 9eaafe74..710cbc27 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -65,6 +65,9 @@ export default { { id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" }, { id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" }, { id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" }, + // Codex CLI's auto-review virtual model. Unlike the "-review" variants above it is not derived + // from a base model, so it is forwarded verbatim instead of having "-review" stripped (#1398). + { id: "codex-auto-review", name: "Codex Auto Review", upstreamModelId: "codex-auto-review", quotaFamily: "review" }, { id: "gpt-image-2.5", name: "GPT Image 2.5", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, { id: "gpt-image-2.5-flare", name: "GPT Image 2.5 Flare", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, { id: "gpt-image-2.5-sunburst", name: "GPT Image 2.5 Sunburst", capabilities: ["text2img","edit","multiImage"], params: ["size","quality","background","image_detail","output_format"], kind: "image" }, diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 5b88809c..15c50195 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -124,6 +124,8 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { // Config-driven prefix → provider inference (first match wins, fallback "openai"). const MODEL_PREFIX_PROVIDERS = [ + // Codex CLI sends this bare virtual model for auto-review — keep it on OAuth Codex (#1398). + [/^codex-auto-review$/, "codex"], [/^claude-/, "anthropic"], [/^gemini-/, "gemini"], [/^gpt-/, "openai"], diff --git a/tests/unit/codex-auto-review-routing.test.js b/tests/unit/codex-auto-review-routing.test.js new file mode 100644 index 00000000..e96b0fef --- /dev/null +++ b/tests/unit/codex-auto-review-routing.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + getDefaultModel, + getModelQuotaFamily, + getModelUpstreamId, + getProviderModels, +} from "../../open-sse/config/providerModels.js"; +import { getModelInfoCore } from "../../open-sse/services/model.js"; + +// Codex CLI's auto-review sends the bare model id "codex-auto-review". Before #1398 it fell +// through prefix inference to the "openai" default and failed with +// "No active credentials for provider: openai". +describe("codex auto-review routing (#1398)", () => { + it("routes the bare Codex auto-review model to the OAuth Codex provider", async () => { + await expect(getModelInfoCore("codex-auto-review", {})).resolves.toEqual({ + provider: "codex", + model: "codex-auto-review", + }); + }); + + it("exposes Codex auto-review as a review-quota Codex model", () => { + const autoReview = getProviderModels("cx").find( + (model) => model.id === "codex-auto-review", + ); + + expect(autoReview).toBeTruthy(); + expect(autoReview.name).toBe("Codex Auto Review"); + expect(getModelQuotaFamily("cx", "codex-auto-review")).toBe("review"); + }); + + // getModelUpstreamId strips CODEX_REVIEW_SUFFIX from unregistered "cx" ids, which would send + // "codex-auto" upstream. This model is not a derived review variant, so it must go out verbatim. + it("forwards the id upstream without stripping the -review suffix", () => { + expect(getModelUpstreamId("cx", "codex-auto-review")).toBe( + "codex-auto-review", + ); + }); + + // Registering it must not push it to the front of the cx list — getDefaultModel takes models[0]. + it("does not become the default Codex model", () => { + expect(getDefaultModel("cx")).not.toBe("codex-auto-review"); + }); +}); From b3d6e089c660389fd4153ee95cfda5e5c35c511f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Ph=E1=BA=A1m?= Date: Fri, 18 Sep 2026 17:06:36 +0700 Subject: [PATCH 24/31] fix(antigravity): strip Claude Code billing header from system prompts --- open-sse/config/appConstants.js | 4 ++ ...antigravity-billing-header-rewrite.test.js | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/unit/antigravity-billing-header-rewrite.test.js diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 106cba50..62ddfb6b 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -179,6 +179,10 @@ export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official C export const ANTIGRAVITY_PROMPT_REWRITES = [ { from: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", to: "" }, { from: /You are Hermes Agent,\s*(an intelligent AI assistant)(?: created by Nous Research)?\./gi, to: "You are Hermes Agent. You are $1." }, + // Claude Code prepends this line to its system prompt. The Claude-format translator strips it, + // but OpenAI-format clients (e.g. proxies that convert Claude Code to /v1/chat/completions) + // pass it through, and any system text containing it gets a fake 429 RESOURCE_EXHAUSTED. + { from: /^x-anthropic-billing-header:[^\n]*(?:\r?\n)*/gim, to: "" }, { from: /opencode/gi, to: (m) => (m === "OpenCode" ? "Antigravity" : m === "OPENCODE" ? "ANTIGRAVITY" : "antigravity") } ]; diff --git a/tests/unit/antigravity-billing-header-rewrite.test.js b/tests/unit/antigravity-billing-header-rewrite.test.js new file mode 100644 index 00000000..e324294f --- /dev/null +++ b/tests/unit/antigravity-billing-header-rewrite.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; +import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.js"; + +const HEADER = "x-anthropic-billing-header: cc_version=2.1.275.f15; cc_entrypoint=cli;"; + +function systemTextSentToAntigravity(systemContent) { + // OpenAI-format client (e.g. a proxy converting Claude Code to /v1/chat/completions). + const body = openaiToAntigravityRequest("gemini-3.8-flash-tiered", { + messages: [ + { role: "system", content: systemContent }, + { role: "user", content: "hi" }, + ], + }, true); + const finalBody = new AntigravityExecutor().transformRequest("gemini-3.8-flash-tiered", body, true, {}); + return finalBody.request.systemInstruction.parts.map((p) => p.text).join("\n"); +} + +describe("Antigravity strips the Claude Code billing header from system prompts", () => { + it("removes the header line prepended by Claude Code", () => { + const text = systemTextSentToAntigravity(`${HEADER}\n\nYou are Claude Code, Anthropic's official CLI for Claude.`); + expect(text).not.toContain("x-anthropic-billing-header"); + expect(text).toContain("You are Claude Code, Anthropic's official CLI for Claude."); + }); + + it("removes the header when it is not the first line", () => { + const text = systemTextSentToAntigravity(`Some preamble\n${HEADER}\nRest of prompt`); + expect(text).not.toContain("x-anthropic-billing-header"); + expect(text).toContain("Some preamble"); + expect(text).toContain("Rest of prompt"); + }); + + it("leaves prompts without the header untouched", () => { + const text = systemTextSentToAntigravity("You are a helpful assistant."); + expect(text).toContain("You are a helpful assistant."); + }); +}); From 092c84eac9d0006c99328a1812a454066531a1df Mon Sep 17 00:00:00 2001 From: Christian Gennari Date: Fri, 18 Sep 2026 17:07:32 +0700 Subject: [PATCH 25/31] fix(commandcode): retry on transient stream error and avoid fake stop chunks --- open-sse/executors/commandcode.js | 22 ++++++++-- .../request/openai-to-commandcode.js | 4 ++ .../response/commandcode-to-openai.js | 7 ++-- tests/unit/commandcode-executor.test.js | 40 +++++++++++++++++++ 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.js index f694e61b..d923fd50 100644 --- a/open-sse/executors/commandcode.js +++ b/open-sse/executors/commandcode.js @@ -40,10 +40,24 @@ export class CommandCodeExecutor extends BaseExecutor { } async execute(opts) { - const result = await super.execute(opts); - if (!result?.response?.ok || !result.response.body) return result; - result.response = await inspectAndWrapCommandCodeResponse(result.response, opts.model); - return result; + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const result = await super.execute(opts); + if (!result?.response?.ok || !result.response.body) return result; + + const wrappedResponse = await inspectAndWrapCommandCodeResponse(result.response, opts.model); + if (!wrappedResponse.ok && attempt < maxRetries) { + const isRetryableStatus = wrappedResponse.status === 502 || wrappedResponse.status === 503 || wrappedResponse.status === 504; + if (isRetryableStatus) { + opts.log?.debug?.("RETRY", `CommandCode upstream returned status ${wrappedResponse.status}, retrying ${attempt + 1}/${maxRetries}...`); + await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); + continue; + } + } + + result.response = wrappedResponse; + return result; + } } parseError(response, bodyText) { diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js index d0a55e43..ac9067f6 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -133,6 +133,10 @@ function convertMessages(messages = []) { if (role === ROLE.ASSISTANT) { const blocks = []; + const rc = m.reasoning_content || m.thought || m.reasoning; + if (rc || (Array.isArray(m.tool_calls) && m.tool_calls.length > 0)) { + blocks.push({ type: "reasoning", text: rc || " " }); + } const text = flattenText(m.content); if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text }); if (Array.isArray(m.tool_calls)) { diff --git a/open-sse/translator/response/commandcode-to-openai.js b/open-sse/translator/response/commandcode-to-openai.js index ab3d7d7b..b75a2e70 100644 --- a/open-sse/translator/response/commandcode-to-openai.js +++ b/open-sse/translator/response/commandcode-to-openai.js @@ -165,12 +165,11 @@ export function commandCodeToOpenAIResponse(chunk, state) { break; } case "error": { - state.finishReason = OPENAI_FINISH.STOP; const errVal = event.error ?? event.message ?? "unknown"; const errStr = typeof errVal === "string" ? errVal : JSON.stringify(errVal); - out.push(makeChunk(state, { content: `\n\n[CommandCode error: ${errStr}]` })); - out.push(makeChunk(state, {}, OPENAI_FINISH.STOP)); - break; + // Mid-stream error: throw rather than emitting as fake content with finish_reason: "stop" + // This ensures the downstream stream handler marks the stream as errored/aborted. + throw new Error(`[CommandCode error: ${errStr}]`); } // Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end, // provider-metadata, message-metadata, etc. They carry no client-visible content. diff --git a/tests/unit/commandcode-executor.test.js b/tests/unit/commandcode-executor.test.js index bd0a23cd..f498b39c 100644 --- a/tests/unit/commandcode-executor.test.js +++ b/tests/unit/commandcode-executor.test.js @@ -132,6 +132,46 @@ describe("inspectAndWrapCommandCodeResponse", () => { expect(text).toContain("Hello from Laguna"); expect(text).toContain("data: [DONE]"); }); + + it("retries when initial stream yields an error and succeeds on second attempt", async () => { + let callCount = 0; + const executor = new CommandCodeExecutor(); + + // Override execute on instance to test retry behavior + executor.execute = async (opts) => { + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + callCount++; + let rawResponse; + if (callCount === 1) { + rawResponse = new Response(createNdjsonStream([ + JSON.stringify({ + type: "error", + error: { type: "server_error", message: "Network connection lost." } + }) + "\n" + ]), { status: 200, headers: { "Content-Type": "text/event-stream" } }); + } else { + rawResponse = new Response(createNdjsonStream([ + JSON.stringify({ type: "start" }) + "\n", + JSON.stringify({ type: "text-delta", text: "Recovered from lost connection" }) + "\n", + JSON.stringify({ type: "finish" }) + "\n" + ]), { status: 200, headers: { "Content-Type": "text/event-stream" } }); + } + + const wrappedResponse = await inspectAndWrapCommandCodeResponse(rawResponse, opts.model); + if (!wrappedResponse.ok && attempt < maxRetries) { + continue; + } + return { response: wrappedResponse }; + } + }; + + const res = await executor.execute({ model: "deepseek/deepseek-v4.1-flash" }); + expect(res.response.ok).toBe(true); + expect(callCount).toBe(2); + const text = await res.response.text(); + expect(text).toContain("Recovered from lost connection"); + }); }); describe("CommandCode in Combo Fallback", () => { From bc3be0cb284f9edd711aabd6c7a8d84c1a4a8f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20Ph=E1=BA=A1m?= Date: Fri, 18 Sep 2026 17:06:50 +0700 Subject: [PATCH 26/31] fix(antigravity): scope cached thought signatures to the model family --- open-sse/executors/antigravity.js | 2 +- open-sse/services/thoughtSignatureStore.js | 51 +++++++-- .../translator/request/openai-to-gemini.js | 4 +- .../translator/response/gemini-to-openai.js | 4 +- ...tigravity-thought-signature-family.test.js | 104 ++++++++++++++++++ 5 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 tests/unit/antigravity-thought-signature-family.test.js diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index fb7beee0..da4a7981 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -212,7 +212,7 @@ export class AntigravityExecutor extends BaseExecutor { const modifiedParts = parts?.map(p => { if (!p.functionCall) return p; const callId = p.functionCall.id; - const cachedSig = callId ? getGeminiThoughtSignatureSync(callId, sessionId) : null; + const cachedSig = callId ? getGeminiThoughtSignatureSync(callId, sessionId, body.model || model) : null; const callSig = p.thoughtSignature || cachedSig || (!firstFunctionCallSeen ? DEFAULT_THINKING_AG_SIGNATURE : undefined); firstFunctionCallSeen = true; if (callSig) { diff --git a/open-sse/services/thoughtSignatureStore.js b/open-sse/services/thoughtSignatureStore.js index eb8a60e4..71fb94b8 100644 --- a/open-sse/services/thoughtSignatureStore.js +++ b/open-sse/services/thoughtSignatureStore.js @@ -10,6 +10,24 @@ const signatureKv = makeKv(SCOPE); const memorySignatures = new Map(); let pruneCounter = 0; +/** + * Model family that produced / will consume a signature. Antigravity serves Gemini and Claude + * models behind the same API, and each backend only accepts its own signatures: a Claude + * signature replayed to Gemini fails with 400 "Corrupted thought signature." (and vice versa). + */ +export function signatureFamily(model) { + const m = typeof model === "string" ? model.toLowerCase() : ""; + if (!m) return null; + if (m.includes("claude")) return "claude"; + if (m.includes("gemini")) return "gemini"; + return m; +} + +// Entries stored before families were recorded (no `family`) stay usable for any model. +function isCompatible(entry, family) { + return !entry.family || !family || entry.family === family; +} + function pruneMemoryExpired() { const now = Date.now(); for (const [key, value] of memorySignatures.entries()) { @@ -62,13 +80,15 @@ async function maybePrunePersisted() { } /** - * Store a thought signature for a tool_call_id with optional sessionId namespace (RAM + SQLite async) + * Store a thought signature for a tool_call_id with optional sessionId namespace (RAM + SQLite async). + * `model` is the model that produced the signature; lookups for another model family skip it. */ -export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = null) { +export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = null, model = null) { if (typeof toolCallId !== "string" || !toolCallId) return; if (typeof signature !== "string" || !signature) return; const now = Date.now(); + const family = signatureFamily(model); pruneMemoryExpired(); const keys = []; @@ -80,12 +100,14 @@ export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = n for (const k of keys) { memorySignatures.set(k, { signature, + family, expiresAt: now + MEMORY_TTL_MS, }); // Async persist to SQLite kv table without blocking signatureKv.set(k, { signature, + family, createdAt: now, expiresAt: now + PERSISTED_TTL_MS, }).catch(() => {}); @@ -95,23 +117,25 @@ export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = n } /** - * Retrieve a thought signature by tool_call_id (RAM first, then SQLite fallback) + * Retrieve a thought signature by tool_call_id (RAM first, then SQLite fallback). + * `model` is the target model; signatures produced by another model family are ignored. */ -export async function getGeminiThoughtSignature(toolCallId, sessionId = null) { +export async function getGeminiThoughtSignature(toolCallId, sessionId = null, model = null) { if (typeof toolCallId !== "string" || !toolCallId) return null; + const family = signatureFamily(model); pruneMemoryExpired(); if (sessionId && typeof sessionId === "string") { const sessionKey = `${sessionId}:${toolCallId}`; const sessionEntry = memorySignatures.get(sessionKey); - if (sessionEntry && sessionEntry.expiresAt > Date.now()) { + if (sessionEntry && sessionEntry.expiresAt > Date.now() && isCompatible(sessionEntry, family)) { return sessionEntry.signature; } } const entry = memorySignatures.get(toolCallId); - if (entry && entry.expiresAt > Date.now()) { + if (entry && entry.expiresAt > Date.now() && isCompatible(entry, family)) { return entry.signature; } @@ -119,9 +143,10 @@ export async function getGeminiThoughtSignature(toolCallId, sessionId = null) { if (sessionId && typeof sessionId === "string") { const sessionKey = `${sessionId}:${toolCallId}`; const sessionRow = await signatureKv.get(sessionKey); - if (sessionRow && typeof sessionRow.signature === "string" && (!sessionRow.expiresAt || sessionRow.expiresAt > Date.now())) { + if (sessionRow && typeof sessionRow.signature === "string" && (!sessionRow.expiresAt || sessionRow.expiresAt > Date.now()) && isCompatible(sessionRow, family)) { memorySignatures.set(sessionKey, { signature: sessionRow.signature, + family: sessionRow.family || null, expiresAt: Date.now() + MEMORY_TTL_MS, }); return sessionRow.signature; @@ -134,8 +159,10 @@ export async function getGeminiThoughtSignature(toolCallId, sessionId = null) { signatureKv.remove(toolCallId).catch(() => {}); return null; } + if (!isCompatible(row, family)) return null; memorySignatures.set(toolCallId, { signature: row.signature, + family: row.family || null, expiresAt: Date.now() + MEMORY_TTL_MS, }); return row.signature; @@ -148,22 +175,24 @@ export async function getGeminiThoughtSignature(toolCallId, sessionId = null) { } /** - * Synchronous get from RAM cache only (for sync translators) + * Synchronous get from RAM cache only (for sync translators). + * `model` is the target model; signatures produced by another model family are ignored. */ -export function getGeminiThoughtSignatureSync(toolCallId, sessionId = null) { +export function getGeminiThoughtSignatureSync(toolCallId, sessionId = null, model = null) { if (typeof toolCallId !== "string" || !toolCallId) return null; + const family = signatureFamily(model); pruneMemoryExpired(); if (sessionId && typeof sessionId === "string") { const sessionKey = `${sessionId}:${toolCallId}`; const sessionEntry = memorySignatures.get(sessionKey); - if (sessionEntry && sessionEntry.expiresAt > Date.now()) { + if (sessionEntry && sessionEntry.expiresAt > Date.now() && isCompatible(sessionEntry, family)) { return sessionEntry.signature; } } const entry = memorySignatures.get(toolCallId); - if (entry && entry.expiresAt > Date.now()) { + if (entry && entry.expiresAt > Date.now() && isCompatible(entry, family)) { return entry.signature; } return null; diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 24e7f262..305eaca0 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -129,7 +129,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG if (tc.type !== OPENAI_BLOCK.FUNCTION) continue; const args = tryParseJSON(tc.function?.arguments || "{}"); - const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId) : null; + const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId, model) : null; // First call gets cached signature or fallback; sibling calls remain unsigned if no cached sig const callSig = cachedSig || (!firstFunctionCallSeen ? signature : undefined); firstFunctionCallSeen = true; @@ -341,7 +341,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu if (block.type === CLAUDE_BLOCK.TEXT) { parts.push({ text: block.text }); } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { - const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId) : null; + const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId, model) : null; const callSig = cachedSig || (!firstToolUseSeen ? signature : undefined); firstToolUseSeen = true; diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.js index 66bf1022..14c47939 100644 --- a/open-sse/translator/response/gemini-to-openai.js +++ b/open-sse/translator/response/gemini-to-openai.js @@ -22,7 +22,7 @@ function emitFunctionCall(functionCall, state, signature = null) { const toolCallIndex = state.functionIndex++; const callId = functionCall.id || `${fcName}-${Date.now()}-${toolCallIndex}`; if (signature) { - storeGeminiThoughtSignature(callId, signature, state.sessionId); + storeGeminiThoughtSignature(callId, signature, state.sessionId, state.model); } const toolCall = { id: callId, @@ -52,7 +52,7 @@ export function geminiToOpenAIResponse(chunk, state) { // Initialize state if (!state.messageId) { state.messageId = response.responseId || `msg_${Date.now()}`; - state.model = response.modelVersion || "gemini"; + state.model = response.modelVersion || state.model || "gemini"; state.functionIndex = 0; state.geminiToolCallCount = 0; results.push(buildChunk(chunkMeta(state), { role: ROLE.ASSISTANT }, null)); diff --git a/tests/unit/antigravity-thought-signature-family.test.js b/tests/unit/antigravity-thought-signature-family.test.js new file mode 100644 index 00000000..7e34a17c --- /dev/null +++ b/tests/unit/antigravity-thought-signature-family.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Keep the signature store in RAM only; the SQLite kv layer is not under test here. +vi.mock("@/lib/db/helpers/kvStore.js", () => ({ + makeKv: () => ({ + get: async () => null, + set: async () => {}, + remove: async () => {}, + getAll: async () => ({}), + }), +})); + +const { + storeGeminiThoughtSignature, + getGeminiThoughtSignatureSync, + signatureFamily, +} = await import("../../open-sse/services/thoughtSignatureStore.js"); +const { openaiToAntigravityRequest } = await import("../../open-sse/translator/request/openai-to-gemini.js"); +const { geminiToOpenAIResponse } = await import("../../open-sse/translator/response/gemini-to-openai.js"); +const { DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } = await import("../../open-sse/config/defaultThinkingSignature.js"); + +let n = 0; +const uid = (p) => `${p}_${Date.now()}_${n++}`; + +function toolHistory(callId) { + return { + messages: [ + { role: "user", content: "list files" }, + { role: "assistant", content: null, tool_calls: [{ id: callId, type: "function", function: { name: "ls", arguments: "{}" } }] }, + { role: "tool", tool_call_id: callId, content: "a.txt" }, + ], + }; +} + +function functionCallSignatures(req) { + return req.request.contents.flatMap((c) => c.parts || []).filter((p) => p.functionCall).map((p) => p.thoughtSignature); +} + +describe("antigravity thought signatures are scoped to the model family", () => { + beforeEach(() => { n++; }); + + it("classifies model families", () => { + expect(signatureFamily("claude-opus-4-6-thinking")).toBe("claude"); + expect(signatureFamily("gemini-3.8-flash-tiered")).toBe("gemini"); + expect(signatureFamily("gpt-oss-120b-medium")).toBe("gpt-oss-120b-medium"); + expect(signatureFamily(null)).toBe(null); + }); + + it("does not return a Claude signature for a Gemini target (and vice versa)", () => { + const claudeCall = uid("toolu"); + const geminiCall = uid("call"); + storeGeminiThoughtSignature(claudeCall, "CLAUDE_SIG", "sess", "claude-opus-4-6-thinking"); + storeGeminiThoughtSignature(geminiCall, "GEMINI_SIG", "sess", "gemini-3.8-flash-tiered"); + + expect(getGeminiThoughtSignatureSync(claudeCall, "sess", "gemini-3.8-flash")).toBe(null); + expect(getGeminiThoughtSignatureSync(claudeCall, "sess", "claude-opus-4-6-thinking")).toBe("CLAUDE_SIG"); + expect(getGeminiThoughtSignatureSync(geminiCall, "sess", "gemini-3.7-flash")).toBe("GEMINI_SIG"); + expect(getGeminiThoughtSignatureSync(geminiCall, null, "claude-sonnet-4-6")).toBe(null); + }); + + it("keeps old behaviour for untagged entries and untargeted lookups", () => { + const call = uid("call"); + storeGeminiThoughtSignature(call, "LEGACY_SIG", "sess"); + expect(getGeminiThoughtSignatureSync(call, "sess", "gemini-3.8-flash")).toBe("LEGACY_SIG"); + + const tagged = uid("toolu"); + storeGeminiThoughtSignature(tagged, "CLAUDE_SIG", "sess", "claude-opus-4-6-thinking"); + expect(getGeminiThoughtSignatureSync(tagged, "sess")).toBe("CLAUDE_SIG"); + }); + + it("records the producing model from the Gemini response stream", () => { + const call = uid("toolu_vrtx"); + const state = { model: "claude-opus-4-6-thinking", sessionId: null, toolNameMap: null }; + geminiToOpenAIResponse({ + response: { + responseId: "r1", + candidates: [{ content: { role: "model", parts: [{ functionCall: { id: call, name: "ls", args: {} }, thoughtSignature: "CLAUDE_SIG" }] } }], + }, + }, state); + expect(getGeminiThoughtSignatureSync(call, null, "gemini-3.8-flash-tiered")).toBe(null); + expect(getGeminiThoughtSignatureSync(call, null, "claude-opus-4-6-thinking")).toBe("CLAUDE_SIG"); + }); + + it("switching Claude -> Gemini mid-conversation sends the default signature, not Claude's", () => { + const call = uid("toolu_vrtx"); + storeGeminiThoughtSignature(call, "CLAUDE_SIG", null, "claude-opus-4-6-thinking"); + const req = openaiToAntigravityRequest("gemini-3.8-flash-tiered", toolHistory(call), true); + expect(functionCallSignatures(req)).toEqual([DEFAULT_THINKING_GEMINI_CLI_SIGNATURE]); + }); + + it("switching Gemini -> Claude mid-conversation does not replay Gemini's signature", () => { + const call = uid("call"); + storeGeminiThoughtSignature(call, "GEMINI_SIG", null, "gemini-3.8-flash-tiered"); + const req = openaiToAntigravityRequest("claude-opus-4-6-thinking", toolHistory(call), true); + expect(functionCallSignatures(req)).not.toContain("GEMINI_SIG"); + }); + + it("same model family still reuses the cached signature", () => { + const call = uid("call"); + storeGeminiThoughtSignature(call, "GEMINI_SIG", null, "gemini-3.8-flash-tiered"); + const req = openaiToAntigravityRequest("gemini-3.8-flash-tiered", toolHistory(call), true); + expect(functionCallSignatures(req)).toEqual(["GEMINI_SIG"]); + }); +}); From d99bc8201daec4ad2a387d30cab79db5cac84a22 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 18 Sep 2026 17:12:57 +0700 Subject: [PATCH 27/31] fix(sidebar): temporarily hide NEW badge from 9Remote menu Co-Authored-By: Claude Code --- src/shared/components/Sidebar.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 01dd1334..9e97f0e6 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -303,9 +303,9 @@ export default function Sidebar({ onClose }) { computer 9Remote - + {/* New - + */} {/* 9English */} From 058ceace48e93379b4f24ac03160d27b894536cf Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 18 Sep 2026 17:32:52 +0700 Subject: [PATCH 28/31] fix(opencode): declare forceStream on transport for free-tier SSE aggregation Declares forceStream: true so chatCore properly converts upstream forced-stream responses to JSON for non-streaming callers. Co-authored-by: anojndr Co-authored-by: TEGAR-SRC Co-authored-by: yxxrn Co-Authored-By: Claude Code --- open-sse/providers/registry/opencode.js | 1 + tests/unit/opencode-session.test.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index 0914f431..b0705066 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -17,6 +17,7 @@ export default { headers: { "x-opencode-client": "desktop", }, + forceStream: true, noAuth: true, quirks: { forceAutoToolChoiceModels: ["muse-spark-1.3-contributor-free"], diff --git a/tests/unit/opencode-session.test.js b/tests/unit/opencode-session.test.js index 89e06a8f..3e50b9fe 100644 --- a/tests/unit/opencode-session.test.js +++ b/tests/unit/opencode-session.test.js @@ -311,4 +311,9 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => { expect(chatFull.tools.length).toBe(2); expect(chatFull.tools[0].function.description).toBe("existing"); }); + + it("declares forceStream on the opencode transport so chatCore serves SSE upstream", async () => { + const { PROVIDERS } = await import("../../open-sse/config/providers.js"); + expect(PROVIDERS.opencode?.forceStream).toBe(true); + }); }); From 8e15f0bdd8ab857723d77d7ba566d9f7042b81d7 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 18 Sep 2026 18:09:32 +0700 Subject: [PATCH 29/31] # v0.5.79 (2026-09-18) ## Features - **Xiaomi MiMo**: merge MiMo Desktop support into `xiaomi-mimo` with dual auth (API key + Desktop/OAuth session), Preview models support, and encrypted-callback OAuth flow - **Claude Code**: add 1M-context toggle (`[1m]` marker) and drive `CLAUDE_CODE_AUTO_COMPACT_WINDOW` directly from the dashboard - **Models**: add DeepSeek-V4.1-Flash to DeepSeek provider, CodeBuddy-Intl, and Ollama (`deepseek-v4.1-flash:cloud`); enable `low`..`max` reasoning effort levels and vision capability for DeepSeek-V4.* - **i18n**: integrate Persian (fa) translation ## Fixes - **OpenCode / OpenCode Go**: resolve 403 `FreeTierError` and 429 rate limits with canonical session format, valid User-Agent, and stable upstream session reuse; force stream and declare `forceStream` for free-tier SSE aggregation; cloak decoy tools, normalize Muse Free tool choice, and strip prior reasoning items on Responses models; route Union Alpha via Messages API - **Kiro**: preserve underscores in tool names (`mcp__server__tool`) and restore client tool names in responses; use neutral placeholder for tool-result-only turns; forward tool-result images - **Stream**: report aborts after HTTP 200 in-band (per-format error frames) instead of closing silently - **Command Code**: preserve images and `reasoning_effort` on `/alpha/generate`; retry transient stream errors and avoid fake stop chunks; add Quota Tracker support - **Zed**: harden OAuth lifecycle (preserve `systemId`, renew proxy timeout), support live model resolution, and lower display priority in OAuth list - **Antigravity**: scope cached thought signatures to model family; strip Claude Code billing headers from system prompts; sanitize Hermes system identity - **Codex**: route bare `codex-auto-review` requests to the Codex provider (#4135) - **Auth**: do not cool down an account for request-scoped 4xx errors - **Usage**: improve DeepSeek credit balance display as currency credit instead of 0/total quota bar - **Model Catalog**: scope synced catalog to gateways and declare vision capabilities for DeepSeek V4.1-Flash IDs --- CHANGELOG.md | 20 +++++++++++++ cli/package.json | 2 +- open-sse/providers/pricing.js | 2 ++ open-sse/providers/registry/deepseek.js | 1 + package.json | 2 +- tests/__baseline__/providers-baseline.json | 34 ++++++++++++++++++++-- 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b6139d..0585de42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# v0.5.79 (2026-09-18) + +## Features +- **Xiaomi MiMo**: merge MiMo Desktop support into `xiaomi-mimo` with dual auth (API key + Desktop/OAuth session), Preview models support, and encrypted-callback OAuth flow +- **Claude Code**: add 1M-context toggle (`[1m]` marker) and drive `CLAUDE_CODE_AUTO_COMPACT_WINDOW` directly from the dashboard +- **Models**: add DeepSeek-V4.1-Flash to DeepSeek provider, CodeBuddy-Intl, and Ollama (`deepseek-v4.1-flash:cloud`); enable `low`..`max` reasoning effort levels and vision capability for DeepSeek-V4.* +- **i18n**: integrate Persian (fa) translation + +## Fixes +- **OpenCode / OpenCode Go**: resolve 403 `FreeTierError` and 429 rate limits with canonical session format, valid User-Agent, and stable upstream session reuse; force stream and declare `forceStream` for free-tier SSE aggregation; cloak decoy tools, normalize Muse Free tool choice, and strip prior reasoning items on Responses models; route Union Alpha via Messages API +- **Kiro**: preserve underscores in tool names (`mcp__server__tool`) and restore client tool names in responses; use neutral placeholder for tool-result-only turns; forward tool-result images +- **Stream**: report aborts after HTTP 200 in-band (per-format error frames) instead of closing silently +- **Command Code**: preserve images and `reasoning_effort` on `/alpha/generate`; retry transient stream errors and avoid fake stop chunks; add Quota Tracker support +- **Zed**: harden OAuth lifecycle (preserve `systemId`, renew proxy timeout), support live model resolution, and lower display priority in OAuth list +- **Antigravity**: scope cached thought signatures to model family; strip Claude Code billing headers from system prompts; sanitize Hermes system identity +- **Codex**: route bare `codex-auto-review` requests to the Codex provider (#4135) +- **Auth**: do not cool down an account for request-scoped 4xx errors +- **Usage**: improve DeepSeek credit balance display as currency credit instead of 0/total quota bar +- **Model Catalog**: scope synced catalog to gateways and declare vision capabilities for DeepSeek V4.1-Flash IDs + # v0.5.75 (2026-09-10) ## Features diff --git a/cli/package.json b/cli/package.json index 58b9dfa8..3e658e63 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.75", + "version": "0.5.79", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index 7a7a0019..d09452e2 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -111,6 +111,8 @@ export const MODEL_PRICING = { "deepseek-v3.2-chat": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, "deepseek-v3.2-reasoner": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, "deepseek-v4-flash": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-v4.1-flash": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-flash": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, "deepseek-v4-pro": { input: 0.435, output: 0.87, cached: 0.003625, reasoning: 0.87, cache_creation: 0.435 }, // === GLM === diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index 2c3a3e73..997840e3 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -59,6 +59,7 @@ export default { { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, { id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro Max", upstreamModelId: "deepseek-v4-pro" }, { id: "deepseek-v4-pro-none", name: "DeepSeek V4 Pro No Thinking", upstreamModelId: "deepseek-v4-pro" }, + { id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)" }, { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, diff --git a/package.json b/package.json index 15b7eca7..a0f7ac88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.75", + "version": "0.5.79", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 2dd09f55..89a3db64 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -44,6 +44,7 @@ }, "usage": { "quotaApiUrl": "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + "quotaSummaryApiUrl": "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", "loadProjectApiUrl": "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", "tokenUrl": "https://oauth2.googleapis.com/token" }, @@ -131,6 +132,9 @@ "HTTP-Referer": "https://cline.bot", "X-Title": "Cline" }, + "quirks": { + "clineEnvelope": true + }, "tokenUrl": "https://api.cline.bot/api/v1/auth/token", "refreshUrl": "https://api.cline.bot/api/v1/auth/refresh", "auth": { @@ -149,6 +153,9 @@ "HTTP-Referer": "https://cline.bot", "X-Title": "Cline" }, + "quirks": { + "clineEnvelope": true + }, "auth": { "combined": true, "header": "Authorization", @@ -241,6 +248,12 @@ "reasoningInject": { "scope": "all" }, + "quirks": { + "claudeSupportedToolTypes": [ + "web_search_20250305", + "web_search_20260209" + ] + }, "format": "openai", "transports": [ { @@ -438,6 +451,9 @@ "groq": { "baseUrl": "https://api.groq.com/openai/v1/chat/completions", "validateUrl": "https://api.groq.com/openai/v1/models", + "usage": { + "url": "https://api.groq.com/openai/v1/models" + }, "format": "openai" }, "hyperbolic": { @@ -571,7 +587,8 @@ "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" }, "quirks": { - "dropOutputConfig": true + "dropOutputConfig": true, + "requireClaudeToolType": true }, "reasoningInject": { "scope": "all" @@ -622,7 +639,8 @@ "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" }, "quirks": { - "dropOutputConfig": true + "dropOutputConfig": true, + "requireClaudeToolType": true }, "reasoningInject": { "scope": "all" @@ -709,6 +727,9 @@ "opencode-go": { "baseUrl": "https://opencode.ai/zen/go/v1/chat/completions", "headers": {}, + "usage": { + "url": "https://opencode.ai/zen/go/v1/usage" + }, "format": "openai", "transports": [ { @@ -746,7 +767,13 @@ "headers": { "x-opencode-client": "desktop" }, + "forceStream": true, "noAuth": true, + "quirks": { + "forceAutoToolChoiceModels": [ + "muse-spark-1.3-contributor-free" + ] + }, "format": "openai" }, "openrouter": { @@ -949,6 +976,7 @@ "HTTP-Referer": "https://endpoint-proxy.local", "X-Title": "Endpoint Proxy" }, + "forceStream": true, "format": "openai" }, "baidu": { @@ -1010,4 +1038,4 @@ }, "format": "openai" } -} +} \ No newline at end of file From 23ae82d8e3bd2306b85ac798050be5e406763a92 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 18 Sep 2026 18:31:12 +0700 Subject: [PATCH 30/31] # v0.5.81 (2026-09-18) ## Features - **Xiaomi MiMo**: merge MiMo Desktop support into `xiaomi-mimo` with dual auth (API key + Desktop/OAuth session), Preview models support, and encrypted-callback OAuth flow - **Claude Code**: add 1M-context toggle (`[1m]` marker) and drive `CLAUDE_CODE_AUTO_COMPACT_WINDOW` directly from the dashboard - **Models**: add DeepSeek-V4.1-Flash to DeepSeek provider, CodeBuddy-Intl, and Ollama (`deepseek-v4.1-flash:cloud`); enable `low`..`max` reasoning effort levels and vision capability for DeepSeek-V4.* - **i18n**: integrate Persian (fa) translation ## Fixes - **OpenCode / OpenCode Go**: resolve 403 `FreeTierError` and 429 rate limits with canonical session format, valid User-Agent, and stable upstream session reuse; force stream and declare `forceStream` for free-tier SSE aggregation; cloak decoy tools, normalize Muse Free tool choice, and strip prior reasoning items on Responses models; route Union Alpha via Messages API - **Kiro**: preserve underscores in tool names (`mcp__server__tool`) and restore client tool names in responses; use neutral placeholder for tool-result-only turns; forward tool-result images - **Stream**: report aborts after HTTP 200 in-band (per-format error frames) instead of closing silently - **Command Code**: preserve images and `reasoning_effort` on `/alpha/generate`; retry transient stream errors and avoid fake stop chunks; add Quota Tracker support - **Zed**: harden OAuth lifecycle (preserve `systemId`, renew proxy timeout), support live model resolution, and lower display priority in OAuth list - **Antigravity**: scope cached thought signatures to model family; strip Claude Code billing headers from system prompts; sanitize Hermes system identity - **Codex**: route bare `codex-auto-review` requests to the Codex provider (#4135) - **Auth**: do not cool down an account for request-scoped 4xx errors - **Usage**: improve DeepSeek credit balance display as currency credit instead of 0/total quota bar - **Model Catalog**: scope synced catalog to gateways and declare vision capabilities for DeepSeek V4.1-Flash IDs --- cli/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/package.json b/cli/package.json index 3e658e63..c75e6dc9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.79", + "version": "0.5.81", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/package.json b/package.json index a0f7ac88..c354a14a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.79", + "version": "0.5.81", "description": "9Router web dashboard", "private": true, "scripts": { From a8c9d3802c5933500fba95416f5bf0c130581396 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 18 Sep 2026 18:32:14 +0700 Subject: [PATCH 31/31] docs: update changelog header to v0.5.81 Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0585de42..f16c8f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# v0.5.79 (2026-09-18) +# v0.5.81 (2026-09-18) ## Features - **Xiaomi MiMo**: merge MiMo Desktop support into `xiaomi-mimo` with dual auth (API key + Desktop/OAuth session), Preview models support, and encrypted-callback OAuth flow