From 2aa99d9f39f665fa6d7f3c663a0d243bf8a89963 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 23 Sep 2026 11:45:34 +0700 Subject: [PATCH 01/38] feat(opencode-go): complete Go catalog (40 models) with auto-fetch + family endpoint regex - Add 12 missing models from live /zen/go/v1/models (glm-5, kimi-k2.5, mimo v2/v2.6 line, qwen3.5-plus, grok-4.5/4.7, omen-alpha, ...) - modelsFetcher + passthroughModels + "opencode-go" suggested-models filter - Family regex fallback (opencodeFamilyFormats) keeps unknown/passthrough ids on the right endpoint lane (/responses, /messages, /chat/completions); curated registry entries always win - isResponsesModel now routes passthrough grok/gpt ids to /responses Co-Authored-By: Claude Code --- open-sse/config/providerModels.js | 20 +++++++-- open-sse/executors/opencode-go.js | 16 +++---- open-sse/providers/models/helpers.js | 19 ++++++++ open-sse/providers/registry/opencode-go.js | 19 +++++++- .../api/providers/suggested-models/filters.js | 7 +++ tests/unit/opencode-go-models.test.js | 45 ++++++++++++++----- 6 files changed, 100 insertions(+), 26 deletions(-) diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index f4747cd4..9427b598 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -3,10 +3,13 @@ import REGISTRY from "../providers/registry/index.js"; // PROVIDER_MODELS now built from providers/registry (transport + models co-located) import { PROVIDER_MODELS } from "../providers/index.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js"; -import { CODEX_REVIEW_SUFFIX, isMuseSparkModel } from "../providers/models/helpers.js"; +import { CODEX_REVIEW_SUFFIX, isMuseSparkModel, opencodeFamilyFormats } from "../providers/models/helpers.js"; import { FORMATS } from "../translator/formats.js"; export { PROVIDER_MODELS }; +// OpenCode providers sharing the endpoint-family fallback for unknown model ids +const isOpenCodeAlias = (aliasOrId) => !aliasOrId || ["oc", "opencode", "ocg", "opencode-go", "ocz", "opencode-zen"].includes(aliasOrId); + // Helper functions export function getProviderModels(aliasOrId) { @@ -53,20 +56,29 @@ export function findModelName(aliasOrId, modelId) { } export function getModelTargetFormat(aliasOrId, modelId) { - if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go" || aliasOrId === "ocz" || aliasOrId === "opencode-zen") && isMuseSparkModel(modelId)) { + if (isOpenCodeAlias(aliasOrId) && isMuseSparkModel(modelId)) { return FORMATS.OPENAI_RESPONSES; } const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - return modelTargetFormat(findModel(models, modelId, aliasOrId)); + const found = findModel(models, modelId, aliasOrId); + if (found) return modelTargetFormat(found); + // Family fallback keeps modelsFetcher/passthrough ids on their endpoint lane + if (isOpenCodeAlias(aliasOrId)) return opencodeFamilyFormats(modelId)?.targetFormat || null; + return null; } // Declared upstream formats for a model (registry `supportedFormats`). Drives the // per-model guard on the sourceFormat-matched transport; null when undeclared. +// Unknown OpenCode ids fall back to the family regex (chat lane by default) so +// auto-fetched models never wrongly use the sourceFormat-matched transport. export function getModelSupportedFormats(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - return modelSupportedFormats(findModel(models, modelId, aliasOrId)); + const found = findModel(models, modelId, aliasOrId); + if (found) return modelSupportedFormats(found); + if (isOpenCodeAlias(aliasOrId)) return opencodeFamilyFormats(modelId)?.supportedFormats || [FORMATS.OPENAI]; + return null; } export function getModelType(aliasOrId, modelId) { diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js index fe90c6eb..7eb645a8 100644 --- a/open-sse/executors/opencode-go.js +++ b/open-sse/executors/opencode-go.js @@ -1,8 +1,8 @@ import crypto from "node:crypto"; import { DefaultExecutor } from "./default.js"; import { resolveSessionId } from "../utils/sessionManager.js"; -import { modelTargetFormat } from "../providers/models/schema.js"; -import { getProviderModels } from "../config/providerModels.js"; +import { getModelTargetFormat } from "../config/providerModels.js"; +import { FORMATS } from "../translator/formats.js"; import { normalizeResponsesInput, clampResponsesCallId, @@ -41,16 +41,10 @@ function translatedSession(sessionId, clientTool) { return `ses_${digest}`; } -// Strip the thinking suffix "model(level)" so checks hit the base id. -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. +// Responses-only per the provider registry (grok-4.6, gpt-5.6-luna, muse-spark, …), +// including the family-regex fallback for passthrough ids — never hardcode model ids here. function isResponsesModel(model) { - const entry = getProviderModels("opencode-go").find((m) => m.id === baseModelId(model)); - return modelTargetFormat(entry) === "openai-responses"; + return getModelTargetFormat("opencode-go", model) === FORMATS.OPENAI_RESPONSES; } // Flatten Chat Completions tool declarations into the Responses flat shape and diff --git a/open-sse/providers/models/helpers.js b/open-sse/providers/models/helpers.js index c66d662b..cf8076bd 100644 --- a/open-sse/providers/models/helpers.js +++ b/open-sse/providers/models/helpers.js @@ -1,3 +1,5 @@ +import { FORMATS } from "../../translator/formats.js"; + // Codex auto-generates a "-review" variant for each llm model (review quota family) export const CODEX_REVIEW_SUFFIX = "-review"; @@ -25,3 +27,20 @@ export function isMuseSparkModel(modelId) { const base = clean.includes("/") ? clean.split("/").pop() : clean; return /^muse[-_]?spark(?:$|[-_:.\s])/i.test(base); } + +// Endpoint families for OpenCode models outside the curated registry (modelsFetcher / +// passthrough ids) — regex keeps auto-fetched models on the right endpoint: +// /responses (gpt/grok/muse-spark), /messages (minimax/qwen), /chat/completions (rest). +// Curated registry entries always win; this is the unknown-id fallback only. +const OPENCODE_FAMILIES = [ + { match: /^(grok|gpt|muse[-_]?spark)/i, supportedFormats: [FORMATS.OPENAI_RESPONSES], targetFormat: FORMATS.OPENAI_RESPONSES }, + { match: /^deepseek-v4-(pro|flash)/, supportedFormats: [FORMATS.OPENAI, FORMATS.CLAUDE, FORMATS.OPENAI_RESPONSES] }, + { match: /^(minimax|qwen)/, supportedFormats: [FORMATS.OPENAI, FORMATS.CLAUDE] }, + { match: /^claude-/i, supportedFormats: [FORMATS.CLAUDE] }, +]; + +export function opencodeFamilyFormats(modelId) { + if (!modelId || typeof modelId !== "string") return null; + const base = modelId.replace(/\([^()]+\)\s*$/, "").trim(); + return OPENCODE_FAMILIES.find((f) => f.match.test(base)) || null; +} diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index d65d4c15..b442a4f1 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -35,20 +35,27 @@ export default { ], // supportedFormats follow the endpoint table in https://opencode.ai/docs/go/ models: [ - { id: "deepseek-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] }, + { id: "deepseek-flash", name: "DeepSeek Flash", supportedFormats: ["openai"] }, { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] }, { id: "glm-5.3", name: "GLM 5.3", supportedFormats: ["openai"] }, { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] }, { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] }, + { id: "glm-5", name: "GLM 5", supportedFormats: ["openai"] }, { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] }, { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] }, + { id: "kimi-k2.5", name: "Kimi K2.5", supportedFormats: ["openai"] }, { id: "kimi-k3", name: "Kimi K3", supportedFormats: ["openai"] }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] }, { id: "longcat-2.0", name: "LongCat 2.0", supportedFormats: ["openai"] }, + { id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", supportedFormats: ["openai"] }, + { id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", supportedFormats: ["openai"] }, { id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] }, { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] }, + { id: "mimo-v2-pro", name: "MiMo V2 Pro", supportedFormats: ["openai"] }, + { id: "mimo-v2-omni", name: "MiMo V2 Omni", supportedFormats: ["openai"] }, { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] }, { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] }, { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] }, @@ -57,15 +64,25 @@ export default { { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] }, { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] }, { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.5-plus", name: "Qwen 3.5 Plus", supportedFormats: ["openai", "claude"] }, { id: "hy4-preview", name: "Hy4 Preview", supportedFormats: ["openai"] }, { id: "hy3", name: "Hy3", supportedFormats: ["openai"] }, + { id: "hy3-preview", name: "Hy3 Preview", supportedFormats: ["openai"] }, + // In /zen/go/v1/models but absent from the docs endpoint table — chat lane is the fallback guess + { id: "omen-alpha", name: "Omen Alpha", supportedFormats: ["openai"] }, // Served by /zen/go/v1/responses only — the responses-only entry forces chatCore // past the sourceFormat-matched transports into translation (see chatCore guard). + { id: "grok-4.7", name: "Grok 4.7", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "grok-4.5", name: "Grok 4.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, ], + // Live catalogue; ids outside this curated list get their endpoint lane from the + // family regex in providers/models/helpers.js (opencodeFamilyFormats). + modelsFetcher: { url: "https://opencode.ai/zen/go/v1/models", type: "opencode-go" }, + passthroughModels: true, features: { usage: true, usageApikey: true, diff --git a/src/app/api/providers/suggested-models/filters.js b/src/app/api/providers/suggested-models/filters.js index 8babbb9b..b41ca33e 100644 --- a/src/app/api/providers/suggested-models/filters.js +++ b/src/app/api/providers/suggested-models/filters.js @@ -21,6 +21,13 @@ export const FILTERS = { .filter((m) => (m.id?.endsWith("-free") || KNOWN_FREE_OPENCODE_MODELS.includes(m.id)) && !DEAD_FREE_OPENCODE_MODELS.has(m.id)) .map((m) => ({ id: m.id, name: m.id })), + // Go subscription catalogue — every /models id is selectable; the endpoint lane + // per model is resolved by the family regex (see open-sse/providers/models/helpers.js) + "opencode-go": (models) => + (Array.isArray(models) ? models : []) + .filter((m) => typeof m?.id === "string") + .map((m) => ({ id: m.id, name: m.id })), + // models.dev returns a large catalog; keep only mimo models "mimo-free": (models) => (Array.isArray(models) ? models : []) diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 92e7a059..1979e692 100644 --- a/tests/unit/opencode-go-models.test.js +++ b/tests/unit/opencode-go-models.test.js @@ -4,11 +4,12 @@ import { PROVIDERS } from "../../open-sse/config/providers.js"; import { resolveTransport } from "../../open-sse/services/provider.js"; // Chat-only models (no /messages, no /responses support on opencode-go) -const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3", - "deepseek-flash", "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", "hy4-preview", "hy3"]; +const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-k3", + "deepseek-flash", "deepseek-v4.1-flash", "longcat-2.0", "mimo-v2.6-flash", "mimo-v2.6-pro", + "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni", "hy4-preview", "hy3", "hy3-preview", "omen-alpha"]; // Models that also expose the Anthropic /messages endpoint const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", - "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"]; + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus"]; // Models that also expose the OpenAI /responses endpoint const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"]; @@ -25,18 +26,42 @@ describe("OpenCode Go model catalog", () => { const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id); expect(ids).toEqual([ "deepseek-flash", - "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3", - "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", - "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", + "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-k3", + "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4.1-flash", + "longcat-2.0", "mimo-v2.6-flash", "mimo-v2.6-pro", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni", "minimax-m3", "minimax-m2.7", "minimax-m2.5", - "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", - "hy4-preview", "hy3", - "grok-4.6", "gpt-5.6-luna", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", + "hy4-preview", "hy3", "hy3-preview", "omen-alpha", + "grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna", "muse-spark-1.2-contributor", "muse-spark-1.3-contributor", ]); }); }); +describe("OpenCode Go family fallback (unknown/passthrough ids)", () => { + it("routes unknown grok/gpt ids to the responses lane", () => { + expect(getModelSupportedFormats("opencode-go", "grok-4.8")).toEqual(["openai-responses"]); + expect(getModelTargetFormat("opencode-go", "gpt-6-foo")).toBe("openai-responses"); + }); + + it("gives unknown chat-family ids the chat-only lane, never /messages", () => { + for (const m of ["kimi-k4", "glm-6", "mimo-v3", "omen-beta"]) { + expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai"]); + } + }); + + it("keeps unknown minimax/qwen ids on the /messages lane too", () => { + for (const m of ["minimax-m9", "qwen4-max"]) { + expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude"]); + } + }); + + it("curated entries win over the family regex", () => { + expect(getModelSupportedFormats("opencode-go", "deepseek-v4.1-flash")).toEqual(["openai"]); + expect(getModelSupportedFormats("opencode-go", "deepseek-v4-pro")).toEqual(["openai", "claude", "openai-responses"]); + }); +}); + 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"]); @@ -108,7 +133,7 @@ describe("OpenCode Go per-model transport guard (chatCore logic)", () => { }); it("routes Muse Spark (responses-only) to /responses, never to /messages", () => { - for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.6", "gpt-5.6-luna"]) { + for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna"]) { expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai-responses"]); expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses"); expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull(); From 1b72f02e3bce942f578dde21834ffc0c20a8f5fe Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 23 Sep 2026 11:45:40 +0700 Subject: [PATCH 02/38] fix(opencode-go): clamp deepseek reasoning_effort "max" to "high" for mimo backends that reject it mimo-v2.5-pro/v2.6 on the Go lane return 400 on reasoning_effort "max" (probed live; mimo-v2.5 accepts it). The deepseek applyFormat case now honors the declared thinking levels, and mimo-v2.5-pro gets a levels entry. Co-Authored-By: Claude Code --- open-sse/providers/thinkingLevels.js | 2 ++ open-sse/translator/concerns/thinkingUnified.js | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 0890e4ec..89865593 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -42,6 +42,8 @@ const PATTERN_THINKING = [ { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking { pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] }, + // mimo-v2.5-pro on opencode-go rejects reasoning_effort "max" (probed live); v2.5 accepts it. + { pattern: "*mimo*v2.5-pro*", levels: ["none", "low", "medium", "high", "xhigh"] }, // 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. diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 4bc9601f..3ccd442f 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -302,9 +302,12 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels, display) { case "deepseek": { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } body.thinking = { type: "enabled" }; - // DeepSeek: low/medium→high, xhigh/max→max. + // DeepSeek: low/medium→high, xhigh/max→max. Some backends (mimo v2.5-pro/v2.6 + // on opencode-go, probed live) 400 on "max" — clamp to high when the declared + // levels exclude it. const level = toLevel(eff); - body.reasoning_effort = level === "xhigh" || level === "max" ? "max" : "high"; + const want = level === "xhigh" || level === "max" ? "max" : "high"; + body.reasoning_effort = want === "max" && supportedLevels && !supportedLevels.includes("max") ? "high" : want; break; } case "kimi": { From a61fc6a01eec3e53cc3c03aae1b8c802172df7f1 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 23 Sep 2026 11:46:05 +0700 Subject: [PATCH 03/38] fix(antigravity): rewrite all Hermes identity variants, not just the legacy sentence The old rule only matched 'You are Hermes Agent, an intelligent AI assistant created by Nous Research.' Current Hermes versions use 'You are Hermes Agent, built by Nous Research.' and similar variants, which slipped through and got a fake 429 RESOURCE_EXHAUSTED. Replace every variant with a neutral 'You are an AI assistant.' identity. Co-Authored-By: Claude Code --- open-sse/config/appConstants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 62ddfb6b..ed627535 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -178,7 +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: /You are Hermes(?: Agent)?(?:,\s*(?:an intelligent AI assistant|an AI assistant|an AI agent))?(?:,?\s*(?:built|created)\s+by\s+Nous Research)?\./gi, to: "You are an AI assistant." }, // 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 001b2928769080f0de11572e9342b08017f833eb Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 23 Sep 2026 11:46:13 +0700 Subject: [PATCH 04/38] fix(dashboard): replace Hermes icon with official Nous Research logo Co-Authored-By: Claude Code --- public/providers/hermes.png | Bin 23273 -> 14115 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/providers/hermes.png b/public/providers/hermes.png index d108d0c38d5b197feb906e0a0c6b8bdd377f106c..1854f967f74d153b49ce3da72e4f88c6c516f2b6 100644 GIT binary patch literal 14115 zcmYM5V|XM@+lFW2jkB?B+qP}n_QuJ^wza{=#%5#NHa516cb-4r_oKRcW~!(A=<2$0 z-DgKCDM%u~;=lp`00e0%F%?iB_+Nv80&V*RjK@J8&{aiJ1W+@DcLMq#X09b|AukW0 z0qw&8fZ^5vi2sIw8V;xd0N@2c063@z{`Xx0*#G?+SOEV2`~MA;ii7Y3061c$#e~(o zfET&Y9cqIQ!_X)w=x8M?C1q4mQ7{z}Mlq)j6EO{CB9izEiX ztVn_p#Fdn&F@(etNnl~7zMlA>CNDSNE-%~PJhuDEzV2Smw>bQ!a=7g+vbc^E7O%c` z1DYVS%C`KI_DB@Wj*MpOAGRYPa=;D#)o$-DUZN+@`y>I)qvVQIHfApfA$ zpKbB5?P)t;TRxLzQ2xy=EBoa|j?;1q?`e{A`_EPP*Vc31K^iJp)t_Iss}-rX+g+XO zMj;4zzOxv*cDsK)Hsz+z+R-(%h?_F^_EAWm=E%#%KgW??E(MW+SpyPQ3r&XpFD2PL z?iIM44mr527BN0=7p*wCz*5yzTa_ zn@blB;|vbr_#RGY)vX4XKE&oQwO_6YyMj=_y&~V>lS@ZwNSSBh_86rw*XY z@f2KtvUGUj_wYQcX?I}AT^qUXm;6b{2lg}0@cBZ7Q`_vFH1<9;8sjgAZIiREV`sCj z&qXsn86TW4Mbx!p>?JgzR}cT)1nUr-StKSBtIwm|({#Y+yubZg6Z6lvhuVOP*N3xo z+TnojBy8#j443PzwQsZW{%EyFESSi#mQw$4Z;5g4;#%+peE;}Jn|}%`2QsZ$ z$9Qge4R^##E= zo9!HwW;>kLbROb(-aGpLW8nJoeR>xBR8FwqMnZl?A(dD`64>p7V|0V3DPYug|9PuWy>^sNIAoFpHYa@M1zd7>ALaS{m^>;p zQg3UqSsg>dYyU?w$7je_vS<8~>={8lT|#a1IG{dL|rjWO!E3eSNI)H;Mg)-hiw zQ})mId%Pj}Qn!76wt|1k<#E47;=flKX~Fou&=j4|=iP9%+Gx2rHrL_p8Zd~iiIqxx zCt_1)mk{Ve#|lB@x|6IZa03nne{Hf+v9way7_H9^{)Vc^H#?m#xILz{f~|aXZTxy~ z-KAM;piaMB>;FK@s4pth6&nwMW|Bz+z-H9{J0P3)XtGkV)-%Y%fAOKfdtpYJdy6It zhOCy=Q;~KvEcjWEDJt7i@0(5!W`O7D#0_SXk(9gJkMaYy2BsASKiEnq^;-6f!AXV4rswrq zxBc7aM_d)9BX30k=iq`LCyFJeUaU*lFG)cV`hVS=J-1#s=lS=$&AOx4kwmJP9b=ul zKQ0_|Gv>6q#u2~aAOlHq`G35U#`DeL``yph=&{>bHObT}5v7O$XK=4WU<*0?pEjRN za#2LYS@>K&$kX+b05 z{gD5uwfkd|W_u-|u!#MD$N684{kjyh7&ph76{#u#=zNlwJ0VC(Nhfz|WJFa1Qk2r6 z>vG6y^BH;UtL^lE-Bk^$_0ASGSmDTYCLK08JT(p7-KQ7`%lTNy4Ht#fB?1fj+#Rk> zd|7k6PPl#9R+mi^VB1z8Ga%5xKwC75kEqOS_+1IvSwTBezDd}o8-ECc98C9b855YPjO+c`hqJ} z;e>17k=Snp`C`~{%)8vK=;)cgFfz&qr)sz3zKAdG7t*{%~wGY>+|U{ zj`x!3^ctx=_++20KntJO!_B2g=1uQxb8m248=FZDSY+ro+3c{nX5W0EQIr2ngq(W< zErIrL?yR=k;VLb(vp@zW;p$!ReHZDnI9YKC#>zS@Ju`pOEki9CdBm&{J*zF2V`F48 zUvN73IuAvY0V2$Hx6g62?I=%mzCht*N>fFC19TC`JnrZFEvu!!-T1yJ*(0})3+y2 zMZTM#y0Z8UC1nG!B78erK3D9?m!Pfvh|3iB(F$KK+mmmIU>W$FFvfb6=c0j-jdB;^8uR|d3NwMk%2fvTb^|xE~3^K|T<=U7iNymjZ zSbLxiSg*axW!ZvCp4;8W+tX8)XD62tYn>IEmwTz-@J@ry+2XkYhg-nos^vPrb0q03 z?GJ~&4hcDWbY^2QVi)HKO#g=KRZh1r7EGVmA8!=5XW|ysIl?#nA*JNooj#t7TRq*6 zYqkav)-U}0W>R!^N(*9RxHm`!9r6r{w;1`mj9tfjYP>@ri@tZ&ekCBQDCMB}*`?bs zaH+_9!PcF%wqGImwW^rSvAH#fz=`0jNFj`>MGP4sB^Z_Z4;tR7^XDQZJXi0g=XR$9 zC>+6@Us!7XJ`WB(r8l9j!Nch}rT1?HWR*x%Tgp%nh|=Z7=>yX$o0?wq%$tv{hwl@} z+JbIOD^51blULikJ?G^)wfzG=&I~8za-_z}W?aVE+1I(uo;g_%lDAVe^&DbM#FD^6 zqsM^aJRkS-aAkmB4vLA7ACK#dXsn)oSDoLuhm#M)R;j?d57d#?AW)z1n9D?Xo=z)^ z=)d&#CH|Dd0Ii55a6~3;F15JC#TI+sCz(mT=SIILciIROJkJg1nsOYhhm+rXIlpBbu4W2D`1^SQ zQdlDoRu8ZDC;xh{mjM%WZYiR>zEcqP7a<@ex`HeA*3JUT!{Zu`3!O zWBc!nQsy_!GQ#DZwYmz%s%24C(0Z>Pt9!@soKh`!>HD;*cIR%OwUrZl?TiJw3Jn9|`P^{JT>JgJNB1IsD%-hc#+Og9o-%=NPvT~{# z6E+JWMs{gy8ya4a`>vH0#3ZDc;c)|@=VCdxtadZwwvxk;2TJCXuaooN2XdWXu`g@d zuUyt2*Ou^qtQccJh#fE2<}2fC12%)8@vO3hkg=nH$#A!P*E`@6{H-VTvA8^LWeDHX zY$Gw1-*d_Nn_4|beR22N>?oATHf--Rm!s>}Y}I_)%xUl{es$|! zHt_Lg&r2Vgv>nj>@srprXSE-mR$=q@x1832PK(}P9(lS?WCFK^D`-WtS}3Vzb)QyL z(vK%-ypKU+H@jH(1*$k^OtVgLkvH-lgd}oRs)n2VnG5TKCsBslZj9Y3SOg z!fm&EIx>bYX8D4H>ul0SYED3yP@AgtC-`0WLWgY{+e6%5dg4@T8hDq0f^ot5)>t`+ z5NV#b^Trr8Q3m3GZ0d7<&+Y1Mev<6;F%izCP0&-o5%5dK&til%>|WRoo)plQrySFQ za`?SI?p}@4-I;AVtnM0GiwL~q{k9vO;YSwD?PlpDPTMSng5LbF$UO8$z}H8XLE1_V z{+`YrJCeu(9&5x1+=FnVN7H7b0TA|Sr6?YUFyweV1-u*eG+g6*J$e{N zN8<>>y(R~$RAI(2$o0|__tXsSG~2C?@es?UjnbKy{)g!ATSq(rN&&6szugIJu+n-4=Mj@NsTJqk zGV}M!xeVH7a3%P4L0~;WzgDp$3dGHJ3{td@Bz8%*>+1hL|RTt&H157=_3^s0&p_6c~fGJ7B9Shq9v9{Mus+EJa8z%>tHjxMOXDXR^;nw0p!j9FO;Vk-wK~8os1WE)0lXX*>4Qy-i zV>P(+CFo)yQJdCmD*t-B9*s`C)?_s5=N|H*qBD;+6oA_V8uE~#IK0INycbn(3Bz&f z>?bMYlhcSEb^KYs z-?Ebpc?mM#POJw8T}|< zIPu;(R3loTlh3dQz+9AXz==FhJj8BZhbKOZjOOo5ys^H}DojsB1}FkkFj*eraB=9k zfjaY|ncUdyeA8(0jZ(m(9~?rfJ`@&*ABE&Tls@_zqpgilp=)aPRU;@4mYZ(_BEJ!B zUt{Hu<|RPje5;2g14h7olTd~Li_COY zYCG3+!#WqUGx9U@%`@4IMMFwqeE_&`p^kcn)o#BfFZJ5h4E-8YHv*~b9D=~JA{0Gb zJ>-W69K!%VF>~&x@D#l6`B=)FWc#iy^s-_ap9Qyg+GoM@IrWne5F2Tg4&~;BcdUgh zl+&o&_>rY^%uV{adMV9_NQ7#ZseFGkoS#u8Xnu;8ZRl-CSU`$04a%J@M<7n1UkYUA ze=Bkxww_t&$DR(%hp&+?S06)^B>O<8r7NNo`B?=6ET2zB@9UXpo>IAo+m9$4HThP)q$& z#N6C7(}0=Dg!>oC(^E;5)rVS82{0}+>1_q#L!udN zb-aWTSzD{UGN6J5=CbY378x+#STrmhgrm$TnAyop6o{IVHfq92Io3!(xn4JbsZA;J zz5H~316SZ8)`Wk@H;3}@lucBJr%>?SZbos%bVI&z(gLpLLCK)xXn;4${h4mg{+1_- zN+ITte)DJX@AOZA*^u5tBo^(TUC-@F%!nvIHPZ`Hyb0Z;vjac!>nyV~HdR}4>oinM zmyh=CkEw}4DMnzz6O!Pb(=w@eH*%lQSmt;uCTj*%j6+jK6E1jc5IUObv3fz3>2Oyv zMl;P73n_p`hKAMh5^vI{(KVA1eO1KGS}L&IEbn5Y+*Q;<)pBj*ptgbzCC_7}x-^SqSL=GBuM5f4}pLL=`)kjBk`CGl<=-$n~rBJ(i* z?C%Pc=H-eEd(y*x5tf1B`>sYI_e~0lDYtCf`pQPs0z#-p5eyP61Q(nj6$trTZel1F zf#1i0l){Nr9z1;oMt+>|WM?7hcb1>YB#x*OA6^U@R23V{oxqxCAT=csN(bg1Yqlmk z#6lzhcm|eE$Oy3?)#)<^EQatu0Q(V;BVAv~Nx{U!ETc~&OJ0O7R1U#zB>CMJpGQhY zI9hT=)6j3xFG48f4tp=6l{g@`7>b9-T= za}?gM4oE48=3NS$Nlb^c+Pvm@y>L`iBriEAO=suBXLdVooLkv7xZIFA^%WDApf?x! z7lGj#Ilw8_W)f%;-iF^U{klp$nm0rx}EplVYkghg^*^;n4k{;wmIi(nghQwgk+ZK^;Sbh@|?b_6ur5fk$& zjsC&JLM?;Ats=g&nuqV!$oDk6xKfbn03k%Ly3DnYrnQ7}P3J{W#G_u3#z`{;68th# z>vF`bk<(mfzhUY*`Ex5zz7FO5r3)$t%M_HyDdgd-f!5@gIlVtEz!_# zPA4=&g+>P8^5k}n$9v4;bW5G|fywd(o`UhYwA*bAN~q`?^+cark>>ur*%SHpHglLDmE{iu240v&oR3MQ8Ula@E z$?PcuC>K3k1>{)&+Y8;xE17jBmw4x`(7itf2~>6K^@@gV3Y{r$@E(#_1S8t_8&#{Y zhZ+h;g0y52KgCGMi%Lgc$6vDK8Fd;2>i}xaVm8fqJME3n>8m0 zikkN8u2EIE9w|V&1f;PLA#|`_=F$nXhL1)7W?5+I-CIKAM;DZ%MW*HhW0P-EE&bk= zCGk(X)Q)xsqsLtf?FbTtSX-f-gyL$lZ4yJ%Kw5{%Py4BB)t4tqU?}{0525c&ZwoT2 zbP(HfB!ZOCZuH@#rkwBe=Lm_q-omQa-_(ts#y(BM$wU{+AmxH8XA!QAsmgEI$VA)B zX1-hAN0G{B>Pk@On^jTYpkkhfEt@V806y7K-0P~)Jihd$@MJJWOaub{NWntFTlj)g zhnK?yeiQF+d&Yk#@k|I>z)E95x@~WZkMX2Q0#TB4pDXhide`z_8pZ*oDO@35X!Jgn zsG2-_3oLkTqlpYFV%xO38zv=T5rBNMtBxFp zu4(hRY_ptHzGn<*o6052|FErTTY7}=*@)D2(?@?XfacxR^1f&eV4mWRwKde7ILU8% z+&M^>Tyr42vT}xrAuZMIh`b$d6FL<6S@WS+2@A>SvG_|#vr_F-3xA4aMh); z@;Hn#jRws!B&lSV3S%~m3PiL6==NZSV=(+lDJs62H9ed8WnjDeZf4))yJ8%ExK*Ce zau!Y{+RGbj696hgJO_NiI>pb7u+rX*SX=m^Pj11 z{h=Jg(@*j5$dieB)1%~6e|Ara3K&IPWi~t_4bBo(ktCn?`T5i?N2gkdK=ctIz$_NxI{of znr|8p0@YEbU_tZv{3fnXm=tlp-vrA;M0}zWu58}tmP4Y00aGG=%!fP`mlL}yhPt4R z*jn>^zjcde6p00LCs~fC za6MZ5ZoD+@AnCWk5}I%qZ^OT70$(Do6;7j*-okcFxt!zPU3bITRpQeG6RcS&`3Rn)TzkPz`q^`3|;sq}aTO z$7|2#*cB`y}4Tn>BH;QUcR$1wcHEA*pb1;As>WZT6p+UWX3LN2RQXwX} z_8&wiO=s*d0wZFm?>qFw{Fe#uD| zOGdUy^W8?CCN;p@_4pXAF((GhQ)Vlufdj{OfKlrChf!Z5;o@+(B6Kl?!vsDtZ;Q17 z=C-fgE%N-h0>AbCSq~-1)kda$(xw_*&^cMjXM@><+x_7*-NG}?*8J5^1uY?SqmEDp z2DZQeWrjN?*hO#!Lth~DG?iri2m~~EY6_Tr)_8OsbQyal4Bgr=XmH#;CaO2AWCfet zte_xtl`&BK)q20~5=IUjAaBKFV<4HHl_WG>;)m6DxelL0*E@N=h9m5A{S)@ITI00> zp?Pk{j6;D2OFOZ1&@-&UR~S@{h64{oUInv^4vj0BF`kOz$ycakT&V3&ZtG2cM+Qj7 zyDw?C)BQt6!@#_kDeOfC)H9aZ?=J1`xpqNh%@u;}YFd+@;^xa^`NIh5?!H8O=^_p& zax+pI@Y#hB_ng;SWM_z4RO?jRgp>1w+_c22@rhB_3Y*R6do69=@E-b@3(_9_tb$R5 ziaNMDtOP@}sf?|U zhwF~r)fNOb0pzEuFWPHf!QM@JQnq#}s&dZPs1FC!D}*Q%ZFoJR5r*V502pLo3JA!xm$WsB=D4g$GZqSjXt!) zhB4m|o@TrfWhtyfxNg2ut{mMJ?-+X%C)IISYg~<9CdR}fS}7$&ctU}yoSo)A4kEDq z=D?VkXR2+H@xDHrlxI7|qg%ri8|T zBs=IpYCx%pMh}#itj%s8m%h6VZynYN3=Kk{Wr~KeZO?cgle03 zWNk?CT|X4^joZGG>KRka^HY;gLZ-gp8JxN#05%vS!J8SN3Lue%*h*91_CCTN3jNph zFiM$If_nlfk`pEtT)xKFP;U=_gdvh(S@UKy)V{#{ z)3fC@>KEcP1EGbrTCOU&&WHt_^kO*8>FIC|ehnE!BDPGz9yexi6~GqwfWCa1Or49` z;!i5zT}!n5M8@Qla29>RS{L_dp^!{e2Un*k>{lR+yaQ(sgT2sGsI=0K(%v(7JX2D7 zi}QDN)8&r-QjR;h!J5tXOABl$1!x^IY6BxxQ(p-;=_FN-wgUts1b{jD%Ht3T6miRg zlR+$TQ2^!lXicEbz!8E0#+)%;K`Ry>Qj@z#plW`eUV97k!;$9q-*JMUUU!eHhH!%U z{;&Bhb}`9Ak}+QO24?hmj%)n3=X=O4t?}J(Pu0y^35`${K<@9=!U;p*3DEYfL#HRd zb}&^8KsZ9YYV<9ra$M3?du9%aNIaNa+Bk1CQ=ofMU@<~$7Plx;+x;2#at%2*Wc-bZ zo$gPMp~6C=jSzHCd>t1XVu@HA2y2)^ETn11Hsh-{dD_QE4zw+EWZ{DeOrl0uVfQ<$ zZpSTxNsR&j49y7NrI%TxBnW?lDgi>u-Zh^C&lzZtobl^A z>MyW+_TUCeTsok>ua*cjG@Un!{Y$lB;wZEGjZi=XITC;ejN%sU$$^TmFw2vRw)&dm zm=tT|))=dHDHKL7$cW2ry8>YXEbxP*5eUDzFHElm30hBtz(siztIW2sUrKmNH=@F> zbH>BVdEg4_R&8rLZR6s`zZZFBJY(<@fse1x_l!x1q5z}C zgHjZO7%(NJG__bP=DsH5fCO1i0+AK)K5cLBBt!8v#UXZ(0(@mDLAS63DqfT>eTZ8k zgtDEw#u6$HqiIdx<5L6NU9HB=3{WW)oHp4JinS4=a|aXTNI!Fcz_G@4)gTo((jGP6 zmsr##D?y=X3C#tt$#*Nqj5wrJhT&+6KiQb7n_+B?dM9pDT!AEISm?j-xl}0RRi!qt zRWL*3tmgsm^Htgh7gRdXU^6cYEFU=&9+vRr6OAQ-7mM0>u=|p}&JCJ0)h^xUi-3kF z!1z3Hpdo^gnzNOPZ>WZXN1C39TUCe|f(VK-8$%QjqZF}%=aUaF+MZsC!4L#cKI&&e zAb8KXaL1)ls=%JHFn4YmpyzY3p-a_y=n_`ZPBO6h5p@(+$?-c5tkMPurB={YXCl(x z>UNTpRBB3=YIYRXv6`7GPuuRXIgxf0`lNS~uh|S~$gPA1#EMTiadrD7jArEOqlc5j z;FRh33oxI!Xx+|(S*lYDR8B|B{|-6Af7$xw-}c5X2HYFpa3N&bs`C_5vYD+{T2Qec zVAGM4Bf}>HDq!!H<&kwHt_udXRJHR*lK`BxCnB@zHL(dQ@s85axfl@JS`$vH+h?@r z6~nUm-^O)lPR+3Lxce=Cck!-LN}%g548`hNJIi#6#=w*1$HlHo&+X0j3fpG9D|owc z)O0GW2j}(IXo)G6eaKJyn*n`bDwZjf9|7gjA82>)9~a+No-OE69X1%XVAA;=z;GrPD7BG#Ot~CT_K=|0Qlu zO8|L7f~bTO*9g6%d)fJvmlHQ=Kv5>F3_<0NtdgR15r9=y&Yrpane`cl;yp%9iL0s> z0dcY`+;ngF2f8nQQev7>;40tvMO0-JeWSm+!*l=^4HOj9N$^Mv9AvP=X0T zBQ>1emInAJE%$>s`7`{M3F>+f;u}NPvxyJGdvs}{4vY|aqGm{k-qgl$#Wo#TOC8Y^ zV)DM123rJ?@V)Gn6%Ci99GY%P9pMbf_*m#J@BYKwJDj(?KO(+ZS$SVRjzjs$haZSP z!v#oA9*AbPAhugeourLkm6+?hLxifwg51#y-cgkjEFo4a02DWa2%AjQOBm{Ht9-KJ zhDRgA>(dhBSvlsbvI|I*Lo(F8OjGkSxbt>a71`oPm7vzk`sQ4Pu=Dp#&uIKZbL7PS z@|EcDgWU=H8|7fEbJO^O&>A;66^rZy!7@8SU@Xq1=5R^`XXgu8IOI|&tU%GpUMVjubm2w zrN29Qwrm4*4JtqSYMmT$x=!4tblwh;yXymz=%^H`s%H7Gn%M12Z|~Q=kMwejn$(7qD_I+a}bYy zuf&)Jhh!)|lrDy7jYP=sg~|-_@DOrsfMmQWnO(lm{#R!v7}`23(-lapATgnTFw$%! zTMz;8_-0Abt;a(cu^!60Ss)@GuJVx{PK@6DJ*$E1wbg%nyvY9BZ&e+r?$!IwZPWv} z6P6HTd`RnUx>)PpAh`Q zWq{J+DO@vUYW{B9zro7M5-^=ToLD(VT2c9e1M%QcpYuf#7C%L5a34*w?L3U%LTOa* zHTvW{miVr*ZRwRTmCM<5TpQ=V=s_zhOG{+;JQPF-piJ<8Il$a))`vp|RE5tEayKgG zmCXai;a2rbtOQ^v@($p8s-~ z3s07*F#0wTMP9b)OFRvtDy!+a*xx~=bB3t>)Re}YKX`eorSkg>9%t17^g8JqZn~^K z*7@S&P@c~Pu;~KK>FzP#(6(R|_-@1meV-$K*qfB8Nw4~UyguBmO^!LXyjZk(?q=65 z*ydF|2>8|xsblalCQhBQxg9I5Pq5hj{f{Hl>DbfMcVC2}H(k0#z6812mcJdJL%0Si zGP5qDYIeI|sG#(n@~Cf&bi6B9GwR#VpB%(P3eenbM;a*S!x&dh<2UVp@F+t4{R5K&pRH#Y zF+~QbIT9*}+F1StXqbBe5tcrykDYO5qsB#_{#*CPBVgs`&WnFOBx4XDQwo_%YU+ z#CYq53kIBGCSibYUAmOMB3=I1JG}Hs0fjGpt{VklTYZzEidp?+?dG6n~jeUIycU%yY`;pu(stn zRu@9vg>qqt@Ll6z6Tacqdpt7P7*Q9$Tc4ya`**p=o%>vNE}u>G=;WqW$(2ke@KMPF za_5XA@DlVMp{L8r*zyTgeDMtlE6&Up>B`m%!wIt9G`<5xz(;%Q03l^mD_l-HLX(p5 z*%9mx8(xCGD^85bMShi)%uU8k7Z*Nr9wkm+_Gff}MXh^z*k3=MWJOe?>Eu9EB8%IPD}mk#!nl>S%x?*zYpjHqBgl}VPVx;&u z=4H|j{ws}?lWco=e;(3MH`gfBNQBtk5`;!HS!Y3G>SP3-^A)hoYC{`96;-6uYcGZU zSn^iTVC3?{y7LnF9c2^#!-YNw5UNH*1pJjd^)fjSImC+iWCe*rZEiVZWv8$72e~wf zHmg37loTZ#HV?!=w(&KMEU<3(7ajj(1TBlB?_`pYBKY2v=^XJ9S!DUzV^GsTOhk3D zzTKz+@O+?ZN75g!GK;jPF$7Xclp-^DuredQE(IF)PC2zBtH6aIz=@O)SeZt=I{0Xr zN`f?B|1*E=?YQCR-1un9aTl>fzz7m#ipNHi?|#l{^W_W=1g`)YvKZIrGjNHT$7rmN z`Bpga`riSF_;!DsHFjT$N4!|AOdtNN3B@s>%)lZPGq@&tU(zayrSABTaXT=gVSkf0 zh^zZNwXH$PBslgw-fX9dIX=+TGk)Mct7^1KeVdad{g6~!o`6wWVY-1Lp}ctbclGy- z0qG?5R5{h^V7d{2>p zz=@j$TMZh0_FbYg6*K#Qv$xS?QyDze%#g#W&XK`{i*69}LEOJB{dcgwbFgfAMR8`v znji+PjWY@lJczU%W00_y()|qdpD9>>&-L80mI6G$4v}-mJ`?C3DawbGht7?RYrk|m zSc^UojZ!TMHHfRk_Q&^Vu_D-0Ln6gwD=1-JH7tm!E&LgM;Z32uSQAm5$Tl5%*8CZ> zz)Aom7V;ge@U9IEyEJg-#d_H{m<;27o-)_%*JNV{Tu%p*tNKL6kkqPWem({opF!m7 z#?^}Da86%{%ZTiV7p4SOL+tpSnqod%VD9ofc)@i=^Gp*+&<0R(>KHF*HO1^-#;Gq#V zvhR?ZfRT^ga6$)PFza9J#pk!=bwr9%XVj9&xS1VBJpED_(}e1kIr<_=kfgD_8mE{f z@>y)7rE+uTW?)5<@f9T@kw@d5h!yuCCGm;zLc*CwMhDFHCuF?cV=%}x&z*vDI;Cd7 zvma@$k?FKvLCqC4T-x2Nn{uER6>x;%MA#%5>U|{e|3-AW^^oHENICw;Zj&1L&h}kH zV}F4#VAp973-+^9z$_5i((udS&To-E~KsRw^df$tMZ~2?`E{%7% z4o~0`?K4QxA0`X(%HkQ)C*yP+AbFP+`AMDp+sXF&w%d^5*jG%!4IVg$({|};d@ZIq z_bcY@syn|KT8=G3R^aB=C0;9&t)!nbhx!|lQjy9_vZgu;zz%A^-I1(0B{hDCS6t>B z6P{6plt!J-^t#=AKNURR$l6tHioLlH`F*BPlEB++lw(a^0GEf4#5EHJBsjsK6FL96h8wiD zjm_QGAd6IP`7L$%f~mJNyzs{g1(5+ev0ev1PY$|^O#W7gnq9`#E>Owiod|?93H!)P z)(h+@oiK^~jbV9?0K81as1CSX&L;}RWs@8vvOqsE(6`;$)~X-#1}y|N#>G{zlNHNp zkBhF*kO0u9G=C2K6DmwXHZa#OL*XGBSFhRT4okU?NWZmuT(|Ge9ldQG&iGr_`)kga zV$tO}ha?gu^ptCbF2h!t4qMA_{Nz~0_|6`4Es^iK>=(m=@bKO1I72&Mcm$JQ#=*aY z<~z{;QvVkhmufuWF z)PyN~Rtg>Nkd8PxLNUz*DogDun<|mdO4tOaUG)mxZ@6+A(c;)zaIto9koo9}|kp z>oJG!Q3)BpSGiB&)F7jb+>tOhEPNrQfApSJnOp0wAJX|h(8HZDkU|jIUS--wXJ$0I zd9c7&XjJ(HdGGnu%-dKLM06bTlF&qQP8UBc=`ORm?c)CkNf=I){9J2t^R09YNylW2 zhuDv#P(Gs9-JBu#l?!jce^8>U^<*9hP?Icj{`B3}ybZA0ajDf>PGAXskFJMQKXx>; zL9`~;p5YSuLApv=dtirg#1dNrU}NwAn_Cl}pgn+`n;+%0aqosEi#YAM{~3%hr`TEO zmYNdzCpHG4VbH%3g2+R)tX$aNpqCl1JpHNf%JVnGeP0N0FB(i^@(vMnJ@U z`cH02eh=N~nhBBE5gLp45nq&vETS&r*WX-Sf;lAydq_taLbfoS;H!FFH5H0EIw64l z>{L4xq#HzrJm_w~6m>>+EK&VP6Ej^9mi#=S%>Qx0VRK^Dt{sr3HcHI5#*b6VAoN8* z1#>vhRq#g1Uz+-5Q7~Oj2`A0lug+Hrez>po@!K2$?I4*q+Gn&SBH~< z-nx*>Rt)A*YhBwJhtIoaAlyf#i;$dURMbJI)vub0S5Ap*G#X(>`?F&6Or3iFi*k;O b{yLyMENGc#Uj65PFQ`e2D~Q#I7zO_i0DZwS{Z*D+t5!5bN}H z)loYv)u~I#&kXviJE4G_R#dxPyKOH&uJ}wpIgZxbi~+eVcSUo!N+^C>64}(G%Q-Bx z6lo$J@QD22O1UuPUjAC+Vd2maf54}GTU^<4*%~Y0C98ZNzf|Xu!}oy!vE;w!gB%2C z2O)rZGe8y8@d3gV`%@){5=m|>gPe?*goDbGnR)%vl9|Q*DQ{l82CayE;C_|UM-z;I zKgS{5RJyT1&d2nc+ayTgKM~&q&J!}Y)FE%Qep}}vWF}NEn3jCxCfu-1H3~@c@);sl zIGiv!_^HuO?Z|%dP2BX*u!%9yhq73mrq#z{8c)~S#59nFQq*w{5|Ef9#UWaP`0X0# z=ky>mF`*INQ2H*25+3!Z1Cb6I?sL?2`tXX5B0vT%Bknp$$Oyaj$m+MVi=Tj;g(ipH zx!EJ0326O{hZuf*%Z!zd#!7t>ZI7HdIm)>)_GTb|=%J|JF#2s)*~w^K+0y&D^~nl{?GuZPKpHTQT4>I=%hY3)#od#?bd(2 z4kgV%N0_fWJ_QRSzZ~sRt@kQ|;4r{Bf+X`92rte{>zSWcP@X@R(lwJhesHARO-nZY zh@b#FVoky^|MkP(S6-$zo-L4FCYykZo|O-QRwjv8hh!=uTyZEY#q>s~K+(2900^R*z`c3_s(Q>?7`zm zRVB3L@5e31=N-K7D%Pp zYJ2eoY=n+u;k!x`)fe!RgmZ8ggZE6V?0!i(V zGr=(WjP3B&{VPg9IsqHoc+Zi9kWhI3(az*y*7p&$#k1~9jJO6ksBXlTk(438Mza;rvL1E{#p4QXfw)}_zz?VsAm9~ zzZj<2D52C(R3u5LHKK1!+lA;$7oo;%Z`Z;`tPr zDUwlTB}qiY(`3{|S47PVwep?hcomyU|0>y9;Ic$;#%PFE6=mmb7In)jDl#eF$wjE3 zR7@#TDXvwx=v?@(iaA?5b2(RggtrTNm%YM2pq#cWZv8Q1rb;KENlO;fl&mPu%HJsQ z{-bBcZgI^Vgzd;o$$ZLOZPr<+c%1A6@BO38Pe+oEzeo6LuFy=w+=1mKy@7>_8NqUM z;e6J>ENU)%VSG^|ze({d;4hPPd~=dZZnK=H|D(!<;%W>g=0IkjF(l?4)*7ZMCLX6S zo+VzkOtMVav`v;umW{TxHpT|%hIu<$yO{G@8s>cXnBgIP3d0kVEj=F`6ZS9`DR#4) z+C?qZz~&j;(%n4D3EJ7kY)AEzL{1SwX--v-V2{pMHjs^;DWWy~qFzBCR$%oG=Rj8o zOb7$8(88Z-`eD0}9hCv}0eoV^0zKlgSFfq4bzBwrib&`5MQaN)Y=d(84TDvS*Okdt>dneORxv9)j^!L{+#6m^?hWoO z!C^yc5ozcw=slVW)GNtp$(A%T>fJ5%&6158X3Dk?lNvMWrrLVihTcmdCVQ4lL&bB4 z=`^#+)*03veUJ5c7DpB-=BoOxWB5(34H#>-wYE3{m^%!DjQI|O(h$xN(L&uqQ3D`i z#fGhhd4^qk6LILo31Zcl?l${uWU3{&CDt;oqUJHsc*^YM_6wG#T&Dhx!FNrwjk5v; zN0By|ALB!tBK!5T2gy_e&Xs3^OD0P-HkUSxEgCH{t}stZPY;)DE1c)#kI0Yi2mzrz zBe*f7F)`UT+3wkIJYTmC*W-_?Cl6MU516y5)`s}*8SZyoEp9DuwO$I|tZ$8P>94om zRNe%yX>U*;&2Kyh%MSJI9qe=;S?|W5q3>IcJZ%lFWWcq4+kU%#fB7$ZpL=h6-vmqq zumo5HHu|djm;z*j5C!`MRf5*{v2H*+Ep%KJ>^#x6bs{pk&23ms0#CiVWBZG9cob%yC|GbFT2`*+E46W z4z3MS?Pj1HLs392LS3PNAghu#$KY_-{B4bp0GHS;3^7u05WIcd&%W{BTZ+6Ax0j}n zY-8y2=x>C=AjOuNOmlNW_D1%d^H&nIFgTrFpnDhh`PoAjmD0>xrP+&CiPnku3Z)9e zC9;{HlwWePc|tXxW9(*HIet8OKSe!tj_f#eu&b(0q@Gc~X)0!NHF`NEF}afPYg&4W zhk7>I=$DKvYc{vFL&k!R28ngfE{E|{qK$g0RR?aIY%JUUNiV?%dT`V(*-)TY&U^Sf zf+9*Werhp`G-H)<`^Npyd=5G&+B+ji4pHhX_q@|#XBvWuy@_>N)TB(t2KR{9bN~mQ zW8jg>RKfH__LF9w;?8(~%N@!Q8!f#Cy=z}VPJnMY#TJ<^HJ=#p>Q^5);bJCV&mS~o16a5p|f7L5ByDxvao4h~*#CNV zZoADuorF~xmQ9NQliq3iB-E!)?>)+8`>G`Yc z)S4jeyoub>>6A&pDoI^-Nv2zNC>2#E=wlIKA{iIs@#GJN+x_ zDNsijq4B+w?{Mj(<@4;FBTg?(Q)%M~`XLjODDnAs{l(8&b<~#$;<&DbjAt51Qx z=AS)ZpI>BxO%3}qRf8!Y(p5htr*67oTc-Qb@hlh}yriApBx=La3as1{(JuXOoF=-Z zUiWKZ#+m>-0}< ziMHmDanXhKmk_iGtI;gc-T7)lFx;e=TE1uYUCNJ}B_B4!F z^t477C)OZ_%>=>*U<^SMPz5qM7@n-b7najW?Z&%SNSv+Ciuf9biit74^G)L&3v)k@ zCcf}eewE=xKWD=qZaOCNT!r#MKt+(}w-s;#%4KVLUt5JfwTAF+EdO-RY$yZ|J$hU+ z#C7z)1@OwhGc0qR@bvzroBKF^J1x2BKbJ-F6+cEk9_bas7BSb%8S>7+|B-!E1OK8) zse7V`kmjE)UCn!O_Yx|cg4XYNpLC%<+D-P{^>y>+$H)2gmyFx{a74Jf>B)L-?K4&B zh7XO*d@+H*W+AnK;C`i1pvOI@W4gwZ->1Cs(Y$L5oqam;@eUey#}&p_Y{tFGYSrR> z{qQK+cmq?F`*n+(jgR)Lg8-=@rtk$>YR;q3c4SfgH_M}Y8me1oOAsSiZ(H*Ph`!f%0CjE00@#iv!q@N5tskE-`wEveov&SS@+W(z z?1Jqp;sh5hP=?YEIybng)8&C4G)b`Ye_FD)tY%R%nUFgY49uqavtVAGTH@j}md{uM zZm~~Rlri@!sXGoZ_fs9PayJQme*zWKkay*WhXb75xEbUP1BOAPpg03NyN_Y9VI8(^ z#v%CviRLl)(U4l5tL+VE54}9m?VB*Ota5F>s2YQTk*j85r(?~zO>uxeK0^>}8j}9b82LD0iSE+Bm9y?ph!2qr{LWVL$ zWt~SO9zF-Vp|Y%#3)jjr!#JbHjeZ$7Z8+3mxG|lQHyk%i64+OXIV&cNF&ov$(c;Ca z`zw{ZE=pKimcg48T>}3Dii%@iw$OC+r!~s##tP1Q+}ZTQv8x@OmM0e)No*9e6Q?j) z320#fObHL3|9q{=@{d92P7}ehc@7kvJ|>bRsBEURb36C2<>C3iX&{ZHhEbWE)$`Mt zR21ES5oRwEV&%(5f%Jfvjg#<@q?(>>sQ6l`gH|Te=@LX*8j!8PyJoPjdK{kG$V*W2 z{dvQ-ZRFq~n6gqr6(A1s(>-dKBr{4tq) zpR`u4VO})tYq}{L8?&!nn1BN={}kY-z?S15SJ)W*XmTISY@VITghXb6?zAHb0chHD zN5QI+P^@gOG^ZB=`1V1esvH<6t$R(k_w?sk4XxamC_bxBTf?rYW)}uT7sME7gs7v< zj9RObI`4rAI3xPb)jvt@=nRk6YXdkmA`d|^X>$AmhoW|Hmo>Y@ApTw-Fb2KH(0S8{ zoU7>V+PJWTuuQPMm>iEc|J1I-W= zAR2xB;WI=8LJ>h8K!;+$9l*^P1o5eAsbW}tTaZc}%bJdm8a4h1>~yu7^Ptw|i9;rm z8EF{L#g}QA9 zp+)R;AGWSvhy=cdE5$C#-LJ;O4!i@bZxU@B6y|f7sh__MWcTKB4{agRUjr)tz%=fsEDMcVY-`i8m1qZh&n%r zP*w=#HVart8Ji8j9ez@BXO#vYYX|c8CV}1M<a^2Ei8`T@NY{aoo{ zuWtWH)}*LpJrWtHl>8H9+}(N>Qv}UpIh6k=*o8O+NDuK)Tx~flQFIY-3@A(^V$vfi zVj4&X>Pck~q0uA7(i$Nt{%`4bDA6PiPB1ihYFwOVHAPkuy)$&JKF0$h(tTL`)mvc% zy_8r1mt6cxLJVMtw9!d+f#~2`@CE;-NUX66o`PDbF=xUW{R4r-#Y`BSEMb_|@$FMp@-vC=+KD46hp)`5=0p! zz^8mN4G2pE;G}vAaCFz?13ENlz~GfcfAj<|@)lyT?hn*(o#}`@;mE@(CtQ_bvE(h3!_hA{0j*XMRa{7_{pIy6y3T+9!)f)m3^05y zrp>=(Fv(c)#yDM_ES_QAQn3o+HZ5Y|?;WCpa{}_`GD_*PZ>~4~(5SIa^p1UOMI-wn zT*}!80pOf6fhiZ!N?q~dr-|^r za|Hq>CRZfhjFJRD3B1omC3Cz5!BTof#1Avp>m=STd&Tz`@t9c$+F$)bB5Oxg1&4D0 z-hxlw1XgkE)Yl6saVjjHmGH3!kjV6Gn`m4}?pv3v_<-!|9-WYilp zIJ2Wy_e>kQ@;BMap*Ue3{EubQKcs zULP@iI%V9vy{~6@mmQ zdV)Ji0{wMi68NuVdE@{7q!dZ*e6)LT9qHDM)gmyud!R*r?Yp{r9w`}UnJL$0v(=~Y zDxf(HDY|i7*Xyu;O%^zGpFIkD>=l-_dqczAuLvxD_DzB1D%ruNlO7zT}jO?5&Y*h60ob>cojm_u(O~KmM$lS#J|Ez#_ z0x$QiK>nW^oXo9_9i8;8?f$0@2PX^5|5FKLp7M9d3_$uv$Jou>gj2}Y%GN>APT$ZN zz<`heEb%QQ{NIGucD6RgHcpO0wl*f_rp^xfPUf~Y09smFi`zV-Zx-c0tjM=DSrZdS zV*s$PhfqpLSS|Q3+c)%Y%Q)JaIJxRO7z50loa`Jq>FCU@^-Ybx zlewWKt*wJ;=L}xfH~X*Cl+2v14LCW4Z4I6O`4CP{xo^}Fz)Qu_3a9 z5PVztx5NDxqG;}63}9iRXJ%N4BeeP-RVXV+0BGsVj2*taGOdN3=^`Lx@EZZLb&yd4 zn#p%f2LQOGC4>c(+*U8T%xkSBT+koAsT{8yr?WP_9lNmG4^OKvPxCzFTnm~DlQk@w zYDI&v{Rs&pe+JR(*ZR=|fszthed@vBx~Me^Kw81lK!F1L$Hi*Uloe%Hv|DDMEqc~$ zx*a)AZ*n<$f5yFKdR}E@#D4fZ8!L5kK5aHQnjVg84YY*p#}SL!b!@bow0Ho%d~I8x z>>}2zf!B9kG5{^D8>GE7HvgAJSMKvm4;O*}C`2$YKy=`DCj1|DlgW&W zLF6Gw^&Y7oI{_q^z=4MaY*CO9xVh?`nSx81tkQ`^+d%NHWB_mtb?A;mM`MECK0=AP zcRKI}ekK^`d_s`?W2d(2Hm@KYkV#Ovw_!S0LLt!zW<)kN7Ay*W&}}HjAB31ty`x@^ zsHNu0K|mHzAe8FFp#2IR^OsH^)3K;l?6{yqv?V5;#%#ZgLZu2Rg8KwZE%`5;6t%&t zg+%DqKqdp=zzYxsS5!#Ws?9UXkep0V;)2ADU;_K}oT}z2H0GrgQskqjz1{^`N~@xg z6&fxj*xGz4!jOT(`t0I{*QGmdP?L!|R)h#m9IJ8J*-I@Y{6GTK*)F;d1|Yi6G~s1R z!V0mPn=5i&+LpH1OAtCOp9gZ-=MF0~aLKo?+snTeYjmD3=q?wCyl*`(-Hrwd%ro?t zcyCC+X8m7t-XoTB+*wo^kW*PtK4NG-wy*dS+pU6??P%E|X;>Jp8 zKYiq|Kf_&d{*lpCBmYHLRy{!X6$+E{(oX{Jb=!f^bygLTK$^gojx8XdL~j*&GynBG z`Qq$#ys=2|Jl)a0fE{3`NY;zHNH{!Zq_BjR3M7z(G}ZXl+o9@vKe81)1r_<*ZSRLJMsb?z2>8k z5Co6+y=o@w{kEY@1G@H)%gIGdq|f^Z{(uoIvJ>GCQ|hIj`v}v|o0%LeSmLPInbIvq z!jem_?w4v=pIaX*8={bvU$`)VibvhIS3YNRxAgBNTlTwqIjuNraQ#!~G<{*cO}}14 zzs?t?Tix&T4Ea6{uX>G8Oj?PcCIGPvT9Hq<1mE2RXPDfGlDyVh!|L+fO2)kM$H*FI zJF*OJ)TXg@G}-*&@ERy09&~JsLV1PP3|=w(8W)OHq-%46x?|MGPjMyGjU;poyP3M3 z#;F9YxZ~Bzs2d|r)>KVk*ff|nc{Uz2l>bQd3*;K|wMx86J-r#&_`E`WvbYmE>r%84v0ag=VPn0}%?~VlMLCZ#;3o&Fw$; zZRZC=C%Ze2druqT?WWD|Ex6GiW0Q>lFO8fOsj|9|-W-o#GhSZJH6^t;Om?#J-ZjD0 zba94+k%|}Vte2T-za4{@5sQZ;Uf(yA%w%eUQuR0Lc98S6<9$ zC(B4+n!6;GNkE#+aJ$|-Bye4Z5eaqZc8vEvMj^=^DR_o0 zxz0K-Y7h|pbW3HDG2)|WAUWHh4xy@UoHfHHt- zde6F>eki3EE_x-QS&9?Y_RDQ|NP_F3O>|%bOJP(gr~v!GF{K@|0TmD+kr;$|eOh*T z-^MB`0FLAoOL3FEx2Snpn|!hZfst-wXGX+#Cy3t?>^T|^1bC2aIUDUeorMs(EI-m& z=XUOn03AB#Y&Ed$Vv||*y0;TfW)NyR#ID5ziQ;WQjKD%IKPH=Dt?5}&28vBe@#oD+ zpFgP!=k#XaK%PYs66Cj|{O$BM-mrJ0W};d|LP8VIA*qg2zaj%d{4VJr9qaZu@O`%UpKtTC*hm`q~n^J>aT6OoF7~72(mS} zY7R5~>kjKY!n0|HHX2{UBM3BflI5U4I)Ffx#{}QKF|wTWVfp2mFc%DU)Q|M`ZDq6O zBSUm4=;`I+<@P^m9sY#K9*=%29KXjg^NQjVAt5T4P|K*LI3qZUX#=rpxAV+rjUl3x zM1(n~4%D+JjD+*Zf*|LBNI?RAQn*jAB&a)$m&Q2+nDz?vPZmwMtX8WLk5iOrJh#r% zk5p3~iz6saB6uLLS1@1XW+o{qnbw--4HoVV3T=QrMdA;wOpuZH?yLUnU5xJUP7oe9 z+kCz6>wt*irPtCa7b*=Bj$;CbByB%-stKf_hLg!YX9oE`(DK(ItLLsj?2;Y7LJt}0 zk5y;G?X;NaF*e)pF-DBD=Zh_`>+TljiU}gJhY@7J0b_nZ;tA17;FY#CR87{nu6Os( zlY65TBKqD!?d_xT1jAyipvWYF=kAy-Zztutaxl73Ls#!93IzbOMziD58X|p4F3>dv zzNgVa-`njRA_MqE!A)e1_yeKvJs{qLRY$J&BXHjbMH`hUA<^2Np&I{Bona^&TfK<9 zp9hbuk^nKlv6V?jO>?6D7CICNGfI>!`j9Jz&K0jv9J1I$4k47d6$uE@IPN7Ig~~RY zr>hZjpXn9dJEs~rpl@Toj(7rLJ)^lqQ;@M&c-+J{^tJDH?~LHBEr~wP1D-*$hr)vViSe}iY_BrvS`>07Z1IM?ZfG6i!~-Mpr8)$0mPHT zE|bv!)j2q@k-^IdJjiaYeTPU1Ckj{dd%CZwF|zC}%)d$DA=K?Jun1XGQ`99{InRa& zDZ!9-{9}rb?=Lmg-iIx;`Pgz}f?B!+1RU4fBfK8M3B$1H7rQTyX-Bli?ha(+&?F>+ zI#LE;{e!2je6AMj!RJ`@k+4bTV9>*`!dF&GX#^x@xYt!TNb#H%rrg?$&fZ?lUFmj$ zDR0;A))sqoS;LS1x+aYHT142oxXkd)t!?CYn`ZX`O;FU#CgMk1i;85WBFRzfU{#(5 zWwfK;qkG?hFx-CTqPONJB@fs%yT|yNUdrZK`Rjj~p2d+yY|Yl*IDc9AJC?1v`$$Nv zB!=MTTrGsr@Y0E4K-kJFbW&7IvdU(~N zpENcguZK6|u98kaSug|t8_oB6g96}vp>gNzYUd#oV=3Pa95FAQBoSvO%n&@=ll(qG2fFkGpb`?R@7rReU;M|KDi|zfAcEeQD_=I1bv}4_UREtEf zWI~v#EuCPtlaw&O^|{-MzdH0Q>t`7n)Pn}_F+Z|K>4ct?gjfoHeC1@ML%^6ijcU&w znsp3{V01Ht{f`3$sz&4ke!vw6Y(H?+`4q-EL>WnlJl>j-pbI+a@R3Ivn!bDxylFLl z{ktV}{W;{OF_GocGh-lU;mDQ>0k0a*+v;?%^rU`H{!|?4nGx1+ZB{hZ?{hP#^-y+{ zYPnphDGzlBcxt5|*@pVo-VpY*S%ric1g%t??niTbwiQHi$MNIM_Q0b-ebfogS$wyf z!Cdewvx~>H8Z(}t>=EJ&0m1+xk0Vj}+BEqpYU-=Of{F)Muj}o6hUVECbsaufe?4nPc%JcCo8!GhIb)R1uoG{6Cqj^ zZN2rJ&TFluZ_|G5vC~UrS_cX$-$9d;(e1cIe836K`X(nBu_*+L;?|At65bV1Bq6I)MG{BRMf zBR>f;8BK?5yRF+>eB11{JIv>MI$d^EE%Ulg!zoakX1#NI{KDi<3#1PvA)+6Yk!iya zL6u14FR1S2IzrCWOQ9&IG)zCs3g$MzWP2%E!hvx7%d(9aQ1(d#G=3grVi9weix1qSx(qPGr~oVmc%G^CIh!kFTUeVhOJ{T%jep{jz0x8$ z3F=#}Ra34_y5nvTuX+8ElYT*4ZC){rr|sAMc{k zeG6QVOf*s@kB6e3UvpZ|dcJ&M&(Qjap94oTUxz3ei+>LJ$;Kqse9pZ?dk$_u?02{B zq+QuVO6U$0O?COa>n`x(hMJKBROK+rXJKmC_`c3e*UWvMh^b;WTjpG%QU!V3F8A+q zzD}tsMRk4L&!Pv2A8d9kZD1HDXkhnBdOB}TW^xE%EyrMV<2%cr?_t&gi&i^$iqp8^ z%S&`HzutRz+U8wsGtK7~W4J*@S^2K(I=&~9<}46I?$4^PkHwvS@(DQFMWX$&>3h2O zo9i$HyeyfuiVj!kC$?yuuM=PUTg#f~TzD)S%1r8KX5VX`UkDpq@gyewP8NNVta1yH!-*sLf#fUUvzw~_2&9regHi^;CdM4 z{Rpy=o@aCG>Z^so>*`R1W^EV*=fQ}Q5#Kvg+3<&*qUQ|)^^o-XtLpA|eunD`f#(e} zw$DR8H}}TY_^v#Qdldo|i}zl#OUaEy#xx9XJ-MM0-Hlh%9v736z8c>fgN*!(xIB?p zv`bdly{DeLW6djUW@}(5`b~EyJ2&HnLajd;h>dMkLcdb*>bwgw)vtau)ib(XS0f5Z z0*nNL+-)+FiYSYKMHfW)fjog+IZt|Pn>(VZEAiDX6(Iil32q<$QAzo+Z<8u>l4n`c z-EJfXKboqXxf^SA99$?^DafLwA^D3~Fsp(#v)sgcsc+V-(ac`seSQbro{R)ws=ZP- z5NHU2(N4=LM1TbbD~KbM?N%3BvD=Yc8Sbo%plaBD^;|0*cMivW5$-05i!G)+`4It1 z=yN~dy5Y*ri20Mjzs~&D6JJN1V$Jy@nq+O7pw;pQ#1@B!E?%YI8}e{Bl9^Jtzyn6F zCZ^U8_mUO+AjW(CrULgmw zaS$^T?5pEh6qYE*caW4eyf*@{R3fsR?lzSy-Zdp8KbXSUI6*b9y>}}rExO(y(h*BDkvymw4V9y4UPv;18)AXA^|8a!9uHAr;d4KL zos10Gd$N&<&4JXB)3}ZrYA!2n;n!U~z|7g8Ye)3(N|$|}LSwM=sZxrf7(Fkv-l%fc z3`+A51y|QIJHP6R1yvC!%RmAE^k5f3 zl%fUkno4MbK)INhg5caC=>cgy4%KfDGu9et$05)eI0?4yS3t0lDBwb93>R}JFow#g z^+YB!Y;) z5GDL)L1_H)gSXW$KgZJ!Q}`T#9s>UG19fSu3~ejJ3PRtiVfk0?sOqG;W$U4Lx4{Ta zPbYy8OMNaNZ0IUmaFXf!GqljJ*ePk!x2M6)3kpK7VYD?Fny84_*pyq2;%1bwE(KY| zRvL5!d%{ykqMtskceP`;t0kr4u8COx9GkQ=@%He^5S3}Ja~xwO^XF%)H3?q;l`IGz z7X&Lp6l{_6G);(DUd>mpHpI0BpE-nU55cj#h4oV&eDpZiaFQO4p6$g9t;x30b+h-d zZ^)8jQ5)6uc29R8^wMXK9a|sKTy8g7GmN)_$YdRla!%W0Xr?pwv;w_bz^LM6&4;J) z+FOHn$3@hJDmoTnQL9|t-wD3cwC;}s+jvP%%3jl2gU8foaM9ljkY0uKzz%I~(BC7K zh(&Qo0dd)}dYVj0ziA{^2jgl>V_gae0H?|Ra1f${d`m3nyIB^R?TF!(QDdu1}iQhzn5iS6Ya1*Hg-e$Y#Q!iCkM^bA`ZzQXtMNXO@(tO{c$rssCmqg2h^ z?MYKsSXI)YgecRi$eXk=ffc2cQriPX6X);2>q5oJ$*dD6*i_kA*p5HfRq;mIm3w78 znwbz2DheqI+Gtaq&nl8?FK@ ziz*NNg6bLH_li_e9MTI)-%lv665vv%!5EN85(-=HM~In*41pJVDSet>5HL}nBBxaE zwqcpySxo?l1rkJKc3=%c3Ort+n1wa;0E%##WssBsBi>NF&EyiR_DXjY^nZHnWlA6O z+YLdYPEeu;D~%qKU+A!Kbq3lZ9tJb1J&uzxDHS$g%2?)Vm3F$o?*>kpFehy*3BA;M z{_Bdz=WDOH8UDB9@X4Z?C&-{7cO$>8KXw+xNWCitz!T}uk1p##)F#EOLC@=OysB3S!s}0o1DS@1Y0Uh#iAFB>)wMzUjY;%#;Ju;$&d#? zj8zVP*;vqxcN~j|@CV2>VpnXN?v}yoep?ot-~D!-7zEnuvjW1Uxg@knj{00&Gli)@{e=|9ZhSCgMYikT_r=~(2 zazOwov&7bjl|Q+O1&MxzIV|9vBzvc5qYD^C4UtR$IxPg#6P=LqnQS8`C+XeI2pKvk z<{7iBM8G0~1v_5736q2q*85=35J3Z%s+3oT<0Qad7#nIL*~M48{>n5=;BzfX? zC~>>6cRIQ3E%j7hOy_VkT?9x>Pw;1VW!7X;f?ZE~cwW^dHB0BXpE^1CzU~iaq_iVi zJI-ymS}CutXj8jY?qcY$ds-nKv6awux1j(JD=suu!S6NHMEe@=Sl!<5TUv_BXc*n} z0;$~)g)KK#AKK*ph|Rn|58QfQ9g4`9!bu@5p|uHBtEq~*;_}g(bMAQ@#7Svxp0RZy zqm!x#xW7b0c*_8wnDJ(DQfguinab$A2G7hndK|1Xzf29%ro`$G^_K2RQ*$Qkq0d-J z>2mipG;MrNJ7Vy_I#>|&eAyXwGEdd$WLib8&G7n`DI2p*3)9bPt5w=vW&LU!Sc#ZA z!?;V)UGv$QvZu$@fqlwSE4t+#AK!t!nbdgOeYrdzI&9JH?4?)A*m@eZfZXYyKCHlK zE}ov@?b%cxrRX`!Hjc#-ksRTTw2QgW0gXgFu{%(uyMPUSd{4_Vm8s4vSEclw$bwir z*$*Dm)8C1T(46V)R#u*M=9v@+HMTKG*}B^;#QtQ)EGb#iw+peroQ&+=Z0TDw-qb%yw5|ians`nc*d%~;Yf_J!7L6^c3 zecWC`6F04iAxh}mXAmuju7)iZ!$91nH-TBf%_|4Fn>}FXU^R$rCJWy`B?DkVt~4>y z7vbgyQ^JmB_yX$?zf3M&RO30Y-{%|El*8{NKUm6xQ8$lFjMhQ&4=oYu|fu%Aq( zpdte4BggRM5Sb`c*UfI{_>-_4faI7Hx>a$AJ@%@v80_ZA$*Ln$w@I0dAffH&ip?#d zMGiT{DX0;c>0hW9m5rKfS!7_6R9WIY6Z{kPaOQD9_h!h@%aY_TviapFg-J%NZR%Cf zE!azO_w!$xT9QY3S>?c);xf3*<;>Z0b+-;0O40xz2zZ=Qb833UU2zdQnMVzN&|*-b zu?vB3DMfi&QJ`Hba_j3h^dR$E{Yl;=54B7yT*3gtR)w6gxxG_l9{S!S2Bu%LIB@$@ z#Wv8R*%AY=2t@;tyqbRT`2{%ks(otx5fb8kQj~~6c8)?66BtvHymH-PXL4PH}Dg`B_2@i>^$t!)X*F`3hAO$D~r9okg8=q+oY ztwR^AKsNRr(teujHYnTwTTz4y5Ar=e?$Wvy!}-h+sYu4xLkPMafh<|yh95EpO^Fl{ zrio8*MzirFtYfknbpzy@sp=zZbH;?NoEA*Jx^3$`5!K$r5QS_*cjELCpExR02C9@4 zQzMIwbqSAU)JM)+(B{q`LF$|VoiEU^;)iDCI_)9L2PRw5wp>JZ+>kUP>4g3D%;=v0 z{T?Bxr8lko`E5|q0e&X=gfP>qM}suf4aS2&0W^c)Gl8kUtYsI-kfWvBRu$#tXV~Ku zW#-uOa@6XWY?zw|RC03|ia|>S0U&{V?_E#2&cyfCY`-COJ-)+p5p{{HtSkVswj{zv z$8|FVhC<5du3X0_2jM>YK=7}PlR23>2Y1{<(6Yqm7cy4X zVdk^Vx8u{Bfn=>a_7G$d;8he^+iZ?2hTC#Cps>vMSqOA|w+X?stT3ll0A2SS_tW@b zX-AA|OqcV|Cjo2SCn)k8YWgU$=YCZ-QaFjhuf40zw{qQ~NW4GgvY#tk9|yWy8d{MY zSKW(|TnU$+Jr~EiFA*^=I9#*4o0SWeP3QO+AvrG>zxeJ^bnRw$3eyX@Vuo^F)?#A7 zFOOSG$a|v>g$2-Cn~JVp6MEWSa4CQBy%Zq$jI`j5ds2R}qb?9tzTfbDW$=Af;5hS) zznmINv^ReUZFzp>yv&B)9i{?}(wWmq%WE+}V zFG*z#53$z*1TW=Miiuj+ZOeBTtRmPEGp$35FNSG4r#WP2nWdL5Bw|#Oh32crE=ZOb zGPLn}CW0IUnamY%n0P#o)0;0=>y9|bv@Z%zR<(QHmgYg#lKWN@SBEpqEe9}blE4Eu z1HXR_uRnJ}*_wC`D@q2tSbjtx%nRWP6rfaAHBKk7*&< zj%P6!0HE3Ge!Frv);9Jsnbv3OjU{J@gpK{)zK6KP^Lh5ZaKqK*Oo~zw6nacXIv&WB zT!DdxwOhBR_Id7t;Yz?~gaf!a>TFS|_%RbLfcPG*p&Hw(AKdE(=C!K${S+3?24TFNk)izai1UbXHYq{4pGA3TUt zHzT7jToFfoURC_uyd{MviM3z=HWju+n|SV+OM=Kpq(#>0H6)8;J?$h6^mKbo_PmEm z?MTDln~z07-7JAtGSjD8bT`# ztNisD_;Gr8wUuv+|_=p zAj(r;$D;jdL_gFkxA8nCW}}acVQJLA|7E={mI}W86EUcu*q3^DzoJiLK{uTf>E~5& z6BfJ*9ZP zQY{wyRtp4W#V&`=vP%U21x zP3mr9`*6Ff(wBlfJ>I&&V9w|G!F)2vaV}YiyJZ~7*=_0*`IxtY0%oEyyo%sgtD&AT zI*!o=^wD0nfT^x!l6QVQZw)l*txdjpTTg%cOmJLE6>i0Ad2CzL9o9M#w0#wFz-Wi6 zvUxu4FRqKn>i6(VX^gQHP;6f}t$ zo3#!UFGl3pv0{n3c~%5;m!tSad!i{4ExRu}Jd+AbaWqfs^9fDd$J9)tWLmm~e<{+$yVPSX1n<=IU{)SL1Fb85*X9c`s$ z`caS+;bl-sIt236J-gX~9D}D+-QomhwBC+TQ7LsGQ6>}hgP7v&@5}V=4qz>GM>n6? zxt^AV$B#+xT5$sb37(-&X2x=%$Bth}5>lycGxw30noI{inpQlIqb=EF(^CpM^K)UR zpGfN7T>+%%CtB4L+JRrKQRT5Uvwr$P2&gW5N{o!p6PKD z-tiY7(?!r7mvA-H?lE;XK28^?nV z&%m4Curu4+b&$eU5N~D&ykjpo3wU{G0ch+exGy}BI<6*#vrV_`{~O^<83TPVSU4z~ zB{*;&aMI&z`{Uh^0ckrOmsq~`%$$F~T_iiNRaXOG>3p{s5xvYuUXsVvdor!^R$@kT z;&qYbg_f&j_S|r(jKOCIFFxVVHSMjfsKo5zFa%&tdwOpihE4Q_Tc?wzzz~{aRNUKO zXkHM+))myRTSgHlu`Q`v#EeqY>qbx?P@w;T)!C$F0S|p}+ljp#|Pyt7txvNppiC#>rxZO(ZSdO^t zetPkqrp~+)bk_jLmQ?=){j3;C4YM<#jazmEO$-4>GudRAZx>6Ac9>o?o6w_ zayZrAA$eLFw7q4&Xyj)c2??+(5L2w=U)Z`RWP6o^(o>L!oacUlNR@<>wIfiB)+9C= zY2%=6cOqPauJGb0jWN zZf3i%7^COx7>Xj;wdpqDB1%*1A#FQ31F6%D$I0JcLYSfq1?flqdJ9|UDT}B{6UH^d z(urm$6vH=Hq>9i~h7-IVpr3>&>kGHVN68p0tY_p>TDv(vfk+XT4+fD%o=D!kN|$@M zCG9bO0>6#z*$!+J7;|evPh-&FAXx|dv2A)*Js*!1OTBc#dc7rY98nF4Og&K<)AZD5 zC0x^m61gpF_qP#>H!oAR+g6O@$yoc*87XC~rN)v8e)8>*RMzhjx< zIYX3gN8u6rG+od99akQfia#8T(+eEWLCMS1$2z<}wrVhQOFl~|eW-rC*w#}-0Ms6R z!)}o&U3QQr#P!jM{N2V^COCe`<~T#|UFR-AxovzEXHMnleC5&PtdqIIUJm!Dr8JHM zNomGle)lg8WcQVSx9gIT$Zd^8z>TlN@uOc`~3i}y^?X=yeQ zFjtwP;!!;{Z%3fbG{bHQ*w)k4GkG>e#hBSr00|KfIsB_ty?_19Z4B;WE0xiZ5F z`Sr!IPZkyu4;rvy`X5ft+iSsB1U2FFQ+&9dnfP(OeC3i5+?LaKt8rWecEoxLombzB zSFNk=Q$p%C%MZ9ZcGcbW<*P`))acM(I&8jG(r~ewp$Oo|E5#`|wc2_W@KI*sm%jIi zxN^GlCrU1GSvCQV@bH;XeIgY|+&OKL4~&Jjv!+(6ocO-fJm8Y2Y3p;fW*Bxs0@9|8 zj8ZqB@m^|k4*1IxAn>ybokeT5>ocpB>|spOg@c&*tDEahpN)^qbGEKKTE||>ki|=v z)%u+YK~}5(7_kP?ll*3ib#rzq=gnHY=W)o6VzdabdFr>v1u#zYeoBO=Ax`sdK!FKf z&o2uK3T|^P-dT#hr`Mj00oTZ9@fUgs?nQ{9CcKb%b~H;dqpSR zzGtz$=wuC9so;Yy3qxw;3ak6%#xwv+0vqDM7P9tr4dN^-cX5908RGr@Ag)L~J4qR< z2~#?FcN4aitWrCJ(Npz{|I0falhSiD(pP1%_TnL5uh`jqf~=n}2ocdLp;xkX2w7Q= zH}BkEoSwH>&i9?z=k4{fXcaX+kl{#v_xj~TSK|EP&rh~)6csaV?qZBaO;$BFKs0#( zF|_OSU@;u^`MF1@^yf7HybM`leq-@GiAaGDq>J+rM)@2!wT}VmPNzw|FRM?ydJJ)2 z<9=1JJ_7&y(cVWP>*c(lb&qX! zP2}P43LxXsiftCp6ft1!tzeYMVe#Wwbz$MCeJy1D_}{iFE{p%cJf>65a0&6xy2}1p zdpH*3&1xFY+b4glr(EAv2)z~swjym#Hn^Y5&Kxs&%HNgV%qqC!9HLn?Nasj-yTCQU z*C?%}&C!$){;56Bx?FS*+iB_^jM;asN);Qrk@md4i6r;A zehSHgC29xYjwi*heCZvgUi5rYAt47gy$?Mx|4g-czsjkq76nt`YxwuVCSs%vV{Y>D zf-&}MJd8h90{G>I%y9-6@5+4xN+CXysN>NC!F=2C{Z;eFFa4bA)M#eeC$f=?dga zBcodO`4O16l*O>%#A)}bgn{)Y6BcMQIP@i@X8o@YgMyk`qvd2}MhZgL%PxWvWNdQ% zN5B3ZmIHX8wz3oW@I}(LF+9_ePOsu5cxxnf)!6x+zmkBjV-l`eP+$ilTC`^G`{TgOqw!+bA7;e}`K-_--l-i`IPN*O#2$Dp0T2_m$U z1y=4D*=H<=5zu*JbJ>p`OZ-|-+pMj9_6>>JuSSv2iD8Ul3LK(TD%y`WU4 zMEn89vT;=qA%`=oz!EET+9rd6R9(QV*4NW_zN8R5VJ}mqOwr`ns+_c}J2OOG{(`;i zz{Xq5tOiOhV#cb{Kxcp`ALBJVos{j+t5_}|hPGxV3OT8UPsV}l{Xsn0u-A%Zp9z-H&sPt-Y1Ppfs-Jn@|dx$ zk9uu7qAyVX^AQp5P`mz2wdCli?;lTk1N<)HjCXp1-QF}gLrLpMB&Vdw=PMuX^DWD5 zF@JWsW(3C=dt%iM$owh(-S>JcH)>0_BKdp}LqD;VqSelxBN53j&d2l=0Lh;*RtE99$ zjt0WdPPc7Z=MA@9L8m z9og(GT>t5}{XK^hQxmg!if`24r*i{4gp+9WdJd+b`=143mbM2L2bbM)31o=h*VO{= zS|x=F3Ow8-S%Q`}JU?L0D$K)fzeU8Fn7-}%TP;)<|6e#`dFf;^a;W2Zq}+nBp~ZOL z4pi}CAo**w##oe0afo!vSM-(E9{o$4ptVVATw%P|<6}_glP%&1VosGpjs>+#0xStt z#$0YPRZbaSAP;$UX9D}r09SifULu^Jak4Hu_Wy_s?Qn5&U*X_~=X~tfGJPMhrxPCu zRax-O?|jGB0<|ILT%-z(pZmrv2&1gPWnd*CU-=e}Rhy8Q58xEHe+uJH^fqY_%5jvO zKV(shN3cR2Lyb?qDdFp@vWDtu^zg-a)mK_^ws?g7+Byzq5^;od%5BBJ{Jv0#iL8L< zvtXr;Nw0<7?EOMI=uIHEBPsIu>u?v_|KrEBtO3d zK#=-vcfe>zzhi2BchpMVV$NMC>Cc8|uc18VZ;vhUtdA~^-^U@{(td%RjS%Foqsho>%<>J{%x!Y; zurKo(aou!%XFBL=o%||dY=RHmaYiy6NYHfH_4@ZWUwTv=8KZNy2#GzSc#RqxPz(c& z!`|gFZ~dPI3xOqG?|6AM%Oa)$i@DnLv5B@`A7pdcAkqYNDrRew^@S%xGc605e4_P# zC9*8O)*M`KH_!CAy%eB$V?4j!`Z4{#l_N2ond2|5AEMqx(6r^xwzi3g#8uAaNeZIU z<2}W4g)7|;rCtPOK;}GI?HWY0E9#4jB>&qNMQ$Y1&c zBTE0)GPBiY3oxB3b~Qjc)yt;ej#=DH;@Heg9-HmS2QzPa%K2(urh_E^Wu9|7=ckkH z=_H24Uq>17u_*xm<2mG*qHqUG@k&^ZjQB(8-0l zU|<)r#>A4oA9k%{1Tkk(sEvL;!vSFM@2d+m!5r81pWUg~Lj^M%u$W*{A#mYIrhnS_ zt!MB3-QuL!M_}NwTl$TLN(9%FYLX3{llI~KTG{U$F>eA7E*BLs+5z@{SEJd2Jw2<$ z*d5EOVZ*l8-d;l7;S@UCtH0!zR7!q8P;3%?CbUXuAGr)IK<%Gx;c=y`PS;MNbQAlq zZ}o$b`jemUgb|32^@ceDb6ak^P9nb)uys1#k@OOFsz{2zapQtzeVTH9<4PV6cr@GR zO^g5L2?&$MG#+#K>)0(%(2f?j8RJztH@*^VETEnNO8G+C+>qYB;(hQMwDTeXQuu`` zg*gr`c#?H++)33uEr$_4JyvOr&N%wUK4E#&!(YP6%4->2EM=y{fV=w3SO4~~_zA~NKf#_06P(OIlPD^1s`N>uUv8`k{(@!pS(B>n z)5Hby++rysNdSVr>k%5yzU{4V`>kt}Y1gzF0F4uF#?=g#=@9YSCbQG^_fm(=!TIoh zV_2kRoKYTrmp;XAkIAeT1@8JRvh7||@u;+iH;aLC8gGgM2w!G&GYANh&?)%*{&YTp zBB7+4)&ALz;&a`lZsOUhBXjqL-0s;1V=PJ58d7S(&%{PIVae413v)ukwY%GhGN066 zi!N)+9x$c_IdVphPx&tT^(3doU7J`L;oUXM#HN+o1r|mNl1~UZk3nDY@F1~d2mgia zMiHG{_Lry!O)fEg=0WY#*TtQL-m1r4E$TM+G_OGos&bOyL%ctH9X+4$Mz^orAtvBf zp>F4!wNbW4yOU`bA4I$I35m`9gY16t=DZq4;wiR^sq>sIJ`}sM;6GuD_WIzv%(3cM z1r0{%c88QcLoK#hZfDER<*F$ezngaCMGepfa@c%Fu=f zp)WQ=D*2jKahnMGyE*U)38NG^j$+)g_0kcor!@PoNHZD&lwpBkInr283o~5_NC!yl zyya{A1Y`}x7LoeG7D*mFwhh?aPcZ>=s`w;-lu2Gzt4QKsd^u^$H)1(gDOu5XERDHE{=l(FKuYW=R9aeaW zDv>IdDAn}}Rmg%CRY|1b^v$rKIRft|e?xotHT?zh^%Nf>Aw4%Xg&9$%Ds>&WzEimq zv^cBHjeztZ|8!F{7wvkX!t6Pet9Ru(UAJobM&R3{9mMV3NG Date: Wed, 23 Sep 2026 11:46:21 +0700 Subject: [PATCH 05/38] feat(hermes): multi-role model config (delegation + auxiliary slots) POST /api/cli-tools/hermes-settings now accepts selections: [{role, model}] and writes model/delegation/auxiliary. blocks into ~/.hermes/config.yaml non-destructively (comments and other keys preserved). GET returns the current delegation/auxiliary config, DELETE removes all 9router-managed blocks. Dashboard card gains a collapsible Model Roles section aligned with the existing rows; the bare `model` payload from the CLI quick setup still works. Co-Authored-By: Claude Code --- .../cli-tools/components/HermesToolCard.js | 88 ++++++++++++++- .../api/cli-tools/hermes-settings/route.js | 104 ++++++++++++++++-- src/shared/constants/cliTools.js | 16 +++ 3 files changed, 193 insertions(+), 15 deletions(-) diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index bf5c25d1..74758980 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -7,8 +7,10 @@ import BaseUrlSelect from "./BaseUrlSelect"; import { rememberEndpoint } from "./cliEndpointPresets"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; const ENDPOINT = "/api/cli-tools/hermes-settings"; +const HERMES_ROLES = CLI_TOOLS.hermes?.roles || []; export default function HermesToolCard({ tool, @@ -32,6 +34,8 @@ export default function HermesToolCard({ const [message, setMessage] = useState(null); const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedModel, setSelectedModel] = useState(""); + const [roleModels, setRoleModels] = useState({}); + const [modalTarget, setModalTarget] = useState("default"); const [modalOpen, setModalOpen] = useState(false); const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); @@ -82,6 +86,12 @@ export default function HermesToolCard({ hasInitializedModel.current = true; const cfg = hermesStatus.settings?.model; if (cfg?.default) setSelectedModel(cfg.default); + const initial = {}; + if (hermesStatus.settings?.delegation?.model) initial.delegation = hermesStatus.settings.delegation.model; + for (const [role, rcfg] of Object.entries(hermesStatus.settings?.auxiliary || {})) { + if (rcfg?.model) initial[role] = rcfg.model; + } + setRoleModels(initial); } }, [hermesStatus]); @@ -126,7 +136,12 @@ export default function HermesToolCard({ body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, - model: selectedModel, + selections: [ + { role: "default", model: selectedModel }, + ...Object.entries(roleModels) + .filter(([, model]) => model?.trim()) + .map(([role, model]) => ({ role, model: model.trim() })), + ], }), }); const data = await res.json(); @@ -154,6 +169,7 @@ export default function HermesToolCard({ if (res.ok) { setMessage({ type: "success", text: "Settings reset successfully!" }); setSelectedModel(""); + setRoleModels({}); checkStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); @@ -166,16 +182,35 @@ export default function HermesToolCard({ }; const handleModelSelect = (model) => { - setSelectedModel(model.value); + if (modalTarget === "default") { + setSelectedModel(model.value); + } else { + setRoleModels((prev) => ({ ...prev, [modalTarget]: model.value })); + } setModalOpen(false); }; + const openModelModal = (target) => { + setModalTarget(target); + setModalOpen(true); + }; + const getManualConfigs = () => { const keyToUse = (selectedApiKey && selectedApiKey.trim()) ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); - const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`; + const base = getEffectiveBaseUrl(); + let yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`; + if (roleModels.delegation?.trim()) { + yamlContent += `delegation:\n model: "${roleModels.delegation.trim()}"\n provider: "custom"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`; + } + const auxRoles = Object.entries(roleModels).filter(([role, model]) => role !== "delegation" && model?.trim()); + if (auxRoles.length > 0) { + yamlContent += `auxiliary:\n${auxRoles.map(([role, model]) => + ` ${role}:\n provider: "custom"\n model: "${model.trim()}"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n` + ).join("")}`; + } const envContent = `OPENAI_API_KEY=${keyToUse}\n`; return [ @@ -274,8 +309,49 @@ export default function HermesToolCard({ setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> {selectedModel && } - + + +
+ + chevron_right + Model Roles (optional) + +
+ {HERMES_ROLES.map((role) => ( +
+ {role.label} + arrow_forward +
+ setRoleModels((prev) => ({ ...prev, [role.id]: e.target.value }))} + placeholder="inherit default" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {roleModels[role.id] && ( + + )} +
+ +
+ ))} +

Empty roles inherit the default model.

+
+
{message && ( @@ -306,10 +382,10 @@ export default function HermesToolCard({ isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} - selectedModel={selectedModel} + selectedModel={modalTarget === "default" ? selectedModel : roleModels[modalTarget] || ""} activeProviders={activeProviders} modelAliases={modelAliases} - title="Select Model for Hermes Agent" + title={`Select Model for Hermes Agent${modalTarget !== "default" ? ` — ${HERMES_ROLES.find((r) => r.id === modalTarget)?.label || modalTarget}` : ""}`} /> )} diff --git a/src/app/api/cli-tools/hermes-settings/route.js b/src/app/api/cli-tools/hermes-settings/route.js index 1636de4e..64844141 100644 --- a/src/app/api/cli-tools/hermes-settings/route.js +++ b/src/app/api/cli-tools/hermes-settings/route.js @@ -18,10 +18,20 @@ const getHermesEnvPath = () => path.join(getHermesDir(), ".env"); // Match top-level "model:" block (until next non-indented, non-empty line) const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m; +const DELEGATION_BLOCK_RE = /^delegation:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m; +// "auxiliary:" block; children are 2-space-indented role keys with 4+-space fields +const AUX_BLOCK_RE = /^auxiliary:[ \t]*\r?\n((?:(?:[ \t]+.*\r?\n?)|(?:[ \t]*\r?\n))*)/m; +const auxRoleRe = (role) => new RegExp(`^ ${role}:[ \\t]*\\r?\\n(?:(?:[ \\t]{4,}.*\\r?\\n?)|(?:[ \\t]*\\r?\\n))*`, "m"); const buildModelBlock = (model, baseUrl) => `model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`; +const buildDelegationBlock = (model, baseUrl) => + `delegation:\n model: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`; + +const buildAuxRoleBlock = (role, model, baseUrl) => + ` ${role}:\n provider: "custom"\n model: "${model}"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`; + // Parse current model block back to fields (best-effort, simple key:value) const parseModelBlock = (yaml) => { const match = yaml.match(MODEL_BLOCK_RE); @@ -44,6 +54,60 @@ const upsertModelBlock = (yaml, newBlock) => { return yaml.length > 0 ? `${newBlock}\n${yaml}` : newBlock; }; +const upsertDelegationBlock = (yaml, newBlock) => { + if (DELEGATION_BLOCK_RE.test(yaml)) return yaml.replace(DELEGATION_BLOCK_RE, newBlock); + return yaml.endsWith("\n") || yaml.length === 0 ? `${yaml}${newBlock}` : `${yaml}\n${newBlock}`; +}; + +const removeDelegationBlock = (yaml) => yaml.replace(DELEGATION_BLOCK_RE, ""); + +const upsertAuxRole = (yaml, role, roleBlock) => { + const re = auxRoleRe(role); + const m = yaml.match(AUX_BLOCK_RE); + if (!m) { + const block = `auxiliary:\n${roleBlock}`; + return yaml.endsWith("\n") || yaml.length === 0 ? `${yaml}${block}` : `${yaml}\n${block}`; + } + const body = re.test(m[1]) ? m[1].replace(re, roleBlock) : `${m[1]}${roleBlock}`; + return yaml.replace(AUX_BLOCK_RE, `auxiliary:\n${body}`); +}; + +const removeAuxRole = (yaml, role) => { + const m = yaml.match(AUX_BLOCK_RE); + if (!m) return yaml; + const body = m[1].replace(auxRoleRe(role), ""); + if (body.trim() === "") return yaml.replace(AUX_BLOCK_RE, ""); + return yaml.replace(AUX_BLOCK_RE, `auxiliary:\n${body}`); +}; + +// role -> { model, provider, base_url } for every entry under "auxiliary:" +const parseAuxRoles = (yaml) => { + const m = yaml.match(AUX_BLOCK_RE); + if (!m) return {}; + const roles = {}; + const subRe = /^ ([A-Za-z0-9_]+):[ \t]*\r?\n((?:(?:[ \t]{4,}.*\r?\n?)|(?:[ \t]*\r?\n))*)/gm; + let sm; + while ((sm = subRe.exec(m[1]))) { + const get = (key) => { + const km = sm[2].match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m")); + return km ? km[1].trim() : null; + }; + roles[sm[1]] = { model: get("model"), provider: get("provider"), base_url: get("base_url") }; + } + return roles; +}; + +const parseDelegationBlock = (yaml) => { + const match = yaml.match(DELEGATION_BLOCK_RE); + if (!match) return null; + const body = match[1] || ""; + const get = (key) => { + const m = body.match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m")); + return m ? m[1].trim() : null; + }; + return { model: get("model"), provider: get("provider"), base_url: get("base_url") }; +}; + const removeModelBlock = (yaml) => yaml.replace(MODEL_BLOCK_RE, "").replace(/^\n+/, ""); // .env helpers — upsert/remove single KEY=VALUE line @@ -107,10 +171,12 @@ export async function GET() { } const yaml = await readConfigYaml(); const model = parseModelBlock(yaml); + const delegation = parseDelegationBlock(yaml); + const auxiliary = parseAuxRoles(yaml); return NextResponse.json({ installed: true, - settings: { model }, - has9Router: has9RouterConfig(model), + settings: { model, delegation, auxiliary }, + has9Router: has9RouterConfig(model) || has9RouterConfig(delegation) || Object.values(auxiliary).some(has9RouterConfig), configPath: getHermesConfigPath(), }); } catch (error) { @@ -121,8 +187,14 @@ export async function GET() { export async function POST(request) { try { - const { baseUrl, apiKey, model } = await request.json(); - if (!baseUrl || !model) { + const { baseUrl, apiKey, model, selections } = await request.json(); + // selections: [{role, model}] — "default" plus any auxiliary/delegation slots. + // Legacy callers (CLI quick setup) send a bare `model` → treat as default role. + const sel = Array.isArray(selections) && selections.some((s) => s?.role && s?.model) + ? selections.filter((s) => s?.role && s?.model) + : model ? [{ role: "default", model }] : []; + const defaultSel = sel.find((s) => s.role === "default"); + if (!baseUrl || !defaultSel) { return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); } @@ -131,9 +203,17 @@ export async function POST(request) { const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - // Update config.yaml — replace/insert model: block, keep everything else - const existingYaml = await readConfigYaml(); - const newYaml = upsertModelBlock(existingYaml, buildModelBlock(model, normalizedBaseUrl)); + // Update config.yaml — upsert each role block, keep everything else + let newYaml = await readConfigYaml(); + for (const { role, model: roleModel } of sel) { + if (role === "default") { + newYaml = upsertModelBlock(newYaml, buildModelBlock(roleModel, normalizedBaseUrl)); + } else if (role === "delegation") { + newYaml = upsertDelegationBlock(newYaml, buildDelegationBlock(roleModel, normalizedBaseUrl)); + } else { + newYaml = upsertAuxRole(newYaml, role, buildAuxRoleBlock(role, roleModel, normalizedBaseUrl)); + } + } await fs.writeFile(getHermesConfigPath(), newYaml); // Update .env — upsert OPENAI_API_KEY only when caller provides one @@ -166,9 +246,15 @@ export async function DELETE() { } throw error; } - const newYaml = removeModelBlock(yaml); + let newYaml = removeModelBlock(yaml); + newYaml = removeDelegationBlock(newYaml); + // Only drop auxiliary entries we manage (custom provider, any base URL — covers tunnels) + for (const [role, cfg] of Object.entries(parseAuxRoles(yaml))) { + if (cfg?.provider === "custom") newYaml = removeAuxRole(newYaml, role); + } + newYaml = newYaml.replace(/^\n+/, ""); await fs.writeFile(configPath, newYaml); - return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model block removed` }); + return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model blocks removed` }); } catch (error) { console.log("Error resetting hermes settings:", error); return NextResponse.json({ error: "Failed to reset hermes settings" }, { status: 500 }); diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 77c307fb..3f521231 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -165,6 +165,22 @@ export const CLI_TOOLS = { color: "#8B5CF6", description: "Nous Research self-improving AI agent", configType: "custom", + // Model slots Hermes supports besides the default ("model:" block). + // "default" is not listed — the card renders it as the main model picker. + roles: [ + { id: "delegation", label: "Delegation (subagents)" }, + { id: "vision", label: "Vision" }, + { id: "web_extract", label: "Web Extract" }, + { id: "compression", label: "Compression" }, + { id: "title_generation", label: "Title Generation" }, + { id: "approval", label: "Approval" }, + { id: "skills_hub", label: "Skills Hub" }, + { id: "mcp", label: "MCP" }, + { id: "memory_query_rewrite", label: "Memory Query Rewrite" }, + { id: "background_review", label: "Background Review" }, + { id: "curator", label: "Curator" }, + { id: "monitor", label: "Monitor" }, + ], }, droid: { id: "droid", From 95600db17b3cc95e9a405897bfc619fe16cc09a5 Mon Sep 17 00:00:00 2001 From: "mxlanparty@dp14" Date: Wed, 23 Sep 2026 12:10:03 +0700 Subject: [PATCH 06/38] feat(codex): add GPT-6 Sol and Luna support - Add GPT-6 Sol and Luna to the Codex model registry. - Send both models using the Codex 0.155 Responses Lite request shape, including `reasoning.context: "all_turns"`. --- open-sse/executors/codex.js | 56 +++++++++++--- open-sse/providers/registry/codex.js | 6 +- open-sse/providers/thinkingLevels.js | 7 +- tests/unit/codex-gpt6-lite.test.js | 105 +++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 tests/unit/codex-gpt6-lite.test.js diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index de2af822..8b8c5f79 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -7,7 +7,7 @@ import { } from "../services/oauthCredentialManager.js"; import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; import { fetchImageAsBase64 } from "../translator/concerns/image.js"; -import { getModelUpstreamId } from "../config/providerModels.js"; +import { getModelUpstreamId, getProviderModels } from "../config/providerModels.js"; import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; import { dbg } from "../utils/debugLog.js"; @@ -25,6 +25,10 @@ const CODEX_SSE_USER_OUTPUT_PATTERNS = [ ]; const CODEX_SSE_PEEK_BYTES = 256 * 1024; const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model."; +function isCodexResponsesLiteModel(model) { + const baseId = String(model || "").replace(/\([^()]+\)\s*$/, ""); + return getProviderModels("cx").some((entry) => entry.id === baseId && entry.responsesLite === true); +} // Server-generated item id prefixes that Codex /responses cannot resolve when store=false const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; @@ -43,7 +47,7 @@ const CODEX_PASSTHROUGH_TOOL_TYPES = new Set(["custom"]); const RESPONSES_API_ALLOWLIST = new Set([ "model", "input", "instructions", "tools", "tool_choice", "stream", "store", "reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata", - "text" + "text", "parallel_tool_calls" ]); // Convert role=system → role=developer in body.input (keeps content in cacheable prefix) @@ -57,13 +61,14 @@ function convertSystemToDeveloperRole(body) { } // Strip server-generated item IDs (rs_/fc_/resp_/msg_) from input — avoids 404 with store=false -function stripStoredItemReferences(body) { +function stripStoredItemReferences(body, preserveLitePrefix = false) { if (!Array.isArray(body.input)) return; body.input = body.input.filter((item) => { if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false; if (item && typeof item === "object" && !Array.isArray(item)) { if (item.type === "item_reference") return false; - if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id; + if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id) + && !(preserveLitePrefix && item.role === "developer" && item.id.startsWith("msg_"))) delete item.id; } return true; }); @@ -138,6 +143,7 @@ function resolveCacheSessionId(body, credentials) { function normalizeReasoningEffort(model, value) { const supportedLevels = getThinkingLevels("codex", model); if (supportedLevels?.includes(value)) return value; + if (isCodexResponsesLiteModel(model) && (value === "none" || value === "minimal")) return "low"; if (value === "ultra" && supportedLevels?.includes("max")) return "max"; if (value === "max" || value === "ultra") return "xhigh"; return value; @@ -209,8 +215,11 @@ export class CodexExecutor extends BaseExecutor { * Override headers to add codex-specific identity headers. * transformRequest runs BEFORE buildHeaders, sets this._currentSessionId. */ - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, _url = null, model = null) { const headers = super.buildHeaders(credentials, stream); + if (isCodexResponsesLiteModel(model && getModelUpstreamId("cx", model))) { + headers["x-openai-internal-codex-responses-lite"] = "true"; + } headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default"; // Identify client type to Codex backend (matches official codex CLI) if (!headers["originator"]) headers["originator"] = "codex_cli_rs"; @@ -408,6 +417,8 @@ export class CodexExecutor extends BaseExecutor { // Convert string input to array format (Codex API requires input as array) const normalized = normalizeResponsesInput(body.input); if (normalized) body.input = normalized; + const upstreamModel = getModelUpstreamId("cx", body.model || model); + const responsesLite = isCodexResponsesLiteModel(upstreamModel); // Ensure input is present and non-empty (Codex API rejects empty input) if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) { @@ -417,7 +428,7 @@ export class CodexExecutor extends BaseExecutor { // Keep system prompts in body.input as role=developer so they stay in the cacheable prefix convertSystemToDeveloperRole(body); // Strip server-generated item IDs (rs_/fc_/resp_/msg_) — Codex /responses can't resolve when store=false - stripStoredItemReferences(body); + stripStoredItemReferences(body, responsesLite); // Flatten function tools + drop unsupported types normalizeCodexTools(body); @@ -425,7 +436,7 @@ export class CodexExecutor extends BaseExecutor { body.stream = true; // If no instructions provided, inject default Codex instructions - if (!body.instructions || body.instructions.trim() === "") { + if (!responsesLite && (!body.instructions || body.instructions.trim() === "")) { body.instructions = CODEX_DEFAULT_INSTRUCTIONS; } @@ -438,7 +449,29 @@ export class CodexExecutor extends BaseExecutor { } // Map virtual Codex review models to the upstream Codex model before suffix parsing. - body.model = getModelUpstreamId("cx", body.model || model); + body.model = upstreamModel; + + if (responsesLite) { + // Codex 0.155 carries tools and instructions as input prefix items. + const input = Array.isArray(body.input) ? body.input : [body.input]; + const hasLitePrefix = input.some((item) => item?.type === "additional_tools"); + if (!hasLitePrefix) { + const instructions = typeof body.instructions === "string" && body.instructions.trim() + ? body.instructions : CODEX_DEFAULT_INSTRUCTIONS; + const prefix = [{ type: "additional_tools", role: "developer", tools: Array.isArray(body.tools) ? body.tools : [] }]; + if (instructions) { + prefix.push({ type: "message", role: "developer", content: [{ type: "input_text", text: instructions }] }); + } + input.unshift(...prefix); + } + body.input = input; + body.instructions = ""; + body.tools = null; + body.tool_choice ||= "auto"; + body.parallel_tool_calls = false; + } else { + delete body.parallel_tool_calls; + } // Extract thinking level from model name suffix // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) @@ -455,12 +488,13 @@ export class CodexExecutor extends BaseExecutor { // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) if (!body.reasoning) { - const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low'); - body.reasoning = { effort, summary: "auto" }; + const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || (responsesLite ? 'medium' : 'low')); + body.reasoning = responsesLite ? { effort } : { effort, summary: "auto" }; } else { body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort); - if (!body.reasoning.summary) body.reasoning.summary = "auto"; + if (!responsesLite && !body.reasoning.summary) body.reasoning.summary = "auto"; } + if (responsesLite) body.reasoning.context = "all_turns"; delete body.reasoning_effort; // Include reasoning encrypted content (required by Codex backend for reasoning models) diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 710cbc27..c7e192e9 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -2,7 +2,8 @@ import { withCodexReviewModels } from "../models/helpers.js"; // Codex CLI version seen by OpenAI's backend — single source for the Version / // User-Agent identity headers. Bump when the installed codex CLI is upgraded. -const CODEX_CLI_VERSION = "0.154.0"; +const CODEX_CLI_VERSION = "0.155.0"; +const GPT_6_LITE_THINKING_LEVELS = ["low", "medium", "high", "xhigh", "max"]; export default { id: "codex", @@ -42,6 +43,7 @@ export default { headers: { originator: "codex_cli_rs", "User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}`, + version: CODEX_CLI_VERSION, }, usage: { url: "https://chatgpt.com/backend-api/wham/usage", @@ -51,6 +53,8 @@ export default { }, models: [ { id: "gpt-6-astra", name: "GPT 6.0 Astra" }, + { id: "gpt-6-sol", name: "GPT 6.0 Sol", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS }, + { id: "gpt-6-luna", name: "GPT 6.0 Luna", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS }, { id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, { id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" }, { id: "gpt-5.6-terra", name: "GPT 5.6 Terra" }, diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 89865593..0b020100 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -3,6 +3,7 @@ import { getCapabilitiesForModel } from "./capabilities.js"; import { matchPattern } from "./pricing.js"; import { resolveKiroEffortPath } from "../config/kiroConstants.js"; +import { getProviderModels } from "../config/providerModels.js"; // Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. const L = { @@ -69,10 +70,14 @@ export function getThinkingLevels(provider, model) { if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; const caps = getCapabilitiesForModel(provider, model); if (!caps.reasoning) return null; + const baseId = String(model || "").replace(/\([^()]+\)\s*$/, ""); + const modelLevels = provider === "codex" + ? getProviderModels("cx").find((entry) => entry.id === baseId)?.thinkingLevels + : null; const hit = PATTERN_THINKING.find((entry) => (!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model) ); - let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; + let levels = modelLevels || hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none"); return levels; } diff --git a/tests/unit/codex-gpt6-lite.test.js b/tests/unit/codex-gpt6-lite.test.js new file mode 100644 index 00000000..035c8329 --- /dev/null +++ b/tests/unit/codex-gpt6-lite.test.js @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CodexExecutor } from "../../open-sse/executors/codex.js"; +import { getModelsByProviderId } from "../../open-sse/config/providerModels.js"; +import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; +import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; +import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js"; + +const credentials = { connectionId: "fixture", accessToken: "fixture-token" }; +afterEach(() => vi.restoreAllMocks()); + +describe("Codex GPT-6 Sol/Luna transport", () => { + it.each(["gpt-6-sol", "gpt-6-luna"])("lists %s with Codex capabilities", (model) => { + const entry = getModelsByProviderId("codex").find((item) => item.id === model); + expect(entry?.responsesLite).toBe(true); + expect(entry?.thinkingLevels).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(getCapabilitiesForModel("codex", model)).toMatchObject({ + vision: true, + reasoning: true, + thinkingFormat: "openai", + }); + expect(getThinkingLevels("codex", model)).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(getThinkingLevels("codex", `${model}(high)`)).toEqual(entry.thinkingLevels); + }); + + it("keeps a native Responses Lite request intact", () => { + const executor = new CodexExecutor(); + const input = [ + { type: "additional_tools", role: "developer", tools: [{ type: "function", name: "run", parameters: { type: "object", properties: {} } }] }, + { type: "message", id: "msg_native", role: "developer", content: [{ type: "input_text", text: "Native instructions" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }, + ]; + const body = executor.transformRequest("gpt-6-luna", { + model: "gpt-6-luna", input: structuredClone(input), instructions: "", tools: null, parallel_tool_calls: false, + reasoning: { effort: "high", context: "all_turns" }, + }, true, credentials); + const headers = executor.buildHeaders(credentials, true, null, "gpt-6-luna"); + + expect(headers["x-openai-internal-codex-responses-lite"]).toBe("true"); + expect(body.instructions).toBe(""); + expect(body.tools).toBeNull(); + expect(body.parallel_tool_calls).toBe(false); + expect(body.input).toEqual(input); + expect(body.reasoning).toEqual({ effort: "high", context: "all_turns" }); + }); + + it("converts an ordinary Responses request to the Lite shape", () => { + const executor = new CodexExecutor(); + const tool = { type: "function", name: "run", parameters: { type: "object", properties: {} } }; + const body = executor.transformRequest("gpt-6-sol", { + model: "gpt-6-sol", input: "hello", instructions: "Do the task", tools: [tool], + }, true, credentials); + + expect(body.instructions).toBe(""); + expect(body.tools).toBeNull(); + expect(body.parallel_tool_calls).toBe(false); + expect(body.reasoning).toEqual({ effort: "medium", context: "all_turns" }); + expect(body.input[0]).toEqual({ type: "additional_tools", role: "developer", tools: [tool] }); + expect(body.input[1]).toEqual({ type: "message", role: "developer", content: [{ type: "input_text", text: "Do the task" }] }); + expect(executor.buildHeaders(credentials, true, null, "gpt-6-sol")["x-openai-internal-codex-responses-lite"]).toBe("true"); + }); + + it("clamps unsupported GPT-6 reasoning values to Codex's lowest supported level", () => { + const body = new CodexExecutor().transformRequest("gpt-6-luna", { + model: "gpt-6-luna", input: "hello", reasoning: { effort: "none" }, + }, true, credentials); + + expect(body.reasoning.effort).toBe("low"); + expect(body.reasoning.context).toBe("all_turns"); + }); + + it("sends the Lite shape and header in the actual outbound request", async () => { + const fetchMock = vi.spyOn(proxyFetchModule, "proxyAwareFetch").mockResolvedValue({ + ok: true, status: 200, headers: new Map(), + }); + await new CodexExecutor().execute({ + model: "gpt-6-luna", + body: { model: "gpt-6-luna", input: "hello", instructions: "Do the task" }, + stream: true, + credentials, + }); + + const [url, options] = fetchMock.mock.calls[0]; + const body = JSON.parse(options.body); + expect(url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(options.headers["x-openai-internal-codex-responses-lite"]).toBe("true"); + expect(options.headers.version).toBe("0.155.0"); + expect(body.model).toBe("gpt-6-luna"); + expect(body.instructions).toBe(""); + expect(body.input[0].type).toBe("additional_tools"); + expect(body.reasoning.context).toBe("all_turns"); + }); + + it("keeps the legacy transport for other models", () => { + const executor = new CodexExecutor(); + const body = executor.transformRequest("gpt-5.5", { model: "gpt-5.5", input: "hello" }, true, credentials); + + expect(body.instructions).toBeTruthy(); + expect(body.input[0].type).not.toBe("additional_tools"); + expect(body.reasoning.context).toBeUndefined(); + expect(executor.buildHeaders(credentials, true, null, "gpt-5.5")["x-openai-internal-codex-responses-lite"]).toBeUndefined(); + expect(getThinkingLevels("codex", "gpt-6-astra")).toContain("none"); + expect(executor.buildHeaders(credentials, true, null, "gpt-6-astra")["x-openai-internal-codex-responses-lite"]).toBeUndefined(); + }); +}); From e7c269b8c97c1d548629af5276c3148cb238d04b Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 23 Sep 2026 12:12:10 +0700 Subject: [PATCH 07/38] fix(tailscale): cap enable-flow health wait at 20s Enable waited out the full 180s HEALTH_CHECK.timeoutMs when funnel URL DNS was unreachable, making the toggle hang ~3 minutes before returning success. waitForHealth now takes an optional timeoutMs; enable uses HEALTH_CHECK.enableTimeoutMs (20s) while watchdog/cloudflare keep 180s. Co-Authored-By: Claude Code --- src/lib/tunnel/tailscale/config.js | 1 + src/lib/tunnel/tailscale/healthCheck.js | 6 +++--- src/lib/tunnel/tailscale/manager.js | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/tunnel/tailscale/config.js b/src/lib/tunnel/tailscale/config.js index 3195ad69..fda18401 100644 --- a/src/lib/tunnel/tailscale/config.js +++ b/src/lib/tunnel/tailscale/config.js @@ -2,6 +2,7 @@ export const HEALTH_CHECK = { intervalMs: 2000, timeoutMs: 180000, + enableTimeoutMs: 20000, // Enable flow waits short; watchdog re-verifies afterwards fetchTimeoutMs: 8000, dnsTimeoutMs: 3000, }; diff --git a/src/lib/tunnel/tailscale/healthCheck.js b/src/lib/tunnel/tailscale/healthCheck.js index 428b351d..91cd7b3b 100644 --- a/src/lib/tunnel/tailscale/healthCheck.js +++ b/src/lib/tunnel/tailscale/healthCheck.js @@ -18,12 +18,12 @@ export async function probeUrlAlive(url) { } } -export async function waitForHealth(url, cancelToken = { cancelled: false }) { +export async function waitForHealth(url, cancelToken = { cancelled: false }, { timeoutMs = HEALTH_CHECK.timeoutMs } = {}) { const start = Date.now(); - while (Date.now() - start < HEALTH_CHECK.timeoutMs) { + while (Date.now() - start < timeoutMs) { if (cancelToken.cancelled) throw new Error("cancelled"); if (await probeUrlAlive(url)) return true; await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); } - throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); + throw new Error(`Health check timeout after ${timeoutMs}ms`); } diff --git a/src/lib/tunnel/tailscale/manager.js b/src/lib/tunnel/tailscale/manager.js index 7fe40e7e..06cef971 100644 --- a/src/lib/tunnel/tailscale/manager.js +++ b/src/lib/tunnel/tailscale/manager.js @@ -1,6 +1,7 @@ import { loadState, generateShortId } from "../shared/state.js"; import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, isTailscaleLoggedInStrict, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js"; import { waitForHealth } from "./healthCheck.js"; +import { HEALTH_CHECK } from "./config.js"; import { getSettings, updateSettings } from "@/lib/localDb"; import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; @@ -88,7 +89,7 @@ export async function enableTailscale(localPort = 20128) { // Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating) let reachableNow = false; try { - await waitForHealth(result.tunnelUrl, token); + await waitForHealth(result.tunnelUrl, token, { timeoutMs: HEALTH_CHECK.enableTimeoutMs }); reachableNow = true; } catch (he) { if (!he.message.startsWith("Health check timeout")) throw he; From 840357609517e3fce6124bca9cf3f83b696672d2 Mon Sep 17 00:00:00 2001 From: "Ilhom (MBP M5 Pro)" Date: Wed, 23 Sep 2026 12:15:34 +0700 Subject: [PATCH 08/38] fix(claude): update spoofed cli version to 2.1.280 to support Opus 5.5 Anthropic's newly released Claude Opus 5.5 model strictly requires claude-cli version 2.1.280 or newer. Spoofing the older 2.1.258 version results in an HTTP 400 `claude_code_version_too_old` error. This commit updates the hardcoded `CLAUDE_CLI_VERSION` in `open-sse/providers/shared.js` and aligns the corresponding unit tests and baselines to bypass Anthropic's version gating. --- tests/__baseline__/providers-baseline.json | 2 +- tests/unit/claude-cloaking.test.js | 2 +- tests/unit/claude-header-forwarding.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index e28594df..bab11342 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -93,7 +93,7 @@ "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.280 (external, sdk-cli)", "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", diff --git a/tests/unit/claude-cloaking.test.js b/tests/unit/claude-cloaking.test.js index 64994dc5..6f06e594 100644 --- a/tests/unit/claude-cloaking.test.js +++ b/tests/unit/claude-cloaking.test.js @@ -12,7 +12,7 @@ import { CLAUDE_TOOL_SUFFIX } from "../../open-sse/config/appConstants.js"; it("advertises a Claude Code version accepted by Fable 5.1", () => { const body = applyCloaking({ messages: [] }, "sk-ant-oat-test", "session-id"); - expect(body.system[0].text).toMatch(/^x-anthropic-billing-header: cc_version=2.1.258\./); + expect(body.system[0].text).toMatch(/^x-anthropic-billing-header: cc_version=2.1.280\./); }); describe("cloakClaudeTools", () => { diff --git a/tests/unit/claude-header-forwarding.test.js b/tests/unit/claude-header-forwarding.test.js index d813f356..556aafe7 100644 --- a/tests/unit/claude-header-forwarding.test.js +++ b/tests/unit/claude-header-forwarding.test.js @@ -29,7 +29,7 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => { headers["Anthropic-Version"] === "2023-06-01" || headers["anthropic-version"] === "2023-06-01"; expect(hasVersion).toBe(true); - expect(headers["User-Agent"]).toBe("claude-cli/2.1.258 (external, sdk-cli)"); + expect(headers["User-Agent"]).toBe("claude-cli/2.1.280 (external, sdk-cli)"); }); it("includes heavy-agent beta flags for claude-opus-5", () => { From e571a8b6da311813028705711ce551f5a524f120 Mon Sep 17 00:00:00 2001 From: Reid Nguyen Date: Wed, 23 Sep 2026 12:16:10 +0700 Subject: [PATCH 09/38] feat(usage): show and redeem free limit resets for cc accounts Adds the free usage-limit reset (the desktop app's "Reset for free") to the Quota Tracker for cc OAuth accounts, mirroring the existing Codex reset-credit button. - Reset button with remaining count on the card; tooltip shows use-by date and which limits get refilled. - Expiry modal (clock button) listing each grant: label, resets left, refills (session / weekly), use-by date, time remaining, status. - Confirm dialog before redeeming, since a reset is irreversible. - Usage and reset calls send the CLI User-Agent required for cedar_ember. - Usage cache is dropped after a redeem so the card shows the refilled limits. --- open-sse/providers/registry/claude.js | 2 + open-sse/services/usage.js | 4 +- open-sse/services/usage/claude.js | 75 +++++++++++- .../usage/components/ProviderLimits/index.js | 115 +++++++++++++++--- .../[connectionId]/claude-reset/route.js | 40 ++++++ tests/unit/claude-reset-grants.test.js | 23 ++++ 6 files changed, 236 insertions(+), 23 deletions(-) create mode 100644 src/app/api/usage/[connectionId]/claude-reset/route.js create mode 100644 tests/unit/claude-reset-grants.test.js diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index d0bc9302..3677ffb5 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -54,6 +54,8 @@ export default { oauthUrl: "https://api.anthropic.com/api/oauth/usage", orgUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage", settingsUrl: "https://api.anthropic.com/v1/settings", + profileUrl: "https://api.anthropic.com/api/oauth/profile", + resetUrl: "https://api.anthropic.com/api/organizations/{org_id}/reset_rate_limits", }, }, models: [ diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index b431e21a..3ce46cfa 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -4,10 +4,10 @@ import { getGitHubUsage } from "./usage/github.js"; import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js"; -import { getClaudeUsage } from "./usage/claude.js"; +import { getClaudeUsage, consumeClaudeResetGrant } from "./usage/claude.js"; import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "./usage/codex.js"; -export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits }; +export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits, consumeClaudeResetGrant }; import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn.js"; diff --git a/open-sse/services/usage/claude.js b/open-sse/services/usage/claude.js index d93a0682..13665201 100644 --- a/open-sse/services/usage/claude.js +++ b/open-sse/services/usage/claude.js @@ -3,7 +3,7 @@ */ import { proxyAwareFetch } from "../../utils/proxyFetch.js"; -import { ANTHROPIC_API_VERSION } from "../../providers/shared.js"; +import { ANTHROPIC_API_VERSION, CLAUDE_CLI_VERSION } from "../../providers/shared.js"; import { U, parseResetTime } from "./shared.js"; // Claude API config (urls from registry, apiVersion is header logic kept here) @@ -11,7 +11,11 @@ const CLAUDE_CONFIG = { oauthUsageUrl: U("claude").oauthUrl, usageUrl: U("claude").orgUrl, settingsUrl: U("claude").settingsUrl, + profileUrl: U("claude").profileUrl, + resetUrl: U("claude").resetUrl, apiVersion: ANTHROPIC_API_VERSION, + // Reset grants are gated by surface: only "(external, cli)" UA is eligible + userAgent: `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`, }; // OAuth usage endpoint rate-limits (429); cool down per-token to stop hammering it. @@ -64,12 +68,14 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) { } // Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens) - const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, { + // cedar_ember=1 adds the "limit reset" grant block (same flag Claude Code sends) + const oauthResponse = await proxyAwareFetch(`${CLAUDE_CONFIG.oauthUsageUrl}?cedar_ember=1`, { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20", "anthropic-version": CLAUDE_CONFIG.apiVersion, + "User-Agent": CLAUDE_CONFIG.userAgent, }, }, proxyOptions); @@ -129,6 +135,7 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) { return { plan: "Claude Code", extraUsage: data.extra_usage ?? null, + resetCredits: parseClaudeResetGrants(data.cedar_ember), quotas, }; } @@ -146,6 +153,70 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) { } } +// Free "limit reset" grants (Anthropic program id "cedar_ember"). +// Shape: { eligible, next_grant_id, grants: [{ id, resets_left, ends_at, paused, clears }] } +export function parseClaudeResetGrants(block) { + if (!block?.eligible || !Array.isArray(block.grants)) return null; + const grants = block.grants.filter((g) => g?.id && !g.paused && Number(g.resets_left) > 0); + const next = grants.find((g) => g.id === block.next_grant_id) || grants[0] || null; + return { + availableCount: grants.reduce((sum, g) => sum + Number(g.resets_left), 0), + nextGrantId: next?.id || null, + expiresAt: next?.ends_at || null, + clears: next?.clears || [], + cooldownUntil: block.cooldown_until || null, + weeklyResetsAt: block.weekly_resets_at || null, + grants: block.grants.filter((g) => g?.id).map((g) => ({ + id: g.id, + label: g.label || "", + resetsLeft: Number(g.resets_left) || 0, + resetsTotal: Number(g.resets_total) || 0, + startsAt: g.starts_at || null, + endsAt: g.ends_at || null, + clears: Array.isArray(g.clears) ? g.clears : [], + paused: g.paused === true, + usableNow: g.usable_now === true, + useRequiresLimit: g.use_requires_limit !== false, + })), + }; +} + +// Spend one reset grant: refills the limits listed in grant.clears. Irreversible. +export async function consumeClaudeResetGrant(accessToken, grantId, proxyOptions = null) { + if (!accessToken) throw new Error("No Claude access token available. Please re-authorize the connection."); + if (!/^[a-z0-9_-]{1,40}$/.test(grantId || "")) throw new Error("Invalid reset grant id."); + + const headers = { + "Authorization": `Bearer ${accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + "anthropic-version": CLAUDE_CONFIG.apiVersion, + "User-Agent": CLAUDE_CONFIG.userAgent, + "Content-Type": "application/json", + }; + + const profileRes = await proxyAwareFetch(CLAUDE_CONFIG.profileUrl, { method: "GET", headers }, proxyOptions); + const profile = await profileRes.json().catch(() => null); + const orgId = profile?.organization?.uuid; + if (!profileRes.ok || !orgId) throw new Error(`Cannot resolve Claude organization (${profileRes.status}).`); + + const res = await proxyAwareFetch(CLAUDE_CONFIG.resetUrl.replace("{org_id}", orgId), { + method: "POST", + headers, + body: JSON.stringify({ program: "cedar_ember", grant_id: grantId, request_id: crypto.randomUUID() }), + }, proxyOptions); + const data = await res.json().catch(() => null); + + usageCache.delete(accessToken); // next read must show refilled limits + return { + ok: res.ok && data?.result === "reset", + status: res.status, + result: data?.result || null, + reason: data?.reason || null, + resetsLeft: data?.resets_left ?? null, + message: data?.error?.message || null, + }; +} + /** * Legacy Claude usage for API key / org admin users */ diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 0a508265..0090b7c6 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -100,6 +100,28 @@ function getCodexResetCreditCount(quota) { return Number.isFinite(count) ? Math.max(0, count) : 0; } +const CLAUDE_RESET_LIMIT_NAMES = { + five_hour: "session", + seven_day: "weekly", + seven_day_overage_included: "weekly", + seven_day_opus: "Opus weekly", + seven_day_sonnet: "Sonnet weekly", +}; + +function formatClaudeResetClears(clears) { + const names = [...new Set((clears || []).map((c) => CLAUDE_RESET_LIMIT_NAMES[c]).filter(Boolean))]; + return names.length ? `${names.join(" + ")} limits` : "limits"; +} + +function claudeGrantStatus(grant) { + if (grant.resetsLeft <= 0) return "used"; + if (grant.paused) return "paused"; + if (grant.endsAt && new Date(grant.endsAt).getTime() <= Date.now()) return "expired"; + if (grant.usableNow) return "usable now"; + if (grant.startsAt && new Date(grant.startsAt).getTime() > Date.now()) return "not started"; + return grant.useRequiresLimit ? "at limit only" : "unavailable"; +} + function providerLabel(providerId) { return AI_PROVIDERS[providerId]?.name || providerId; } @@ -308,30 +330,41 @@ export default function ProviderLimits() { const handleResetCodexLimit = useCallback( async (connectionId, provider) => { - if (provider !== "codex" || resettingLimitId) return; + if ((provider !== "codex" && provider !== "claude") || resettingLimitId) return; setResettingLimitId(connectionId); setErrors((prev) => ({ ...prev, [connectionId]: null })); try { - const response = await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" }); + const response = provider === "claude" + ? await fetch(`/api/usage/${connectionId}/claude-reset`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grantId: quotaData[connectionId]?.raw?.resetCredits?.nextGrantId }), + }) + : await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" }); const result = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(result.message || result.error || result.code || "Failed to reset Codex limit"); + throw new Error(result.message || result.error || result.code || "Failed to reset limit"); } - await fetchQuota(connectionId, provider); + await fetchQuota(connectionId, provider, { force: true }); setLastUpdated(new Date()); } catch (error) { - setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset Codex limit" })); + setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset limit" })); } finally { setResettingLimitId(null); } }, - [fetchQuota, resettingLimitId], + [fetchQuota, resettingLimitId, quotaData], ); + // Claude grants already arrive with the usage read; no extra fetch + const handleViewClaudeResets = useCallback((connection, resetCredits) => { + setResetCreditsState({ connection, loading: false, error: null, data: { kind: "claude", ...resetCredits } }); + }, []); + const handleViewCodexResetCredits = useCallback(async (connection) => { setResetCreditsState({ connection, loading: true, error: null, data: null }); try { @@ -1055,6 +1088,8 @@ export default function ProviderLimits() { // Use table layout for all providers const isInactive = conn.isActive === false; const isCodex = conn.provider === "codex"; + const claudeReset = conn.provider === "claude" ? quota?.raw?.resetCredits : null; + const resetLabel = isCodex ? "Codex reset credit" : "Claude limit reset"; const resetCreditCount = getCodexResetCreditCount(quota); const isResettingLimit = resettingLimitId === conn.id; const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit; @@ -1140,13 +1175,15 @@ export default function ProviderLimits() {
- {isCodex && ( + {(isCodex || claudeReset) && ( <> 0 - ? `Use one Codex reset credit. Available: ${resetCreditCount}` - : "No Codex reset credits available" + ? claudeReset + ? `Use your reset now (${resetCreditCount} left, use by ${formatCreditDate(claudeReset.expiresAt)}) · refills ${formatClaudeResetClears(claudeReset.clears)}` + : `Use one ${resetLabel}. Available: ${resetCreditCount}` + : `No ${resetLabel}s available` } > - +
+ ) : resetCreditsState.data?.kind === "claude" && resetCreditsState.data.grants?.length ? ( +
+
+ {resetCreditsState.data.availableCount ?? 0} reset{resetCreditsState.data.availableCount === 1 ? "" : "s"} left + Weekly reset day: {formatCreditDate(resetCreditsState.data.weeklyResetsAt)} +
+
+ + + + + + + + + + + + {(resetCreditsState.data.grants || []).map((grant) => ( + + + + + + + + ))} + +
ResetLeftRefillsUse ByRemaining
+
{grant.label || grant.id}
+ + {claudeGrantStatus(grant)} + +
{grant.resetsLeft} / {grant.resetsTotal}{formatClaudeResetClears(grant.clears)}{formatCreditDate(grant.endsAt)}{formatTimeRemaining(grant.endsAt)}
+
+
) : resetCreditsState.data?.credits?.length ? (
@@ -1530,7 +1607,7 @@ export default function ProviderLimits() {
) : (
- No reset credit details returned for this account. + {resetCreditsState.data?.kind === "claude" ? "No limit resets available for this account." : "No reset credit details returned for this account."}
)}
diff --git a/src/app/api/usage/[connectionId]/claude-reset/route.js b/src/app/api/usage/[connectionId]/claude-reset/route.js new file mode 100644 index 00000000..05aa9b18 --- /dev/null +++ b/src/app/api/usage/[connectionId]/claude-reset/route.js @@ -0,0 +1,40 @@ +// Ensure proxyFetch is loaded to patch globalThis.fetch +import "open-sse/index.js"; + +import { getProviderConnectionById } from "@/lib/localDb"; +import { consumeClaudeResetGrant } from "open-sse/services/usage.js"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; +import { refreshAndUpdateCredentials } from "../route.js"; + +// Spend one free Claude "limit reset" grant (irreversible) +export async function POST(request, { params }) { + try { + const { connectionId } = await params; + const { grantId } = await request.json().catch(() => ({})); + + let connection = await getProviderConnectionById(connectionId); + if (!connection) return Response.json({ error: "Connection not found" }, { status: 404 }); + if (connection.provider !== "claude" || connection.authType !== "oauth") { + return Response.json({ error: "Limit reset is only available for Claude OAuth connections." }, { status: 400 }); + } + + const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); + const proxyOptions = { + connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, + connectionProxyUrl: proxyConfig.connectionProxyUrl || "", + connectionNoProxy: proxyConfig.connectionNoProxy || "", + vercelRelayUrl: proxyConfig.vercelRelayUrl || "", + strictProxy: false, + }; + + ({ connection } = await refreshAndUpdateCredentials(connection, false, proxyOptions)); + const result = await consumeClaudeResetGrant(connection.accessToken, grantId, proxyOptions); + + if (result.ok) return Response.json(result); + const status = result.status >= 400 && result.status < 500 ? result.status : 409; + return Response.json({ ...result, message: result.message || `Reset not applied: ${result.reason || result.result || "unknown"}` }, { status }); + } catch (error) { + console.warn(`[Claude Reset] ${error.message}`); + return Response.json({ error: error.message }, { status: 500 }); + } +} diff --git a/tests/unit/claude-reset-grants.test.js b/tests/unit/claude-reset-grants.test.js new file mode 100644 index 00000000..ec7cb6ad --- /dev/null +++ b/tests/unit/claude-reset-grants.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { parseClaudeResetGrants } from "../../open-sse/services/usage/claude.js"; + +describe("parseClaudeResetGrants", () => { + it("sums usable grants and picks next_grant_id", () => { + const r = parseClaudeResetGrants({ + eligible: true, + next_grant_id: "g2", + grants: [ + { id: "g1", resets_left: 1, ends_at: "2026-10-01T00:00:00Z" }, + { id: "g2", resets_left: 2, ends_at: "2026-10-22T00:00:00Z", clears: ["five_hour", "seven_day"] }, + { id: "g3", resets_left: 5, paused: true }, + ], + }); + expect(r).toMatchObject({ availableCount: 3, nextGrantId: "g2", expiresAt: "2026-10-22T00:00:00Z" }); + expect(r.grants.map((g) => g.id)).toEqual(["g1", "g2", "g3"]); // modal lists paused too + expect(r.grants[1].clears).toEqual(["five_hour", "seven_day"]); + }); + it("returns null when ineligible or missing", () => { + expect(parseClaudeResetGrants(undefined)).toBeNull(); + expect(parseClaudeResetGrants({ eligible: false, grants: [] })).toBeNull(); + }); +}); From a406381fad1f4fb685fb6c3565eaaff01276b4e9 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Wed, 23 Sep 2026 14:40:52 +0700 Subject: [PATCH 10/38] fix(usage): preserve API key usage attribution in live stats The 24h/today branch of getUsageStats keyed byApiKey buckets on the masked key, while the daily rollup and the lastUsed overlay key on the full key. Every key minted by one instance shares the sk-{machineId} prefix, so the mask collapsed all of them into a single bucket and attributed one key's usage to another. Key the live branch by the full api key too; the masked value is still carried on the bucket for display. --- src/lib/db/repos/usageRepo.js | 2 +- tests/unit/usage-api-key-attribution.test.js | 57 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/unit/usage-api-key-attribution.test.js diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 2f86ff14..41199937 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -634,7 +634,7 @@ export async function getUsageStats(period = "all") { const keyInfo = apiKeyMap[r.apiKey]; const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "..."; const apiKeyMasked = maskApiKey(r.apiKey); - const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`; + const akKey = `${r.apiKey}|${r.model}|${r.provider || "unknown"}`; if (!stats.byApiKey[akKey]) { stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp }; } diff --git a/tests/unit/usage-api-key-attribution.test.js b/tests/unit/usage-api-key-attribution.test.js new file mode 100644 index 00000000..049c2898 --- /dev/null +++ b/tests/unit/usage-api-key-attribution.test.js @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +let tempDir; +let db; + +beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-api-key-")); + process.env.DATA_DIR = tempDir; + vi.resetModules(); + db = await import("@/lib/db/index.js"); + await db.initDb(); +}); + +afterEach(() => { + delete process.env.DATA_DIR; +}); + +describe("Usage stats API key attribution", () => { + it("keeps API keys with the same masked prefix in separate buckets", async () => { + const apiKeyA = "sk-machine-aaaaaa-11111111"; + const apiKeyB = "sk-machine-bbbbbb-22222222"; + + await db.saveRequestUsage({ + provider: "openai", + model: "gpt-4", + connectionId: "c1", + apiKey: apiKeyA, + tokens: { prompt_tokens: 10, completion_tokens: 5 }, + endpoint: "/v1/chat", + status: "ok", + }); + + await db.saveRequestUsage({ + provider: "openai", + model: "gpt-4", + connectionId: "c1", + apiKey: apiKeyB, + tokens: { prompt_tokens: 20, completion_tokens: 10 }, + endpoint: "/v1/chat", + status: "ok", + }); + + const stats = await db.getUsageStats("24h"); + const apiKeyEntries = Object.values(stats.byApiKey); + + expect(apiKeyEntries).toHaveLength(2); + + expect( + apiKeyEntries + .map((entry) => entry.promptTokens) + .sort((a, b) => a - b) + ).toEqual([10, 20]); + }); +}); From 4a57df8bf959376c502ecb149c5a3d6ccfe026c8 Mon Sep 17 00:00:00 2001 From: Rafli Ahmad Zulfikar Date: Wed, 23 Sep 2026 14:41:03 +0700 Subject: [PATCH 11/38] fix(usage): key live byApiKey stats by full api key to prevent team-key collision maskApiKey kept only the first 8 characters of the key. API keys minted from the same machine id share that prefix, so every key of an instance collapsed into one sk-XXXXXXX*** bucket per model/provider, and the dashboard attributed one holder's usage to another. Key the live path by the full api key - matching the daily rollup (aggregateEntryToDay) and the lastUsed overlay - and keep the last 4 characters in maskApiKey so masked keys stay distinguishable in the UI. --- src/lib/db/repos/usageRepo.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 41199937..4ea3b8bd 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -5,8 +5,9 @@ import { getMeta, setMeta } from "../helpers/metaStore.js"; function maskApiKey(key) { if (!key || typeof key !== "string") return null; - if (key.length <= 8) return key.charAt(0) + "***"; - return key.slice(0, 8) + "***"; + if (key.length <= 12) return key.charAt(0) + "***"; + // Keep the tail: keys sharing a machine-id prefix (team keys) must not collide. + return key.slice(0, 8) + "***" + key.slice(-4); } const PENDING_TIMEOUT_MS = 60 * 1000; @@ -634,6 +635,8 @@ export async function getUsageStats(period = "all") { const keyInfo = apiKeyMap[r.apiKey]; const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "..."; const apiKeyMasked = maskApiKey(r.apiKey); + // Key by the FULL api key (same as the daily rollup + lastUsed overlay) + // — masking here collided all keys sharing a prefix into one bucket. const akKey = `${r.apiKey}|${r.model}|${r.provider || "unknown"}`; if (!stats.byApiKey[akKey]) { stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp }; From dc198dff1fa5d3a8a232d2d56b3525ab2b760345 Mon Sep 17 00:00:00 2001 From: DavidArthurCole Date: Sat, 26 Sep 2026 11:07:46 +0700 Subject: [PATCH 12/38] feat(claude): merge client anthropic-beta flags and forward rate-limit headers --- open-sse/executors/default.js | 7 +- open-sse/handlers/chatCore.js | 3 +- .../handlers/chatCore/nonStreamingHandler.js | 3 +- .../handlers/chatCore/streamingHandler.js | 3 +- open-sse/providers/shared.js | 5 ++ open-sse/utils/error.js | 15 ++-- open-sse/utils/upstreamHeaders.js | 12 +++ src/sse/handlers/chat.js | 7 +- .../anthropic-gateway-passthrough.test.js | 80 +++++++++++++++++++ 9 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 open-sse/utils/upstreamHeaders.js create mode 100644 tests/unit/anthropic-gateway-passthrough.test.js diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 64ad46d0..eadf1797 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -1,6 +1,6 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js"; -import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js"; +import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta, mergeAnthropicBeta } from "../providers/shared.js"; import { resolveOpenAICompatibleApiType } from "../services/provider.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { buildClineHeaders } from "../shared/clineAuth.js"; @@ -164,9 +164,12 @@ export class DefaultExecutor extends BaseExecutor { // a node fronting Kimi or GLM answers on its own ids and never matches, so // gateways that would choke on unknown beta flags are left untouched. const isClaudeModel = typeof model === "string" && /^claude-/.test(model); + const clientBeta = credentials?.rawHeaders?.["anthropic-beta"]; if (model && (this.provider === "claude" || (this.provider?.startsWith?.("anthropic-compatible-") && isClaudeModel))) { - headers["Anthropic-Beta"] = selectAnthropicBeta(model, body); + headers["Anthropic-Beta"] = mergeAnthropicBeta(selectAnthropicBeta(model, body), clientBeta); + } else if (this.provider === "anthropic" && clientBeta) { + headers["Anthropic-Beta"] = mergeAnthropicBeta(headers["Anthropic-Beta"], clientBeta); } // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 0db37ba1..a6230fc4 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -9,6 +9,7 @@ import { createRequestLogger } from "../utils/requestLogger.js"; import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { PROVIDERS } from "../config/providers.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; +import { upstreamResponseHeaders } from "../utils/upstreamHeaders.js"; import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; @@ -486,7 +487,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`); } reqLogger.logError(new Error(message), finalBody || translatedBody); - return createErrorResult(statusCode, errMsg, resetsAtMs); + return createErrorResult(statusCode, errMsg, resetsAtMs, upstreamResponseHeaders(providerResponse.headers)); } const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log }; diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 19018147..514a24b2 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -4,6 +4,7 @@ import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js"; import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js"; import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js"; import { createErrorResult } from "../../utils/error.js"; +import { upstreamResponseHeaders } from "../../utils/upstreamHeaders.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js"; @@ -399,7 +400,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m return { success: true, response: new Response(JSON.stringify(restoreToolNames(translatedResponse, toolNameMap)), { - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", ...upstreamResponseHeaders(providerResponse.headers) } }) }; } diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 6751256b..55106fd5 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -9,6 +9,7 @@ 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"; +import { upstreamResponseHeaders } from "../../utils/upstreamHeaders.js"; // Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat. // Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI. @@ -109,7 +110,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode return { success: true, - response: new Response(transformedBody, { headers: SSE_HEADERS }) + response: new Response(transformedBody, { headers: { ...SSE_HEADERS, ...upstreamResponseHeaders(providerResponse.headers) } }) }; } diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 176c82e9..def3171d 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -77,6 +77,11 @@ export function selectAnthropicBeta(model = "", body = null) { return flags.join(","); } +export function mergeAnthropicBeta(...values) { + const flags = values.flatMap((v) => (typeof v === "string" ? v.split(",") : [])).map((f) => f.trim()).filter(Boolean); + return [...new Set(flags)].join(","); +} + // Shared baseUrls export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages"; diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js index 315723e3..6992d4a4 100644 --- a/open-sse/utils/error.js +++ b/open-sse/utils/error.js @@ -27,12 +27,13 @@ export function buildErrorBody(statusCode, message) { * @param {string} message - Error message * @returns {Response} HTTP Response object */ -export function errorResponse(statusCode, message) { +export function errorResponse(statusCode, message, extraHeaders = null) { return new Response(JSON.stringify(buildErrorBody(statusCode, message)), { status: statusCode, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" + "Access-Control-Allow-Origin": "*", + ...extraHeaders } }); } @@ -95,13 +96,13 @@ export async function parseUpstreamError(response, executor = null) { * @param {number} [resetsAtMs] - Optional precise cooldown expiry (ms epoch) for provider-specific quota errors * @returns {{ success: false, status: number, error: string, response: Response, resetsAtMs?: number }} */ -export function createErrorResult(statusCode, message, resetsAtMs) { +export function createErrorResult(statusCode, message, resetsAtMs, extraHeaders = null) { return { success: false, status: statusCode, error: message, resetsAtMs, - response: errorResponse(statusCode, message) + response: errorResponse(statusCode, message, extraHeaders) }; } @@ -113,7 +114,7 @@ export function createErrorResult(statusCode, message, resetsAtMs) { * @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s" * @returns {Response} */ -export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman) { +export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman, extraHeaders = null) { const retryAfterSec = Math.max(Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000), 1); const msg = `${message} (${retryAfterHuman})`; return new Response( @@ -121,8 +122,10 @@ export function unavailableResponse(statusCode, message, retryAfter, retryAfterH { status: statusCode, headers: { + ...extraHeaders, "Content-Type": "application/json", - "Retry-After": String(retryAfterSec) + // Intentionally mis-cased to prevent duplicate headers + "retry-after": String(retryAfterSec) } } ); diff --git a/open-sse/utils/upstreamHeaders.js b/open-sse/utils/upstreamHeaders.js new file mode 100644 index 00000000..49ec3188 --- /dev/null +++ b/open-sse/utils/upstreamHeaders.js @@ -0,0 +1,12 @@ +const FORWARDED = new Set(["retry-after", "x-should-retry"]); +const FORWARDED_PREFIX = "anthropic-ratelimit-"; + +export function upstreamResponseHeaders(headers) { + const out = {}; + if (typeof headers?.forEach !== "function") return out; + headers.forEach((value, name) => { + const key = name.toLowerCase(); + if (FORWARDED.has(key) || key.startsWith(FORWARDED_PREFIX)) out[key] = value; + }); + return out; +} diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 7aa530d3..172549ea 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -15,6 +15,7 @@ import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect"; import { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js"; import { appendPxpipeEvent } from "@/lib/pxpipe/events.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { upstreamResponseHeaders } from "open-sse/utils/upstreamHeaders.js"; import { handleComboChat, handleFusionChat, detectRequiredCapabilities } from "open-sse/services/combo.js"; import { augmentModelsWithCapacityAdapter, withCapacityAdapterStripping, getActiveAdapterStrategy } from "open-sse/services/capacityAdapter.js"; import { handleBypassRequest } from "open-sse/utils/bypassHandler.js"; @@ -229,6 +230,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re const excludeConnectionIds = new Set(); let lastError = null; let lastStatus = null; + let lastHeaders = null; while (true) { const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); @@ -239,14 +241,14 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re const errorMsg = lastError || credentials.lastError || "Unavailable"; const status = HTTP_STATUS.SERVICE_UNAVAILABLE; log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); - return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); + return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman, lastHeaders); } if (excludeConnectionIds.size === 0) { log.warn("AUTH", `No active credentials for provider: ${provider}`); return errorResponse(HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}`); } log.warn("CHAT", "No more accounts available", { provider }); - return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); + return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable", lastHeaders); } // Account selection shown in the unified "▶" line (acc:...) @@ -331,6 +333,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re excludeConnectionIds.add(credentials.connectionId); lastError = result.error; lastStatus = result.status; + lastHeaders = upstreamResponseHeaders(result.response?.headers); continue; } diff --git a/tests/unit/anthropic-gateway-passthrough.test.js b/tests/unit/anthropic-gateway-passthrough.test.js new file mode 100644 index 00000000..308f6636 --- /dev/null +++ b/tests/unit/anthropic-gateway-passthrough.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mergeAnthropicBeta } from "open-sse/providers/shared.js"; +import { upstreamResponseHeaders } from "open-sse/utils/upstreamHeaders.js"; +import { createErrorResult, unavailableResponse } from "open-sse/utils/error.js"; + +const betaFlags = (headers) => (headers["Anthropic-Beta"] || "").split(",").map((s) => s.trim()).filter(Boolean); + +describe("mergeAnthropicBeta", () => { + it("unions and dedupes comma lists, ignoring blanks", () => { + expect(mergeAnthropicBeta("a,b", " b , c ,", undefined, "")).toBe("a,b,c"); + }); +}); + +describe("DefaultExecutor.buildHeaders() forwards client anthropic-beta", () => { + let DefaultExecutor; + + beforeEach(async () => { + vi.resetModules(); + ({ DefaultExecutor } = await import("open-sse/executors/default.js")); + }); + + it("keeps unknown client flags alongside the pinned set on claude", () => { + const executor = new DefaultExecutor("claude"); + const rawHeaders = { "anthropic-beta": "safeguards-2026-09-01,context-1m-2025-08-07" }; + const flags = betaFlags(executor.buildHeaders({ apiKey: "k", rawHeaders }, true, undefined, "claude-opus-5")); + expect(flags).toContain("safeguards-2026-09-01"); + expect(flags).toContain("context-1m-2025-08-07"); + expect(flags).toContain("context-management-2025-06-27"); + expect(new Set(flags).size).toBe(flags.length); + }); + + it("forwards client flags on anthropic-compatible Claude models", () => { + const executor = new DefaultExecutor("anthropic-compatible-custom"); + const creds = { apiKey: "k", rawHeaders: { "anthropic-beta": "safeguards-2026-09-01" }, providerSpecificData: { baseUrl: "https://gw.example.com/v1" } }; + const flags = betaFlags(executor.buildHeaders(creds, true, undefined, "claude-sonnet-5")); + expect(flags).toContain("safeguards-2026-09-01"); + expect(flags).not.toContain("claude-code-20250219"); + }); + + it("forwards client flags on the anthropic provider", () => { + const executor = new DefaultExecutor("anthropic"); + const flags = betaFlags(executor.buildHeaders({ apiKey: "k", rawHeaders: { "anthropic-beta": "safeguards-2026-09-01" } }, true, undefined, "claude-sonnet-5")); + expect(flags).toContain("safeguards-2026-09-01"); + }); +}); + +describe("upstream response header forwarding", () => { + const upstream = new Headers({ + "retry-after": "12", + "x-should-retry": "false", + "anthropic-ratelimit-unified-status": "rejected", + "anthropic-ratelimit-unified-reset": "1790000000", + "set-cookie": "secret=1", + "content-length": "99", + }); + + it("picks only retry and ratelimit headers", () => { + expect(upstreamResponseHeaders(upstream)).toEqual({ + "retry-after": "12", + "x-should-retry": "false", + "anthropic-ratelimit-unified-status": "rejected", + "anthropic-ratelimit-unified-reset": "1790000000", + }); + expect(upstreamResponseHeaders(undefined)).toEqual({}); + }); + + it("attaches them to error results", () => { + const { response } = createErrorResult(429, "limited", undefined, upstreamResponseHeaders(upstream)); + expect(response.headers.get("x-should-retry")).toBe("false"); + expect(response.headers.get("anthropic-ratelimit-unified-status")).toBe("rejected"); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + it("keeps the gateway retry-after on all-accounts-limited responses", () => { + const retryAt = new Date(Date.now() + 30000).toISOString(); + const res = unavailableResponse(503, "busy", retryAt, "30s", upstreamResponseHeaders(upstream)); + expect(Number(res.headers.get("retry-after"))).toBeGreaterThan(20); + expect(res.headers.get("anthropic-ratelimit-unified-reset")).toBe("1790000000"); + }); +}); From f4628375360ca460a5bbcf581160824626c255fe Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 11:14:04 +0700 Subject: [PATCH 13/38] fix(cli): filter model selector by active connections and noAuth providers Co-Authored-By: Claude Code --- CLAUDE.md | 1 + cli/src/cli/utils/modelSelector.js | 83 ++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7c21345..8cbdaf9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,4 +88,5 @@ Pre-translate hooks that compress `tool_result` content in-place to cut tokens. - `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code. - Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix. - Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator. +- **Security-first on PRs**: Security is the top priority when reviewing or creating PRs. Audit authentication, credential/token storage & leaks, header manipulation (`X-Forwarded-For`), and SSRF risks before functional logic. Always include explicit security warnings/notes when reporting PR reviews or changes to the user. - Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`). diff --git a/cli/src/cli/utils/modelSelector.js b/cli/src/cli/utils/modelSelector.js index a2bd3fdc..cec99deb 100644 --- a/cli/src/cli/utils/modelSelector.js +++ b/cli/src/cli/utils/modelSelector.js @@ -2,22 +2,24 @@ const api = require("../api/client"); const { prompt } = require("./input"); const { clearScreen } = require("./display"); -// Provider alias order: OAuth first, then API Key (matches ModelSelectModal) +// Provider alias order: OAuth first, then Free, then API Key const PROVIDER_ALIAS_ORDER = [ - "cc", "ag", "cx", "if", "qw", "gc", "gh", "kr", + "cc", "ag", "cx", "if", "qw", "gc", "gh", "kr", "oc", "openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini" ]; // Alias to display name mapping const PROVIDER_ALIAS_NAMES = { cc: "Claude Code", - ag: "Antigravity", + ag: "Antigravity", cx: "OpenAI Codex", if: "iFlow AI", qw: "Qwen Code", gc: "Gemini CLI", gh: "GitHub Copilot", kr: "Kiro AI", + oc: "OpenCode Free", + opencode: "OpenCode Free", openrouter: "OpenRouter", glm: "GLM Coding", kimi: "Kimi Coding", @@ -27,30 +29,78 @@ const PROVIDER_ALIAS_NAMES = { gemini: "Gemini" }; +const PROVIDER_ID_TO_ALIAS = { + claude: "cc", + codex: "cx", + "gemini-cli": "gc", + github: "gh", + antigravity: "ag", + iflow: "if", + qwen: "qw", + kiro: "kr", + cursor: "cu", + cline: "cline", + clinepass: "clinepass", + qoder: "qd", + "qoder-cn": "qd", + gitlab: "gitlab", + "codebuddy-cn": "cb", + "codebuddy-intl": "cbai", + kimchi: "kimchi", + "grok-cli": "grok-cli", + trae: "trae", + windsurf: "windsurf", + zed: "zed", + opencode: "oc", + "opencode-go": "ocg", + "opencode-zen": "ocz", +}; + +// Providers usable without stored credentials +const NO_AUTH_PROVIDERS = new Set(["opencode", "oc"]); + /** - * Get all available models grouped by provider + combos + * Get all available models grouped by provider + combos (filtered by active connections) * @returns {Promise<{combos: Array, groups: Object}>} */ async function getAvailableModelsGrouped() { - const result = await api.getAvailableModels(); - if (!result.success) return { combos: [], groups: {} }; - - const models = result.data?.data || []; + const [modelsResult, providersResult] = await Promise.all([ + api.getAvailableModels(), + api.getProviders() + ]); + + if (!modelsResult.success) return { combos: [], groups: {} }; + + const connections = providersResult.success ? (providersResult.data?.connections || []) : []; + const activeAliases = new Set(NO_AUTH_PROVIDERS); + + connections.forEach(conn => { + if (conn.isActive === false) return; + const p = conn.provider; + if (!p) return; + activeAliases.add(p); + const alias = conn.providerSpecificData?.prefix || PROVIDER_ID_TO_ALIAS[p] || p; + activeAliases.add(alias); + }); + + const models = modelsResult.data?.data || []; const combos = []; const groups = {}; - + models.forEach(m => { if (m.owned_by === "combo") { combos.push(m.id); } else { const provider = m.owned_by; + // Only keep connected providers or noAuth providers + if (!activeAliases.has(provider)) return; if (!groups[provider]) { groups[provider] = []; } groups[provider].push(m.id); } }); - + return { combos, groups }; } @@ -68,6 +118,19 @@ async function selectModelFromList(title, currentValue = "", options = {}) { const totalModels = combos.length + Object.values(groups).flat().length; if (totalModels === 0) { + clearScreen(); + console.log(`\n🎯 ${title}`); + console.log("=".repeat(50)); + console.log("\n No connected providers found."); + console.log(" Please connect a provider in Providers menu first.\n"); + console.log(" m. ✍️ Enter custom model ID"); + console.log(" 0. Cancel\n"); + const act = await prompt("Select option (m/0): "); + const trimmed = act.trim(); + if (trimmed.toLowerCase() === "m") { + const custom = await prompt("Enter custom model ID: "); + return custom.trim() || null; + } return null; } From 249f6c2fb8f33b036638933d9b8d432fbec934de Mon Sep 17 00:00:00 2001 From: Doan Anh Dung Date: Sat, 26 Sep 2026 11:16:10 +0700 Subject: [PATCH 14/38] fix(tray): native arm64 macOS menubar binary, no Rosetta required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systray2 ships only an x86_64 tray_darwin_release and selects it by process.platform with no process.arch branch, so there is no native slice to choose. Apple Silicon users therefore need Rosetta 2, and without it the tray dies with EBADARCH ("bad CPU type in executable") and no icon appears. Overlay a native arm64 build of the same upstream source (felixhao28/systray-portable @ 6eddc91) instead. On darwin/arm64, ensureArm64TrayBin() detects the Intel binary by parsing the Mach-O cputype, downloads the artifact from the pinned tray-binaries release, verifies it against a sha256 constant, atomically renames it over systray2's binary, and busts systray2's copyDir cache — that cached copy is what actually executes, so without the bust the swap has no effect. Intel Macs keep using systray2's binary unchanged and Windows is unaffected (PowerShell NotifyIcon, no binary). Any download or checksum failure leaves the Intel binary in place and tells the user how to install Rosetta; a marker file throttles retries to once per 24h because ensureTrayRuntime runs synchronously on every CLI start, and is cleared on success so a clobbered binary recovers immediately. Binaries stay out of the npm tarball per the existing Kaspersky false-positive constraint — the artifact is fetched on demand. Adds cli/scripts/buildTrayArm64.js (-trimpath, bit-for-bit reproducible for a given Go version and macOS SDK) and a workflow_dispatch action that builds on a macos-15 runner and refuses to publish when the sha diverges from the pin. Also corrects comments claiming the systray -> systray2 switch fixed Apple Silicon; it only fixed the dyld header rejection on macOS 14+, the binary was still amd64-only. --- .github/workflows/tray-binaries.yml | 176 ++++++++++++++++++++++++++++ cli/.gitignore | 1 + cli/hooks/trayRuntime.js | 159 +++++++++++++++++++++++-- cli/package.json | 4 +- cli/scripts/buildTrayArm64.js | 108 +++++++++++++++++ cli/src/cli/tray/tray.js | 13 +- 6 files changed, 448 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/tray-binaries.yml create mode 100644 cli/scripts/buildTrayArm64.js diff --git a/.github/workflows/tray-binaries.yml b/.github/workflows/tray-binaries.yml new file mode 100644 index 00000000..d1d99c98 --- /dev/null +++ b/.github/workflows/tray-binaries.yml @@ -0,0 +1,176 @@ +name: Build macOS tray binary (arm64) + +# systray2 ships only an x86_64 tray_darwin_release, so Apple Silicon users need +# Rosetta 2 for the menubar icon. This builds the native arm64 overlay that +# cli/hooks/trayRuntime.js downloads from the `tray-binaries` release. +# +# Manual-only: the artifact's sha256 is pinned in cli/hooks/trayRuntime.js and +# verified on every download, so a new build is only publishable together with a +# matching pin. Running this with publish=true against a mismatched pin fails +# rather than silently bricking every Apple Silicon client. + +on: + workflow_dispatch: + inputs: + publish: + description: "Upload to the tray-binaries release (requires sha to match ARM64_TRAY_SHA256)" + required: false + default: false + type: boolean + +concurrency: + group: tray-binaries-${{ github.repository }} + cancel-in-progress: false + +permissions: + contents: read + +env: + # Pinned because -trimpath only makes the build reproducible for a given Go + # version and macOS SDK. Bumping this changes the sha256. + GO_VERSION: "1.27.1" + +jobs: + build: + name: Build darwin/arm64 + runs-on: macos-15 + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + # The Go module lives in a temp clone of the upstream repo, so there is + # no go.sum at the workspace root for setup-go's cache to key on. + cache: false + + - name: Record SDK provenance + run: | + { + echo "runner macOS: $(sw_vers -productVersion)" + echo "Xcode: $(xcodebuild -version | head -1)" + echo "clang: $(clang --version | head -1)" + echo "Go: $(go version)" + } | tee sdk-provenance.txt + + - name: Build + run: node cli/scripts/buildTrayArm64.js + + - name: Compare against pinned checksum + id: sha + run: | + BUILT=$(shasum -a 256 cli/.tray-build/tray_darwin_arm64 | cut -d' ' -f1) + # Whitespace-tolerant, and a missing constant must fail loudly: a null + # match would otherwise surface as an opaque TypeError from [1]. + PINNED=$(node -e ' + const m = require("fs").readFileSync("cli/hooks/trayRuntime.js", "utf8") + .match(/ARM64_TRAY_SHA256\s*=\s*"([0-9a-f]{64})"/); + if (!m) { console.error("::error::ARM64_TRAY_SHA256 not found in cli/hooks/trayRuntime.js"); process.exit(1); } + process.stdout.write(m[1]); + ') + { + echo "built=$BUILT" + echo "pinned=$PINNED" + if [ "$BUILT" = "$PINNED" ]; then echo "match=true"; else echo "match=false"; fi + } >> "$GITHUB_OUTPUT" + + - name: Write job summary + run: | + { + echo "### tray_darwin_arm64" + echo "" + echo "| | |" + echo "|---|---|" + echo "| built sha256 | \`${{ steps.sha.outputs.built }}\` |" + echo "| pinned sha256 | \`${{ steps.sha.outputs.pinned }}\` |" + echo "| match | ${{ steps.sha.outputs.match }} |" + echo "" + echo '```' + cat sdk-provenance.txt + echo '```' + echo "" + if [ "${{ steps.sha.outputs.match }}" = "true" ]; then + echo "Pin already matches — safe to re-run with \`publish=true\`." + else + echo "⚠️ Pin does **not** match. To publish this build, set \`ARM64_TRAY_SHA256\`" + echo "in \`cli/hooks/trayRuntime.js\` to the built sha256 above and land that" + echo "change first. Publishing without it makes every Apple Silicon client fail" + echo "checksum verification and fall back to the Rosetta binary." + fi + } >> "$GITHUB_STEP_SUMMARY" + + # Uploaded before the mismatch gate below, so a publish run that fails on a + # checksum mismatch still leaves the bytes downloadable — that is exactly + # the run where a maintainer needs them to verify the new sha256. + - uses: actions/upload-artifact@v4 + with: + name: tray_darwin_arm64 + path: | + cli/.tray-build/tray_darwin_arm64 + sdk-provenance.txt + + - name: Refuse to publish on checksum mismatch + if: ${{ inputs.publish && steps.sha.outputs.match != 'true' }} + run: | + echo "::error::publish requested but built sha256 != ARM64_TRAY_SHA256" + echo " built: ${{ steps.sha.outputs.built }}" + echo " pinned: ${{ steps.sha.outputs.pinned }}" + echo "Update cli/hooks/trayRuntime.js and land it before publishing." + exit 1 + + - name: Publish to tray-binaries release + if: ${{ inputs.publish && steps.sha.outputs.match == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Clients fetch from the hardcoded ARM64_TRAY_URL, so publishing from a + # different repo would gate an asset stream nobody downloads and silently + # decouple the sha pin from the bytes Apple Silicon users execute. + URL_REPO=$(node -e ' + const m = require("fs").readFileSync("cli/hooks/trayRuntime.js", "utf8") + .match(/"https:\/\/github\.com\/([^\/"]+\/[^\/"]+)\/releases\/download\/tray-binaries\/tray_darwin_arm64"/); + if (!m) { console.error("::error::ARM64_TRAY_URL not found in cli/hooks/trayRuntime.js"); process.exit(1); } + process.stdout.write(m[1]); + ') + if [ "${{ github.repository }}" != "$URL_REPO" ]; then + echo "::error::publishing to ${{ github.repository }}, but ARM64_TRAY_URL points clients at $URL_REPO" + echo "Repoint ARM64_TRAY_URL in cli/hooks/trayRuntime.js at this repo, or run the publish from $URL_REPO." + exit 1 + fi + + # gh release upload does not create the release, so bootstrap it on the + # first publish run rather than failing with "release not found". + if ! gh release view tray-binaries >/dev/null 2>&1; then + echo "Release 'tray-binaries' does not exist yet — creating it" + gh release create tray-binaries --latest=false \ + --title "Native macOS tray binaries" \ + --notes "Built by .github/workflows/tray-binaries.yml. Provenance and the pinned sha256 live in that workflow and in cli/hooks/trayRuntime.js (ARM64_TRAY_SHA256)." + fi + + gh release upload tray-binaries cli/.tray-build/tray_darwin_arm64 --clobber + + echo "Uploaded. Verifying public download URL..." + URL="https://github.com/${{ github.repository }}/releases/download/tray-binaries/tray_darwin_arm64" + GOT="" + for attempt in 1 2 3; do + if curl -fsSL --max-time 60 -o /tmp/verify "$URL"; then + GOT=$(shasum -a 256 /tmp/verify | cut -d' ' -f1) + if [ "$GOT" = "${{ steps.sha.outputs.built }}" ]; then break; fi + fi + # A just-uploaded asset can 404 or serve stale bytes until the CDN catches up. + echo "attempt $attempt: got '${GOT:-}' — retrying in 15s" + sleep 15 + done + if [ "$GOT" != "${{ steps.sha.outputs.built }}" ]; then + echo "::error::downloaded asset sha256 '${GOT:-}' != built ${{ steps.sha.outputs.built }} after 3 attempts" + exit 1 + fi + echo "✅ $URL serves the expected bytes" + diff --git a/cli/.gitignore b/cli/.gitignore index 55fd8c49..86262d36 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,2 +1,3 @@ app/* node_modules/* +.tray-build/ diff --git a/cli/hooks/trayRuntime.js b/cli/hooks/trayRuntime.js index dafb2154..50e6cb97 100644 --- a/cli/hooks/trayRuntime.js +++ b/cli/hooks/trayRuntime.js @@ -5,9 +5,17 @@ // // We use the maintained `systray2` fork. The original `systray@1.0.5` package // bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern -// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon. +// dyld (macOS 14+), so it fails to load at all. +// +// Note that systray2 is NOT an Apple Silicon fix: like its predecessor it ships +// only an x86_64 `tray_darwin_release`, and picks it by process.platform with no +// process.arch branch, so there is no native slice to select. On arm64 macOS the +// tray therefore needs Rosetta 2 and dies with EBADARCH without it. We overlay +// our own arm64 build of the same upstream source on top — see ensureArm64TrayBin. const { spawnSync } = require("child_process"); +const crypto = require("crypto"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime"); @@ -15,6 +23,19 @@ const SYSTRAY_PKG = "systray2"; const SYSTRAY_VERSION = "2.1.4"; const LEGACY_SYSTRAY_PKG = "systray"; +// Pinned `tray-binaries` release rather than `latest`, so the URL is stable and +// the artifact can only change by a deliberate re-upload. The workflow's publish +// step re-derives this repo from the literal below and refuses to upload +// anywhere else, so the integrity gate can't drift from what clients fetch. +// +// The asset is built by .github/workflows/tray-binaries.yml on a macos-15 runner. +// cgo compiles AppKit against the runner's SDK, so this value tracks that image: +// when GitHub updates it the sha changes, the workflow refuses to publish, and +// this constant must be bumped in the same change as the re-upload. +const ARM64_TRAY_URL = "https://github.com/decolua/9router/releases/download/tray-binaries/tray_darwin_arm64"; +const ARM64_TRAY_SHA256 = "487e3c365aaa1eb6ad295bf3989711e975b52cee07505bf641c8559954881c81"; +const ARM64_RETRY_COOLDOWN_MS = 24 * 60 * 60 * 1000; + function hasSystray() { return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json")); } @@ -71,6 +92,124 @@ function ensureRuntimeDir() { return dir; } +// A thin (non-fat) 64-bit Mach-O stores its magic then cputype, both LE. +// CPU_TYPE_ARM64 is CPU_TYPE_ARM | CPU_ARCH_ABI64. Fat/universal binaries use a +// different magic and are reported as "not arm64" here, which is fine: we only +// ever overlay a thin arm64 build and only need to tell it apart from x86_64. +function isArm64MachO(file) { + let fd = null; + try { + fd = fs.openSync(file, "r"); + const buf = Buffer.alloc(8); + fs.readSync(fd, buf, 0, 8, 0); + if (buf.readUInt32LE(0) !== 0xfeedfacf) return false; + return buf.readUInt32LE(4) === 0x0100000c; + } catch { + return false; + } finally { + if (fd !== null) try { fs.closeSync(fd); } catch {} + } +} + +// systray2 is constructed with copyDir:true, so what actually executes is +// ~/.cache/node-systray//tray_darwin_release, and index.js only re-copies +// when that path is absent. An overlaid binary stays invisible until this is cleared. +// Scoped to our systray2 version: the parent dir is machine-global and shared +// with any other node-systray consumer. +function bustSystrayCopyCache() { + try { + fs.rmSync(path.join(os.homedir(), ".cache", "node-systray", SYSTRAY_VERSION), { recursive: true, force: true }); + } catch {} +} + +function arm64AttemptMarker() { + return path.join(getRuntimeDir(), ".tray-arm64-attempt"); +} + +// ensureTrayRuntime runs synchronously on every `9router` start (cli.js), so a +// failed download must not re-block the next launch. Retry at most daily. +function recentlyAttemptedArm64() { + try { + const at = Number(fs.readFileSync(arm64AttemptMarker(), "utf8").trim()); + return Number.isFinite(at) && Date.now() - at < ARM64_RETRY_COOLDOWN_MS; + } catch { + return false; + } +} + +function markArm64Attempt() { + try { fs.writeFileSync(arm64AttemptMarker(), String(Date.now())); } catch {} +} + +// Cleared on success so the cooldown only ever throttles *failures*. Without +// this, anything that restores systray2's x86_64 binary later — notably a +// globally installed 9router older than this change, which shares the same +// ~/.9router/runtime — would leave the user waiting out the cooldown. +function clearArm64Attempt() { + try { fs.rmSync(arm64AttemptMarker(), { force: true }); } catch {} +} + +// Throws on any failure so the caller has a single error path. +function downloadFile(url, dest, timeoutSec) { + // darwin-only path, and curl ships with macOS, so this needs no extra dep and + // keeps the caller synchronous. + const res = spawnSync("curl", ["-fsSL", "--max-time", String(timeoutSec), "-o", dest, url], { + encoding: "utf8", + timeout: (timeoutSec + 5) * 1000 + }); + if (res.status === 0 && fs.existsSync(dest)) return; + const detail = (res.stderr || res.error?.message || `curl exit ${res.status}`).trim().split("\n").pop(); + throw new Error(detail || "download failed"); +} + +function sha256File(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +// Replace systray2's x86_64 macOS binary with a native arm64 build so Apple +// Silicon users get a tray without installing Rosetta 2. Any failure leaves the +// Intel binary untouched, which still works under Rosetta. +// +// Takes no `silent` flag on purpose: cli.js calls ensureTrayRuntime({silent:true}) +// synchronously on every start, and a stalled curl would otherwise freeze the +// launch for up to 30s with no output at all. These lines print at most once per +// 24h on failure and once ever on success, so they are worth more than the quiet. +function ensureArm64TrayBin() { + if (process.platform !== "darwin" || process.arch !== "arm64") return { skipped: true }; + + const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", "tray_darwin_release"); + if (!fs.existsSync(binPath)) return { skipped: true }; + if (isArm64MachO(binPath)) return { native: true }; + if (recentlyAttemptedArm64()) return { deferred: true }; + + markArm64Attempt(); + console.log("⏳ Downloading native Apple Silicon tray binary..."); + // pid-scoped: two concurrent starts (postinstall racing cli.js, or two + // terminals) would otherwise interleave writes to one file, fail each other's + // checksum, and delete each other's in-flight download from the catch below. + const tmp = `${binPath}.arm64.${process.pid}.tmp`; + try { + downloadFile(ARM64_TRAY_URL, tmp, 30); + const sum = sha256File(tmp); + // Integrity matters more than usual: this is an executable that runs on + // every Apple Silicon user's machine. + if (sum !== ARM64_TRAY_SHA256) throw new Error(`checksum mismatch (got ${sum.slice(0, 12)}…)`); + if (!isArm64MachO(tmp)) throw new Error("downloaded file is not an arm64 Mach-O"); + fs.chmodSync(tmp, 0o755); + fs.renameSync(tmp, binPath); + bustSystrayCopyCache(); + clearArm64Attempt(); + console.log("✅ Native Apple Silicon tray installed"); + return { native: true, installed: true }; + } catch (e) { + try { fs.rmSync(tmp, { force: true }); } catch {} + console.warn("⚠️ Native tray download failed — falling back to the Intel binary"); + console.warn(` Reason: ${e.message}`); + console.warn(" The Intel tray needs Rosetta 2: softwareupdate --install-rosetta --agree-to-license"); + return { native: false, error: e.message }; + } +} + function npmInstall(pkgs, { silent = false } = {}) { const cwd = ensureRuntimeDir(); if (!silent) console.log("⏳ Installing system tray (first run)..."); @@ -94,14 +233,20 @@ function ensureTrayRuntime({ silent = false } = {}) { if (process.platform === "win32") { return { systray: false, skipped: true }; } - if (hasSystray()) { + + let ready = hasSystray(); + if (!ready) { + ready = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }) && hasSystray(); + } + if (ready) { chmodSystrayBin({ silent }); if (!silent) console.log("✅ System tray ready"); - return { systray: true }; } - const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }); - if (ok) chmodSystrayBin({ silent }); - return { systray: ok && hasSystray() }; + + // Runs after the ready log so a download failure doesn't read as a broken + // tray — the Intel binary still works under Rosetta. + const arm64 = ready ? ensureArm64TrayBin() : { skipped: true }; + return { systray: ready, arm64 }; } -module.exports = { ensureTrayRuntime }; +module.exports = { ensureTrayRuntime, ensureArm64TrayBin }; diff --git a/cli/package.json b/cli/package.json index f7a0c677..4851a71b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -16,6 +16,7 @@ "scripts": { "dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js", "build": "node scripts/build-cli.js", + "build:tray-arm64": "node scripts/buildTrayArm64.js", "pack:cli": "npm run build && npm pack --pack-destination ..", "publish:cli": "npm run build && npm publish", "postinstall": "node hooks/postinstall.js", @@ -29,7 +30,8 @@ "react-dom": "19.2.1" }, "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", - "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", + "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails to load on modern macOS dyld. Neither package ships an arm64 macOS binary, so on Apple Silicon hooks/trayRuntime.js overlays our own arm64 build from the tray-binaries GitHub release; without it the tray requires Rosetta 2.", + "comment_tray_arm64": "tray_darwin_arm64 is built by scripts/buildTrayArm64.js (npm run build:tray-arm64) from felixhao28/systray-portable — the same source systray2's binary comes from — and uploaded to the pinned 'tray-binaries' GitHub release. Its sha256 is pinned as ARM64_TRAY_SHA256 in hooks/trayRuntime.js and verified after every download; rebuild and update both together. .github/workflows/tray-binaries.yml builds the same artifact in CI but refuses to publish when the sha diverges from the pin.", "engines": { "node": ">=18.0.0" }, diff --git a/cli/scripts/buildTrayArm64.js b/cli/scripts/buildTrayArm64.js new file mode 100644 index 00000000..d32990da --- /dev/null +++ b/cli/scripts/buildTrayArm64.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +// Rebuilds tray_darwin_arm64, the native Apple Silicon menubar binary that +// hooks/trayRuntime.js overlays on top of systray2's x86_64-only build. +// +// Must run on macOS: getlantern/systray is cgo against AppKit, so the arm64 +// slice needs a real macOS SDK. Requires Go on PATH (`mise use -g go@latest`). +// +// Output is NOT reproducible across Go versions even with -s -w, so after a +// rebuild you must re-upload the asset and update ARM64_TRAY_SHA256 in +// hooks/trayRuntime.js — this script prints both and fails if they diverge. + +const { execFileSync, spawnSync } = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const UPSTREAM_REPO = "https://github.com/felixhao28/systray-portable.git"; +// master as of 2021-09-15, the commit systray2@2.1.4's own binary was built from. +const UPSTREAM_COMMIT = "6eddc917bf39fcc0d95b57a0741d0c065fbd1e23"; + +const outDir = path.join(__dirname, "..", ".tray-build"); +const outFile = path.join(outDir, "tray_darwin_arm64"); +const trayRuntimePath = path.join(__dirname, "..", "hooks", "trayRuntime.js"); + +function fail(msg) { + console.error(`\n❌ ${msg}`); + process.exit(1); +} + +function run(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, { stdio: "inherit", ...opts }); + if (res.status !== 0) fail(`${cmd} ${args.join(" ")} exited with ${res.status}`); +} + +if (process.platform !== "darwin") fail("must be run on macOS (cgo needs the AppKit SDK)"); + +const go = spawnSync("go", ["version"], { encoding: "utf8" }); +if (go.status !== 0) { + fail("Go toolchain not found on PATH. Install it with: mise use -g go@latest"); +} +console.log(`Go: ${go.stdout.trim()}`); + +const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "systray-portable-")); +// fail() exits through process.exit(), which does not unwind the stack, so a +// try/finally here would leak the clone on every failed build. An exit handler +// covers normal completion, fail(), and uncaught exceptions alike. +process.on("exit", () => { + try { fs.rmSync(srcDir, { recursive: true, force: true }); } catch {} +}); + +console.log(`\nCloning ${UPSTREAM_REPO} @ ${UPSTREAM_COMMIT.slice(0, 7)}`); +run("git", ["clone", "--quiet", UPSTREAM_REPO, srcDir]); +run("git", ["-C", srcDir, "checkout", "--quiet", UPSTREAM_COMMIT]); + +const head = execFileSync("git", ["-C", srcDir, "log", "-1", "--format=%H %ad %s", "--date=short"], { + encoding: "utf8" +}).trim(); +console.log(`HEAD: ${head}`); + +run("go", ["mod", "download"], { cwd: srcDir }); + +console.log("\nBuilding darwin/arm64..."); +// -trimpath strips the local build directory from the binary, so two builds +// from the same commit + Go version hash identically regardless of where they +// ran. Without it the pinned sha256 could never be regenerated. +run("go", ["build", "-trimpath", "-ldflags", "-s -w", "-o", outFile, "tray.go"], { + cwd: srcDir, + env: { ...process.env, CGO_ENABLED: "1", GOOS: "darwin", GOARCH: "arm64" } +}); + +// ── Verify ──────────────────────────────────────────────────────────────── +const buf = Buffer.alloc(8); +const fd = fs.openSync(outFile, "r"); +fs.readSync(fd, buf, 0, 8, 0); +fs.closeSync(fd); +if (buf.readUInt32LE(0) !== 0xfeedfacf || buf.readUInt32LE(4) !== 0x0100000c) { + fail("output is not a thin arm64 Mach-O"); +} + +// Apple Silicon refuses to execute an unsigned binary. Go's linker applies an +// ad-hoc signature automatically; confirm it survived. +const sig = spawnSync("codesign", ["--verify", outFile], { encoding: "utf8" }); +if (sig.status !== 0) fail(`ad-hoc signature invalid: ${(sig.stderr || "").trim()}`); + +const sha256 = crypto.createHash("sha256").update(fs.readFileSync(outFile)).digest("hex"); +const sizeMb = (fs.statSync(outFile).size / 1024 / 1024).toFixed(2); + +console.log(`\n✅ ${outFile}`); +console.log(` arch: arm64 (ad-hoc signed, verified)`); +console.log(` size: ${sizeMb} MB`); +console.log(` sha256: ${sha256}`); + +// Whitespace-tolerant: a formatter could wrap the assignment across lines, and +// a null match must fail loudly rather than silently read as "no pin". +const pinMatch = fs.readFileSync(trayRuntimePath, "utf8").match(/ARM64_TRAY_SHA256\s*=\s*"([0-9a-f]{64})"/); +if (!pinMatch) fail(`could not find ARM64_TRAY_SHA256 in ${trayRuntimePath}`); +const pinned = pinMatch[1]; +if (pinned === sha256) { + console.log(`\n Matches ARM64_TRAY_SHA256 in hooks/trayRuntime.js — no code change needed.`); +} else { + console.log(`\n⚠️ Differs from ARM64_TRAY_SHA256 in hooks/trayRuntime.js (${pinned}).`); + console.log(` Re-upload the release asset, then update that constant to the sha256 above.`); +} + +console.log(`\nNext: upload to the pinned release tag, keeping the asset name stable:`); +console.log(` gh release upload tray-binaries "${outFile}" --clobber`); diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js index 6658e948..0d643f29 100644 --- a/cli/src/cli/tray/tray.js +++ b/cli/src/cli/tray/tray.js @@ -141,11 +141,14 @@ function initWindowsTray(options) { /** * macOS/Linux tray via systray binary * - * Prefers `systray2` (active fork of `systray`, ships newer - * getlantern/systray-portable binaries that work on macOS 14+ and Apple - * Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2 - * is not available, though that binary's Mach-O headers are rejected by - * modern dyld and the icon will not appear. + * Prefers `systray2`, the active fork of `systray`. Both ship only an x86_64 + * `tray_darwin_release` and select it by process.platform alone, so on Apple + * Silicon the tray runs under Rosetta 2 and fails with EBADARCH when Rosetta is + * absent. hooks/trayRuntime.js overlays a native arm64 build over that file to + * avoid the dependency; the fallbacks below are Intel-only. + * + * Falls back to legacy `systray@1.0.5` if systray2 is unavailable, though that + * binary's Mach-O headers are rejected by modern dyld and no icon will appear. */ function resolveSystray() { let runtimeDir = null; From 30464bc227432c431829f4092ad0dab634778770 Mon Sep 17 00:00:00 2001 From: akmal safari pellu Date: Sat, 26 Sep 2026 11:17:35 +0700 Subject: [PATCH 15/38] fix(gemini): guard terminal model turns and unresponded functionCalls in normalizeGeminiContents --- open-sse/translator/formats/gemini.js | 19 ++- .../gemini-contents-normalization.test.js | 141 ++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/unit/gemini-contents-normalization.test.js diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index 729e4c10..412fadcc 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -432,7 +432,7 @@ export function cleanJSONSchemaForAntigravity(schema) { return cleaned; } -// Merge adjacent same-role messages, strip empty parts, ensure initial user turn +// Merge adjacent same-role messages, strip empty parts, ensure initial and terminal user turns export function normalizeGeminiContents(contents) { const out = []; for (const c of contents || []) { @@ -446,6 +446,23 @@ export function normalizeGeminiContents(contents) { if (out.length > 0 && out[0].role !== "user") { out.unshift({ role: "user", parts: [{ text: "..." }] }); } + if (out.length > 0 && out.at(-1).role === "model") { + const fnCalls = (out.at(-1).parts || []).filter(p => p && p.functionCall); + if (fnCalls.length > 0) { + const responses = fnCalls.map(p => { + const call = p.functionCall || {}; + const fr = { + name: call.name || "tool", + response: { result: "Continue." } + }; + if (call.id) fr.id = call.id; + return { functionResponse: fr }; + }); + out.push({ role: "user", parts: responses }); + } else { + out.push({ role: "user", parts: [{ text: "Continue." }] }); + } + } return out; } diff --git a/tests/unit/gemini-contents-normalization.test.js b/tests/unit/gemini-contents-normalization.test.js new file mode 100644 index 00000000..4a4b46b0 --- /dev/null +++ b/tests/unit/gemini-contents-normalization.test.js @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { normalizeGeminiContents } from "../../open-sse/translator/formats/gemini.js"; + +describe("normalizeGeminiContents terminal turn guards", () => { + it("appends user Continue turn when ending with model text turn", () => { + const contents = [ + { role: "user", parts: [{ text: "hi" }] }, + { role: "model", parts: [{ text: "hello" }] } + ]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] }); + }); + + it("appends functionResponse user turn when ending with functionCall", () => { + const contents = [ + { role: "user", parts: [{ text: "run" }] }, + { + role: "model", + parts: [ + { functionCall: { id: "call_1", name: "search", args: { q: "test" } } } + ] + } + ]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[2]).toEqual({ + role: "user", + parts: [ + { + functionResponse: { + id: "call_1", + name: "search", + response: { result: "Continue." } + } + } + ] + }); + }); + + it("handles multiple functionCalls in terminal model turn", () => { + const contents = [ + { role: "user", parts: [{ text: "run" }] }, + { + role: "model", + parts: [ + { functionCall: { id: "call_1", name: "fn_1" } }, + { functionCall: { id: "call_2", name: "fn_2" } } + ] + } + ]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[2].parts).toHaveLength(2); + expect(out[2].parts[0].functionResponse.id).toBe("call_1"); + expect(out[2].parts[1].functionResponse.id).toBe("call_2"); + }); + + it("handles terminal model turn with both text and functionCall", () => { + const contents = [ + { role: "user", parts: [{ text: "run" }] }, + { + role: "model", + parts: [ + { text: "Executing..." }, + { functionCall: { id: "call_3", name: "exec" } } + ] + } + ]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[2].parts[0].functionResponse.id).toBe("call_3"); + }); + + it("handles single model turn by prepending user prompt and appending terminal user", () => { + const contents = [{ role: "model", parts: [{ text: "prefill" }] }]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[0]).toEqual({ role: "user", parts: [{ text: "..." }] }); + expect(out[1]).toEqual({ role: "model", parts: [{ text: "prefill" }] }); + expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] }); + }); + + it("does not mutate payloads already ending with a user turn", () => { + const contents = [{ role: "user", parts: [{ text: "question" }] }]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(1); + expect(out[0].role).toBe("user"); + }); + + it("handles functionCall without name or id with fallback defaults", () => { + const contents = [ + { role: "user", parts: [{ text: "Go" }] }, + { role: "model", parts: [{ functionCall: {} }] } + ]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[2].parts[0]).toEqual({ + functionResponse: { + name: "tool", + response: { result: "Continue." } + } + }); + expect(out[2].parts[0].functionResponse.id).toBeUndefined(); + }); + + it("merges adjacent model turns before appending terminal user turn", () => { + const contents = [ + { role: "user", parts: [{ text: "Prompt" }] }, + { role: "model", parts: [{ text: "Part A" }] }, + { role: "model", parts: [{ text: "Part B" }] } + ]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[1].role).toBe("model"); + expect(out[1].parts).toHaveLength(2); + expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] }); + }); + + it("appends user Continue turn when terminal model turn has thought parts", () => { + const contents = [ + { role: "user", parts: [{ text: "Solve math" }] }, + { + role: "model", + parts: [ + { thought: true, text: "Let 2x = 4..." }, + { thoughtSignature: "sig123", text: "" } + ] + } + ]; + const out = normalizeGeminiContents(contents); + expect(out).toHaveLength(3); + expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] }); + }); + + it("handles empty, null, and undefined inputs gracefully", () => { + expect(normalizeGeminiContents([])).toEqual([]); + expect(normalizeGeminiContents(null)).toEqual([]); + expect(normalizeGeminiContents(undefined)).toEqual([]); + }); +}); From ddfdb0df0405daf410f87631bd85d91ed4aaf091 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 11:34:08 +0700 Subject: [PATCH 16/38] perf(dashboard): lazy-load charts and marked, preload in background on idle - Drop UsageStats from shared barrel so recharts (~589KB) stays out of the initial bundle of every page; usage page imports it directly - Convert UsageChart/ProviderBarChart/TopModelsChart to next/dynamic - Preload chart chunks via requestIdleCallback in DashboardLayout so navigating to Usage is still instant - Lazy-import marked inside ChangelogModal (only when opened) - Route Sidebar/EndpointPageClient through settingsStore: coalesce concurrent in-flight GET, merge PATCH response over cache (PATCH omits GET-only hasPassword) to kill duplicate /api/settings calls - Delay /api/version npm check 2.5s after first render Co-Authored-By: Claude Code --- .../dashboard/endpoint/EndpointPageClient.js | 32 +++++++---------- src/app/(dashboard)/dashboard/usage/page.js | 3 +- src/shared/components/ChangelogModal.js | 14 ++++---- src/shared/components/Sidebar.js | 21 ++++++----- src/shared/components/UsageStats.js | 8 ++--- src/shared/components/index.js | 1 - .../components/layouts/DashboardLayout.js | 20 ++++++++++- src/store/settingsStore.js | 36 ++++++++++++------- 8 files changed, 80 insertions(+), 55 deletions(-) diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index b6bfd2e4..66dd866b 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -13,6 +13,7 @@ import { CLIENT_PING_FAST_MS, } from "./endpointConstants"; import { clientPingUrl, clientPingAny } from "./endpointPing"; +import useSettingsStore from "@/store/settingsStore"; import EndpointRow from "./components/EndpointRow"; import StatusAlert from "./components/StatusAlert"; import Tooltip from "./components/Tooltip"; @@ -194,16 +195,15 @@ export default function APIPageClient({ machineId }) { const loadSettings = async () => { setTunnelChecking(true); try { - const [settingsRes, statusRes] = await Promise.all([ - fetch("/api/settings"), + const [settingsData, statusRes] = await Promise.all([ + useSettingsStore.getState().fetchSettings(), fetch("/api/tunnel/status", { cache: "no-store" }) ]); - if (settingsRes.ok) { - const data = await settingsRes.json(); - setRequireApiKey(data.requireApiKey || false); - setRequireLogin(data.requireLogin !== false); - setHasPassword(data.hasPassword || false); - setTunnelDashboardAccess(data.tunnelDashboardAccess || false); + if (settingsData) { + setRequireApiKey(settingsData.requireApiKey || false); + setRequireLogin(settingsData.requireLogin !== false); + setHasPassword(settingsData.hasPassword || false); + setTunnelDashboardAccess(settingsData.tunnelDashboardAccess || false); } if (statusRes.ok) { const data = await statusRes.json(); @@ -229,12 +229,8 @@ export default function APIPageClient({ machineId }) { const handleTunnelDashboardAccess = async (value) => { try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tunnelDashboardAccess: value }), - }); - if (res.ok) setTunnelDashboardAccess(value); + const updated = await useSettingsStore.getState().patchSettings({ tunnelDashboardAccess: value }); + if (updated) setTunnelDashboardAccess(value); } catch (error) { console.log("Error updating tunnelDashboardAccess:", error); } @@ -242,12 +238,8 @@ export default function APIPageClient({ machineId }) { const handleRequireApiKey = async (value) => { try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requireApiKey: value }), - }); - if (res.ok) setRequireApiKey(value); + const updated = await useSettingsStore.getState().patchSettings({ requireApiKey: value }); + if (updated) setRequireApiKey(value); } catch (error) { console.log("Error updating requireApiKey:", error); } diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index 2b43198b..3f47b644 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -2,7 +2,8 @@ import { Suspense, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; +import { RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; +import UsageStats from "@/shared/components/UsageStats"; import RequestDetailsTab from "./components/RequestDetailsTab"; const PERIODS = [ diff --git a/src/shared/components/ChangelogModal.js b/src/shared/components/ChangelogModal.js index 591f6a9e..abc320a7 100644 --- a/src/shared/components/ChangelogModal.js +++ b/src/shared/components/ChangelogModal.js @@ -3,11 +3,8 @@ import { useEffect, useState, useRef } from "react"; import { createPortal } from "react-dom"; import PropTypes from "prop-types"; -import { marked } from "marked"; import { GITHUB_CONFIG } from "@/shared/constants/config"; -marked.setOptions({ gfm: true, breaks: true }); - export default function ChangelogModal({ isOpen, onClose }) { const [html, setHtml] = useState(""); const [loading, setLoading] = useState(false); @@ -18,12 +15,17 @@ export default function ChangelogModal({ isOpen, onClose }) { if (!isOpen || html) return; setLoading(true); setError(""); - fetch(GITHUB_CONFIG.changelogUrl) - .then((res) => { + Promise.all([ + fetch(GITHUB_CONFIG.changelogUrl).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); + }), + import("marked"), + ]) + .then(([md, { marked }]) => { + marked.setOptions({ gfm: true, breaks: true }); + setHtml(marked.parse(md)); }) - .then((md) => setHtml(marked.parse(md))) .catch((err) => setError(err.message || "Failed to load")) .finally(() => setLoading(false)); }, [isOpen, html]); diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 716f59fb..b3aa65a7 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn"; import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config"; import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import useSettingsStore from "@/store/settingsStore"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; import NineRemotePromoModal from "./NineRemotePromoModal"; @@ -54,18 +55,20 @@ export default function Sidebar({ onClose }) { const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest; useEffect(() => { - fetch("/api/settings") - .then(res => res.json()) - .then(data => { if (data.enableTranslator) setEnableTranslator(true); }) - .catch(() => {}); + useSettingsStore.getState().fetchSettings().then((data) => { + if (data?.enableTranslator) setEnableTranslator(true); + }); }, []); - // Lazy check for new npm version on mount + // Lazy check for new npm version in background after initial render useEffect(() => { - fetch("/api/version") - .then(res => res.json()) - .then(data => { if (data.hasUpdate) setUpdateInfo(data); }) - .catch(() => {}); + const timer = setTimeout(() => { + fetch("/api/version") + .then(res => res.json()) + .then(data => { if (data.hasUpdate) setUpdateInfo(data); }) + .catch(() => {}); + }, 2500); + return () => clearTimeout(timer); }, []); const isActive = (href) => { diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index bcac0bad..c4007812 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -15,11 +15,11 @@ import Card from "./Card"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable"; import dynamic from "next/dynamic"; -// Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders +// Lazy-load: keeps @xyflow/react and recharts out of the initial bundle const ProviderTopology = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), { ssr: false }); -import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart"; -import ProviderBarChart from "@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"; -import TopModelsChart from "@/app/(dashboard)/dashboard/usage/components/TopModelsChart"; +const UsageChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/UsageChart"), { ssr: false }); +const ProviderBarChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"), { ssr: false }); +const TopModelsChart = dynamic(() => import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart"), { ssr: false }); function timeAgo(timestamp) { const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000); diff --git a/src/shared/components/index.js b/src/shared/components/index.js index 3d508e91..e5698a63 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -18,7 +18,6 @@ export { default as ModelSelectModal } from "./ModelSelectModal"; export { default as ManualConfigModal } from "./ManualConfigModal"; export { default as ComboFormModal } from "./ComboFormModal"; export { default as McpMarketplaceModal } from "./McpMarketplaceModal"; -export { default as UsageStats } from "./UsageStats"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as NineRemoteButton } from "./NineRemoteButton"; export { default as HeaderMenu } from "./HeaderMenu"; diff --git a/src/shared/components/layouts/DashboardLayout.js b/src/shared/components/layouts/DashboardLayout.js index aa555bd5..0f175f95 100644 --- a/src/shared/components/layouts/DashboardLayout.js +++ b/src/shared/components/layouts/DashboardLayout.js @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { usePathname } from "next/navigation"; import { useNotificationStore } from "@/store/notificationStore"; import Sidebar from "../Sidebar"; @@ -37,6 +37,24 @@ export default function DashboardLayout({ children }) { const notifications = useNotificationStore((state) => state.notifications); const removeNotification = useNotificationStore((state) => state.removeNotification); + // Preload heavy usage charts in background when browser is idle + useEffect(() => { + const preload = () => { + import("@/shared/components/UsageStats").catch(() => {}); + import("@/app/(dashboard)/dashboard/usage/components/UsageChart").catch(() => {}); + import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart").catch(() => {}); + import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart").catch(() => {}); + }; + if (typeof window !== "undefined") { + if ("requestIdleCallback" in window) { + const id = window.requestIdleCallback(preload, { timeout: 4000 }); + return () => window.cancelIdleCallback(id); + } + const timer = setTimeout(preload, 2500); + return () => clearTimeout(timer); + } + }, []); + return (
diff --git a/src/store/settingsStore.js b/src/store/settingsStore.js index e5f0a713..563d2e39 100644 --- a/src/store/settingsStore.js +++ b/src/store/settingsStore.js @@ -3,6 +3,8 @@ import { create } from "zustand"; import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config"; +let inFlightSettingsPromise = null; + const useSettingsStore = create((set, get) => ({ settings: null, loading: false, @@ -11,23 +13,30 @@ const useSettingsStore = create((set, get) => ({ invalidate: () => set({ lastFetched: 0 }), - // Skips network when cache is fresh; pass {force:true} to override + // Skips network when cache is fresh; coalesce concurrent in-flight requests fetchSettings: async ({ force = false } = {}) => { const { lastFetched, settings } = get(); if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings; + if (inFlightSettingsPromise) return inFlightSettingsPromise; + set({ loading: true, error: null }); - try { - const res = await fetch("/api/settings"); - const data = await res.json(); - if (res.ok) { - set({ settings: data, loading: false, lastFetched: Date.now() }); - return data; + inFlightSettingsPromise = (async () => { + try { + const res = await fetch("/api/settings"); + const data = await res.json(); + if (res.ok) { + set({ settings: data, loading: false, lastFetched: Date.now() }); + return data; + } + set({ error: data.error, loading: false }); + } catch (e) { + set({ error: "Failed to fetch settings", loading: false }); + } finally { + inFlightSettingsPromise = null; } - set({ error: data.error, loading: false }); - } catch (e) { - set({ error: "Failed to fetch settings", loading: false }); - } - return null; + return null; + })(); + return inFlightSettingsPromise; }, // PATCH server + merge into local cache (no extra fetch needed) @@ -40,7 +49,8 @@ const useSettingsStore = create((set, get) => ({ }); if (!res.ok) return null; const updated = await res.json(); - set({ settings: updated, lastFetched: Date.now() }); + // Merge, not replace: PATCH response omits GET-only fields (e.g. hasPassword) + set({ settings: { ...get().settings, ...updated }, lastFetched: Date.now() }); return updated; } catch { return null; From 975f28a57c059b0189d2d898a1055b09f6ea5960 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 11:40:08 +0700 Subject: [PATCH 17/38] test(zed): isolate zed-live-models suite DB via temp DATA_DIR Seeding via createProviderConnection wrote zed-live-* accounts into the real ~/.9router DB, showing up as junk accounts in the running dashboard. Set DATA_DIR to a mkdtemp dir before dynamic-importing the DB-backed modules and clean it up afterwards, matching the pattern in compatible-provider-connections.test.js. Co-Authored-By: Claude Code --- tests/unit/zed-live-models.test.js | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/unit/zed-live-models.test.js b/tests/unit/zed-live-models.test.js index 1b999044..eaaea2fe 100644 --- a/tests/unit/zed-live-models.test.js +++ b/tests/unit/zed-live-models.test.js @@ -1,9 +1,10 @@ // 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"; +// Self-isolating: DATA_DIR points at a temp dir so seeding never touches ~/.9router. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest"; // Transport stub BELOW resolveZedModels: proxyAwareFetch captures the native // fetch at import time, so stubbing globalThis.fetch cannot intercept it. @@ -72,6 +73,24 @@ afterEach(() => { vi.restoreAllMocks(); }); +// Imports must be dynamic so DATA_DIR is set before the DB layer loads. +const originalDataDir = process.env.DATA_DIR; +let GET; +let createProviderConnection; + +beforeAll(async () => { + process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "9router-zed-live-")); + vi.resetModules(); + ({ GET } = await import("@/app/api/providers/[id]/models/route.js")); + ({ createProviderConnection } = await import("@/models/index.js")); +}); + +afterAll(() => { + fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + async function seedZed(n) { return createProviderConnection({ provider: "zed", From 3e4323e2fd139a4cd95aeb91770605f661041850 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 11:43:22 +0700 Subject: [PATCH 18/38] feat(combos): display vision adapter models in an ordered table view Co-Authored-By: Claude Code --- src/app/(dashboard)/dashboard/combos/page.js | 126 ++++++++++++++----- 1 file changed, 96 insertions(+), 30 deletions(-) diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 60f633a5..6e0367bd 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -734,8 +734,15 @@ function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps }) const patch = (p) => onChange({ ...entry, ...p }); const handleAdd = (model) => { - if (models.includes(model.value)) return; - patch({ models: [...models, model.value] }); + const value = model?.value || model?.name || model; + if (!value || models.includes(value)) return; + patch({ models: [...models, value] }); + }; + + const handleDeselect = (model) => { + const value = model?.value || model?.name || model; + const next = models.filter((m) => m !== value); + patch({ models: next.length === 0 ? [DEFAULT_FALLBACK_MODEL] : next }); }; const handleRemove = (index) => { @@ -754,7 +761,7 @@ function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps }) return (
- {/* Master toggle + icon + label + chips */} + {/* Master toggle + icon + label */}
{cap.label} — {cap.desc}
-
- {models.length === 0 ? ( - No models - ) : ( - models.slice(0, 3).map((model, index) => ( - - {model} - - - - - - )) - )} - {models.length > 3 && ( - +{models.length - 3} more - )} -
@@ -823,11 +803,97 @@ function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps })
+ {/* Model pool list/table */} + {models.length === 0 ? ( +
+ No models in pool (will fallback to {DEFAULT_FALLBACK_MODEL}) +
+ ) : ( +
+ + + + + + + + + + + {models.map((model, index) => ( + + + + + + + ))} + +
#ModelOrder
+ #{index + 1} + +
+ {model} + + {model === DEFAULT_FALLBACK_MODEL && ( + + free default + + )} +
+
+
+ + +
+
+ +
+
+ )} + {showModelSelect && ( setShowModelSelect(false)} onSelect={handleAdd} + onDeselect={handleDeselect} activeProviders={activeProviders} title={`Add ${cap.label} Model`} addedModelValues={models} From 5d2cfbf3c5449a736a8cc643b6ef8e73439e962a Mon Sep 17 00:00:00 2001 From: MrBeanDev Date: Sat, 26 Sep 2026 11:31:54 +0700 Subject: [PATCH 19/38] fix(translator): stop emitting empty markers into OpenAI content --- open-sse/transformer/responsesTransformer.js | 4 + .../translator/response/claude-to-openai.js | 10 +- .../translator/response/openai-responses.js | 4 + .../golden-response-stream.test.js.snap | 30 --- .../claude-thinking-stream-boundaries.test.js | 175 ++++++++++++++++++ 5 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 tests/unit/claude-thinking-stream-boundaries.test.js diff --git a/open-sse/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.js index ac84db20..8b77eaf2 100644 --- a/open-sse/transformer/responsesTransformer.js +++ b/open-sse/transformer/responsesTransformer.js @@ -333,6 +333,9 @@ export function createResponsesApiTransformStream(logger = null) { // Regular text content if (content) { + // The answer starts, so thinking is over. Upstreams that send reasoning via + // reasoning_content never emit "", so close it here rather than at finish. + closeReasoning(controller); if (!state.msgItemAdded[idx]) { state.msgItemAdded[idx] = true; const msgId = `msg_${state.responseId}_${idx}`; @@ -372,6 +375,7 @@ export function createResponsesApiTransformStream(logger = null) { // Handle tool_calls if (delta.tool_calls) { + closeReasoning(controller); closeMessage(controller, idx); for (const tc of delta.tool_calls) { diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js index 80de2f18..42a82a66 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.js @@ -59,10 +59,6 @@ export function claudeToOpenAIResponse(chunk, state) { } if (block?.type === CLAUDE_BLOCK.TEXT) { state.textBlockStarted = true; - } else if (block?.type === CLAUDE_BLOCK.THINKING) { - state.inThinkingBlock = true; - state.currentBlockIndex = chunk.index; - results.push(createChunk(state, { content: "" })); } else if (block?.type === CLAUDE_BLOCK.TOOL_USE) { const toolCallIndex = state.toolCallIndex++; // Restore original tool name from mapping (Claude OAuth) @@ -89,6 +85,8 @@ export function claudeToOpenAIResponse(chunk, state) { if (delta?.type === "text_delta" && delta.text) { results.push(createChunk(state, { content: delta.text })); } else if (delta?.type === "thinking_delta" && delta.thinking) { + // Thinking travels only in reasoning_content. No "" markers in + // content: OpenAI-format clients render them as literal text. results.push(createChunk(state, reasoningDelta(delta.thinking))); } else if (delta?.type === "input_json_delta" && delta.partial_json) { const toolCall = state.toolCalls.get(chunk.index); @@ -112,10 +110,6 @@ export function claudeToOpenAIResponse(chunk, state) { state.serverToolBlockIndex = -1; break; } - if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) { - results.push(createChunk(state, { content: "" })); - state.inThinkingBlock = false; - } state.textBlockStarted = false; state.thinkingBlockStarted = false; break; diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index c3d16448..80f8a785 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -129,12 +129,16 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } if (content) { + // The answer starts, so thinking is over. Upstreams that send reasoning via + // reasoning_content never emit "", so close it here rather than at finish. + closeReasoning(state, emit); emitTextContent(state, emit, idx, content); } } // Handle tool_calls (empty array is truthy; require a real call) if (delta.tool_calls && delta.tool_calls.length) { + closeReasoning(state, emit); closeMessage(state, emit, idx); for (const tc of delta.tool_calls) { emitToolCall(state, emit, tc); diff --git a/tests/translator/__snapshots__/golden-response-stream.test.js.snap b/tests/translator/__snapshots__/golden-response-stream.test.js.snap index 190047fe..c7050947 100644 --- a/tests/translator/__snapshots__/golden-response-stream.test.js.snap +++ b/tests/translator/__snapshots__/golden-response-stream.test.js.snap @@ -17,21 +17,6 @@ exports[`GOLDEN response stream: Claude → OpenAI > text + thinking + tool_use "model": "claude-opus-4-6", "object": "chat.completion.chunk", }, - { - "choices": [ - { - "delta": { - "content": "", - }, - "finish_reason": null, - "index": 0, - }, - ], - "created": 0, - "id": "chatcmpl-msg_1", - "model": "claude-opus-4-6", - "object": "chat.completion.chunk", - }, { "choices": [ { @@ -47,21 +32,6 @@ exports[`GOLDEN response stream: Claude → OpenAI > text + thinking + tool_use "model": "claude-opus-4-6", "object": "chat.completion.chunk", }, - { - "choices": [ - { - "delta": { - "content": "", - }, - "finish_reason": null, - "index": 0, - }, - ], - "created": 0, - "id": "chatcmpl-msg_1", - "model": "claude-opus-4-6", - "object": "chat.completion.chunk", - }, { "choices": [ { diff --git a/tests/unit/claude-thinking-stream-boundaries.test.js b/tests/unit/claude-thinking-stream-boundaries.test.js new file mode 100644 index 00000000..99ab7925 --- /dev/null +++ b/tests/unit/claude-thinking-stream-boundaries.test.js @@ -0,0 +1,175 @@ +// Thinking/answer boundaries across the OpenAI pivot. +// +// claude-to-openai used to mark a Claude thinking block with literal "" / +// "" chunks in delta.content while the thinking text itself went out in +// reasoning_content. The pair always arrived empty and adjacent, so OpenAI-format +// clients (opencode, DeepSeek Harness, ...) rendered a bare "" above +// every answer (#3399, #4199). +// +// The Responses translators leaned on that "" marker as their only signal +// to close the reasoning item before the answer. Dropping the marker therefore +// requires closing reasoning when the first message text or tool call arrives — +// which also fixes item ordering for every reasoning_content provider (DeepSeek, +// GLM, Qwen, Kimi), not just Claude. +import { describe, it, expect } from "vitest"; +import { claudeToOpenAIResponse } from "../../open-sse/translator/response/claude-to-openai.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js"; +import { createResponsesApiTransformStream } from "../../open-sse/transformer/responsesTransformer.js"; + +const THINKING = "391 factors as 17 times 23, so it's not prime."; +const ANSWER = "No — 391 = 17 × 23."; + +function claudeThinkingStream({ thinkingText = THINKING, answer = ANSWER } = {}) { + const thinkingDeltas = thinkingText + ? [{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: thinkingText } }] + : []; + return [ + { type: "message_start", message: { id: "msg_1", model: "claude-opus-5", role: "assistant", content: [], usage: { input_tokens: 10, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "", signature: "" } }, + ...thinkingDeltas, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_abc" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: answer } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 20 } }, + { type: "message_stop" }, + ]; +} + +function runClaudeToOpenAI(events) { + const state = {}; + const out = []; + for (const ev of events) { + const r = claudeToOpenAIResponse(ev, state); + if (Array.isArray(r)) out.push(...r); + else if (r) out.push(r); + } + const deltas = out.map((c) => c.choices?.[0]?.delta || {}); + return { + content: deltas.map((d) => d.content || "").join(""), + reasoning: deltas.map((d) => d.reasoning_content || "").join(""), + contentChunks: deltas.map((d) => d.content).filter((c) => c != null), + }; +} + +async function drain(stream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + text += typeof value === "string" ? value : decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); +} + +function sseStream(chunks) { + const encoder = new TextEncoder(); + const body = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + "data: [DONE]\n\n"; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); +} + +// Upstream speaks `upstream`, client speaks the Responses API. +async function viaResponsesTranslator(chunks, upstream, provider, model) { + const out = sseStream(chunks).pipeThrough( + createSSETransformStreamWithLogger(upstream, FORMATS.OPENAI_RESPONSES, provider, null, null, model), + ); + return parseEvents(await drain(out)); +} + +async function viaResponsesTransformer(chunks) { + return parseEvents(await drain(sseStream(chunks).pipeThrough(createResponsesApiTransformStream(null)))); +} + +function parseEvents(text) { + return text + .split("\n") + .filter((l) => l.startsWith("data: ") && l.slice(6).trim() !== "[DONE]") + .map((l) => { + try { return JSON.parse(l.slice(6)); } catch { return null; } + }) + .filter(Boolean); +} + +// Index of the event announcing/finishing an output item of the given type. +function itemEventIndex(events, eventType, itemType) { + return events.findIndex((e) => e.type === eventType && e.item?.type === itemType); +} + +function expectReasoningClosedBefore(events, nextItemType) { + const reasoningDone = itemEventIndex(events, "response.output_item.done", "reasoning"); + const nextAdded = itemEventIndex(events, "response.output_item.added", nextItemType); + expect(reasoningDone).toBeGreaterThanOrEqual(0); + expect(nextAdded).toBeGreaterThanOrEqual(0); + expect(reasoningDone).toBeLessThan(nextAdded); +} + +describe("claude-to-openai: thinking never leaks markers into content", () => { + it("summarized thinking goes to reasoning_content, answer to content, no text", () => { + const { content, reasoning, contentChunks } = runClaudeToOpenAI(claudeThinkingStream()); + expect(reasoning).toBe(THINKING); + expect(content).toBe(ANSWER); + expect(contentChunks.some((c) => c.includes("") || c.includes(""))).toBe(false); + }); + + it("signature-only (redacted) thinking yields no stray markers", () => { + const { content, reasoning } = runClaudeToOpenAI(claudeThinkingStream({ thinkingText: "" })); + expect(reasoning).toBe(""); + expect(content).toBe(ANSWER); + }); +}); + +describe("Responses translator: reasoning closes before the answer", () => { + it("Claude upstream: reasoning item is done before the message item opens", async () => { + const events = await viaResponsesTranslator(claudeThinkingStream(), FORMATS.CLAUDE, "claude", "claude-opus-5"); + expectReasoningClosedBefore(events, "message"); + const summary = events.find((e) => e.type === "response.reasoning_summary_text.done"); + expect(summary?.text).toBe(THINKING); + }); + + it("reasoning_content upstream: reasoning item is done before the message item opens", async () => { + const events = await viaResponsesTranslator([ + { id: "c1", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] }, + { id: "c1", choices: [{ index: 0, delta: { content: ANSWER } }] }, + { id: "c1", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ], FORMATS.OPENAI, "deepseek", "deepseek-flash"); + expectReasoningClosedBefore(events, "message"); + }); + + it("reasoning_content upstream: reasoning item is done before a tool call opens", async () => { + const events = await viaResponsesTranslator([ + { id: "c2", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] }, + { id: "c2", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }] } }] }, + { id: "c2", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }, + ], FORMATS.OPENAI, "deepseek", "deepseek-flash"); + expectReasoningClosedBefore(events, "function_call"); + }); +}); + +describe("responsesTransformer (/v1/responses handler): reasoning closes before the answer", () => { + it("reasoning item is done before the message item opens", async () => { + const events = await viaResponsesTransformer([ + { id: "c3", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] }, + { id: "c3", choices: [{ index: 0, delta: { content: ANSWER } }] }, + { id: "c3", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ]); + expectReasoningClosedBefore(events, "message"); + }); + + it("reasoning item is done before a tool call opens", async () => { + const events = await viaResponsesTransformer([ + { id: "c4", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] }, + { id: "c4", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_2", type: "function", function: { name: "lookup", arguments: "{}" } }] } }] }, + { id: "c4", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }, + ]); + expectReasoningClosedBefore(events, "function_call"); + }); +}); From c2148179c06f8bba8bebde8fa610c864d4232eec Mon Sep 17 00:00:00 2001 From: Christian Gennari Date: Sat, 26 Sep 2026 11:53:44 +0700 Subject: [PATCH 20/38] fix(commandcode): replay raw byte chunks to preserve all NDJSON lines --- open-sse/executors/commandcode.js | 34 +++++++------------------ tests/unit/commandcode-executor.test.js | 24 +++++++++++++++++ 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.js index d923fd50..c1af7e69 100644 --- a/open-sse/executors/commandcode.js +++ b/open-sse/executors/commandcode.js @@ -141,7 +141,7 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model) const reader = originalResponse.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - const bufferedLines = []; + const rawChunks = []; let detectedError = null; try { @@ -155,16 +155,15 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model) const parsed = JSON.parse(jsonStr); if (parsed?.type === "error") { detectedError = parsed; - } else { - bufferedLines.push(trimmed); } } catch { - bufferedLines.push(trimmed); + /* ignore */ } } break; } + rawChunks.push(value); buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; @@ -175,7 +174,6 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model) if (!trimmed) continue; const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed; if (!jsonStr || jsonStr === "[DONE]") { - bufferedLines.push(trimmed); stopLoop = true; break; } @@ -184,7 +182,6 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model) try { event = JSON.parse(jsonStr); } catch { - bufferedLines.push(trimmed); continue; } @@ -194,8 +191,6 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model) break; } - bufferedLines.push(trimmed); - if ( event?.type === "text-delta" || event?.type === "reasoning-delta" || @@ -238,29 +233,18 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model) ); } - const combinedStream = createReplayedStream(bufferedLines, buffer, reader); + const combinedStream = createRawReplayedStream(rawChunks, reader); return wrapNdjsonAsOpenAISse(combinedStream, model, originalResponse); } -function createReplayedStream(bufferedLines, remainingBuffer, reader) { - const encoder = new TextEncoder(); - let replayed = false; +function createRawReplayedStream(rawChunks, reader) { + let chunkIndex = 0; return new ReadableStream({ async pull(controller) { - if (!replayed) { - replayed = true; - let prefix = bufferedLines.join("\n"); - if (prefix && remainingBuffer) { - prefix += "\n" + remainingBuffer; - } else if (remainingBuffer) { - prefix = remainingBuffer; - } else if (prefix) { - prefix += "\n"; - } - if (prefix) { - controller.enqueue(encoder.encode(prefix)); - } + if (chunkIndex < rawChunks.length) { + controller.enqueue(rawChunks[chunkIndex++]); + return; } try { diff --git a/tests/unit/commandcode-executor.test.js b/tests/unit/commandcode-executor.test.js index f498b39c..3c97e27a 100644 --- a/tests/unit/commandcode-executor.test.js +++ b/tests/unit/commandcode-executor.test.js @@ -133,6 +133,30 @@ describe("inspectAndWrapCommandCodeResponse", () => { expect(text).toContain("data: [DONE]"); }); + it("preserves all lines in a multi-line packet when inspecting tool-input-start", async () => { + const packet = [ + JSON.stringify({ type: "start" }), + JSON.stringify({ type: "start-step" }), + JSON.stringify({ type: "tool-input-start", id: "call_1", toolName: "terminal" }), + JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '{"command": "ls"}' }), + JSON.stringify({ type: "finish-step", finishReason: "tool-calls" }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), + ].join("\n") + "\n"; + + const ndjsonBody = createNdjsonStream([packet]); + + const fakeResponse = new Response(ndjsonBody, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "cmc/deepseek/deepseek-v4.1-flash"); + expect(result.ok).toBe(true); + const text = await result.text(); + expect(text).toContain('"name":"terminal"'); + expect(text).toContain('"arguments":"{\\"command\\": \\"ls\\"}"'); + }); + it("retries when initial stream yields an error and succeeds on second attempt", async () => { let callCount = 0; const executor = new CommandCodeExecutor(); From 273f0c32cd377ffa81823eaa50e43cfb1370a0a5 Mon Sep 17 00:00:00 2001 From: ANIRUDDHA ADAK Date: Sat, 26 Sep 2026 11:59:12 +0700 Subject: [PATCH 21/38] fix(responses): carry the streamed output items in response.completed (#4307) --- .../translator/response/openai-responses.js | 73 ++++++--- tests/unit/responses-completed-output.test.js | 151 ++++++++++++++++++ 2 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 tests/unit/responses-completed-output.test.js diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index 80f8a785..6a865d1a 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -223,15 +223,19 @@ function closeReasoning(state, emit) { part: { type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf } }); + const item = { + id: state.reasoningId, + type: RESPONSES_ITEM.REASONING, + summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf }] + }; + emit("response.output_item.done", { type: "response.output_item.done", output_index: state.reasoningIndex, - item: { - id: state.reasoningId, - type: RESPONSES_ITEM.REASONING, - summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf }] - } + item }); + + recordCompletedOutputItem(state, state.reasoningIndex, item); } } @@ -295,16 +299,20 @@ function closeMessage(state, emit, idx) { part: { type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText } }); + const item = { + id: msgId, + type: RESPONSES_ITEM.MESSAGE, + content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText }], + role: ROLE.ASSISTANT + }; + emit("response.output_item.done", { type: "response.output_item.done", output_index: parseInt(idx), - item: { - id: msgId, - type: RESPONSES_ITEM.MESSAGE, - content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText }], - role: ROLE.ASSISTANT - } + item }); + + recordCompletedOutputItem(state, parseInt(idx), item); } } @@ -398,23 +406,51 @@ function closeToolCall(state, emit, idx) { }); } + const item = { + id: `${custom ? "ctc" : "fc"}_${callId}`, + type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL, + ...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }), + call_id: callId, + name: state.funcNames[idx] || "" + }; + emit("response.output_item.done", { type: "response.output_item.done", output_index: parseInt(idx), - item: { - id: `${custom ? "ctc" : "fc"}_${callId}`, - type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL, - ...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }), - call_id: callId, - name: state.funcNames[idx] || "" - } + item }); + recordCompletedOutputItem(state, parseInt(idx), item); + state.funcItemDone[idx] = true; state.funcArgsDone[idx] = true; } } +// response.completed carries the finished Response object, so response.output has +// to repeat the items already delivered in response.output_item.done. Clients that +// build their final result from the terminal event (GitHub Copilot CLI, the OpenAI +// SDK "final response" helpers) otherwise treat the turn as empty even though the +// text was streamed - see issue #4307. +// +// Keyed by output_index so a repeated close overwrites rather than duplicating the +// item, and ordered by output_index so response.output matches the order the items +// were emitted in. Lazily created because stream.js can hand us a state it built +// itself rather than one from initState(). +function recordCompletedOutputItem(state, outputIndex, item) { + state.completedOutputItems ??= new Map(); + const index = Number.isInteger(outputIndex) ? outputIndex : Number.parseInt(outputIndex, 10) || 0; + state.completedOutputItems.set(index, item); +} + +function collectCompletedOutputItems(state) { + const recorded = state.completedOutputItems; + if (!(recorded instanceof Map) || recorded.size === 0) return []; + return [...recorded.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([, item]) => item); +} + function sendCompleted(state, emit) { if (!state.completedSent) { state.completedSent = true; @@ -427,6 +463,7 @@ function sendCompleted(state, emit) { status: "completed", background: false, error: null, + output: collectCompletedOutputItems(state), ...(state.responsesUsage ? { usage: state.responsesUsage } : {}) } }); diff --git a/tests/unit/responses-completed-output.test.js b/tests/unit/responses-completed-output.test.js new file mode 100644 index 00000000..f72c4603 --- /dev/null +++ b/tests/unit/responses-completed-output.test.js @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { initState } from "../../open-sse/translator/index.js"; +import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.js"; + +// targetFormat === OPENAI is the direct openai -> openai-responses route, which is +// the only one where flush() reaches this translator (see the flushReachesUs note +// above the finish_reason branch). +function newState() { + return { ...initState(FORMATS.OPENAI_RESPONSES), targetFormat: FORMATS.OPENAI }; +} + +function textChunk(text, index = 0) { + return { id: "chatcmpl-1", choices: [{ index, delta: { content: text } }] }; +} + +function reasoningChunk(text, index = 0) { + return { id: "chatcmpl-1", choices: [{ index, delta: { reasoning_content: text } }] }; +} + +function finishChunk(usage) { + return { id: "chatcmpl-1", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage }; +} + +function runChunks(chunks) { + const state = newState(); + const events = []; + for (const chunk of chunks) { + for (const event of openaiToOpenAIResponsesResponse(chunk, state)) events.push(event); + } + return { state, events }; +} + +function completedResponse(events) { + const completed = events.find((event) => event.event === "response.completed"); + expect(completed, "expected a response.completed event").toBeTruthy(); + return completed.data.response; +} + +function doneItems(events) { + return events + .filter((event) => event.event === "response.output_item.done") + .map((event) => event.data.item); +} + +describe("response.completed output (issue #4307)", () => { + // The regression: sendCompleted() built the response object without an `output` + // key at all, so response.completed arrived with no output even though the + // message had already been streamed. Clients that build the final result from + // the terminal event (GitHub Copilot CLI 1.0.89 with a BYOK provider) printed + // the text and then failed with "No response was returned". + it("repeats the streamed message in response.completed", () => { + const state = newState(); + openaiToOpenAIResponsesResponse(textChunk("O"), state); + openaiToOpenAIResponsesResponse(textChunk("K"), state); + const response = completedResponse(openaiToOpenAIResponsesResponse(null, state)); + + expect(response.status).toBe("completed"); + expect(Array.isArray(response.output)).toBe(true); + expect(response.output).toHaveLength(1); + expect(response.output[0]).toMatchObject({ type: "message", role: "assistant" }); + expect(response.output[0].content[0]).toMatchObject({ type: "output_text", text: "OK" }); + }); + + it("matches exactly the items already delivered in response.output_item.done", () => { + const { events } = runChunks([ + textChunk("hello"), + finishChunk({ prompt_tokens: 7, completion_tokens: 2, total_tokens: 9 }), + ]); + const response = completedResponse(events); + const streamed = doneItems(events); + + expect(streamed).toHaveLength(1); + expect(response.output).toEqual(streamed); + }); + + it("includes a function_call item", () => { + const { events } = runChunks([ + { + id: "chatcmpl-1", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { index: 0, id: "call_1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }, + ], + }, + }, + ], + }, + finishChunk({ prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + const response = completedResponse(events); + + expect(response.output).toHaveLength(1); + expect(response.output[0]).toMatchObject({ + type: "function_call", + name: "get_weather", + arguments: '{"city":"Paris"}', + call_id: "call_1", + }); + }); + + it("orders output by output_index", () => { + const { events } = runChunks([ + reasoningChunk("thinking", 0), + textChunk("answer", 1), + finishChunk({ prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 }), + ]); + const response = completedResponse(events); + + expect(response.output.map((item) => item.type)).toEqual(["reasoning", "message"]); + expect(response.output[1].content[0]).toMatchObject({ type: "output_text", text: "answer" }); + }); + + it("reports an empty output array when nothing was produced", () => { + const state = newState(); + const response = completedResponse(openaiToOpenAIResponsesResponse(null, state)); + expect(response.output).toEqual([]); + }); + + it("keeps the usage block alongside output", () => { + const { events } = runChunks([ + textChunk("OK"), + finishChunk({ prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }), + ]); + const response = completedResponse(events); + + expect(response.usage).toMatchObject({ input_tokens: 3, output_tokens: 1, total_tokens: 4 }); + expect(response.output).toHaveLength(1); + }); + + it("leaves the in-progress response.created output empty", () => { + const { events } = runChunks([textChunk("hi")]); + const created = events.find((event) => event.event === "response.created"); + expect(created.data.response.status).toBe("in_progress"); + expect(created.data.response.output).toEqual([]); + }); + + it("does not duplicate items when flush runs more than once", () => { + const state = newState(); + openaiToOpenAIResponsesResponse(textChunk("once"), state); + openaiToOpenAIResponsesResponse(null, state); + const second = openaiToOpenAIResponsesResponse(null, state); + + expect(second).toEqual([]); + expect(state.completedOutputItems.size).toBe(1); + }); +}); \ No newline at end of file From fe347e4ea522759199eaac9d2187777d04353a12 Mon Sep 17 00:00:00 2001 From: Nick Nyanjui Date: Sat, 26 Sep 2026 11:48:48 +0700 Subject: [PATCH 22/38] fix(stt): dispatch live-API-only Gemini models over the Live WebSocket transport (#4006) Addresses #4006 by letting Gemini STT models that only exist on the Live API transcribe instead of failing. transcribeGemini sends audio to :generateContent and that is the only Gemini path. A model that is realtime-only (exposed by the Live API's bidiGenerateContent WebSocket) therefore fails outright, even though the account can transcribe it. open-sse/handlers/geminiLiveStt.js owns the WebSocket lifecycle: opens :bidiGenerateContent, sends setup frame, waits for setupComplete, streams audio as realtimeInput media chunks, and settles on turnComplete. Dispatch is driven by transport marker 'gemini-live'. Adds custom model transport persistence and selection on the dashboard. --- open-sse/handlers/geminiLiveStt.js | 266 +++++++++ open-sse/handlers/sttCore.js | 46 +- open-sse/providers/registry/gemini.js | 1 + .../providers/[id]/AddCustomModelModal.js | 38 +- .../dashboard/providers/[id]/page.js | 12 +- src/app/api/models/custom/route.js | 17 +- src/lib/db/repos/aliasRepo.js | 8 +- src/shared/constants/models.js | 20 + src/sse/handlers/stt.js | 25 +- tests/unit/gemini-live-stt.test.js | 534 ++++++++++++++++++ 10 files changed, 945 insertions(+), 22 deletions(-) create mode 100644 open-sse/handlers/geminiLiveStt.js create mode 100644 tests/unit/gemini-live-stt.test.js diff --git a/open-sse/handlers/geminiLiveStt.js b/open-sse/handlers/geminiLiveStt.js new file mode 100644 index 00000000..51837732 --- /dev/null +++ b/open-sse/handlers/geminiLiveStt.js @@ -0,0 +1,266 @@ +import { Buffer } from "node:buffer"; + +// Gemini Live API realtime STT transport. +// +// The REST generateContent path (sttCore.transcribeGemini) only transcribes +// whole files inline. The Live API's `:bidiGenerateContent` WebSocket is the +// streaming counterpart: audio goes up as realtimeInput mediaChunks and the +// server pushes incremental `serverContent.inputTranscription` events back. +// This module owns the socket lifecycle only — envelope/response shaping +// stays in sttCore so the engine's single STT exit shape is preserved. +// +// Marker contract: dispatched from sttCore's format-switch when the model +// entry carries `transport: "gemini-live"` (registry) or the caller passes a +// transport string (custom models). Never keyed on a hardcoded model id here. +// +// Transport behavior: +// - Node >= 22 global WebSocket (undici). No new dependency. +// - Live API expects low-latency PCM; other containers are forwarded with +// their declared MIME unchanged (provider-side rejection is surfaced). +// - Text accumulation is append-only over inputTranscription segments and +// ends on serverContent.turnComplete (or graceful close with partial text). +// - Transcription deltas are kept per-frame (chunks[]) so sttCore can shape +// verbose_json segments without fabricating timestamps. goAway advisements +// rotate the socket once per call: setup replay + byte-offset resume. + +const SETUP_TIMEOUT_MS = 10_000; // open → setupComplete +const TURN_TIMEOUT_MS = 60_000; // audio streamed → turnComplete +const MAX_TIMEOUT_MS = 300_000; // clamp ceiling for client-supplied lifecycle knobs +const CHUNK_BYTES = 16_384; // ~0.5s of 16-bit 16kHz mono PCM +const GOAWAY_RECONNECTS = 1; // socket rotations honoured per call + +class GeminiLiveError extends Error { + constructor(message, status) { + super(message); + this.name = "GeminiLiveError"; + this.status = status || 502; + } +} + +// REST base (https://host/v1beta/models) → Live WS base +// (wss://host/ws/api/v1beta/models), then the bidiGenerateContent endpoint. +function toLiveWsUrl(baseUrl, model, token) { + const url = new URL(baseUrl); + url.protocol = "wss:"; + if (!url.pathname.startsWith("/ws/")) url.pathname = `/ws/api${url.pathname}`; + const base = url.toString().replace(/\/+$/, ""); + return `${base}/${encodeURIComponent(model)}:bidiGenerateContent?key=${encodeURIComponent(token || "")}`; +} + +// Bind socket events supporting BOTH handler styles: addEventListener +// (browser WebSocket, undici) and onopen/onmessage property assignment +// (minimal polyfills). Whichever the implementation exposes, it works. +function bindSocket(ws, { onOpen, onMessage, onError, onClose }) { + if (typeof ws.addEventListener === "function") { + ws.addEventListener("open", onOpen); + ws.addEventListener("message", onMessage); + ws.addEventListener("error", onError); + ws.addEventListener("close", onClose); + return; + } + ws.onopen = onOpen; + ws.onmessage = onMessage; + ws.onerror = onError; + ws.onclose = onClose; +} + +function parseFrame(data) { + try { + return JSON.parse(typeof data === "string" ? data : String(data)); + } catch { + return null; // non-JSON frames carry no Live API semantics + } +} + +function firstStringField(formData, key) { + const v = typeof formData?.get === "function" ? formData.get(key) : null; + return typeof v === "string" && v.trim() ? v.trim() : ""; +} + +// Lifecycle knobs the live registry entry advertises in params[] +// (setup/turn timeouts). They ride the same formData pass-through sttCore +// gives every transport — no sttCore change needed to reach this leaf. +function firstNumberField(formData, key, fallback) { + const n = Number(firstStringField(formData, key)); + return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_TIMEOUT_MS) : fallback; +} + +/** + * Transcribe an audio File via the Gemini Live bidirectional stream. + * @returns {Promise<{text: string, chunks: string[]}>} transcript plus the raw + * incremental inputTranscription deltas (sttCore shapes verbose_json from them). + * @throws {GeminiLiveError} with .status for the sttCore error envelope. + */ +export async function transcribeGeminiLive({ cfg, file, model, token, formData, mimeType }) { + const WS = globalThis.WebSocket; + if (!WS) throw new GeminiLiveError("Gemini Live transport needs global WebSocket (Node >= 22)", 502); + + const buf = Buffer.from(await file.arrayBuffer()); + if (!buf.length) throw new GeminiLiveError("Empty audio file", 400); + + const instruction = firstStringField(formData, "prompt") || "Transcribe the spoken audio verbatim."; + const language = firstStringField(formData, "language"); + const setupTimeoutMs = firstNumberField(formData, "setup_timeout_ms", SETUP_TIMEOUT_MS); + const turnTimeoutMs = firstNumberField(formData, "turn_timeout_ms", TURN_TIMEOUT_MS); + // system_instruction (registry param) overrides the built-in transcription + // directive wholesale; prompt/language only shape the default. + const instructionOverride = firstStringField(formData, "system_instruction"); + const systemText = instructionOverride + || (language ? `${instruction} Language: ${language}.` : instruction); + const wsUrl = toLiveWsUrl(cfg.baseUrl, model, token); + + return await new Promise((resolve, reject) => { + let text = ""; + const chunks = []; // raw inputTranscription deltas, shaped by sttCore + let settled = false; + let timer = null; + let goAwayTimer = null; + let ws = null; + let generation = 0; // socket identity: superseded closes never settle + let sentBytes = 0; // audio prefix already handed to the live socket + let goAwayReconnects = GOAWAY_RECONNECTS; + + const arm = (ms, message) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fail(new GeminiLiveError(message, 504)), ms); + }; + const shutdown = () => { + if (timer) { clearTimeout(timer); timer = null; } + if (goAwayTimer) { clearTimeout(goAwayTimer); goAwayTimer = null; } + // ws is null until the first open() dials (and stays null when the + // constructor throws) — fail() runs shutdown() on that path. + if (!ws) return; + try { + if (ws.readyState === WS.OPEN || ws.readyState === WS.CONNECTING) ws.close(1000); + } catch { /* socket already dead — outcome is already settled */ } + }; + const succeed = () => { + if (settled) return; + settled = true; + shutdown(); + resolve({ text, chunks }); + }; + const fail = (err) => { + if (settled) return; + settled = true; + shutdown(); + reject(err); + }; + const send = (frame) => { + if (ws.readyState !== WS.OPEN) return false; + try { + ws.send(JSON.stringify(frame)); + } catch { + return false; // socket died mid-send — streamAudioAndPrompt maps this to a 502 + } + return true; + }; + + // Streams every byte not yet sent, then the flushing text turn. After a + // goAway rotation this resumes from sentBytes — no audio re-upload. + const streamAudioAndPrompt = () => { + for (let off = sentBytes; off < buf.length; off += CHUNK_BYTES) { + const mediaChunk = buf.subarray(off, off + CHUNK_BYTES).toString("base64"); + if (!send({ realtimeInput: { mediaChunks: [{ mimeType, data: mediaChunk }] } })) { + fail(new GeminiLiveError("Gemini Live socket closed while streaming audio", 502)); + return; + } + sentBytes = Math.min(off + CHUNK_BYTES, buf.length); + } + // Final user turn: flushes the recognizer and yields turnComplete. + send({ clientContent: { turns: [{ parts: [{ text: systemText }] }], turnComplete: true } }); + }; + + // goAway: the server names the instant it will force-close this socket. + // Graceful play = rotate BEFORE the deadline: retire the live socket, + // dial a fresh one, replay setup, resume audio from sentBytes — text and + // chunks survive the hop. Once the advisory budget is spent a later + // goAway is left to the close path, which settles on partial transcript. + const scheduleGoAwayReconnect = (goAway) => { + if (settled || goAwayTimer || goAwayReconnects <= 0) return; + const deadline = Date.parse(typeof goAway?.time === "string" ? goAway.time : ""); + const delay = Number.isFinite(deadline) + ? Math.max(0, Math.min(deadline - Date.now(), setupTimeoutMs)) + : 0; + goAwayTimer = setTimeout(() => { + goAwayTimer = null; + if (settled) return; + goAwayReconnects--; + generation++; + try { ws?.close(1000); } catch { /* deadline crossed mid-flight — re-dial anyway */ } + open(); + }, delay); + }; + + const open = () => { + const gen = ++generation; + try { + ws = new WS(wsUrl); + } catch { + fail(new GeminiLiveError("Gemini Live websocket connection failed", 502)); + return; + } + bindSocket(ws, { + onOpen: () => { + if (settled || gen !== generation) return; + arm(setupTimeoutMs, "Gemini Live timed out waiting for setupComplete"); + send({ + setup: { + model: `models/${model}`, + generationConfig: { + responseModalities: ["TEXT"], + inputAudioTranscription: {}, + }, + systemInstruction: { parts: [{ text: systemText }] }, + }, + }); + }, + onMessage: (ev) => { + if (settled || gen !== generation) return; + const frame = parseFrame(ev?.data); + if (!frame) return; + + if (frame.error) { + const e = frame.error; + fail(new GeminiLiveError(`Gemini Live error${e.status ? ` (${e.status})` : ""}: ${e.message || "unknown"}`, 502)); + return; + } + if (frame.goAway) { + scheduleGoAwayReconnect(frame.goAway); + return; + } + + const sc = frame.serverContent; + if (!sc) return; + + const delta = typeof sc.inputTranscription?.text === "string" ? sc.inputTranscription.text : ""; + // Trim before testing: a padding-only frame carries no transcript and + // must not make an empty run look like a partial success on close. + if (delta.trim()) { + text += delta; + chunks.push(delta); + } + + if (sc.setupComplete) { + arm(turnTimeoutMs, "Gemini Live transcription timed out"); + streamAudioAndPrompt(); + return; + } + if (sc.turnComplete) succeed(); + }, + onError: () => { + if (settled || gen !== generation) return; + fail(new GeminiLiveError("Gemini Live websocket connection failed", 502)); + }, + onClose: (ev) => { + if (settled || gen !== generation) return; + // Partial transcript beats a hard error on graceful close; silence is one. + if (text.trim()) succeed(); + else fail(new GeminiLiveError(`Gemini Live socket closed before completion${ev?.code ? ` (code ${ev.code})` : ""}`, 502)); + }, + }); + }; + + open(); + }); +} diff --git a/open-sse/handlers/sttCore.js b/open-sse/handlers/sttCore.js index 8127782a..21582c4d 100644 --- a/open-sse/handlers/sttCore.js +++ b/open-sse/handlers/sttCore.js @@ -1,5 +1,7 @@ import { Buffer } from "node:buffer"; import { createErrorResult } from "../utils/error.js"; +import { transcribeGeminiLive } from "./geminiLiveStt.js"; +import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; // Build auth headers from sttConfig + token @@ -162,11 +164,26 @@ function jsonResponse(obj) { }; } +// Model-level transport marker (registry models[].transport, e.g. the Gemini +// live STT entry's "gemini-live", or a custom model's stored transport). +// Dispatch reads the marker — never a hardcoded model id — so new realtime +// providers extend sttCore through data, not code. +function resolveModelTransport(provider, model) { + const key = PROVIDER_ID_TO_ALIAS[provider] || provider; + const models = PROVIDER_MODELS[key] || PROVIDER_MODELS[provider]; + if (!Array.isArray(models)) return null; + const entry = models.find((m) => m && m.id === model && (m.kind || "llm") === "stt"); + const marker = typeof entry?.transport === "string" ? entry.transport.trim() : ""; + return marker || null; +} + /** - * STT core handler — dispatch by sttConfig.format. + * STT core handler — dispatch by model transport marker, else sttConfig.format. + * `transport` is the caller-supplied marker override (custom models resolve + * it in the app layer; built-ins fall back to the registry entry marker). * @returns {Promise<{success, response, status?, error?}>} */ -export async function handleSttCore({ provider, model, formData, credentials, sttConfig }) { +export async function handleSttCore({ provider, model, formData, credentials, sttConfig, transport }) { const file = formData.get("file"); if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); @@ -186,8 +203,29 @@ export async function handleSttCore({ provider, model, formData, credentials, st return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`); } + // Format-switch extension: an explicit caller marker wins over the registry + // marker; with neither, the provider-default sttConfig.format applies. + const marker = (typeof transport === "string" && transport.trim()) ? transport.trim() : resolveModelTransport(provider, model); + try { - switch (cfg.format) { + switch (marker || cfg.format) { + case "gemini-live": { + const live = await transcribeGeminiLive({ cfg, file, model, token, formData, mimeType: resolveAudioContentType(file) }); + // response_format parity with the OpenAI-compatible transport: default + // envelope stays {text}; verbose_json adds segments mapped from the + // Live API's incremental inputTranscription deltas. Those frames carry + // NO timestamps, so segments expose {id,text} only (id = delta order, + // Whisper-compatible 0-based) — start/end/duration are deliberately + // absent rather than fabricated as zeros, which would misrepresent + // provider data to callers diffing transports. + const fmt = typeof formData?.get === "function" + ? String(formData.get("response_format") ?? "").trim().toLowerCase() + : ""; + if (fmt === "verbose_json") { + return jsonResponse({ text: live.text, segments: live.chunks.map((segText, id) => ({ id, text: segText })) }); + } + return jsonResponse({ text: live.text }); + } case "deepgram": return await transcribeDeepgram(cfg, file, model, token, formData); case "assemblyai": return await transcribeAssemblyAI(cfg, file, model, token); case "nvidia-asr": return await transcribeNvidia(cfg, file, model, token); @@ -196,6 +234,6 @@ export async function handleSttCore({ provider, model, formData, credentials, st default: return await transcribeOpenAICompatible(cfg, file, model, token, formData); } } catch (err) { - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "STT request failed"); + return createErrorResult(err.status || HTTP_STATUS.BAD_GATEWAY, err.message || "STT request failed"); } } diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index 590bf48e..e0fb109d 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -58,6 +58,7 @@ export default { { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", params: ["language","prompt"], kind: "stt" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)", params: ["language","prompt"], kind: "stt" }, { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", params: ["language","prompt"], kind: "stt" }, + { id: "gemini-2.5-flash-native-audio-preview-09-17", name: "Gemini Live Transcription (Realtime)", params: ["language","prompt","system_instruction","setup_timeout_ms","turn_timeout_ms"], kind: "stt", transport: "gemini-live" }, { id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", kind: "tts" }, { id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", kind: "tts" }, { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", kind: "tts" }, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js index 3c9b21d0..2636be3d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js @@ -2,8 +2,8 @@ import { useState, useEffect } from "react"; import PropTypes from "prop-types"; -import { Button, Modal, Toggle } from "@/shared/components"; -import { CAPACITY_META } from "@/shared/constants/models"; +import { Button, Modal, Select, Toggle } from "@/shared/components"; +import { CAPACITY_META, STT_TRANSPORT_META, STT_TRANSPORTS } from "@/shared/constants/models"; const defaultCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false])); @@ -13,10 +13,12 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" const [testError, setTestError] = useState(""); const [saving, setSaving] = useState(false); + // Realtime dispatch marker for the transport select; "" = provider default REST. + const [transport, setTransport] = useState(""); // Reset state when modal opens useEffect(() => { - if (isOpen) { setModelId(""); setCaps(defaultCaps()); setTestStatus(null); setTestError(""); } + if (isOpen) { setModelId(""); setCaps(defaultCaps()); setTransport(""); setTestStatus(null); setTestError(""); } }, [isOpen]); // Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider) @@ -50,7 +52,9 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis if (!cleanId || saving) return; setSaving(true); try { - await onSave(cleanId, caps); + // caps.stt is UI-only; the parent save flow derives the model type from + // it and forwards the pinned transport (null unless the caller picked one). + await onSave(cleanId, caps, caps.stt ? transport : null); } finally { setSaving(false); } @@ -106,6 +110,32 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis + {/* STT is a model TYPE, not a chat capability: the save flow turns this + flag into type "stt" (the API honours a transport only on stt + records). The select pins the realtime dispatch marker persisted + with the model; the whitelist is the shared STT_TRANSPORT_META. */} +
+ { setCaps((prev) => ({ ...prev, stt: v })); if (!v) setTransport(""); }} + label="Speech to text" + description="Transcribes audio via /v1/audio/transcriptions" + size="sm" + /> + {caps.stt && ( +
+ + +
+
+ +
+

Step 2: Paste the callback URL here

+

+ After authorization, copy the full URL from your browser (or the local callback page). +

+ setCallbackUrl(e.target.value)} + placeholder="http://127.0.0.1:.../?user_id=...&access_token=..." + className="font-mono text-xs" + /> +
+ + + {error && ( +
+

{error}

+
+ )} + +
+ + +
+ + )} + + + ); +} + +ZedAuthModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + providerInfo: PropTypes.object, + onSuccess: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index e5698a63..b51e27e1 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -27,6 +27,7 @@ export { default as KiroAuthModal } from "./KiroAuthModal"; export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper"; export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal"; export { default as CursorAuthModal } from "./CursorAuthModal"; +export { default as ZedAuthModal } from "./ZedAuthModal"; export { default as XiaomiMimoAuthModal } from "./XiaomiMimoAuthModal"; export { default as IFlowCookieModal } from "./IFlowCookieModal"; export { default as GitLabAuthModal } from "./GitLabAuthModal"; From dd293d3c620e8d7612a2db40f4e534351e1d2b59 Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 26 Sep 2026 16:06:47 +0700 Subject: [PATCH 28/38] feat(opencode-go): add the seven models upstream serves but the registry omits (#4357) Upstream /zen/go/v1/models serves 35 ids; the registry listed 28. Add the seven missing models with their corresponding supported and target formats: - deepseek-v4.1-flash - mimo-v2.6-flash, mimo-v2.6-pro - space-bunny-free - omen-alpha - grok-4.7 (responses-only) - gpt-6-luna (responses-only) --- open-sse/providers/registry/opencode-go.js | 4 +++- tests/unit/opencode-go-models.test.js | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index b442a4f1..7d161270 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -48,7 +48,7 @@ export default { { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)", supportedFormats: ["openai", "claude", "openai-responses"] }, - { id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] }, + { id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "longcat-2.0", name: "LongCat 2.0", supportedFormats: ["openai"] }, { id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", supportedFormats: ["openai"] }, { id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", supportedFormats: ["openai"] }, @@ -59,6 +59,7 @@ export default { { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] }, { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] }, { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] }, + { id: "space-bunny-free", name: "Space Bunny Free", supportedFormats: ["openai", "claude"] }, { id: "qwen3.8-max", name: "Qwen 3.8 Max", supportedFormats: ["openai", "claude"] }, { id: "qwen3.8-flash", name: "Qwen 3.8 Flash", supportedFormats: ["openai", "claude"] }, { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] }, @@ -76,6 +77,7 @@ export default { { id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "grok-4.5", name: "Grok 4.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-6-luna", name: "GPT 6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, ], diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 1979e692..fcdf3888 100644 --- a/tests/unit/opencode-go-models.test.js +++ b/tests/unit/opencode-go-models.test.js @@ -5,13 +5,13 @@ import { resolveTransport } from "../../open-sse/services/provider.js"; // Chat-only models (no /messages, no /responses support on opencode-go) const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-k3", - "deepseek-flash", "deepseek-v4.1-flash", "longcat-2.0", "mimo-v2.6-flash", "mimo-v2.6-pro", + "deepseek-flash", "longcat-2.0", "mimo-v2.6-flash", "mimo-v2.6-pro", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni", "hy4-preview", "hy3", "hy3-preview", "omen-alpha"]; // Models that also expose the Anthropic /messages endpoint -const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", +const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "space-bunny-free", "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus"]; // Models that also expose the OpenAI /responses endpoint -const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"]; +const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4.1-flash"]; // Mirror of chatCore's per-model transport guard: use the sourceFormat-matched // transport only when the model declares support for that sourceFormat. @@ -29,10 +29,10 @@ describe("OpenCode Go model catalog", () => { "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-k3", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4.1-flash", "longcat-2.0", "mimo-v2.6-flash", "mimo-v2.6-pro", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni", - "minimax-m3", "minimax-m2.7", "minimax-m2.5", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", "space-bunny-free", "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "hy4-preview", "hy3", "hy3-preview", "omen-alpha", - "grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna", + "grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna", "gpt-6-luna", "muse-spark-1.2-contributor", "muse-spark-1.3-contributor", ]); }); @@ -57,7 +57,7 @@ describe("OpenCode Go family fallback (unknown/passthrough ids)", () => { }); it("curated entries win over the family regex", () => { - expect(getModelSupportedFormats("opencode-go", "deepseek-v4.1-flash")).toEqual(["openai"]); + expect(getModelSupportedFormats("opencode-go", "deepseek-flash")).toEqual(["openai"]); expect(getModelSupportedFormats("opencode-go", "deepseek-v4-pro")).toEqual(["openai", "claude", "openai-responses"]); }); }); @@ -133,7 +133,7 @@ describe("OpenCode Go per-model transport guard (chatCore logic)", () => { }); it("routes Muse Spark (responses-only) to /responses, never to /messages", () => { - for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna"]) { + for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna", "gpt-6-luna"]) { expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai-responses"]); expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses"); expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull(); From fdcba3e1b2d475d1755e43c447929f17cc6d0c7f Mon Sep 17 00:00:00 2001 From: Mohammad Hijjawi Date: Sat, 26 Sep 2026 16:18:18 +0700 Subject: [PATCH 29/38] fix(capabilities): stop caching the catalog source per module copy (#4351) --- open-sse/providers/capabilities.js | 8 ++++---- tests/unit/model-catalog-scope.test.js | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index dd61b3e2..e3bb58ac 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -484,7 +484,8 @@ const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; // 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. +// lives on globalThis instead, and every read goes through it: caching it locally +// would keep a reader alive in other copies after setCatalogSource(null). let catalogSource = null; /** @@ -498,9 +499,8 @@ export function setCatalogSource(source) { } function getCatalogSource() { - if (catalogSource) return catalogSource; - if (typeof globalThis === "undefined") return null; - return (catalogSource = globalThis.__9rCatalogSource || null); + if (typeof globalThis === "undefined") return catalogSource; + return globalThis.__9rCatalogSource || null; } // Apply the synced catalog + name heuristic on top of a table-resolved result. diff --git a/tests/unit/model-catalog-scope.test.js b/tests/unit/model-catalog-scope.test.js index c582d28f..5177901f 100644 --- a/tests/unit/model-catalog-scope.test.js +++ b/tests/unit/model-catalog-scope.test.js @@ -122,6 +122,22 @@ describe("model catalog", () => { } expect(globalThis.__9rCatalogSource).toBeNull(); }); + + it("detaches the source from a copy that already resolved through it", async () => { + capabilities.setCatalogSource({ + getModalities: (provider) => (provider === "gateway-a" ? { vision: true } : null), + getLimits: () => null, + }); + const other = await import("../../open-sse/providers/capabilities.js?copy=3"); + try { + expect(other.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true); + } finally { + capabilities.setCatalogSource(null); + } + // the sync resets the source before rebuilding; a copy that has read the + // slot once must not keep serving the uninstalled reader + expect(other.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(false); + }); }); describe("catalog schema", () => { From 8f20daac6b3fdef535780f2ded8649527f63a179 Mon Sep 17 00:00:00 2001 From: Beru Date: Sat, 26 Sep 2026 16:20:48 +0700 Subject: [PATCH 30/38] fix(cli-tools): refresh Codex settings after apply (#4347) --- .../cli-tools/components/BaseUrlSelect.js | 30 +++++++---- .../cli-tools/components/CodexToolCard.js | 31 +++++------ .../cli-tools/components/codexConfig.js | 54 +++++++++++++++++++ src/app/api/cli-tools/codex-settings/route.js | 4 +- .../codex-current-provider-base-url.test.js | 44 +++++++++++++++ tests/unit/codex-settings-refresh.test.js | 30 +++++++++++ 6 files changed, 167 insertions(+), 26 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js create mode 100644 tests/unit/codex-current-provider-base-url.test.js create mode 100644 tests/unit/codex-settings-refresh.test.js diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js index 52ca8b09..cfea9c95 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js @@ -57,6 +57,7 @@ export default function BaseUrlSelect({ const [mode, setMode] = useState(""); const [customInput, setCustomInput] = useState(""); const initializedRef = useRef(false); + const currentUrlRef = useRef(""); const customInputRef = useRef(""); useEffect(() => { @@ -85,23 +86,34 @@ export default function BaseUrlSelect({ [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] ); - // Prefer a saved preset matching the currently configured URL, else first option + // Sync the active config URL without replacing edits unless the config itself changes. useEffect(() => { - if (initializedRef.current) return; if (!presetsLoaded || options.length === 0) return; + const normalizeUrl = (url) => (withV1 ? ensureV1(url) : stripSlash(url)); + const current = normalizeUrl(currentUrl); + if (initializedRef.current && currentUrlRef.current === current) return; initializedRef.current = true; - const current = stripSlash(currentUrl); + currentUrlRef.current = current; const matched = current - ? options.find((o) => o.saved && stripSlash(o.url) === current) + ? options.find((o) => o.value !== CUSTOM_VALUE && normalizeUrl(o.url) === current) : null; - const target = matched || options.find((o) => o.value !== CUSTOM_VALUE); - if (target) { + if (matched) { + setCustomInput(""); + customInputRef.current = ""; + setMode(matched.value); + onChange(matched.url); + } else if (current) { + setCustomInput(current); + customInputRef.current = current; + setMode(CUSTOM_VALUE); + onChange(current); + } else { + const target = options.find((o) => o.value !== CUSTOM_VALUE); + if (!target) return; setMode(target.value); onChange(target.url); - } else { - setMode(CUSTOM_VALUE); } - }, [presetsLoaded, options, onChange, currentUrl]); + }, [presetsLoaded, options, onChange, currentUrl, withV1]); const handleSelect = (e) => { const next = e.target.value; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 6fed52de..cea5522a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -7,6 +7,7 @@ import BaseUrlSelect from "./BaseUrlSelect"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; import { rememberEndpoint } from "./cliEndpointPresets"; +import { getCurrentCodexProviderSettings } from "./codexConfig"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [codexStatus, setCodexStatus] = useState(initialStatus || null); @@ -25,10 +26,10 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const [customBaseUrl, setCustomBaseUrl] = useState(""); useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { + if (apiKeys?.length > 0 && !selectedApiKey && !codexStatus?.config) { setSelectedApiKey(apiKeys[0].key); } - }, [apiKeys, selectedApiKey]); + }, [apiKeys, selectedApiKey, codexStatus?.config]); useEffect(() => { if (initialStatus) setCodexStatus(initialStatus); @@ -51,24 +52,24 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api } }; - // Parse model and subagent settings from config content + // Sync only when config content changes so local form edits are retained. useEffect(() => { - if (codexStatus?.config) { - const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m); + const config = codexStatus?.config; + if (config) { + const { baseUrl, apiKey } = getCurrentCodexProviderSettings(config); + setCustomBaseUrl(baseUrl); + setSelectedApiKey(apiKey); + + const modelMatch = config.match(/^model\s*=\s*"([^"]+)"/m); if (modelMatch) setSelectedModel(modelMatch[1]); // Parse subagent settings - const subagentModelMatch = codexStatus.config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m); + const subagentModelMatch = config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m); if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]); } - }, [codexStatus]); + }, [codexStatus?.config]); - const getCurrentBaseUrl = () => { - const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/); - return parsed ? parsed[1] : ""; - }; - - const currentBaseUrl = getCurrentBaseUrl(); + const currentBaseUrl = getCurrentCodexProviderSettings(codexStatus?.config).baseUrl; const getConfigStatus = () => { if (!codexStatus?.installed) return null; @@ -79,7 +80,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const configStatus = getConfigStatus(); const getEffectiveBaseUrl = () => { - const url = customBaseUrl || `${baseUrl}/v1`; + const url = (customBaseUrl || `${baseUrl}/v1`).replace(/\/+$/, ""); // Ensure URL ends with /v1 return url.endsWith("/v1") ? url : `${url}/v1`; }; @@ -89,7 +90,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const checkCodexStatus = async () => { setCheckingCodex(true); try { - const res = await fetch("/api/cli-tools/codex-settings"); + const res = await fetch("/api/cli-tools/codex-settings", { cache: "no-store" }); const data = await res.json(); setCodexStatus(data); } catch (error) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js new file mode 100644 index 00000000..9f628fbd --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js @@ -0,0 +1,54 @@ +const parseTomlString = (line, key) => { + const match = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(["'])([^\\n]*?)\\1\\s*(?:#.*)?$`)); + return match ? match[2] : ""; +}; + +// Only inspect the active provider tables so other providers cannot affect the form. +export function getCurrentCodexProviderSettings(config) { + if (typeof config !== "string") return { baseUrl: "", apiKey: "" }; + + const lines = config.split(/\r?\n/); + let modelProvider = ""; + let inRootTable = true; + + for (const line of lines) { + if (/^\s*\[/.test(line)) { + inRootTable = false; + continue; + } + if (inRootTable) { + modelProvider = parseTomlString(line, "model_provider") || modelProvider; + } + } + + if (!modelProvider) return { baseUrl: "", apiKey: "" }; + + const activeTable = `model_providers.${modelProvider}`; + let inActiveProviderTable = false; + let inActiveHeadersTable = false; + let baseUrl = ""; + let apiKey = ""; + + for (const line of lines) { + const tableMatch = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/); + if (tableMatch) { + inActiveProviderTable = tableMatch[1] === activeTable; + inActiveHeadersTable = tableMatch[1] === `${activeTable}.http_headers`; + continue; + } + if (inActiveProviderTable) { + baseUrl = parseTomlString(line, "base_url") || baseUrl; + } + if (inActiveHeadersTable) { + const authorization = parseTomlString(line, "Authorization"); + const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i); + apiKey = bearerMatch ? bearerMatch[1] : apiKey; + } + } + + return { baseUrl, apiKey }; +} + +export function getCurrentCodexProviderBaseUrl(config) { + return getCurrentCodexProviderSettings(config).baseUrl; +} diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js index 1a6d016f..badfc00b 100644 --- a/src/app/api/cli-tools/codex-settings/route.js +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -1,5 +1,3 @@ -"use server"; - import { NextResponse } from "next/server"; import { exec } from "child_process"; import { promisify } from "util"; @@ -8,6 +6,8 @@ import path from "path"; import os from "os"; import { parseTOML, stringifyTOML } from "confbox"; +export const dynamic = "force-dynamic"; + const execAsync = promisify(exec); const getCodexDir = () => path.join(os.homedir(), ".codex"); diff --git a/tests/unit/codex-current-provider-base-url.test.js b/tests/unit/codex-current-provider-base-url.test.js new file mode 100644 index 00000000..8c3ef06a --- /dev/null +++ b/tests/unit/codex-current-provider-base-url.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { getCurrentCodexProviderBaseUrl, getCurrentCodexProviderSettings } from "../../src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js"; + +describe("Codex current provider base URL", () => { + it("uses the base URL from the configured model provider, not an earlier provider", () => { + const config = `model = "gpt-5" +model_provider = "9router" + +[model_providers.omniroute] +base_url = "https://omniroute.example/v1" + +[model_providers.9router] +base_url = "http://127.0.0.1:20128/v1" +`; + + expect(getCurrentCodexProviderBaseUrl(config)).toBe("http://127.0.0.1:20128/v1"); + }); + + it("reads the active provider URL and bearer key when another provider appears first", () => { + const config = `model_provider = "9router" + +[model_providers.omniroute] +base_url = "https://omniroute.example/v1" + +[model_providers.omniroute.http_headers] +Authorization = "Bearer placeholder-omniroute-key" + +[model_providers.9router] +base_url = "https://9router.example/v1/" + +[model_providers.9router.http_headers] +Authorization = "Bearer placeholder-9router-key" +`; + + expect(getCurrentCodexProviderSettings(config)).toEqual({ + baseUrl: "https://9router.example/v1/", + apiKey: "placeholder-9router-key", + }); + }); + + it("returns empty settings when no active provider is configured", () => { + expect(getCurrentCodexProviderSettings("model = \"gpt-5\"\n")).toEqual({ baseUrl: "", apiKey: "" }); + }); +}); diff --git a/tests/unit/codex-settings-refresh.test.js b/tests/unit/codex-settings-refresh.test.js new file mode 100644 index 00000000..2e4af6f1 --- /dev/null +++ b/tests/unit/codex-settings-refresh.test.js @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const readSource = (relativePath) => + readFile(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); + +describe("Codex settings refresh", () => { + it("bypasses cached status after applying a selected endpoint", async () => { + const [routeSource, cardSource] = await Promise.all([ + readSource("../../src/app/api/cli-tools/codex-settings/route.js"), + readSource("../../src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js"), + ]); + + // Route Handlers already run on the server; a Server Action directive would reject this export. + expect(routeSource).not.toContain('"use server";'); + expect(routeSource).toContain('export const dynamic = "force-dynamic";'); + expect(cardSource).toContain('fetch("/api/cli-tools/codex-settings", { cache: "no-store" })'); + expect(cardSource).toContain("setSelectedApiKey(apiKey);"); + expect(cardSource).toContain("setCustomBaseUrl(baseUrl);"); + }); + + it("keeps an unmatched active URL in the custom endpoint slot", async () => { + const selectorSource = await readSource("../../src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js"); + + expect(selectorSource).toContain("if (current) {"); + expect(selectorSource).toContain("setCustomInput(current);"); + expect(selectorSource).toContain("onChange(current);"); + }); +}); From 199173fe5878a7b2902d93ae3376afec6dd497f1 Mon Sep 17 00:00:00 2001 From: Nick Nyanjui Date: Sat, 26 Sep 2026 16:29:14 +0700 Subject: [PATCH 31/38] feat(cline): expose the cline-free/* tier and price it at zero (#4334) --- open-sse/providers/pricing.js | 38 +++++-- open-sse/services/clinepassModels.js | 52 ++++++++- tests/unit/cline-free-tier-models.test.js | 125 ++++++++++++++++++++++ 3 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 tests/unit/cline-free-tier-models.test.js diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index d09452e2..0cdd6844 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -2,8 +2,28 @@ // // Fallback order (first match wins): // 1. PROVIDER_PRICING[provider][model] — provider-specific override -// 2. MODEL_PRICING[model] — canonical model price (provider-agnostic) -// 3. PATTERN_PRICING — glob pattern match (e.g. "codex-*") +// 2. FREE_MODEL_NAMESPACES — upstream bills these at $0 +// 3. MODEL_PRICING[model] — canonical model price (provider-agnostic) +// 4. PATTERN_PRICING — glob pattern match (e.g. "codex-*") + +/** + * Namespaces upstream meters at $0. A free model must never inherit a paid + * rate: the vendor-prefix strip in getPricingForModel() would turn + * "cline-free/deepseek-v4.1-flash" into "deepseek-v4.1-flash" and match + * MODEL_PRICING, so the namespace is checked before both fallbacks. + */ +export const FREE_MODEL_NAMESPACES = ["cline-free/"]; + +export const ZERO_PRICING = { + input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0, +}; + +/** True when the model id sits in a namespace upstream bills at $0. */ +export function isFreeModel(model) { + if (!model) return false; + const lower = String(model).toLowerCase(); + return FREE_MODEL_NAMESPACES.some((ns) => lower.startsWith(ns)); +} /** * Canonical model pricing — provider-agnostic. @@ -361,10 +381,11 @@ export function matchPattern(pattern, model) { } /** - * Resolve pricing for a model using the 3-step fallback chain: + * Resolve pricing for a model using the 4-step fallback chain: * 1. PROVIDER_PRICING[provider][model] - * 2. MODEL_PRICING[model] - * 3. PATTERN_PRICING (glob match) + * 2. free namespace (upstream bills $0) + * 3. MODEL_PRICING[model] + * 4. PATTERN_PRICING (glob match) * * @param {string} provider * @param {string} model @@ -378,12 +399,15 @@ export function getPricingForModel(provider, model) { return PROVIDER_PRICING[provider][model]; } - // 2. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat") + // 2. Free namespaces bill $0 regardless of the model name behind them. + if (isFreeModel(model)) return ZERO_PRICING; + + // 3. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat") const baseModel = model.includes("/") ? model.split("/").pop() : model; if (MODEL_PRICING[baseModel]) return MODEL_PRICING[baseModel]; if (MODEL_PRICING[model]) return MODEL_PRICING[model]; - // 3. Pattern match + // 4. Pattern match for (const { pattern, pricing } of PATTERN_PRICING) { if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) { return pricing; diff --git a/open-sse/services/clinepassModels.js b/open-sse/services/clinepassModels.js index 0aa96ffe..8ba86147 100644 --- a/open-sse/services/clinepassModels.js +++ b/open-sse/services/clinepassModels.js @@ -1,6 +1,12 @@ import { buildClineHeaders } from "../shared/clineAuth.js"; const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models"; +// Cline's free tier is published here, not in /api/v1/models: the catalog +// endpoint carries no `cline-free/*` ids at all. Cline's own SDK calls this +// feed unauthenticated (sdk/packages/core/src/services/llms/cline-recommended-models.ts), +// so no Authorization header is sent — adding one would only make the request +// fail on a header the endpoint ignores. +const CLINE_RECOMMENDED_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/ai/cline/recommended-models"; const FETCH_TIMEOUT_MS = 5000; /** @@ -72,6 +78,40 @@ export async function resolveClinepassModels(credentials) { return models.length ? { models } : null; } +/** + * Fetch Cline's recommended-models feed and return only its `free[]` tier. + * Returns null on any failure — the free tier is additive, so a dead feed must + * never take the /api/v1/models catalog down with it. + * @param {{accessToken?: string, apiKey?: string}} credentials + * @returns {Promise<{id: string, name: string}[] | null>} + */ +async function fetchClineFreeTierModels() { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(CLINE_RECOMMENDED_MODELS_ENDPOINT, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + + if (!response.ok) return null; + + const json = await response.json(); + const free = Array.isArray(json?.free) ? json.free : []; + if (!free.length) return null; + + return free + .filter((m) => typeof m?.id === "string" && m.id.trim() !== "") + .map((m) => ({ id: m.id, name: m.name || m.id })); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + /** * Fetch Cline live model catalog from Cline's /models endpoint. * Unlike resolveClinepassModels, this returns ALL models (including @@ -91,5 +131,15 @@ export async function resolveClineModels(credentials) { name: m.name || m.id, })); - return models.length ? { models } : null; + // Free tier: /api/v1/models lists no `cline-free/*` ids, so merge the feed's + // free[] in. First writer wins on a shared id, keeping the catalog's entry + // for anything the two sources agree on. + const freeTier = await fetchClineFreeTierModels(); + const byId = new Map(models.map((m) => [m.id, m])); + for (const m of freeTier || []) { + if (!byId.has(m.id)) byId.set(m.id, m); + } + const merged = Array.from(byId.values()); + + return merged.length ? { models: merged } : null; } diff --git a/tests/unit/cline-free-tier-models.test.js b/tests/unit/cline-free-tier-models.test.js new file mode 100644 index 00000000..7dac1858 --- /dev/null +++ b/tests/unit/cline-free-tier-models.test.js @@ -0,0 +1,125 @@ +// Cline's free tier lives in the `cline-free/` namespace and is published only +// by the recommended-models feed, not by /api/v1/models. These tests pin that +// resolveClineModels() merges the feed's `free[]` into its catalog so the free +// models reach /v1/models and the dashboard picker. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const MODELS_URL = "https://api.cline.bot/api/v1/models"; +const FEED_URL = "https://api.cline.bot/api/v1/ai/cline/recommended-models"; + +const MODELS_RESPONSE = [ + { id: "meta/muse-spark-1.3-contributor" }, + { id: "deepseek/deepseek-v4.1-flash" }, + { id: "stealth/space-bunny-alpha" }, +]; + +const FEED_RESPONSE = { + recommended: [{ id: "anthropic/claude-opus-5", name: "Claude Opus 5", description: "", tags: ["NEW"] }], + free: [ + { id: "stealth/space-bunny-alpha", name: "Space Bunny Alpha", description: "", tags: [] }, + { id: "cline-free/muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", description: "", tags: [] }, + { id: "cline-free/deepseek-v4.1-flash", name: "Deepseek V4.1 Flash", description: "", tags: [] }, + { id: "cline-free/gemini-3.8-flash", name: "Gemini 3.8 Flash", description: "", tags: [] }, + { id: "cline-free/mimo-v2.6-flash", name: "Mimo V2.6 Flash", description: "", tags: [] }, + ], + clinePass: [{ id: "cline-pass/glm-5.3", name: "GLM-5.3", description: "", tags: [] }], +}; + +let fetchMock; + +function jsonResponse(obj) { + return { ok: true, status: 200, json: async () => obj, text: async () => JSON.stringify(obj) }; +} + +beforeEach(() => { + fetchMock = vi.fn(async (url) => { + if (String(url) === MODELS_URL) return jsonResponse(MODELS_RESPONSE); + if (String(url) === FEED_URL) return jsonResponse(FEED_RESPONSE); + throw new Error("unexpected fetch: " + url); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("resolveClineModels free-tier merge", () => { + it("includes the cline-free/* models that /api/v1/models omits", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("cline-free/muse-spark-1.3-contributor"); + expect(ids).toContain("cline-free/deepseek-v4.1-flash"); + expect(ids).toContain("cline-free/gemini-3.8-flash"); + expect(ids).toContain("cline-free/mimo-v2.6-flash"); + }); + + it("keeps every /api/v1/models entry (feed is additive)", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("meta/muse-spark-1.3-contributor"); + expect(ids).toContain("deepseek/deepseek-v4.1-flash"); + }); + + it("deduplicates ids present in both sources", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const ids = result.models.map((m) => m.id); + expect(ids.filter((id) => id === "stealth/space-bunny-alpha")).toHaveLength(1); + }); + + it("returns {id, name} for feed entries", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const entry = result.models.find((m) => m.id === "cline-free/muse-spark-1.3-contributor"); + expect(entry.name).toBe("Muse Spark 1.3 Contributor"); + }); + + it("survives a failing feed and still returns the /models catalog", async () => { + fetchMock.mockImplementation(async (url) => { + if (String(url) === MODELS_URL) return jsonResponse(MODELS_RESPONSE); + return { ok: false, status: 503, json: async () => ({}), text: async () => "" }; + }); + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result.models.map((m) => m.id)).toEqual(MODELS_RESPONSE.map((m) => m.id)); + }); + + it("does not leak the cline-pass/ subscription tier into the cline list", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result.models.map((m) => m.id)).not.toContain("cline-pass/glm-5.3"); + }); +}); + +describe("cline-free namespace pricing", () => { + it("bills cline-free/* at zero", async () => { + const { getPricingForModel } = await import("../../open-sse/providers/pricing.js"); + const pricing = getPricingForModel("cline", "cline-free/deepseek-v4.1-flash"); + expect(pricing).toMatchObject({ + input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0, + }); + }); + + it("bills cline-free/* muse-spark at zero", async () => { + const { getPricingForModel } = await import("../../open-sse/providers/pricing.js"); + expect(getPricingForModel("cline", "cline-free/muse-spark-1.3-contributor").input).toBe(0); + }); + + it("still bills the paid twin at its published rate", async () => { + const { getPricingForModel } = await import("../../open-sse/providers/pricing.js"); + expect(getPricingForModel("cline", "deepseek/deepseek-v4.1-flash").input).toBe(0.14); + expect(getPricingForModel("cline", "meta/muse-spark-1.3-contributor")).toBeNull(); + }); + + it("zero price survives cost calculation over a large usage", async () => { + const { getPricingForModel, calculateCostFromTokens } = await import("../../open-sse/providers/pricing.js"); + const pricing = getPricingForModel("cline", "cline-free/deepseek-v4.1-flash"); + const cost = calculateCostFromTokens( + { prompt_tokens: 1_000_000, completion_tokens: 1_000_000, reasoning_tokens: 500_000 }, + pricing + ); + expect(cost).toBe(0); + }); +}); From 239bcfc568038653ada4bf9b7823f3726450ddfa Mon Sep 17 00:00:00 2001 From: Nikan Wystaf Date: Sat, 26 Sep 2026 16:33:25 +0700 Subject: [PATCH 32/38] perf(providers): make POST /api/providers O(1) and refuse silent key overwrite (#4350) Fixes #4311 - Drop full-pool renumber on insert: new row gets MAX(priority)+1 directly, turning an O(pool) rewrite into O(1) per insert. - For apikey connections, query by (provider, authType, name) and count via SQL aggregate instead of reading the entire pool into memory. - Refuse silent apikey overwrite on name collision with 409 PROVIDER_NAME_CONFLICT, unless caller explicitly sets allowOverwrite: true. - Add 8 unit tests covering priority ordering and name collisions. --- src/app/api/providers/route.js | 9 ++ src/lib/db/repos/connectionsRepo.js | 60 +++++++- .../provider-priority-insert-cost.test.js | 138 ++++++++++++++++++ 3 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 tests/unit/provider-priority-insert-cost.test.js diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 5885472b..49f611aa 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -183,6 +183,9 @@ export async function POST(request) { providerSpecificData: mergedProviderSpecificData, isActive: true, testStatus: testStatus || "unknown", + // POST with an id is an explicit edit of that connection; without one, a + // name collision is refused rather than silently overwriting a key. #4311 + allowOverwrite: body.id ? true : (body.allowOverwrite === true || body.overwrite === true), }); // Hide sensitive fields @@ -191,6 +194,12 @@ export async function POST(request) { return NextResponse.json({ connection: result }, { status: 201 }); } catch (error) { + if (error?.code === "PROVIDER_NAME_CONFLICT") { + return NextResponse.json( + { error: error.message, code: error.code, existingId: error.existingId, existingName: error.existingName }, + { status: 409 } + ); + } console.log("Error creating provider:", error); return NextResponse.json({ error: "Failed to create provider" }, { status: 500 }); } diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index 78abfc90..5c51aeaa 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -108,7 +108,15 @@ export async function getProviderConnectionById(id) { return rowToConn(row); } -// Internal sync reorder — must be called INSIDE a transaction +// Internal sync reorder — must be called INSIDE a transaction. +// +// Normalizes priorities to a contiguous 1..N after a DELETE or an explicit +// reorder, so gaps don't accumulate over time. +// +// Deliberately NOT called on insert: a new connection already gets +// MAX(priority)+1, which sorts after every existing row, so the order is +// identical with or without the rewrite. Skipping it there is what makes +// import O(1) per key instead of O(pool) — see createProviderConnection. function reorderInTx(db, providerId) { const list = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [providerId]).map(rowToConn); list.sort((a, b) => { @@ -117,7 +125,10 @@ function reorderInTx(db, providerId) { return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0); }); list.forEach((c, i) => { - db.run(`UPDATE providerConnections SET priority = ? WHERE id = ?`, [i + 1, c.id]); + const want = i + 1; + if ((c.priority || 0) !== want) { + db.run(`UPDATE providerConnections SET priority = ? WHERE id = ?`, [want, c.id]); + } }); } @@ -127,7 +138,21 @@ export async function createProviderConnection(data) { let result; db.transaction(() => { - const all = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn); + // apikey connections are deduped by name and need only the current max + // priority, so query for those directly instead of loading the whole pool + // (O(pool) per key — the other half of the import cost in #4311). The oauth + // branch below still scans, because its identity rules compare fields + // inside providerSpecificData and have no single-column equivalent. + const isApikey = data.authType === "apikey" && !!data.name; + const all = isApikey + ? db.all( + `SELECT * FROM providerConnections WHERE provider = ? AND authType = ? AND name = ?`, + [data.provider, "apikey", data.name] + ).map(rowToConn) + : db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn); + const poolSize = isApikey + ? db.get(`SELECT COUNT(*) AS n FROM providerConnections WHERE provider = ?`, [data.provider])?.n ?? all.length + : all.length; let existing = null; if (data.authType === "oauth" && data.email) { @@ -169,6 +194,21 @@ export async function createProviderConnection(data) { // access_token: never dedup — user manages duplicates manually if (existing) { + // Name collision on an apikey connection used to silently replace the + // stored apiKey, so a script that reused names ("Key 1", "Key 2", …) + // destroyed existing pool entries with no 409 and no warning. Callers that + // genuinely mean "update this one" pass allowOverwrite; everyone else gets + // a typed error naming the row that would have been replaced. #4311 + if (data.allowOverwrite === false) { + const err = new Error( + `A connection named "${existing.name}" already exists for provider "${data.provider}". ` + + `Pass allowOverwrite: true to replace it.` + ); + err.code = "PROVIDER_NAME_CONFLICT"; + err.existingId = existing.id; + err.existingName = existing.name; + throw err; + } const normalized = resetHealthStateOnActivation(existing, data); const merged = { ...existing, ...normalized, updatedAt: now }; upsert(db, merged); @@ -178,11 +218,15 @@ export async function createProviderConnection(data) { let connectionName = data.name || null; if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) { - connectionName = deriveConnectionName(data, data.email || `Account ${all.length + 1}`); + connectionName = deriveConnectionName(data, data.email || `Account ${poolSize + 1}`); } let connectionPriority = data.priority; if (!connectionPriority) { - connectionPriority = all.reduce((m, c) => Math.max(m, c.priority || 0), 0) + 1; + // MAX(priority)+1 in SQL rather than a reduce over the loaded pool: the + // apikey path no longer has the whole pool in memory, and the aggregate + // is served by the index instead of a row scan. #4311 + const maxRow = db.get(`SELECT MAX(priority) AS m FROM providerConnections WHERE provider = ?`, [data.provider]); + connectionPriority = (maxRow?.m || 0) + 1; } const conn = { @@ -204,7 +248,11 @@ export async function createProviderConnection(data) { if (data.email !== undefined) conn.email = data.email; upsert(db, conn); - reorderInTx(db, data.provider); + // No reorderInTx here. `conn.priority` is already MAX(priority)+1, so the + // row sorts last and the resulting order is what reorderInTx would have + // produced anyway. The rewrite cost ~2N statements per insert — O(pool) — + // which made a 5k-key import O(n*m): ~25M statements at a 5k pool, and it + // serialized every parallel writer on the same transaction. #4311 result = conn; }); diff --git a/tests/unit/provider-priority-insert-cost.test.js b/tests/unit/provider-priority-insert-cost.test.js new file mode 100644 index 00000000..bd2f2ef6 --- /dev/null +++ b/tests/unit/provider-priority-insert-cost.test.js @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + createProviderConnection, + getProviderConnections, + deleteProviderConnection, + updateProviderConnection, +} from "../../src/lib/db/index.js"; + +// #4311: POST /api/providers was O(pool) per insert. Inside one transaction it +// read the whole pool AND renumbered every row's priority, so a 5k-key import +// was O(n*m) — ~25M statements at a 5k pool — and every parallel writer +// serialized on the same transaction. On top of that, an apikey name collision +// silently overwrote the stored key with no 409. +// +// The test DB persists across tests in a file, so each case uses its own +// provider alias; priorities are per-provider. + +async function seed(provider, n) { + for (let i = 0; i < n; i++) { + await createProviderConnection({ + provider, + authType: "apikey", + name: `seed-${i}`, + apiKey: `k${i}`, + }); + } +} + +describe("provider insert is O(1) in pool size (#4311)", () => { + it("assigns sequential priorities without a renumber pass", async () => { + const P = `openai-compatible-seq-${Date.now()}`; + await seed(P, 3); + const list = await getProviderConnections({ provider: P }); + expect(list.map((c) => c.name)).toEqual(["seed-0", "seed-1", "seed-2"]); + expect(list.map((c) => c.priority)).toEqual([1, 2, 3]); + }); + + it("keeps a large pool in insertion order", async () => { + const P = `openai-compatible-ord-${Date.now()}`; + await seed(P, 60); + const list = await getProviderConnections({ provider: P }); + expect(list).toHaveLength(60); + // The bug showed up as reordering once the pool grew past a few rows. + expect(list[0].name).toBe("seed-0"); + expect(list[59].name).toBe("seed-59"); + for (let i = 1; i < list.length; i++) { + expect(list[i].priority).toBeGreaterThan(list[i - 1].priority); + } + }); + + it("still renumbers on delete, so gaps do not accumulate", async () => { + const P = `openai-compatible-del-${Date.now()}`; + await seed(P, 4); + const before = await getProviderConnections({ provider: P }); + await deleteProviderConnection(before[0].id); + const after = await getProviderConnections({ provider: P }); + expect(after.map((c) => c.priority)).toEqual([1, 2, 3]); + }); + + it("still renumbers on an explicit priority update", async () => { + // Unique alias per run: the DB persists across runs, so a fixed alias + // would accumulate rows and make this assertion depend on test order. + const P = `openai-compatible-upd-${Date.now()}`; + await seed(P, 4); + await new Promise((r) => setTimeout(r, 10)); + const list = await getProviderConnections({ provider: P }); + // Move the last one to the front. + await updateProviderConnection(list[3].id, { priority: 1 }); + const after = await getProviderConnections({ provider: P }); + expect(after[0].name).toBe("seed-3"); + }); +}); + +describe("name collision no longer destroys a key silently (#4311)", () => { + // Seeded once: these cases each mutate the SAME row, so a per-test seed + // would make the later assertions depend on earlier ones. + const P = `openai-compatible-clash-${Date.now()}`; + const original = (async () => { + await seed(P, 1); + return (await getProviderConnections({ provider: P }))[0]; + })(); + + it("throws a typed conflict instead of overwriting, when overwrite is refused", async () => { + const orig = await original; + await expect( + createProviderConnection({ + provider: P, + authType: "apikey", + name: orig.name, + apiKey: "REPLACEMENT-KEY", + allowOverwrite: false, + }) + ).rejects.toMatchObject({ code: "PROVIDER_NAME_CONFLICT", existingId: orig.id }); + + // The stored key must be untouched. + const after = (await getProviderConnections({ provider: P }))[0]; + expect(after.apiKey).toBe(orig.apiKey); + }); + + it("still overwrites when the caller opts in", async () => { + const orig = await original; + const updated = await createProviderConnection({ + provider: P, + authType: "apikey", + name: orig.name, + apiKey: "REPLACEMENT-KEY", + allowOverwrite: true, + }); + expect(updated.id).toBe(orig.id); + const after = (await getProviderConnections({ provider: P }))[0]; + expect(after.apiKey).toBe("REPLACEMENT-KEY"); + }); + + it("defaults to the previous overwrite behaviour for existing callers", async () => { + // Every other call site in the repo (oauth routes, bulk import) omits the + // flag, so they must keep working exactly as before. + const orig = await original; + const updated = await createProviderConnection({ + provider: P, + authType: "apikey", + name: orig.name, + apiKey: "LEGACY-PATH-KEY", + }); + expect(updated.id).toBe(orig.id); + }); + + it("does not collide across different providers", async () => { + const orig = await original; + const other = await createProviderConnection({ + provider: "openai-compatible-other", + authType: "apikey", + name: orig.name, + apiKey: "other-key", + }); + expect(other.id).not.toBe(orig.id); + }); +}); From 0249464d74c98ae35b81645f610e80d5ea5d7041 Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 17:03:06 +0700 Subject: [PATCH 33/38] feat(cli-tools): support multiple model profiles for Codex CLI Co-Authored-By: Claude Code --- .../cli-tools/components/CodexToolCard.js | 246 +++++++++++++++++- .../cli-tools/components/codexConfig.js | 32 +++ src/app/api/cli-tools/codex-profiles/route.js | 123 +++++++++ tests/unit/codex-profiles.test.js | 28 ++ 4 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/app/api/cli-tools/codex-profiles/route.js create mode 100644 tests/unit/codex-profiles.test.js diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index cea5522a..f0cdd32b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -3,11 +3,12 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import BaseUrlSelect from "./BaseUrlSelect"; import ApiKeySelect from "./ApiKeySelect"; import { matchKnownEndpoint } from "./cliEndpointMatch"; import { rememberEndpoint } from "./cliEndpointPresets"; -import { getCurrentCodexProviderSettings } from "./codexConfig"; +import { getCurrentCodexProviderSettings, deriveProfileNameFromModel } from "./codexConfig"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [codexStatus, setCodexStatus] = useState(initialStatus || null); @@ -24,6 +25,17 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); + const [profiles, setProfiles] = useState([]); + const [aliasInput, setAliasInput] = useState(""); + const [modelInput, setModelInput] = useState(""); + const [profileModalOpen, setProfileModalOpen] = useState(false); + const [creatingProfile, setCreatingProfile] = useState(false); + const [deletingProfile, setDeletingProfile] = useState(null); + const [copiedCommand, setCopiedCommand] = useState(""); + + useEffect(() => { + fetchProfiles(); + }, []); useEffect(() => { if (apiKeys?.length > 0 && !selectedApiKey && !codexStatus?.config) { @@ -39,6 +51,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api if (isExpanded) { if (!codexStatus) checkCodexStatus(); fetchModelAliases(); + fetchProfiles(); } }, [isExpanded]); @@ -52,6 +65,83 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api } }; + const fetchProfiles = async () => { + try { + const res = await fetch("/api/cli-tools/codex-profiles"); + const data = await res.json(); + if (res.ok) setProfiles(data.profiles || []); + } catch (error) { + console.log("Error fetching codex profiles:", error); + } + }; + + const handleModelSelectForAlias = (model) => { + setProfileModalOpen(false); + const selectedModelId = model?.value || model?.id; + if (!selectedModelId) return; + + setModelInput(selectedModelId); + const providerName = model?.provider || (selectedModelId.includes("/") ? selectedModelId.split("/")[0] : selectedModelId); + const existingNames = profiles.map((p) => p.name); + setAliasInput(deriveProfileNameFromModel(providerName, existingNames)); + }; + + const handleAddProfileWithAlias = async () => { + const cleanAlias = aliasInput.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ""); + const cleanModel = modelInput.trim(); + if (!cleanAlias || !cleanModel) return; + + setCreatingProfile(true); + try { + const res = await fetch("/api/cli-tools/codex-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: cleanAlias, + model: cleanModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setAliasInput(""); + setModelInput(""); + fetchProfiles(); + } else { + setMessage({ type: "error", text: data.error || "Failed to add model" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setCreatingProfile(false); + } + }; + + const handleDeleteProfile = async (name) => { + setDeletingProfile(name); + try { + const res = await fetch("/api/cli-tools/codex-profiles", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (res.ok) fetchProfiles(); + } catch (error) { + console.log("Error deleting codex profile:", error); + } finally { + setDeletingProfile(null); + } + }; + + const handleCopyCommand = async (cmd) => { + try { + await navigator.clipboard.writeText(cmd); + setCopiedCommand(cmd); + setTimeout(() => setCopiedCommand(""), 2000); + } catch (e) { + console.log("Copy failed", e); + } + }; + // Sync only when config content changes so local form edits are retained. useEffect(() => { const config = codexStatus?.config; @@ -367,6 +457,148 @@ default_subagent_model = "${effectiveSubagentModel}" content_copyManual Config + + {/* Additional Models */} +
+
+
+ + layers + Additional Models + + {profiles.length > 0 && ( + + {profiles.length} + + )} +
+
+ + {/* Quick Add Model bar */} +
+
+ setAliasInput(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))} + placeholder="Alias (e.g. claude)" + className="w-full min-w-0 px-2.5 py-1.5 bg-surface rounded border border-border text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50" + onKeyDown={(e) => e.key === "Enter" && handleAddProfileWithAlias()} + /> +
+ { + const val = e.target.value; + setModelInput(val); + const provider = val.includes("/") ? val.split("/")[0] : val; + setAliasInput(deriveProfileNameFromModel(provider, profiles.map((p) => p.name))); + }} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2.5 pr-7 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + onKeyDown={(e) => e.key === "Enter" && handleAddProfileWithAlias()} + /> + {modelInput && ( + + )} +
+ + +
+
+ + {profiles.length === 0 ? ( +
+ + terminal + +

+ Only the main model is active. Add an alias above to configure more models for Codex CLI. +

+
+ ) : ( +
+ {profiles.map((p) => { + const providerId = p.model.includes("/") ? p.model.split("/")[0] : p.name; + const isCopied = copiedCommand === p.command; + return ( +
+
+
+ +
+
+ + {p.name} + + + {p.model} + +
+
+ +
+ + + +
+
+ ); + })} +
+ )} +
)} @@ -396,6 +628,18 @@ default_subagent_model = "${effectiveSubagentModel}" /> )} + {profileModalOpen && ( + setProfileModalOpen(false)} + onSelect={handleModelSelectForAlias} + selectedModel={modelInput} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Codex CLI" + /> + )} + setShowManualConfigModal(false)} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js index 9f628fbd..2856e8a1 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js @@ -52,3 +52,35 @@ export function getCurrentCodexProviderSettings(config) { export function getCurrentCodexProviderBaseUrl(config) { return getCurrentCodexProviderSettings(config).baseUrl; } + +export function deriveProfileNameFromModel(modelId, existingNames = []) { + if (!modelId || typeof modelId !== "string") return "model"; + let base = ""; + const trimmed = modelId.trim(); + if (trimmed.includes("/")) { + base = trimmed.split("/")[0].toLowerCase().replace(/[^a-z0-9_-]/g, ""); + } else { + base = trimmed.toLowerCase().replace(/[^a-z0-9_-]/g, ""); + } + base = base.replace(/^[-_]+|[-_]+$/g, "") || "model"; + if (base === "config") base = "model-config"; + + let candidate = base; + let counter = 2; + while (existingNames.includes(candidate)) { + candidate = `${base}-${counter}`; + counter++; + } + return candidate; +} + +export function buildCodexProfileToml({ name, model }) { + return `# codex -p ${name}\nmodel = "${model}"\nmodel_provider = "9router"\n`; +} + +export function parseCodexProfileModel(content) { + if (typeof content !== "string") return ""; + const match = content.match(/^\s*model\s*=\s*(["'])([^"\n]+)\1/m); + return match ? match[2] : ""; +} + diff --git a/src/app/api/cli-tools/codex-profiles/route.js b/src/app/api/cli-tools/codex-profiles/route.js new file mode 100644 index 00000000..af578de3 --- /dev/null +++ b/src/app/api/cli-tools/codex-profiles/route.js @@ -0,0 +1,123 @@ +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +export const dynamic = "force-dynamic"; + +const getCodexDir = () => path.join(os.homedir(), ".codex"); + +const isValidProfileName = (name) => { + return typeof name === "string" && /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 64 && name.toLowerCase() !== "config"; +}; + +const isValidModel = (model) => { + return typeof model === "string" && model.trim().length > 0 && model.length <= 256 && !/[\r\n"]/.test(model); +}; + +// GET - List all custom profiles from ~/.codex/*.config.toml +export async function GET() { + try { + const codexDir = getCodexDir(); + let files = []; + try { + files = await fs.readdir(codexDir); + } catch (err) { + if (err.code === "ENOENT") return NextResponse.json({ profiles: [] }); + throw err; + } + + const profileFiles = files.filter( + (file) => file.endsWith(".config.toml") && file !== "config.toml" + ); + + const profiles = await Promise.all( + profileFiles.map(async (file) => { + const name = file.replace(/\.config\.toml$/, ""); + try { + const content = await fs.readFile(path.join(codexDir, file), "utf-8"); + const match = content.match(/^\s*model\s*=\s*(["'])([^"\n]+)\1/m); + return { + name, + model: match ? match[2] : "", + command: `codex -p ${name}`, + }; + } catch { + return { name, model: "", command: `codex -p ${name}` }; + } + }) + ); + + profiles.sort((a, b) => a.name.localeCompare(b.name)); + return NextResponse.json({ profiles }); + } catch (error) { + console.error("Error reading codex profiles:", error); + return NextResponse.json({ error: "Failed to read profiles" }, { status: 500 }); + } +} + +// POST - Create or update a profile ~/.codex/.config.toml +export async function POST(request) { + try { + const { name, model } = await request.json(); + + const cleanName = typeof name === "string" ? name.trim().toLowerCase() : ""; + if (!isValidProfileName(cleanName)) { + return NextResponse.json( + { error: "Profile name can only contain alphanumeric characters, dashes, underscores and cannot be 'config'" }, + { status: 400 } + ); + } + + const cleanModel = typeof model === "string" ? model.trim() : ""; + if (!isValidModel(cleanModel)) { + return NextResponse.json({ error: "Invalid model name" }, { status: 400 }); + } + + const codexDir = getCodexDir(); + await fs.mkdir(codexDir, { recursive: true }); + + const filePath = path.join(codexDir, `${cleanName}.config.toml`); + const content = `# codex -p ${cleanName}\nmodel = "${cleanModel}"\nmodel_provider = "9router"\n`; + await fs.writeFile(filePath, content, "utf-8"); + + return NextResponse.json({ + success: true, + profile: { + name: cleanName, + model: cleanModel, + command: `codex -p ${cleanName}`, + }, + }); + } catch (error) { + console.error("Error saving codex profile:", error); + return NextResponse.json({ error: "Failed to save profile" }, { status: 500 }); + } +} + +// DELETE - Remove a profile ~/.codex/.config.toml +export async function DELETE(request) { + try { + const { name } = await request.json(); + const cleanName = typeof name === "string" ? name.trim().toLowerCase() : ""; + + if (!isValidProfileName(cleanName)) { + return NextResponse.json({ error: "Invalid profile name" }, { status: 400 }); + } + + const filePath = path.join(getCodexDir(), `${cleanName}.config.toml`); + try { + await fs.unlink(filePath); + } catch (err) { + if (err.code === "ENOENT") { + return NextResponse.json({ error: "Profile not found" }, { status: 404 }); + } + throw err; + } + + return NextResponse.json({ success: true, message: `Profile ${cleanName} deleted` }); + } catch (error) { + console.error("Error deleting codex profile:", error); + return NextResponse.json({ error: "Failed to delete profile" }, { status: 500 }); + } +} diff --git a/tests/unit/codex-profiles.test.js b/tests/unit/codex-profiles.test.js new file mode 100644 index 00000000..6439f615 --- /dev/null +++ b/tests/unit/codex-profiles.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + deriveProfileNameFromModel, + buildCodexProfileToml, + parseCodexProfileModel, +} from "../../src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js"; + +describe("Codex profiles configuration", () => { + it("derives provider name as profile name and avoids conflicts", () => { + expect(deriveProfileNameFromModel("anthropic/claude-3-7-sonnet")).toBe("anthropic"); + expect(deriveProfileNameFromModel("anthropic/claude-3-5-haiku", ["anthropic"])).toBe("anthropic-2"); + expect(deriveProfileNameFromModel("anthropic/claude-3-5-haiku", ["anthropic", "anthropic-2"])).toBe("anthropic-3"); + expect(deriveProfileNameFromModel("deepseek/deepseek-chat")).toBe("deepseek"); + expect(deriveProfileNameFromModel("google/gemini-2.5-pro")).toBe("google"); + }); + + it("derives model name when model has no slash", () => { + expect(deriveProfileNameFromModel("claude-3-7-sonnet")).toBe("claude-3-7-sonnet"); + expect(deriveProfileNameFromModel("gpt-4o")).toBe("gpt-4o"); + }); + + it("builds and parses profile TOML", () => { + const toml = buildCodexProfileToml({ name: "claude", model: "anthropic/claude-3-7-sonnet" }); + expect(toml).toContain('model = "anthropic/claude-3-7-sonnet"'); + expect(toml).toContain('model_provider = "9router"'); + expect(parseCodexProfileModel(toml)).toBe("anthropic/claude-3-7-sonnet"); + }); +}); From 6aea3875ef20ecdd6c4f162f76bf06a31d43bd8a Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 17:15:12 +0700 Subject: [PATCH 34/38] feat(claude): forward x-claude-code-session-id on OAuth requests --- open-sse/executors/default.js | 10 +++++++ open-sse/utils/claudeCloaking.js | 17 ++++++++++- tests/unit/claude-header-forwarding.test.js | 32 +++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index eadf1797..e5c59741 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -7,6 +7,7 @@ import { buildClineHeaders } from "../shared/clineAuth.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js"; +import { extractClaudeSessionIdFromUserId } from "../utils/claudeCloaking.js"; // Auth header descriptors — derived from registry transport.auth, fallback to hardcoded defaults. const BEARER = { combined: true, header: "Authorization", scheme: "bearer" }; @@ -172,6 +173,15 @@ export class DefaultExecutor extends BaseExecutor { headers["Anthropic-Beta"] = mergeAnthropicBeta(headers["Anthropic-Beta"], clientBeta); } + // Claude OAuth: align x-claude-code-session-id with metadata.user_id.session_id if missing + if (this.provider === "claude" && !headers["x-claude-code-session-id"]) { + const token = credentials?.accessToken || credentials?.apiKey || ""; + if (token.includes("sk-ant-oat")) { + const sid = extractClaudeSessionIdFromUserId(body?.metadata?.user_id); + if (sid) headers["x-claude-code-session-id"] = sid; + } + } + // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams if (this.provider?.startsWith?.("anthropic-compatible-")) { const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 7e3790df..f4b710ff 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -25,10 +25,25 @@ function deriveUuid(seed) { function generateFakeUserID(sessionId, apiKey) { const deviceId = apiKey ? createHash("sha256").update(`device:${apiKey}`).digest("hex") : randomBytes(32).toString("hex"); const accountUuid = apiKey ? deriveUuid(`account:${apiKey}`) : randomUUID(); - const sessionUuid = sessionId || randomUUID(); + const cleanSessionId = typeof sessionId === "string" ? sessionId.replace(/^claude:/i, "").trim() : null; + const sessionUuid = cleanSessionId || randomUUID(); return `{"device_id":"${deviceId}","account_uuid":"${accountUuid}","session_id":"${sessionUuid}"}`; } +export function extractClaudeSessionIdFromUserId(userId) { + if (typeof userId !== "string" || !userId) return null; + if (userId[0] === "{") { + try { + const sid = JSON.parse(userId)?.session_id; + return typeof sid === "string" && sid ? sid.replace(/^claude:/i, "").trim() || null : null; + } catch { + return null; + } + } + const clean = userId.replace(/^claude:/i, "").trim(); + return clean || null; +} + /** * Cloak tools before sending to Claude provider (anti-ban): * - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[] diff --git a/tests/unit/claude-header-forwarding.test.js b/tests/unit/claude-header-forwarding.test.js index 556aafe7..653fa462 100644 --- a/tests/unit/claude-header-forwarding.test.js +++ b/tests/unit/claude-header-forwarding.test.js @@ -95,6 +95,38 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => { const executor = new DefaultExecutor("claude"); expect(() => executor.buildHeaders({ apiKey: "sk" }, false)).not.toThrow(); }); + + it("sets x-claude-code-session-id from metadata.user_id on Claude OAuth", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { accessToken: "sk-ant-oat-test-token" }, + true, + undefined, + "claude-opus-5", + { + metadata: { + user_id: '{"device_id":"d","account_uuid":"a","session_id":"sess-abc"}', + }, + } + ); + expect(headers["x-claude-code-session-id"]).toBe("sess-abc"); + }); + + it("omits x-claude-code-session-id for non-OAuth API keys", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { apiKey: "sk-ant-api03-xxx" }, + true, + undefined, + "claude-opus-5", + { + metadata: { + user_id: '{"device_id":"d","account_uuid":"a","session_id":"sess-abc"}', + }, + } + ); + expect(headers["x-claude-code-session-id"]).toBeUndefined(); + }); }); // ─── anthropic-compatible header stripping ──────────────────────────────────── From b65d2d0a6a4f64aa5d5cde76ad21e8f00c96a149 Mon Sep 17 00:00:00 2001 From: Gesya Gayatree Solih <58933926+dragongesa@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:28:27 +0700 Subject: [PATCH 35/38] fix(claude): decloak tool names when toolNameMap misses (#4342) - Add stripCloakSuffix fallback in decloakToolNames and decloakStreamChunk - Prevent client errors when toolNameMap is missing or lost on retry --- open-sse/utils/claudeCloaking.js | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index f4b710ff..60e40b79 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -104,14 +104,29 @@ export function cloakClaudeTools(body) { }; } +// Strip a trailing CLAUDE_TOOL_SUFFIX from a cloaked name as a last-resort +// fallback when the name isn't in toolNameMap (e.g. map lost across a retry/ +// reconnect). Never strips decoy names — those are meant to reach the client +// unresolved so it can see "tool unavailable" instead of silently no-oping. +function stripCloakSuffix(name) { + if (typeof name !== "string" || !name.endsWith(CLAUDE_TOOL_SUFFIX)) return null; + if (CC_DEFAULT_TOOLS.has(name)) return null; + const original = name.slice(0, -CLAUDE_TOOL_SUFFIX.length); + return original.length > 0 ? original : null; +} + // Decloak tool_use names in non-streaming Claude response body (INPUT side) export function decloakToolNames(body, toolNameMap) { - if (!toolNameMap?.size || !Array.isArray(body?.content)) return body; + if (!Array.isArray(body?.content)) return body; const content = body.content.map(block => { - if (block?.type === "tool_use" && toolNameMap.has(block.name)) { + if (block?.type !== "tool_use") return block; + if (toolNameMap?.has(block.name)) { return { ...block, name: toolNameMap.get(block.name) }; } - return block; + // toolNameMap missing/stale for this name — fall back to suffix stripping + // rather than forwarding an unresolvable "_ide" name to the client. + const fallback = stripCloakSuffix(block.name); + return fallback ? { ...block, name: fallback } : block; }); return { ...body, content }; } @@ -126,19 +141,21 @@ export function decloakToolNames(body, toolNameMap) { * name appears exactly once per call — on the content_block_start event of * a tool_use block; argument deltas carry no name. * - * Unknown names (e.g. a CC decoy tool the model called anyway) pass through - * unchanged, matching the non-streaming decloak behavior. + * Falls back to stripping the literal CLAUDE_TOOL_SUFFIX when the name isn't + * in toolNameMap (map lost across a retry/reconnect), matching the + * non-streaming decloak behavior. Decoy tool names (real CC tool names) and + * anything else pass through unchanged. * * @param {object|null} chunk - Parsed SSE event (may be null on stream flush) * @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools() * @returns {object|null} The chunk, with the tool_use name restored when cloaked */ export function decloakStreamChunk(chunk, toolNameMap) { - if (!toolNameMap?.size || !chunk || typeof chunk !== "object") return chunk; + if (!chunk || typeof chunk !== "object") return chunk; if (chunk.type !== "content_block_start") return chunk; const block = chunk.content_block; if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk; - const original = toolNameMap.get(block.name); + const original = toolNameMap?.get(block.name) || stripCloakSuffix(block.name); if (!original) return chunk; return { ...chunk, content_block: { ...block, name: original } }; } From b54a3f9bb523726a2c51da758c25dc550381e0bb Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 17:30:02 +0700 Subject: [PATCH 36/38] test(claude): update decloak tests for suffix-stripping fallback --- tests/translator/claude-claude-stream-decloak.test.js | 4 ++-- tests/unit/claude-cloaking.test.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/translator/claude-claude-stream-decloak.test.js b/tests/translator/claude-claude-stream-decloak.test.js index 35c0fcdf..d223129c 100644 --- a/tests/translator/claude-claude-stream-decloak.test.js +++ b/tests/translator/claude-claude-stream-decloak.test.js @@ -36,10 +36,10 @@ describe("Claude → Claude streaming passthrough (OAuth tool cloak)", () => { expect(outText).toBe(textChunk); }); - it("is a no-op when no cloak map is present", () => { + it("falls back to suffix-stripping when no cloak map is present", () => { const chunk = toolUseStart(CLOAKED); const [out] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, chunk, {}); - expect(out).toBe(chunk); + expect(out.content_block.name).toBe("run_code"); }); it("tolerates the null flush chunk", () => { diff --git a/tests/unit/claude-cloaking.test.js b/tests/unit/claude-cloaking.test.js index 6f06e594..bc4cf541 100644 --- a/tests/unit/claude-cloaking.test.js +++ b/tests/unit/claude-cloaking.test.js @@ -117,7 +117,8 @@ describe("decloakStreamChunk", () => { it("tolerates null chunks and missing maps (stream flush path)", () => { expect(decloakStreamChunk(null, toolNameMap)).toBeNull(); - expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), null).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX); - expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), new Map()).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX); + expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), null).content_block.name).toBe("run_code"); + expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), new Map()).content_block.name).toBe("run_code"); + expect(decloakStreamChunk(toolUseStart("uncloaked_tool"), null).content_block.name).toBe("uncloaked_tool"); }); }); From c4690307ce641d37488c95914ac2513af7d5234b Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 17:30:59 +0700 Subject: [PATCH 37/38] fix(cli-tools): keep existing ANTHROPIC_AUTH_TOKEN when applying Claude settings Only write the token when settings.json has none, so a real API key or earlier config is never clobbered by Apply. Reset still clears it, re-enabling key selection on the next Apply. Co-Authored-By: Claude Code --- src/app/api/cli-tools/claude-settings/route.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index c7087577..5c7d7cfb 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -151,11 +151,16 @@ export async function POST(request) { // Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix if (env.ANTHROPIC_BASE_URL) { - env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1") - ? env.ANTHROPIC_BASE_URL + env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1") + ? env.ANTHROPIC_BASE_URL : `${env.ANTHROPIC_BASE_URL}/v1`; } + // Keep an existing token (real key or earlier config); only add when absent — Reset clears it. + if (currentSettings.env?.ANTHROPIC_AUTH_TOKEN) { + delete env.ANTHROPIC_AUTH_TOKEN; + } + // Merge new env with existing settings const newSettings = { ...currentSettings, From f01fb909e37189008080632ddaf404f096345cde Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 26 Sep 2026 17:40:15 +0700 Subject: [PATCH 38/38] # v0.5.91 (2026-09-26) ## Features - **Providers**: add Token Harbor provider and four OpenAI-compatible aggregator providers (dahl, atria, agnes, bai) - **Claude**: forward `x-claude-code-session-id` on OAuth requests; merge client `anthropic-beta` flags and forward rate-limit headers; return thinking text to OpenAI-format clients - **Codex**: add GPT-6 Sol and Luna support - **CLI Tools**: support multiple model profiles for Codex CLI - **Hermes**: multi-role model config (delegation + auxiliary slots) - **OpenCode Go**: complete the Go catalog (40 models) with auto-fetch + family endpoint regex - **Usage**: show and redeem free limit resets for cc accounts - **Cline**: expose the `cline-free/*` tier and price it at zero - **Combos**: display vision adapter models in an ordered table view ## Fixes - **Claude**: decloak tool names when `toolNameMap` misses (#4342); update spoofed cli version to 2.1.280 to support Opus 5.5 - **Providers API**: make POST `/api/providers` O(1) and refuse silent key overwrite (#4350) - **Capabilities**: stop caching the catalog source per module copy (#4351) - **OAuth**: stop Zed paste-token crash and add IDE auto-import (#4359) - **Dashboard**: resolve combo limits with the server's capabilities (#4360); lazy-load charts and `marked`, preload in background on idle - **Responses**: carry the streamed output items in `response.completed` (#4307) - **STT**: dispatch live-API-only Gemini models over the Live WebSocket transport (#4006) - **Gemini**: guard terminal model turns and unresponded functionCalls in `normalizeGeminiContents` - **Command Code**: replay raw byte chunks to preserve all NDJSON lines - **Translator**: stop emitting empty `` markers into OpenAI content - **CLI Tools**: refresh Codex settings after apply (#4347); keep existing `ANTHROPIC_AUTH_TOKEN` when applying Claude settings - **Tray**: native arm64 macOS menubar binary, no Rosetta required - **CLI**: filter model selector by active connections and noAuth providers - **Usage**: key live byApiKey stats by full api key to prevent team-key collision and preserve API key usage attribution - **Tailscale**: cap enable-flow health wait at 20s --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ cli/package.json | 2 +- package.json | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f32475..6cb89240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +# v0.5.91 (2026-09-26) + +## Features +- **Providers**: add Token Harbor provider and four OpenAI-compatible aggregator providers (dahl, atria, agnes, bai) +- **Claude**: forward `x-claude-code-session-id` on OAuth requests; merge client `anthropic-beta` flags and forward rate-limit headers; return thinking text to OpenAI-format clients +- **Codex**: add GPT-6 Sol and Luna support +- **CLI Tools**: support multiple model profiles for Codex CLI +- **Hermes**: multi-role model config (delegation + auxiliary slots) +- **OpenCode Go**: complete the Go catalog (40 models) with auto-fetch + family endpoint regex +- **Usage**: show and redeem free limit resets for cc accounts +- **Cline**: expose the `cline-free/*` tier and price it at zero +- **Combos**: display vision adapter models in an ordered table view + +## Fixes +- **Claude**: decloak tool names when `toolNameMap` misses (#4342); update spoofed cli version to 2.1.280 to support Opus 5.5 +- **Providers API**: make POST `/api/providers` O(1) and refuse silent key overwrite (#4350) +- **Capabilities**: stop caching the catalog source per module copy (#4351) +- **OAuth**: stop Zed paste-token crash and add IDE auto-import (#4359) +- **Dashboard**: resolve combo limits with the server's capabilities (#4360); lazy-load charts and `marked`, preload in background on idle +- **Responses**: carry the streamed output items in `response.completed` (#4307) +- **STT**: dispatch live-API-only Gemini models over the Live WebSocket transport (#4006) +- **Gemini**: guard terminal model turns and unresponded functionCalls in `normalizeGeminiContents` +- **Command Code**: replay raw byte chunks to preserve all NDJSON lines +- **Translator**: stop emitting empty `` markers into OpenAI content +- **CLI Tools**: refresh Codex settings after apply (#4347); keep existing `ANTHROPIC_AUTH_TOKEN` when applying Claude settings +- **Tray**: native arm64 macOS menubar binary, no Rosetta required +- **CLI**: filter model selector by active connections and noAuth providers +- **Usage**: key live byApiKey stats by full api key to prevent team-key collision and preserve API key usage attribution +- **Tailscale**: cap enable-flow health wait at 20s + # v0.5.86 (2026-09-23) ## Features diff --git a/cli/package.json b/cli/package.json index 4851a71b..1a67862a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.86", + "version": "0.5.91", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/package.json b/package.json index 4dbc5247..cb0de5b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.86", + "version": "0.5.91", "description": "9Router web dashboard", "private": true, "scripts": {