From 5041494e1c1dfd02a396ac5002c587665713c6e6 Mon Sep 17 00:00:00 2001 From: VitzS7 Date: Sun, 5 Jul 2026 17:34:04 +0700 Subject: [PATCH 01/48] fix(kiro): deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366) - Pass system prompt via native systemInstruction field (+ fallback) so Claude models stop treating it as info-only - Add Opus 4.5/4.7/4.8 (base/thinking/agentic/thinking+agentic) to Kiro registry - normalizeModelId(): dash->dot version separator, scoped to Kiro provider only - Replace with in claude-to-openai/openai-to-kiro Co-authored-by: Cursor --- open-sse/config/providerModels.js | 34 ++++++++++++---- open-sse/providers/models/schema.js | 9 +++++ open-sse/providers/registry/kiro.js | 19 +++++++++ open-sse/translator/request/claude-to-kiro.js | 40 ++++++++++++------- .../translator/request/claude-to-openai.js | 5 ++- open-sse/translator/request/openai-to-kiro.js | 6 ++- tests/unit/model-name-regex.test.js | 24 ++++++++++- 7 files changed, 111 insertions(+), 26 deletions(-) diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index c4cfa413..e1153da2 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -2,7 +2,7 @@ import { PROVIDERS } from "./providers.js"; 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 } from "../providers/models/schema.js"; +import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; export { PROVIDER_MODELS }; @@ -18,37 +18,55 @@ export function getDefaultModel(aliasOrId) { return models?.[0]?.id || null; } +// Providers whose registry uses dots in version numbers (e.g. "claude-sonnet-4.5"). +// For these, we tolerate clients sending dashes ("claude-sonnet-4-5") by normalizing +// digit-hyphen-digit to digit-dot-digit before lookup. Other providers are left untouched. +const DOT_VERSION_PROVIDERS = new Set(["kr", "kiro"]); + +// Find a registry entry by id. For Kiro models, tolerates dash/dot version separators +// ("claude-sonnet-4-5" ~= "claude-sonnet-4.5"). Other providers use exact match only. +function findModel(models, modelId, aliasOrId) { + if (!models) return undefined; + const found = models.find(m => m.id === modelId); + if (found) return found; + if (!DOT_VERSION_PROVIDERS.has(aliasOrId)) return undefined; + const normalized = normalizeModelId(modelId); + if (normalized === modelId) return undefined; + return models.find(m => m.id === normalized); +} + export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) { if (passthroughProviders.has(aliasOrId)) return true; const models = PROVIDER_MODELS[aliasOrId]; if (!models) return false; - return models.some(m => m.id === modelId); + return !!findModel(models, modelId, aliasOrId); } export function findModelName(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return modelId; - const found = models.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); return found?.name || modelId; } export function getModelTargetFormat(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - return modelTargetFormat(models.find(m => m.id === modelId)); + return modelTargetFormat(findModel(models, modelId, aliasOrId)); } export function getModelType(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - const found = models.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); return found?.kind || found?.type || null; } export function getModelUpstreamId(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; - const found = models?.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); if (found?.upstreamModelId) return found.upstreamModelId; + if (found?.id) return found.id; if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length); } @@ -57,7 +75,7 @@ export function getModelUpstreamId(aliasOrId, modelId) { export function getModelQuotaFamily(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; - return modelQuotaFamily(models?.find(m => m.id === modelId)); + return modelQuotaFamily(findModel(models, modelId, aliasOrId)); } // OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id. @@ -79,5 +97,5 @@ export function getModelsByProviderId(providerId) { // Get strip list for a model entry (explicit opt-in only) // Returns array of content types to strip, e.g. ["image", "audio"] export function getModelStrip(alias, modelId) { - return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId)); + return modelStrip(findModel(PROVIDER_MODELS[alias], modelId, alias)); } diff --git a/open-sse/providers/models/schema.js b/open-sse/providers/models/schema.js index 8be351ad..c14a3d58 100644 --- a/open-sse/providers/models/schema.js +++ b/open-sse/providers/models/schema.js @@ -1,5 +1,14 @@ import { deriveModelName } from "./namePatterns.js"; +// Normalize version separators in a model id: hyphen between two digits becomes a dot. +// Registry ids use dots for versions ("claude-sonnet-4.5") but clients (CLIs, aliases) +// often send them with dashes ("claude-sonnet-4-5"). Only digit-digit hyphens are +// touched, so word/suffix hyphens stay intact ("-thinking", "-agentic", "qwen3-coder-next"). +export function normalizeModelId(modelId) { + if (typeof modelId !== "string") return modelId; + return modelId.replace(/(\d)-(\d)/g, "$1.$2"); +} + // Model defaults centralized (was scattered as `m.kind || "llm"`, `quotaFamily || "normal"`, etc.) export const MODEL_DEFAULTS = { kind: "llm", diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index 12015643..dab23a3f 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -42,19 +42,38 @@ export default { }, }, models: [ + // Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models) + { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" }, + { id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" }, + { id: "claude-opus-4.8-thinking-agentic", name: "Claude Opus 4.8 (Thinking + Agentic)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7" }, + { id: "claude-opus-4.7-thinking", name: "Claude Opus 4.7 (Thinking)" }, + { id: "claude-opus-4.7-agentic", name: "Claude Opus 4.7 (Agentic)" }, + { id: "claude-opus-4.7-thinking-agentic", name: "Claude Opus 4.7 (Thinking + Agentic)" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 (Thinking)" }, + { id: "claude-opus-4.5-agentic", name: "Claude Opus 4.5 (Agentic)" }, + { id: "claude-opus-4.5-thinking-agentic", name: "Claude Opus 4.5 (Thinking + Agentic)" }, + // Sonnet { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + // Haiku { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + // Non-Anthropic { id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image","audio"] }, { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image","audio"] }, { id: "glm-5", name: "GLM 5" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + // Thinking variants { id: "claude-sonnet-5-thinking", name: "Claude Sonnet 5 (Thinking)" }, { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" }, { id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" }, + // Agentic variants { id: "claude-sonnet-5-agentic", name: "Claude Sonnet 5 (Agentic)" }, { id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" }, { id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" }, + // Thinking + Agentic variants { id: "claude-sonnet-5-thinking-agentic", name: "Claude Sonnet 5 (Thinking + Agentic)" }, { id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" }, { id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" }, diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 5d891c11..c42823b6 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -404,7 +404,10 @@ export function claudeToKiroRequest(model, body, stream, credentials) { let finalContent = currentMessage?.userInputMessage?.content || ""; - // System prompt → prepend to the user content. + // System prompt: pass via native systemInstruction field (Kiro/Q API supports it) + // and also prepend as in user content as fallback for upstreams + // that don't support the native field. + let systemInstruction = undefined; if (body.system) { let systemText = ""; if (typeof body.system === "string") { @@ -412,7 +415,10 @@ export function claudeToKiroRequest(model, body, stream, credentials) { } else if (Array.isArray(body.system)) { systemText = body.system.map((s) => s.text || "").join("\n"); } - if (systemText) finalContent = `${systemText}\n\n${finalContent}`; + if (systemText) { + systemInstruction = systemText; + finalContent = `\n${systemText}\n\n\n${finalContent}`; + } } // Prefix order: thinking_mode tag, timestamp marker, then agentic prompt. @@ -423,23 +429,29 @@ export function claudeToKiroRequest(model, body, stream, credentials) { if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`; + const userInputMessage = { + content: finalContent, + modelId: upstreamModel, + origin: "AI_EDITOR", + ...(currentMessage?.userInputMessage?.userInputMessageContext && { + userInputMessageContext: + currentMessage.userInputMessage.userInputMessageContext, + }), + ...(currentMessage?.userInputMessage?.images && { + images: currentMessage.userInputMessage.images, + }), + }; + + if (systemInstruction) { + userInputMessage.systemInstruction = systemInstruction; + } + const payload = { conversationState: { chatTriggerType: "MANUAL", conversationId: uuidv4(), currentMessage: { - userInputMessage: { - content: finalContent, - modelId: upstreamModel, - origin: "AI_EDITOR", - ...(currentMessage?.userInputMessage?.userInputMessageContext && { - userInputMessageContext: - currentMessage.userInputMessage.userInputMessageContext, - }), - ...(currentMessage?.userInputMessage?.images && { - images: currentMessage.userInputMessage.images, - }), - }, + userInputMessage, }, history, }, diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index c6e92ed6..3956f828 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -129,14 +129,15 @@ function fixMissingToolResponsesOpenAI(messages) { } } -// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400) +// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400). +// Uses tags that Claude models treat as authoritative directives. function systemReminderText(content) { const parts = Array.isArray(content) ? content.filter(c => c?.type === CLAUDE_BLOCK.TEXT).map(c => c.text || "") : [typeof content === "string" ? content : ""]; const text = parts.filter(Boolean).join("\n"); if (!text.trim()) return ""; - return `\n${text}\n`; + return `\n${text}\n`; } // Convert single Claude message - returns single message or array of messages diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index ee886666..5c8cc00f 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -270,6 +270,7 @@ function convertMessages(messages, tools, model) { let role = msg.role; // Normalize: system/tool -> user + const wasSystem = role === ROLE.SYSTEM; if (role === ROLE.SYSTEM || role === ROLE.TOOL) { role = ROLE.USER; } @@ -338,7 +339,10 @@ function convertMessages(messages, tools, model) { content: [{ text: toolContent }] }); } else if (content) { - pendingUserContent.push(content); + // tags: Claude models treat these as authoritative directives. + pendingUserContent.push( + wasSystem ? `\n${content}\n` : content + ); } } else if (role === ROLE.ASSISTANT) { // Extract text content and tool uses diff --git a/tests/unit/model-name-regex.test.js b/tests/unit/model-name-regex.test.js index f07ace0f..49a16605 100644 --- a/tests/unit/model-name-regex.test.js +++ b/tests/unit/model-name-regex.test.js @@ -1,7 +1,7 @@ // Guards C2: regex name fallback (no catalog). Terse entries derive name; existing names untouched. import { describe, it, expect } from "vitest"; import { deriveModelName } from "../../open-sse/providers/models/namePatterns.js"; -import { normalizeModel } from "../../open-sse/providers/models/schema.js"; +import { normalizeModel, normalizeModelId } from "../../open-sse/providers/models/schema.js"; describe("model name regex fallback (C2)", () => { it("derives display name from id per family", () => { @@ -25,4 +25,26 @@ describe("model name regex fallback (C2)", () => { expect(m.id).toBe("glm-5"); expect(m.name).toBe("GLM 5"); }); + + it("normalizeModelId: dash between digits becomes a dot (version separator)", () => { + expect(normalizeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4.5"); + expect(normalizeModelId("minimax-m2-5")).toBe("minimax-m2.5"); + expect(normalizeModelId("deepseek-3-2")).toBe("deepseek-3.2"); + }); + + it("normalizeModelId: preserves word-suffix hyphens (-thinking, -agentic)", () => { + expect(normalizeModelId("claude-sonnet-4-5-thinking")).toBe("claude-sonnet-4.5-thinking"); + expect(normalizeModelId("claude-sonnet-4-5-thinking-agentic")).toBe("claude-sonnet-4.5-thinking-agentic"); + }); + + it("normalizeModelId: leaves ids with no digit-digit hyphen untouched", () => { + expect(normalizeModelId("qwen3-coder-next")).toBe("qwen3-coder-next"); + expect(normalizeModelId("claude-sonnet-5")).toBe("claude-sonnet-5"); + expect(normalizeModelId("glm-5")).toBe("glm-5"); + }); + + it("normalizeModelId: non-string input passes through", () => { + expect(normalizeModelId(undefined)).toBeUndefined(); + expect(normalizeModelId(null)).toBeNull(); + }); }); From 46e6c01a01350962996abf50143eec9d1c39bc6d Mon Sep 17 00:00:00 2001 From: thienpv Date: Sun, 5 Jul 2026 17:32:25 +0700 Subject: [PATCH 02/48] fix(claude): reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381) On the translated OpenAI->Claude path, adjustMaxTokens capped max_tokens before applyThinking set thinking.budget_tokens, so max-effort budget (128000) could exceed a 64k-clamped max_tokens -> Anthropic 400. prepareClaudeRequest now reconciles after the budget is known: prefer raising max_tokens, only shrink budget when it meets/exceeds the ceiling. Also lift the global 64000 cap: the ceiling is now the model's real maxOutput, so high-output models (fable/mythos, opus-4.8/sonnet-4.6) get their full budget. adjustMaxTokens gains an optional ceiling arg (default unchanged, callers untouched); openai-to-claude passes the model maxOutput. Native Claude Code passthrough is unaffected. Co-Authored-By: Claude Co-authored-by: Cursor --- open-sse/translator/formats/claude.js | 21 +++- open-sse/translator/formats/maxTokens.js | 14 ++- .../translator/request/openai-to-claude.js | 7 +- .../translator/bugs-toClaude-context.test.js | 95 +++++++++++++++++++ 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index ec6e6c47..adbd3ce8 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -192,10 +192,27 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne delete body.output_config; } - // Clamp max_tokens to the model output ceiling (never above DEFAULT_MAX_TOKENS) + // Clamp max_tokens to the model's real output ceiling. Models whose caps + // declare a higher maxOutput (e.g. Opus 4.8 / Sonnet 4.6 = 128000) are allowed + // up to it, so max-effort thinking gets full budget; others fall back to the + // conservative 64000 default. if (body.max_tokens) { - const ceiling = Math.min(getCapabilitiesForModel(provider, body.model).maxOutput, DEFAULT_MAX_TOKENS); + const ceiling = getCapabilitiesForModel(provider, body.model).maxOutput || DEFAULT_MAX_TOKENS; if (body.max_tokens > ceiling) body.max_tokens = ceiling; + + // Reconcile against thinking budget. applyThinking (thinkingUnified.js) runs + // AFTER adjustMaxTokens capped max_tokens, and the claude-budget format maps + // max effort → budget_tokens 128000 — larger than the clamped max_tokens. + // Anthropic requires max_tokens strictly greater than budget_tokens (else 400). + // Prefer raising max_tokens to preserve the requested thinking depth; if the + // budget alone meets/exceeds the ceiling, cap output and shrink the budget so + // some tokens remain for the answer. + if (body.thinking?.type === "enabled" && body.thinking.budget_tokens && body.thinking.budget_tokens >= body.max_tokens) { + body.max_tokens = Math.min(body.thinking.budget_tokens + 1024, ceiling); + if (body.thinking.budget_tokens >= body.max_tokens) { + body.thinking.budget_tokens = Math.max(1024, body.max_tokens - 1024); + } + } } // 1. System: remove all cache_control, add only to last block with ttl 1h diff --git a/open-sse/translator/formats/maxTokens.js b/open-sse/translator/formats/maxTokens.js index 0e5b36f2..4d2cd209 100644 --- a/open-sse/translator/formats/maxTokens.js +++ b/open-sse/translator/formats/maxTokens.js @@ -3,9 +3,13 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf /** * Adjust max_tokens based on request context * @param {object} body - Request body + * @param {number} [ceiling=DEFAULT_MAX_TOKENS] - Upper bound for max_tokens. + * Callers with model context (e.g. openai-to-claude) pass the model's real + * maxOutput so high-output models (Opus 4.8 = 128000) aren't pre-clamped to + * the conservative 64000 default before the model-aware step sees them. * @returns {number} Adjusted max_tokens */ -export function adjustMaxTokens(body) { +export function adjustMaxTokens(body, ceiling = DEFAULT_MAX_TOKENS) { let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; // Auto-increase for tool calling to prevent truncated arguments (min never above max) @@ -16,14 +20,14 @@ export function adjustMaxTokens(body) { } // Ensure max_tokens > thinking.budget_tokens (Claude API requirement) - // Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS - // which could equal budget_tokens when budget_tokens >= 64000 + // Claude API requires strictly greater, so add buffer instead of using the + // ceiling which could equal budget_tokens when budget_tokens >= ceiling if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) { maxTokens = body.thinking.budget_tokens + 1024; } - // Never exceed the global ceiling - if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS; + // Never exceed the ceiling + if (maxTokens > ceiling) maxTokens = ceiling; return maxTokens; } diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index bc73149b..5c7d7613 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -6,6 +6,7 @@ import { safeParseJSON } from "../concerns/json.js"; import { parseDataUri } from "../concerns/image.js"; import { extractTextContent } from "../formats/gemini.js"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; // Empty prefix matches real Claude Code behavior (no tool name prefix). // Previously "proxy_" was used but this is a detectable fingerprint difference. @@ -15,9 +16,13 @@ const CLAUDE_OAUTH_TOOL_PREFIX = ""; export function openaiToClaudeRequest(model, body, stream) { // Tool name mapping for Claude OAuth (capitalizedName → originalName) const toolNameMap = new Map(); + // Cap max_tokens at the model's real output ceiling (e.g. Opus 4.8 = 128000), + // not the conservative 64000 default — otherwise a high-output model is + // pre-clamped here before prepareClaudeRequest's model-aware step runs. + const modelCeiling = getCapabilitiesForModel(null, model).maxOutput || undefined; const result = { model: model, - max_tokens: adjustMaxTokens(body), + max_tokens: adjustMaxTokens(body, modelCeiling), stream: stream }; diff --git a/tests/translator/bugs-toClaude-context.test.js b/tests/translator/bugs-toClaude-context.test.js index 60981e8e..96b704e8 100644 --- a/tests/translator/bugs-toClaude-context.test.js +++ b/tests/translator/bugs-toClaude-context.test.js @@ -67,6 +67,101 @@ describe("OpenAI → Claude context mapping", () => { expect(JSON.stringify(out), "remote image dropped").toContain("pic.png"); }); + // prepareClaudeRequest reconciles max_tokens vs thinking.budget_tokens. + // applyThinking runs after adjustMaxTokens caps max_tokens, so a claude-budget + // model at "max" effort (budget 128000) can exceed the clamped max_tokens and + // trip Anthropic's "max_tokens > budget_tokens" rule (400). See claude.js. + describe("max_tokens vs thinking.budget_tokens reconciliation", () => { + // 64k-ceiling model (maxOutput 64000) + max-effort budget 128000: budget alone + // exceeds the ceiling → cap max_tokens at 64000 and shrink budget below it. + it("max effort budget on a 64k model → budget < max_tokens ≤ 64000", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens: 128000 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + expect(out.thinking.budget_tokens).toBeLessThan(out.max_tokens); + expect(out.thinking.budget_tokens).toBeGreaterThan(0); + }); + + // Budget fits under the ceiling but exceeds a small client max_tokens → + // raise max_tokens to fit, preserving the requested thinking depth. + it("xhigh budget with a low client max_tokens → raise max_tokens, preserve budget", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 16000, + thinking: { type: "enabled", budget_tokens: 32768 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.thinking.budget_tokens).toBe(32768); + expect(out.max_tokens).toBe(33792); // 32768 + 1024, under the 64000 ceiling + }); + + // Budget already below max_tokens → nothing to reconcile. + it("high budget under max_tokens → both unchanged", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens: 24576 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + expect(out.thinking.budget_tokens).toBe(24576); + }); + + // Non-budget thinking shapes (adaptive / disabled) carry no budget_tokens → + // the reconciliation must never touch them. + it("adaptive thinking (no budget_tokens) is left untouched", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 64000, + thinking: { type: "adaptive" }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + expect(out.thinking).toEqual({ type: "adaptive" }); + }); + + // Lifted ceiling: a claude-budget model whose caps declare maxOutput 128000 + // (e.g. fable) may use the full budget at max effort instead of being pinned + // to the conservative 64000 default. + it("max effort budget on a 128k model → max_tokens up to 128000, budget preserved just under", () => { + const out = prepareClaudeRequest({ + model: "claude-fable-5", + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens: 128000 }, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(128000); + expect(out.thinking.budget_tokens).toBe(126976); // 128000 - 1024 + expect(out.thinking.budget_tokens).toBeLessThan(out.max_tokens); + }); + + // Regression: a default 64k-ceiling model still clamps an over-large client + // max_tokens down to 64000 (the lift is per-model, not global). + it("over-large client max_tokens on a 64k model is still clamped to 64000", () => { + const out = prepareClaudeRequest({ + model: "claude-opus-4-20250514", + max_tokens: 120000, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(64000); + }); + + // Lifted ceiling for a 128k model: a large client max_tokens is now allowed + // through instead of being clamped to 64000. + it("large client max_tokens on a 128k model is allowed up to maxOutput", () => { + const out = prepareClaudeRequest({ + model: "claude-fable-5", + max_tokens: 100000, + messages: [{ role: "user", content: "q" }], + }, "anthropic"); + expect(out.max_tokens).toBe(100000); + }); + }); + it("DeepSeek Claude transport adds a thinking placeholder before tool_use in thinking mode", () => { const out = prepareClaudeRequest({ model: "deepseek-v4-pro", From b6454d84dacdd901c8fa66e2880f4da5bdc85a4a Mon Sep 17 00:00:00 2001 From: Arash Kadkhodaei Date: Sun, 5 Jul 2026 17:31:24 +0700 Subject: [PATCH 03/48] feat(i18n): add Farsi (fa) language support (#2385) Co-authored-by: Cursor --- public/i18n/literals/fa.json | 195 ++++++++++++++++++++++ src/i18n/config.js | 99 +++++++---- src/shared/components/LanguageSwitcher.js | 3 +- src/shared/constants/locales.js | 1 + 4 files changed, 267 insertions(+), 31 deletions(-) create mode 100644 public/i18n/literals/fa.json diff --git a/public/i18n/literals/fa.json b/public/i18n/literals/fa.json new file mode 100644 index 00000000..770518a3 --- /dev/null +++ b/public/i18n/literals/fa.json @@ -0,0 +1,195 @@ +{ + "Cancel": "لغو", + "Delete": "حذف", + "Edit": "ویرایش", + "Save": "ذخیره", + "Close": "بستن", + "Add": "افزودن", + "Remove": "حذف", + "Settings": "تنظیمات", + "Profile": "پروفایل", + "Dashboard": "پیش‌خوان", + "Logout": "خروج", + "Login": "ورود", + "Providers": "ارائه‌دهندگان", + "Usage": "آمار مصرف", + "API Key": "کلید API", + "Connected": "متصل", + "Disconnected": "قطع شده", + "Active": "فعال", + "Inactive": "غیرفعال", + "Success": "موفق", + "Failed": "ناموفق", + "Error": "خطا", + "Warning": "هشدار", + "Info": "اطلاعات", + "Loading": "در حال بارگذاری", + "Search": "جستجو", + "Filter": "فیلتر", + "Sort": "مرتب‌سازی", + "Export": "خروجی", + "Import": "ورودی", + "Refresh": "تازه‌سازی", + "Back": "بازگشت", + "Next": "بعدی", + "Previous": "قبلی", + "Submit": "ارسال", + "Confirm": "تأیید", + "Yes": "بله", + "No": "خیر", + "OK": "تأیید", + "Apply": "اعمال", + "Reset": "بازنشانی", + "Clear": "پاک کردن", + "Select": "انتخاب", + "Upload": "آپلود", + "Download": "دانلود", + "Copy": "کپی", + "Paste": "چسباندن", + "Cut": "برش", + "Undo": "بازگشت", + "Redo": "انجام مجدد", + "Name": "نام", + "Description": "توضیحات", + "Status": "وضعیت", + "Type": "نوع", + "Date": "تاریخ", + "Time": "زمان", + "Created": "ایجاد شده", + "Updated": "بروزرسانی شده", + "Actions": "عملیات", + "Details": "جزئیات", + "View": "مشاهده", + "New": "جدید", + "Total": "مجموع", + "Count": "تعداد", + "Price": "قیمت", + "Cost": "هزینه", + "Free": "رایگان", + "Paid": "پولی", + "Enable": "فعال‌سازی", + "Disable": "غیرفعال‌سازی", + "Enabled": "فعال شده", + "Disabled": "غیرفعال شده", + "Online": "آنلاین", + "Offline": "آفلاین", + "Available": "موجود", + "Unavailable": "ناموجود", + "Required": "الزامی", + "Optional": "اختیاری", + "Default": "پیش‌فرض", + "Custom": "سفارشی", + "Advanced": "پیشرفته", + "Basic": "ساده", + "Help": "راهنما", + "Support": "پشتیبانی", + "Documentation": "مستندات", + "Version": "نسخه", + "Language": "زبان", + "Theme": "پوسته", + "Light": "روشن", + "Dark": "تاریک", + "Auto": "خودکار", + "Endpoint": "اندپوینت", + "Combos": "ترکیبات", + "Quota Tracker": "پیگیر سهمیه", + "MITM": "MITM", + "CLI Tools": "ابزارهای CLI", + "Console Log": "لاگ کنسول", + "System": "سیستم", + "Debug": "اشکال‌زدایی", + "Shutdown": "خاموش کردن", + "Close Proxy": "بستن پروکسی", + "Are you sure you want to close the proxy server?": "آیا مطمئن هستید که می‌خواهید سرور پروکسی را ببندید؟", + "Server Disconnected": "سرور قطع شد", + "The proxy server has been stopped.": "سرور پروکسی متوقف شده است.", + "Reload Page": "بارگذاری مجدد صفحه", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "سرویس در ترمینال در حال اجراست. می‌توانید این صفحه وب را ببندید. خاموش کردن، سرویس را متوقف می‌کند.", + "Manage your AI provider connections": "مدیریت اتصالات ارائه‌دهندگان هوش مصنوعی خود", + "Model combos with fallback": "ترکیبات مدل با پشتیبان جایگزین", + "Monitor your API usage, token consumption, and request logs": "نظارت بر مصرف API، مصرف توکن و لاگ درخواست‌ها", + "Intercept CLI tool traffic and route through 9Router": "拦截 ترافیک ابزار CLI و مسیردهی از طریق 9Router", + "Configure CLI tools": "پیکربندی ابزارهای CLI", + "API endpoint configuration": "پیکربندی نقطه پایانی API", + "Manage your preferences": "مدیریت تنظیمات شخصی", + "Debug translation flow between formats": "اشکال‌زدایی جریان ترجمه بین فرمت‌ها", + "Live server console output": "خروجی کنسول سرور زنده", + "Create model combos with fallback support": "ایجاد ترکیبات مدل با پشتیبانی از پشتیبان جایگزین", + "Local Mode": "حالت محلی", + "Running on your machine": "در حال اجرا روی دستگاه شما", + "Database Location": "مکان پایگاه داده", + "Download Backup": "دانلود پشتیبان", + "Import Backup": "وارد کردن پشتیبان", + "Database backup downloaded": "پشتیبان پایگاه داده دانلود شد", + "Database imported successfully": "پایگاه داده با موفقیت وارد شد", + "Security": "امنیت", + "Require login": "نیاز به ورود", + "When ON, dashboard requires password. When OFF, access without login.": "در حالت روشن، داشبورد به رمز عبور نیاز دارد. در حالت خاموش، دسترسی بدون نیاز به ورود.", + "Current Password": "رمز عبور فعلی", + "Enter current password": "رمز عبور فعلی را وارد کنید", + "New Password": "رمز عبور جدید", + "Enter new password": "رمز عبور جدید را وارد کنید", + "Confirm New Password": "تأیید رمز عبور جدید", + "Confirm new password": "تأیید رمز عبور جدید", + "Update Password": "بروزرسانی رمز عبور", + "Set Password": "تنظیم رمز عبور", + "Password updated successfully": "رمز عبور با موفقیت بروزرسانی شد", + "Passwords do not match": "رمزهای عبور مطابقت ندارند", + "Routing Strategy": "استراتژی مسیردهی", + "Round Robin": "چرخشی", + "Cycle through accounts to distribute load": "چرخش بین حساب‌ها برای توزیع بار", + "Sticky Limit": "محدودیت چسبندگی", + "Calls per account before switching": "تعداد تماس به ازای هر حساب قبل از تغییر", + "Network": "شبکه", + "Outbound Proxy": "پروکسی خروجی", + "Enable proxy for OAuth + provider outbound requests.": "فعال‌سازی پروکسی برای درخواست‌های خروجی OAuth + ارائه‌دهنده.", + "Proxy URL": "آدرس پروکسی", + "Leave empty to inherit existing env proxy (if any).": "برای ارث‌بری از پروکسی موجود محیط، خالی بگذارید (در صورت وجود).", + "No Proxy": "بدون پروکسی", + "Comma-separated hostnames/domains to bypass the proxy.": "نام میزبان/دامنه‌ها با جداکننده ویرگول برای دور زدن پروکسی.", + "Test proxy URL": "آزمایش آدرس پروکسی", + "Proxy settings applied": "تنظیمات پروکسی اعمال شد", + "Proxy enabled": "پروکسی فعال شد", + "Proxy disabled": "پروکسی غیرفعال شد", + "Proxy test OK": "آزمایش پروکسی موفق", + "Proxy test failed": "آزمایش پروکسی ناموفق", + "Please enter a Proxy URL to test": "لطفاً یک آدرس پروکسی برای آزمایش وارد کنید", + "Observability": "مشاهده‌پذیری", + "Enable Observability": "فعال‌سازی مشاهده‌پذیری", + "Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری", + "Max Records": "حداکثر تعداد رکوردها", + "Maximum request detail records to keep (older records are auto-deleted)": "حداکثر تعداد رکوردهای جزئیات درخواست برای نگهداری (رکوردهای قدیمی‌تر به صورت خودکار حذف می‌شوند)", + "Batch Size": "اندازه دسته", + "Number of items to accumulate before writing to database (higher = better performance)": "تعداد موارد قبل از نوشتن در پایگاه داده (بیشتر = عملکرد بهتر)", + "Flush Interval (ms)": "فاصله تخلیه (میلی‌ثانیه)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "حداکثر زمان انتظار قبل از تخلیه بافر (از از دست رفتن داده در ترافیک کم جلوگیری می‌کند)", + "Max JSON Size (KB)": "حداکثر اندازه JSON (کیلوبایت)", + "Maximum size for each JSON field (request/response) before truncation": "حداکثر اندازه برای هر فیلد JSON (درخواست/پاسخ) قبل از برش", + "All data stored on your machine": "تمام داده‌ها روی دستگاه شما ذخیره می‌شوند", + "MITM Server": "سرور MITM", + "Running": "در حال اجرا", + "Stopped": "متوقف شده", + "Cert": "گواهی", + "Server": "سرور", + "Purpose:": "هدف:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "استفاده از Antigravity IDE و GitHub Copilot → با هر ارائه‌دهنده/مدلی از 9Router", + "How it works:": "نحوه عملکرد:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "درخواست Antigravity/Copilot IDE → تغییر مسیر DNS به localhost:443 → رهگیری پروکسی MITM → 9Router → پاسخ به Antigravity/Copilot", + "No API keys — create one in Keys page": "بدون کلید API — یکی در صفحه کلیدها ایجاد کنید", + "sk_9router (default)": "sk_9router (پیش‌فرض)", + "Server started": "سرور راه‌اندازی شد", + "Failed to start server": "خطا در راه‌اندازی سرور", + "Server stopped — all DNS cleared": "سرور متوقف شد — تمام DNS پاک شد", + "Failed to stop server": "خطا در توقف سرور", + "Sudo password is required": "گذرواژه sudo الزامی است", + "Stop Server": "توقف سرور", + "Start Server": "راه‌اندازی سرور", + "Enable DNS per tool below to activate interception": "DNS را برای هر ابزار در زیر فعال کنید تا رهگیری فعال شود", + "Sudo Password Required": "گذرواژه sudo الزامی است", + "Enter your sudo password to start/stop MITM server": "رمز عبور sudo خود را برای راه‌اندازی/توقف سرور MITM وارد کنید", + "Sudo Password": "گذرواژه sudo", + "Click to add, click again to remove. Changes are saved automatically.": "کلیک برای افزودن، کلیک مجدد برای حذف. تغییرات به صورت خودکار ذخیره می‌شوند.", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ اطلاعیه ریسک: این ارائه‌دهنده از اشتراک/جلسه OAuth استفاده می‌کند که به طور رسمی برای استفاده پروکسی/روتر مجوز ندارد. حساب ممکن است محدود یا مسدود شود. با مسئولیت خود استفاده کنید.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ترافیک HTTPS ابزارهای IDE (Antigravity, GitHub Copilot, Kiro) را از طریق CA محلی رهگیری می‌کند تا درخواست‌ها را به ارائه‌دهندگان شما مسیردهی کند. ممکن است شرایط خدمات را نقض کند → مسدود شدن حساب. با مسئولیت خود استفاده کنید.", + "Endpoint is exposed without an API key.": "Endpoint بدون کلید API در معرض دسترسی است." +} diff --git a/src/i18n/config.js b/src/i18n/config.js index 60a0c16c..ef6d41cb 100644 --- a/src/i18n/config.js +++ b/src/i18n/config.js @@ -1,41 +1,77 @@ -export const LOCALES = ["en", "vi", "zh-CN", "zh-TW", "ja", "pt-BR", "pt-PT", "ko", "es", "de", "fr", "he", "ar", "ru", "pl", "cs", "nl", "tr", "uk", "tl", "id", "th", "hi", "bn", "ur", "ro", "sv", "it", "el", "hu", "fi", "da", "no"]; +export const LOCALES = [ + "en", + "vi", + "zh-CN", + "zh-TW", + "ja", + "pt-BR", + "pt-PT", + "ko", + "es", + "de", + "fr", + "he", + "ar", + "ru", + "pl", + "cs", + "nl", + "tr", + "uk", + "tl", + "id", + "th", + "hi", + "bn", + "ur", + "ro", + "sv", + "it", + "el", + "hu", + "fi", + "da", + "no", + "fa", +]; export const DEFAULT_LOCALE = "en"; export const LOCALE_COOKIE = "locale"; export const LOCALE_NAMES = { - "en": "English", - "vi": "Tiếng Việt", + en: "English", + vi: "Tiếng Việt", "zh-CN": "简体中文", "zh-TW": "繁體中文", - "ja": "日本語", + ja: "日本語", "pt-BR": "Português (Brasil)", "pt-PT": "Português (Portugal)", - "ko": "한국어", - "es": "Español", - "de": "Deutsch", - "fr": "Français", - "he": "עברית", - "ar": "العربية", - "ru": "Русский", - "pl": "Polski", - "cs": "Čeština", - "nl": "Nederlands", - "tr": "Türkçe", - "uk": "Українська", - "tl": "Tagalog", - "id": "Indonesia", - "th": "ไทย", - "hi": "हिन्दी", - "bn": "বাংলা", - "ur": "اردو", - "ro": "Română", - "sv": "Svenska", - "it": "Italiano", - "el": "Ελληνικά", - "hu": "Magyar", - "fi": "Suomi", - "da": "Dansk", - "no": "Norsk" + ko: "한국어", + es: "Español", + de: "Deutsch", + fr: "Français", + he: "עברית", + ar: "العربية", + ru: "Русский", + pl: "Polski", + cs: "Čeština", + nl: "Nederlands", + tr: "Türkçe", + uk: "Українська", + tl: "Tagalog", + id: "Indonesia", + th: "ไทย", + hi: "हिन्दी", + bn: "বাংলা", + ur: "اردو", + ro: "Română", + sv: "Svenska", + it: "Italiano", + el: "Ελληνικά", + hu: "Magyar", + fi: "Suomi", + da: "Dansk", + no: "Norsk", + fa: "فارسی", }; export function normalizeLocale(locale) { @@ -138,6 +174,9 @@ export function normalizeLocale(locale) { if (locale === "no") { return "no"; } + if (locale === "fa") { + return "fa"; + } return DEFAULT_LOCALE; } diff --git a/src/shared/components/LanguageSwitcher.js b/src/shared/components/LanguageSwitcher.js index b14a8179..7697e649 100644 --- a/src/shared/components/LanguageSwitcher.js +++ b/src/shared/components/LanguageSwitcher.js @@ -49,7 +49,8 @@ const getLocaleInfo = (locale) => { "hu": { name: "Magyar", flag: "🇭🇺" }, "fi": { name: "Suomi", flag: "🇫🇮" }, "da": { name: "Dansk", flag: "🇩🇰" }, - "no": { name: "Norsk", flag: "🇳🇴" } + "no": { name: "Norsk", flag: "🇳🇴" }, + "fa": { name: "فارسی", flag: "🇮🇷" } }; return locales[locale] || { name: locale, flag: "🌐" }; }; diff --git a/src/shared/constants/locales.js b/src/shared/constants/locales.js index 63f7fa97..eff86544 100644 --- a/src/shared/constants/locales.js +++ b/src/shared/constants/locales.js @@ -33,4 +33,5 @@ export const LOCALE_FLAGS = { "fi": "🇫🇮", "da": "🇩🇰", "no": "🇳🇴", + "fa": "🇮🇷", }; From 008de32c065739302db387a96f0ae6055709b0fd Mon Sep 17 00:00:00 2001 From: Mohammed Faheem Date: Sun, 5 Jul 2026 17:40:25 +0700 Subject: [PATCH 04/48] docs: add CLAUDE.md guidance for Claude Code (#2354) Top-level guide for Claude Code / AI coding agents working in this repo, complementing docs/ARCHITECTURE.md and open-sse/AGENTS.md. Docs-only: PR's incidental code changes were dropped as they reverted #2366. Co-Authored-By: Mohammed Faheem Co-authored-by: Cursor --- CLAUDE.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d7c21345 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync. + +Two published artifacts live in this one repo: +- The **dashboard + gateway** (root `package.json`, `9router-app`) — the Next.js server that does the actual routing. +- The **CLI launcher** (`cli/`, published to npm as `9router`) — a separate package that installs/starts the server and manages the tray. It has its own `package.json`, version, and build. + +The code lives in `src/` (Next.js app + dashboard/compat APIs), `open-sse/` (the provider-agnostic routing/translation engine), `cli/` (the launcher package), and `tests/`. + +## Commands + +Dashboard/gateway (run from repo root): +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev # dev (webpack, port 20127 by default via next dev) +npm run build && PORT=20128 HOSTNAME=0.0.0.0 npm run start # production +``` +- Bun variants: `npm run dev:bun` / `build:bun` / `start:bun`. +- Default runtime port is **20128** (dashboard at `/dashboard`, API at `/v1`). +- Lint: `npx eslint .` (config `eslint.config.mjs`, extends `eslint-config-next`). + +CLI package (`cli/`): +```bash +npm run cli:pack # build + npm pack from root +cd cli && npm run dev # nodemon watch +``` + +Tests (vitest, in `tests/`, an **independent** ESM package — not wired into root `npm test`): +```bash +npm install # ROOT deps first — tests import from src/ which needs `open`, `undici`, etc. +cd tests && npm install # then tests' own deps (vitest) → tests/node_modules (allowed by tests/.gitignore) +npx vitest run # all tests; auto-discovers tests/vitest.config.js +npx vitest run unit/capabilities.test.js # single file (path relative to tests/) +``` +> The committed `tests/package.json` `test` script hardcodes Unix paths (`NODE_PATH=/tmp/node_modules …`) — a shared-install workaround from upstream. On Windows (or anywhere), ignore it and use the `npx vitest` form above; `vitest.config.js` resolves the `open-sse`/`@/` aliases from the repo root regardless of where vitest lives. +> +> **The suite is NOT expected to be all-green on a plain checkout.** ~938 pass, ~64 fail. Judge regressions with `tests/__baseline__/verify-no-regression.mjs`, not a raw run. Expected red: +> - 26 catalogued in `tests/__baseline__/known-fails.txt` (rtk, oauth-cursor-auto-import, translator-request-normalization, …). +> - `unit/embeddings.cloud.test.js` imports `cloud/src/handlers/embeddings.js` — the `cloud/` worker dir is **not in this repo**, so it always fails here. +> - `unit/xai-oauth-service.test.js` times out (5s) when the xAI endpoint-discovery fetch isn't reachable/mocked. +> - `real/*.real.test.js` make live provider calls — need credentials, skip otherwise. +- `*.real.test.js` under `tests/translator/real/` make live provider calls — skip unless credentials are set. +- Regression baselines: `tests/__baseline__/verify-*.mjs` compare against committed snapshots (providers, aliases, OAuth URLs). Run these after touching provider registry / alias logic. + +## Architecture + +Two authoritative docs already exist — read them before working in these areas rather than re-deriving: +- `docs/ARCHITECTURE.md` — full system: request lifecycle, combo/account fallback, OAuth + token refresh, cloud sync, data model. +- `open-sse/AGENTS.md` — the routing/translation engine's own conventions and "how to add a provider/executor/translator". **Read this before editing anything under `open-sse/`.** + +### Request flow (the thing to understand first) +`src/app/api/v1/*` route (Next rewrite maps `/v1/*` → `/api/v1/*` in `next.config.mjs`) +→ `src/sse/handlers/chat.js` (parse, combo expansion, account-selection loop) +→ `open-sse/handlers/chatCore.js` (detect source format, translate request, dispatch to executor, retry/refresh, stream setup) +→ `open-sse/executors/*` (per-provider upstream call; `default.js` handles any OpenAI-compatible provider) +→ `open-sse/translator/*` (client format ↔ provider format) +→ SSE back to client. + +`src/sse/` is the app-side entry glue; `open-sse/` is the provider-agnostic engine (also usable standalone). Cross that boundary consciously. + +### Translator engine (`open-sse/translator/`) +- Pivots through **OpenAI as the intermediate format**. A translator registered on an exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop. Prefer a direct route for fragile pairs (thinking blocks, tool ids, non-base64 images, `is_error`). +- Translators **self-register** via `register(from, to, reqFn, resFn)` as an import side effect — a new translator file MUST be imported in `open-sse/translator/index.js` or it never runs. +- Never hardcode role/block/model strings — use `open-sse/translator/schema/` and `open-sse/config/` constants. Config-driven and DRY is enforced by convention here. + +### Provider registry (`open-sse/providers/registry/*`) +- One file per provider. `providers/registry/index.js` is an **auto-generated** static import list — regenerate it with `scripts/migrate-registry.mjs` / `injectDisplayToRegistry.mjs`, don't hand-edit. +- Add a provider: copy `providers/REGISTRY_TEMPLATE.js`, add models to `config/providerModels.js`. Only add an executor for non-OpenAI-compatible upstreams. + +### Persistence — IMPORTANT (ARCHITECTURE.md is stale here) +State is **no longer `db.json`**. It's a SQLite layer under `src/lib/db/` with an adapter fallback chain (`driver.js`): `bun:sqlite` → `better-sqlite3` (optional native dep) → `node:sqlite` (Node ≥22.5) → `sql.js` (pure-JS fallback, always works). `better-sqlite3` is deliberately in `optionalDependencies` so install never fails without build tools. +- `src/lib/localDb.js` is a **backward-compat shim** re-exporting `src/lib/db/index.js`. New code should import from `@/lib/db/index.js`; per-entity logic lives in `src/lib/db/repos/*`. Schema/migrations in `src/lib/db/migrations/`. +- DB file location resolves via `src/lib/db/paths.js` (`DATA_DIR`, else `~/.9router/`). +- Usage/logs (`src/lib/usageDb.js`, `usage.json` + `log.txt`) still live under `~/.9router` and do **not** follow `DATA_DIR`. + +### RTK token saver (`open-sse/rtk/`) +Pre-translate hooks that compress `tool_result` content in-place to cut tokens. **Fail-open**: any error returns null and leaves the body untouched — never throw out of them. Skips `is_error`/`status:"error"` results to preserve traces. + +## Conventions & gotchas + +- Plain JavaScript (ESM), no TypeScript. `@/*` path alias → `src/*` (`jsconfig.json`). +- `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. +- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`). From 481e7e467b3c3ba403769efc211d5db8785ca854 Mon Sep 17 00:00:00 2001 From: Sutarto Jordan Chrisfivo Date: Sun, 5 Jul 2026 17:40:41 +0700 Subject: [PATCH 05/48] fix(headroom): proxy dashboard through app (#2372) Add a 9Router-side proxy so the Headroom dashboard and its data endpoints (/stats, /health, /stats-history, /transformations/feed) stay same-origin when opened remotely through the 9Router app, and add an "Open Headroom Dashboard" link in the Token Saver modal. Gate /api/headroom/proxy as LOCAL_ONLY (loopback + CLI token) to match start/stop, and strip cookie/authorization when the Headroom target is non-loopback to avoid leaking viewer credentials. Co-authored-by: Cursor --- .../dashboard/token-saver/TokenSaverClient.js | 10 ++ src/app/api/headroom/proxy/[...path]/route.js | 104 ++++++++++++++++++ src/dashboardGuard.js | 1 + 3 files changed, 115 insertions(+) create mode 100644 src/app/api/headroom/proxy/[...path]/route.js diff --git a/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js b/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js index 637ddced..e77194d5 100644 --- a/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js +++ b/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js @@ -374,6 +374,16 @@ export default function TokenSaverClient() { {headroomStatusLabel} + {headroomRunning && ( + + Open Headroom Dashboard + + )}

Proxy URL

Date: Sun, 5 Jul 2026 17:45:04 +0700 Subject: [PATCH 06/48] docs(readme): add English and Urdu/Hindi video tutorials (#2305) Co-authored-by: Cursor --- README.md | 196 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 127 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a9e169a9..4929ab61 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) - decolua%2F9router | Trendshift - - [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) +decolua%2F9router | Trendshift + +[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) + +[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) - [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md)
--- @@ -114,6 +115,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run ``` Default URLs: + - Dashboard: `http://localhost:20128/dashboard` - OpenAI-compatible API: `http://localhost:20128/v1` @@ -125,6 +127,20 @@ Default URLs: + + - + + - - - + + - - - + + + +
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
9Router Setup Tutorial @@ -132,7 +148,10 @@ Default URLs: 🇺🇸 English
9Router + Claude Code FREE Setup
by
Build AI With Hamid
+ +
Tiết kiệm chi phí LLM với 9Router
@@ -146,8 +165,6 @@ Default URLs: 🇺🇸 English
Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
Claude CLI Free Setup @@ -155,7 +172,10 @@ Default URLs: 🇺🇸 English
Claude CLI Free Setup with 9Router 🚀
by
CodeVerse Soban
+ +
Cài đặt OpenClaw Free A-Z
@@ -169,8 +189,6 @@ Default URLs: 🇺🇸 English
FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
Claude CLI Free Setup @@ -178,7 +196,11 @@ Default URLs: 🇮🇩 Indonesia
Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by
Krisswuh
+ +
Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
@@ -186,6 +208,7 @@ Default URLs: Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
@@ -408,22 +431,22 @@ Default URLs: ## 💡 Key Features -| Feature | What It Does | Why It Matters | -|---------|--------------|----------------| -| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request | -| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients | -| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** | -| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** | -| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | -| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | -| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool | -| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | -| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | -| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | -| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily | -| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere | -| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending | -| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options | +| Feature | What It Does | Why It Matters | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request | +| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients | +| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** | +| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | +| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | +| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool | +| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy | +| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed | +| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs | +| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily | +| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere | +| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending | +| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options |
📖 Feature Details @@ -474,7 +497,7 @@ If Headroom is down or returns an error, 9Router fails open and sends the origin ### 🐴 Ponytail (Lazy Senior Dev) -Ponytail injects a *"lazy senior dev"* system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). +Ponytail injects a _"lazy senior dev"_ system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). - **Lite** — Build what's asked, name the lazier alternative. - **Full** — YAGNI ladder enforced: stdlib → native → existing deps → one-liner → minimal code. @@ -510,6 +533,7 @@ Combo: "my-coding-stack" ### 🔄 Format Translation Seamless translation between formats: + - **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** - Your CLI tool sends OpenAI format → 9Router translates → Provider receives native format - Works with any tool that supports custom OpenAI endpoints @@ -563,14 +587,14 @@ Seamless translation between formats: - Optimize your AI spending > **💡 IMPORTANT - Understanding Dashboard Costs:** -> -> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**. +> +> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**. > 9Router itself **never charges** you anything. You only pay providers directly (if using paid services). -> -> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents +> +> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents > what you would have paid using paid APIs directly. Your actual cost = **$0** (iFlow is free unlimited). -> -> Think of it as a "savings tracker" showing how much you're saving by using free models or +> +> Think of it as a "savings tracker" showing how much you're saving by using free models or > routing through 9Router! ### 🌐 Deploy Anywhere @@ -586,19 +610,19 @@ Seamless translation between formats: ## 💰 Pricing at a Glance -| Tier | Provider | Cost | Quota Reset | Best For | -|------|----------|------|-------------|----------| -| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** | -| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed | -| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | -| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | -| | Cursor IDE | $20/mo | Monthly | Cursor users | -| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | -| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | -| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | -| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free | -| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models | -| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 | +| Tier | Provider | Cost | Quota Reset | Best For | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** | +| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed | +| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users | +| | GitHub Copilot | $10-19/mo | Monthly | GitHub users | +| | Cursor IDE | $20/mo | Monthly | Cursor users | +| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | +| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | +| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | +| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free | +| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models | +| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 | **💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**! @@ -619,6 +643,7 @@ Seamless translation between formats: The dashboard shows **estimated costs** as if you were using paid APIs directly. This is **not billing** - it's a comparison tool to show your savings. **Example Scenario:** + ``` Dashboard Display: • Total Requests: 1,662 @@ -632,6 +657,7 @@ Reality Check: ``` **Payment Rules:** + - **Subscription providers** (Claude Code, Codex): Pay them directly via their websites - **Cheap providers** (GLM, MiniMax): Pay them directly, 9Router just routes - **FREE providers** (iFlow, Kiro, Qwen): Genuinely free forever, no hidden charges @@ -646,6 +672,7 @@ Reality Check: **Problem:** Quota expires unused, rate limits during heavy coding **Solution:** + ``` Combo: "maximize-claude" 1. cc/claude-opus-4-7 (use subscription fully) @@ -661,6 +688,7 @@ vs. $20 + hitting limits = frustration **Problem:** Can't afford subscriptions, need reliable AI coding **Solution:** + ``` Combo: "free-forever" 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) @@ -676,6 +704,7 @@ Quality: Production-ready models + RTK saves 20-40% tokens **Problem:** Deadlines, can't afford downtime **Solution:** + ``` Combo: "always-on" 1. cc/claude-opus-4-7 (best quality) @@ -693,6 +722,7 @@ Monthly cost: $20-200 (subscriptions) + $10-20 (backup) **Problem:** Need AI assistant in messaging apps (WhatsApp, Telegram, Slack...), completely free **Solution:** + ``` Combo: "openclaw-free" 1. kr/claude-sonnet-4.5 (Claude 4.5 free) @@ -713,6 +743,7 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... The dashboard tracks your token usage and displays **estimated costs** as if you were using paid APIs directly. This is **not actual billing** - it's a reference to show how much you're saving by using free models or existing subscriptions through 9Router. **Example:** + - **Dashboard shows:** "$290 total cost" - **Reality:** You're using iFlow (FREE unlimited) - **Your actual cost:** **$0.00** @@ -728,6 +759,7 @@ The cost display is a "savings tracker" to help you understand your usage patter **No.** 9Router is free, open-source software that runs on your own computer. It never charges you anything. **You only pay:** + - ✅ **Subscription providers** (Claude Code $20/mo, Codex $20-200/mo) → Pay them directly on their websites - ✅ **Cheap providers** (GLM, MiniMax) → Pay them directly, 9Router just routes your requests - ❌ **9Router itself** → **Never charges anything, ever** @@ -742,6 +774,7 @@ The cost display is a "savings tracker" to help you understand your usage patter **Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**. These are free services offered by those respective companies: + - **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth - **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models` - **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days) @@ -749,6 +782,7 @@ These are free services offered by those respective companies: 9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support. **Discontinued free tiers (no longer recommended):** + - ❌ **iFlow**: Was free unlimited, now changed to paid (2026) - ❌ **Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15 - ❌ **Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans — only use if you stick to Gemini CLI itself @@ -761,17 +795,21 @@ These are free services offered by those respective companies: **Free-First Strategy:** 1. **Start with 100% free combo:** + ``` 1. gc/gemini-3-flash (180K/month free from Google) 2. if/kimi-k2-thinking (unlimited free from iFlow) 3. qw/qwen3-coder-plus (unlimited free from Qwen) ``` + **Cost: $0/month** 2. **Add cheap backup** only if you need it: + ``` 4. glm/glm-4.7 ($0.6/1M tokens) ``` + **Additional cost: Only pay for what you actually use** 3. **Use subscription providers last:** @@ -790,10 +828,12 @@ These are free services offered by those respective companies: **Scenario:** You're on a coding sprint and blow through your quotas **Without 9Router:** + - ❌ Hit rate limit → Work stops → Frustration - ❌ Or: Accidentally rack up huge API bills **With 9Router:** + - ✅ Subscription hits limit → Auto-fallback to cheap tier - ✅ Cheap tier gets expensive → Auto-fallback to free tier - ✅ Never stop coding → Predictable costs @@ -1117,6 +1157,7 @@ pm2 startup ### Docker Published images (multi-platform `linux/amd64` + `linux/arm64`): + - Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) - GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) @@ -1144,6 +1185,7 @@ docker run -d --name 9router -p 20128:20128 \ ``` **Container defaults:** + - `PORT=20128` - `HOSTNAME=0.0.0.0` @@ -1160,26 +1202,27 @@ docker pull decolua/9router:latest # update to latest ### Environment Variables -| Variable | Default | Description | -|----------|---------|-------------| -| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) | -| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists | -| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) | -| `PORT` | framework default | Service port (`20128` in examples) | -| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | -| `NODE_ENV` | runtime default | Set `production` for deploy | -| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | -| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | -| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | -| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | -| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | -| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | -| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | -| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | +| Variable | Default | Description | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) | +| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists | +| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) | +| `PORT` | framework default | Service port (`20128` in examples) | +| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | +| `NODE_ENV` | runtime default | Set `production` for deploy | +| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | +| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | +| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | +| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | Notes: + - Lowercase proxy variables are also supported: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. - `.env` is not baked into Docker image (`.dockerignore`); inject runtime config with `--env-file` or `-e`. - On Windows, `APPDATA` can be used for local storage path resolution. @@ -1202,6 +1245,7 @@ Notes: View all available models **Claude Code (`cc/`)** - Pro/Max: + - `cc/claude-opus-4-7` - `cc/claude-opus-4-6` - `cc/claude-sonnet-4-6` @@ -1209,6 +1253,7 @@ Notes: - `cc/claude-haiku-4-5-20251001` **Codex (`cx/`)** - Plus/Pro: + - `cx/gpt-5.5` - `cx/gpt-5.4` - `cx/gpt-5.3-codex` @@ -1216,6 +1261,7 @@ Notes: - `cx/gpt-5.1-codex-max` **GitHub Copilot (`gh/`)**: + - `gh/gpt-5.4` - `gh/claude-opus-4.7` - `gh/claude-sonnet-4.6` @@ -1223,25 +1269,30 @@ Notes: - `gh/grok-code-fast-1` **Cursor (`cu/`)** - Subscription: + - `cu/claude-4.6-opus-max` - `cu/claude-4.5-sonnet-thinking` - `cu/gpt-5.3-codex` - `cu/kimi-k2.5` **GLM (`glm/`)** - $0.6/1M: + - `glm/glm-5.1` - `glm/glm-5` - `glm/glm-4.7` **MiniMax (`minimax/`)** - $0.2/1M: + - `minimax/MiniMax-M2.7` - `minimax/MiniMax-M2.5` **Kimi (`kimi/`)** - $9/mo flat: + - `kimi/kimi-k2.5` - `kimi/kimi-k2.5-thinking` **Kiro (`kr/`)** - FREE unlimited: + - `kr/claude-sonnet-4.5` - `kr/claude-haiku-4.5` - `kr/glm-5` @@ -1250,9 +1301,11 @@ Notes: - `kr/deepseek-3.2` **OpenCode Free (`oc/`)** - FREE no-auth: + - Auto-fetched from `opencode.ai/zen/v1/models` **Vertex AI (`vertex/`)** - $300 free credits: + - `vertex/gemini-3.1-pro-preview` - `vertex/gemini-3-flash-preview` - `vertex/gemini-2.5-flash` @@ -1266,31 +1319,38 @@ Notes: ## 🐛 Troubleshooting **"Language model did not provide messages"** + - Provider quota exhausted → Check dashboard quota tracker - Solution: Use combo fallback or switch to cheaper tier **Rate limiting** + - Subscription quota out → Fallback to GLM/MiniMax - Add combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` **OAuth token expired** + - Auto-refreshed by 9Router - If issues persist: Dashboard → Provider → Reconnect **High costs** + - Enable RTK in Dashboard → Endpoint settings (default ON, saves 20-40% tokens) - Check usage stats in Dashboard - Switch primary model to GLM/MiniMax - Use free tier (Kiro, OpenCode Free, Vertex) for non-critical tasks **Dashboard opens on wrong port** + - Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` **First login not working** + - Check `INITIAL_PASSWORD` in `.env` - If unset, fallback password is `123456` **No request logs under `logs/`** + - Set `ENABLE_REQUEST_LOGS=true` --- @@ -1353,8 +1413,6 @@ Thanks to all contributors who helped make 9Router better! [![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) - - ## 🔀 Forks **[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — A full-featured TypeScript fork of 9Router. Adds 36+ providers, 4-tier auto-fallback, multi-modal APIs (images, embeddings, audio, TTS), circuit breaker, semantic cache, LLM evaluations, and a polished dashboard. 368+ unit tests. Available via npm and Docker. @@ -1367,8 +1425,8 @@ Built on the shoulders of giants: - **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port. - **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request. -- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **−65% output tokens**. -- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — *"lazy senior dev"* skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **−65% output tokens**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**. Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub! From da0149de979057b25bb04e5343d1b8a83cd91b02 Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 7 Jul 2026 11:44:20 +0700 Subject: [PATCH 07/48] fix(mitm): recover from stale lock file on server start Detect dead PID in lock file and reclaim it instead of failing, and drop unused fs dependency. Co-authored-by: Cursor --- package.json | 1 - src/mitm/manager.js | 12 +++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index a08b475d..9099c90a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "bcryptjs": "^3.0.3", "confbox": "^0.2.4", "express": "^5.2.1", - "fs": "^0.0.1-security", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "marked": "^18.0.1", diff --git a/src/mitm/manager.js b/src/mitm/manager.js index b70fbdf0..a9d6e6a6 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -496,9 +496,15 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" }); } catch (e) { if (e.code === "EEXIST") { - throw new Error("MITM server is already starting (lock contention)"); - } - throw e; + let stale = false; + try { + const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10); + stale = !pid || !isProcessAlive(pid); + } catch { stale = true; } // unreadable lock → treat as stale + if (!stale) throw new Error("MITM server is already starting (lock contention)"); + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" }); + } else throw e; } try { From bf7da67859c95474d38c6161b281c3abbcf93038 Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 7 Jul 2026 11:44:30 +0700 Subject: [PATCH 08/48] docs(readme): swap in Vietnamese tutorial video; chore(pricing): minor update Co-authored-by: Cursor --- README.md | 16 ++++++++-------- open-sse/providers/pricing.js | 4 ---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4929ab61..1d5eaa85 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,11 @@ Default URLs:
- - 9Router Setup Tutorial + + Tiết kiệm chi phí LLM với 9Router
- 🇺🇸 English
- 9Router + Claude Code FREE Setup
by Build AI With Hamid
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
@@ -152,11 +152,11 @@ Default URLs:
- - Tiết kiệm chi phí LLM với 9Router + + 9Router Setup Tutorial
- 🇻🇳 Tiếng Việt
- Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index 2fb0cfc9..cbb30db8 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -47,10 +47,6 @@ export const MODEL_PRICING = { "gpt-5.2": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 }, "gpt-5.2-codex": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 }, "gpt-5.3-codex": { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 }, - "gpt-5.3-codex-xhigh": { input: 10.00, output: 40.00, cached: 5.00, reasoning: 60.00, cache_creation: 10.00 }, - "gpt-5.3-codex-high": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 }, - "gpt-5.3-codex-low": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 }, - "gpt-5.3-codex-none": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, "gpt-5.3-codex-spark": { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 }, "o1": { input: 15.00, output: 60.00, cached: 7.50, reasoning: 90.00, cache_creation: 15.00 }, "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, From a3cd7c82bc060d0da5761e1770ac0b557af047aa Mon Sep 17 00:00:00 2001 From: deranalabs <113621505+deranalabs@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:48:55 +0700 Subject: [PATCH 09/48] fix(translator): preserve developer instructions in openai-responses conversion (#2434) Map role="developer" messages to top-level instructions alongside role="system" in openaiToOpenAIResponsesRequest. Previously developer messages matched no branch and were silently dropped from the Responses request, losing GPT-5/Codex system-level prompts. Co-authored-by: Cursor --- open-sse/translator/request/openai-responses.js | 7 ++++--- tests/translator/bugs-codexCli-responses.test.js | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 98c516cd..23603a71 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -221,13 +221,14 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) const messages = body.messages || []; for (const msg of messages) { - if (msg.role === ROLE.SYSTEM) { - // Use first system message as instructions + if (msg.role === ROLE.SYSTEM || msg.role === ROLE.DEVELOPER) { + // Use the first instruction-bearing message as instructions. + // OpenAI recommends role="developer" for GPT-5/Codex as the system-level prompt. if (!hasSystemMessage) { result.instructions = typeof msg.content === "string" ? msg.content : ""; hasSystemMessage = true; } - continue; // Skip system messages in input + continue; // Skip instruction messages in input } // Convert user/assistant messages to input items diff --git a/tests/translator/bugs-codexCli-responses.test.js b/tests/translator/bugs-codexCli-responses.test.js index 6f947833..c3401341 100644 --- a/tests/translator/bugs-codexCli-responses.test.js +++ b/tests/translator/bugs-codexCli-responses.test.js @@ -46,6 +46,20 @@ describe("Codex CLI Responses → OpenAI", () => { }); describe("OpenAI → Codex Responses (reverse)", () => { + it("maps developer messages to Responses API instructions", () => { + const out = O2R({ + messages: [ + { role: "developer", content: "Follow the project rules." }, + { role: "user", content: "Hello" }, + ], + }); + + expect(out.instructions).toBe("Follow the project rules."); + expect(out.input).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "Hello" }] }, + ]); + }); + // openai-responses.js:13 — clampCallId NOT applied on Responses→Chat; but here Chat→Responses must clamp it("call_id longer than 64 chars is clamped", () => { const longId = "call_" + "x".repeat(80); From 97a670865164c5eb1cc1d09fba9780afba93de79 Mon Sep 17 00:00:00 2001 From: KunN-21 Date: Tue, 7 Jul 2026 11:53:50 +0700 Subject: [PATCH 10/48] feat(caveman): add targeted upstream-aligned style rules (#2424) Add four shared Caveman prompt fragments (no invented abbreviations, preserve user language, no self-reference, no decoration) across all six levels, and remove ULTRA contradictions around abbreviations/arrow shorthand. Adds regression tests for the prompt rules. Co-authored-by: Cursor --- open-sse/rtk/cavemanPrompts.js | 36 +++++++++++++- tests/unit/caveman-prompts.test.js | 79 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests/unit/caveman-prompts.test.js diff --git a/open-sse/rtk/cavemanPrompts.js b/open-sse/rtk/cavemanPrompts.js index 0b6f6f57..7b533d82 100644 --- a/open-sse/rtk/cavemanPrompts.js +++ b/open-sse/rtk/cavemanPrompts.js @@ -18,6 +18,14 @@ const SHARED_AUTO_CLARITY = "Auto-Clarity: drop caveman for security warnings, i const SHARED_PERSISTENCE = "ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure."; +const SHARED_NO_INVENTED_ABBREV = "No invented abbreviations. Standard well-known tech acronyms (DB, API, HTTP, URL, JSON, ID, OS, CPU) OK. Names of code symbols, function names, API names, error strings: keep verbatim."; + +const SHARED_PRESERVE_LANGUAGE = "Preserve the user's dominant language. User wrote Vietnamese, reply Vietnamese. User wrote English, reply English. Wenyan/classical-Chinese levels override this language-preservation rule. Code identifiers, error strings, file paths, commands: keep in their original form regardless of language."; + +const SHARED_NO_SELF_REFERENCE = 'No self-reference. Do not name or announce the style (no "caveman mode", no "me caveman think", no "compressed mode active"). Just respond.'; + +const SHARED_NO_DECORATION = 'No decorative emoji. No narrating tool calls ("I will now search", "I used X to find Y"). No status phrases ("Sure!", "Of course!", "I\'d be happy to"). No causal arrow shorthand ("A -> B -> fails"). State the thing, the action, the reason. Then next step.'; + export const CAVEMAN_PROMPTS = { [CAVEMAN_LEVELS.LITE]: [ "Respond tersely. Keep grammar and full sentences but drop filler, hedging and pleasantries (just/really/basically/sure/of course/I'd be happy to).", @@ -26,6 +34,10 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.FULL]: [ @@ -36,16 +48,24 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.ULTRA]: [ "Respond ultra-terse. Maximum compression. Telegraphic.", - "Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, use arrows for causality (X → Y). One word when one word enough.", - "Pattern: [thing] → [result]. [fix].", + "Strip conjunctions. One word when one word enough.", + "Pattern: [thing] [action] [reason]. [next step].", SHARED_EXAMPLES, SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.WENYAN_LITE]: [ @@ -55,6 +75,10 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.WENYAN]: [ @@ -65,6 +89,10 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), [CAVEMAN_LEVELS.WENYAN_ULTRA]: [ @@ -74,5 +102,9 @@ export const CAVEMAN_PROMPTS = { SHARED_BOUNDARIES, SHARED_AUTO_CLARITY, SHARED_PERSISTENCE, + SHARED_NO_INVENTED_ABBREV, + SHARED_PRESERVE_LANGUAGE, + SHARED_NO_SELF_REFERENCE, + SHARED_NO_DECORATION, ].join(" "), }; diff --git a/tests/unit/caveman-prompts.test.js b/tests/unit/caveman-prompts.test.js new file mode 100644 index 00000000..0b92dc82 --- /dev/null +++ b/tests/unit/caveman-prompts.test.js @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { CAVEMAN_LEVELS, CAVEMAN_PROMPTS } from "../../open-sse/rtk/cavemanPrompts.js"; + +const LEVEL_KEYS = [ + CAVEMAN_LEVELS.LITE, + CAVEMAN_LEVELS.FULL, + CAVEMAN_LEVELS.ULTRA, + CAVEMAN_LEVELS.WENYAN_LITE, + CAVEMAN_LEVELS.WENYAN, + CAVEMAN_LEVELS.WENYAN_ULTRA, +]; + +describe("Caveman prompt coverage", () => { + it("every level key has matching prompt and vice versa", () => { + const levelValues = Object.values(CAVEMAN_LEVELS); + for (const key of LEVEL_KEYS) { + expect(levelValues).toContain(key); + } + for (const value of levelValues) { + expect(LEVEL_KEYS).toContain(value); + } + }); + + it("has a prompt string for every level", () => { + for (const level of LEVEL_KEYS) { + expect(typeof CAVEMAN_PROMPTS[level]).toBe("string"); + expect(CAVEMAN_PROMPTS[level].length).toBeGreaterThan(0); + } + }); + + it("adds no-invented-abbreviations guidance to every level", () => { + for (const level of LEVEL_KEYS) { + expect(CAVEMAN_PROMPTS[level]).toContain("No invented abbreviations"); + } + }); + + it("adds preserve-user-language guidance to every level", () => { + for (const level of LEVEL_KEYS) { + expect(CAVEMAN_PROMPTS[level]).toContain("Preserve the user's dominant language"); + } + }); + + it("adds no-self-reference guidance to every level", () => { + for (const level of LEVEL_KEYS) { + expect(CAVEMAN_PROMPTS[level]).toContain("No self-reference"); + } + }); + + it("adds no-decorative-emoji guidance to every level", () => { + for (const level of LEVEL_KEYS) { + expect(CAVEMAN_PROMPTS[level]).toContain("No decorative emoji"); + } + }); +}); + +describe("Caveman internal consistency", () => { + it("no level uses Unicode arrow (SHARED_NO_DECORATION bans arrow shorthand)", () => { + // SHARED_NO_DECORATION uses ASCII -> to quote the banned pattern. + // Unicode → is the character old ULTRA used in "Pattern: [thing] → [result]". + // Verify no level now uses it. + for (const level of LEVEL_KEYS) { + expect(CAVEMAN_PROMPTS[level]).not.toContain("→"); + } + }); +}); + +describe("Caveman ULTRA targeted sync", () => { + it("does not encourage invented abbreviations", () => { + const ultra = CAVEMAN_PROMPTS[CAVEMAN_LEVELS.ULTRA]; + expect(ultra).not.toContain("req/res/fn/impl"); + expect(ultra).not.toContain("Abbreviate (DB/auth/config/req/res/fn/impl)"); + }); + + it("does not encourage arrow shorthand", () => { + const ultra = CAVEMAN_PROMPTS[CAVEMAN_LEVELS.ULTRA]; + expect(ultra).not.toContain("use arrows for causality"); + expect(ultra).not.toContain("X → Y"); + }); +}); From 8c068a1f5c3c7fe16e410db25aa7c6b27129bbef Mon Sep 17 00:00:00 2001 From: whale <87256750+whale9820@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:54:16 +0700 Subject: [PATCH 11/48] fix(kimi): normalize reasoning_effort to backend enum (#2427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map auto→high, minimal→low, xhigh→max and whitelist low/medium/high/max so Kimi/kimchi SGLang backends no longer receive invalid effort values. Co-authored-by: Cursor --- open-sse/translator/concerns/thinkingUnified.js | 13 +++++++++++-- tests/translator/thinking-unified.test.js | 10 ++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 1cf44384..883e5c62 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -132,6 +132,15 @@ function toGeminiThinkingLevel(cfg) { return effortToThinkingLevel(raw); } +function toKimiReasoningEffort(cfg) { + const level = toLevel(cfg); + if (level === "auto") return "high"; + if (level === "minimal") return "low"; + if (level === "xhigh") return "max"; + if (["low", "medium", "high", "max"].includes(level)) return level; + return null; +} + // Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap // the whole request in a { request: { generationConfig } } envelope — target the // envelope's generationConfig when present, else the top-level one. @@ -217,8 +226,8 @@ function applyFormat(fmt, body, cfg, caps) { } case "kimi": { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } - const level = toLevel(eff); - if (level) body.reasoning_effort = level === "max" ? "high" : level; + const effort = toKimiReasoningEffort(eff); + if (effort) body.reasoning_effort = effort; break; } case "minimax": { diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js index bae22c33..d547e947 100644 --- a/tests/translator/thinking-unified.test.js +++ b/tests/translator/thinking-unified.test.js @@ -106,6 +106,16 @@ describe("applyThinking per provider format", () => { const out = apply("openai", "kimi-k2.6", { reasoning_effort: "high" }, "kimi"); expect(out.reasoning_effort).toBe("high"); }); + it("Kimi auto → supported reasoning_effort", () => { + const out = apply("openai", "kimi-k2.7", { reasoning_effort: "auto" }, "kimchi"); + expect(out.reasoning_effort).toBe("high"); + }); + it("Kimi unsupported OpenAI levels → supported reasoning_effort", () => { + const minimal = apply("openai", "kimi-k2.7", { reasoning_effort: "minimal" }, "kimchi"); + const xhigh = apply("openai", "kimi-k2.7", { reasoning_effort: "xhigh" }, "kimchi"); + expect(minimal.reasoning_effort).toBe("low"); + expect(xhigh.reasoning_effort).toBe("max"); + }); it("MiniMax M3 → adaptive", () => { const out = apply("claude", "MiniMax-M3", { reasoning_effort: "high" }, "minimax"); expect(out.thinking).toEqual({ type: "adaptive" }); From bbae990b924ac653272db58bdcc35daa5fe6f6c3 Mon Sep 17 00:00:00 2001 From: whale <87256750+whale9820@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:54:42 +0700 Subject: [PATCH 12/48] fix(volcengine-ark): clamp GLM-5 max_tokens to model output ceiling (#2428) Ark rejects max_tokens above 128000 for GLM-5.2. Add a config-driven STRIP_RULES entry that clamps max_tokens, max_completion_tokens and max_output_tokens down to the model maxOutput before the upstream call. Co-authored-by: Cursor --- open-sse/translator/concerns/paramSupport.js | 17 ++++++++++++++ tests/unit/param-support.test.js | 24 ++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js index dc030194..e165c949 100644 --- a/open-sse/translator/concerns/paramSupport.js +++ b/open-sse/translator/concerns/paramSupport.js @@ -1,3 +1,5 @@ +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; + // Strip request params a given provider/model rejects upstream (e.g. HTTP 400). // Config-driven: add a rule instead of scattering `delete body.x` across executors. @@ -12,6 +14,7 @@ const STRIP_RULES = [ { provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] }, // Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926) { provider: "cloudflare-ai", flattenContent: true }, + { provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true }, ]; // Test a rule's match (regex or predicate) against the model id. @@ -20,6 +23,12 @@ function matches(rule, model) { return typeof rule.match === "function" ? rule.match(model) : rule.match.test(model); } +function clampNumber(body, key, ceiling) { + if (typeof body[key] === "number" && Number.isFinite(body[key]) && body[key] > ceiling) { + body[key] = ceiling; + } +} + // Remove unsupported params from body in place; returns body. export function stripUnsupportedParams(provider, model, body) { if (!model || !body || typeof body !== "object") return body; @@ -39,6 +48,14 @@ export function stripUnsupportedParams(provider, model, body) { } } } + if (rule.clampToModelMaxOutput) { + const ceiling = getCapabilitiesForModel(provider, model).maxOutput; + if (Number.isFinite(ceiling) && ceiling > 0) { + clampNumber(body, "max_tokens", ceiling); + clampNumber(body, "max_completion_tokens", ceiling); + clampNumber(body, "max_output_tokens", ceiling); + } + } } return body; } diff --git a/tests/unit/param-support.test.js b/tests/unit/param-support.test.js index 205a5fe2..c54d132b 100644 --- a/tests/unit/param-support.test.js +++ b/tests/unit/param-support.test.js @@ -28,4 +28,28 @@ describe("stripUnsupportedParams", () => { expect(body).toEqual({ top_p: 1 }); }); + + it("clamps VolcEngine Ark GLM max token fields to the model output ceiling", () => { + const body = { + max_tokens: 131072, + max_completion_tokens: 131072, + max_output_tokens: 131072, + }; + + stripUnsupportedParams("volcengine-ark", "GLM-5.2", body); + + expect(body).toEqual({ + max_tokens: 128000, + max_completion_tokens: 128000, + max_output_tokens: 128000, + }); + }); + + it("keeps VolcEngine Ark GLM max tokens when already under the ceiling", () => { + const body = { max_tokens: 64000 }; + + stripUnsupportedParams("volcengine-ark", "GLM-5.2", body); + + expect(body.max_tokens).toBe(64000); + }); }); From 19281b5524213fb0ee4febfc078b8363df8b4418 Mon Sep 17 00:00:00 2001 From: KunN-21 Date: Tue, 7 Jul 2026 12:01:08 +0700 Subject: [PATCH 13/48] feat(rtk): add JS-native git-log filter (#2423) Compress git log output via dedicated RTK filter: keep commit headers, Author/Date, subject; drop body padding, decoration, embedded diff lines. Wire into autodetect (git-log prioritized before git-diff) and registry. Co-authored-by: Cursor --- open-sse/rtk/autodetect.js | 7 +- open-sse/rtk/constants.js | 1 + open-sse/rtk/filters/gitLog.js | 99 +++++++++ open-sse/rtk/registry.js | 2 + .../unit/buildOutputFilterAdversarial.test.js | 36 +++ tests/unit/rtk.test.js | 205 ++++++++++++++++-- 6 files changed, 329 insertions(+), 21 deletions(-) create mode 100644 open-sse/rtk/filters/gitLog.js diff --git a/open-sse/rtk/autodetect.js b/open-sse/rtk/autodetect.js index 99ab6a77..8bc4356c 100644 --- a/open-sse/rtk/autodetect.js +++ b/open-sse/rtk/autodetect.js @@ -1,9 +1,10 @@ // Port of auto_detect_filter (rtk/src/cmds/system/pipe_cmd.rs:132-188) + JS extras -// Order: git-diff → git-status → build-output → grep → find → tree → ls → search-list -// → read-numbered → dedup-log → smart-truncate → null +// Detection order: git-log → git-diff → git-status → build-output → grep → find → tree → ls → search-list +// → read-numbered → dedup-log → smart-truncate → null import { DETECT_WINDOW, READ_NUMBERED_MIN_HIT_RATIO, SMART_TRUNCATE_MIN_LINES } from "./constants.js"; import { gitDiff } from "./filters/gitDiff.js"; import { gitStatus } from "./filters/gitStatus.js"; +import { gitLog } from "./filters/gitLog.js"; import { buildOutput } from "./filters/buildOutput.js"; import { grep } from "./filters/grep.js"; import { find } from "./filters/find.js"; @@ -17,6 +18,7 @@ import { searchList, SEARCH_LIST_HEADER_RE } from "./filters/searchList.js"; const RE_GIT_DIFF = /^diff --git /m; const RE_GIT_DIFF_HUNK = /^@@ /m; const RE_GIT_STATUS = /^On branch |^nothing to commit|^Changes (not |to be )|^Untracked files:/m; +const RE_GIT_LOG = /^[*|/\\ ]*commit [0-9a-f]{7,40}$/m; const RE_PORCELAIN = /^[ MADRCU?!][ MADRCU?!] \S/m; const RE_BUILD_OUTPUT = /^(npm (warn|error|ERR!)|yarn (warn|error)|\s*Compiling\s+\S+|\s*Downloading\s+\S+|added \d+ package|\[ERROR\]|BUILD (SUCCESS|FAILED)|\s*Finished\s+|Successfully (installed|built)|ERROR:)/im; const RE_TREE_GLYPH = /[├└]──|│ /; @@ -27,6 +29,7 @@ export function autoDetectFilter(text) { // Rust: floor_char_boundary to avoid UTF-8 split — JS .slice() by char is safe const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text; + if (RE_GIT_LOG.test(head)) return gitLog; if (RE_GIT_DIFF.test(head) || RE_GIT_DIFF_HUNK.test(head)) return gitDiff; if (RE_GIT_STATUS.test(head)) return gitStatus; diff --git a/open-sse/rtk/constants.js b/open-sse/rtk/constants.js index 752c2fee..bc80c23a 100644 --- a/open-sse/rtk/constants.js +++ b/open-sse/rtk/constants.js @@ -4,6 +4,7 @@ export const MIN_COMPRESS_SIZE = 500; // bytes; skip tiny blobs export const DETECT_WINDOW = 1024; // autodetect peeks first N chars export const GIT_DIFF_HUNK_MAX_LINES = 100; // per-hunk line cap export const GIT_DIFF_CONTEXT_KEEP = 3; // context lines around changes +export const GIT_LOG_MAX_LINES = 200; // gitLog line cap export const DEDUP_LINE_MAX = 2000; // dedupLog truncation cap // Rust pipe_cmd.rs parity caps diff --git a/open-sse/rtk/filters/gitLog.js b/open-sse/rtk/filters/gitLog.js new file mode 100644 index 00000000..9769c6de --- /dev/null +++ b/open-sse/rtk/filters/gitLog.js @@ -0,0 +1,99 @@ +// JS-native git-log filter +// Compresses `git log` output: keeps commit headers, subjects, Author/Date; +// drops body padding, decoration, embedded diff lines. +import { GIT_LOG_MAX_LINES } from "../constants.js"; + +export function gitLog(text, maxLines = GIT_LOG_MAX_LINES) { + if (!text) return ""; + + const input = String(text); + const lines = input.split("\n"); + const out = []; + let skipped = 0; + let inCommit = false; + let subjectSeen = false; + + function pushLine(l) { + if (out.length < maxLines) { + out.push(l); + return true; + } + skipped++; + return false; + } + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const line = raw.trimEnd(); + const trimmed = line.trim(); + + // commit header — starts new commit entry + // Also matched with leading graph decoration (`* commit abc1234...` — --graph without --oneline) + if (/^commit [0-9a-f]{7,40}$/i.test(trimmed) || /^[*|/\\ ]+commit [0-9a-f]{7,40}/i.test(trimmed)) { + inCommit = true; + subjectSeen = false; + pushLine(line); + continue; + } + + if (inCommit) { + // Author / Date — keep as-is (already column 0 in raw, or graph-prefix stripped by commit-header match) + if (/^[*|/\\ ]*(Author|Date):/i.test(trimmed)) { + pushLine(trimmed); + continue; + } + // blank — skip + if (trimmed === "") continue; + // indented subject (4 spaces, optionally preceded by graph decoration) — first one is subject + if (!subjectSeen && /^[*|/\\ ]* \S/.test(line)) { + pushLine(" Subject: " + trimmed); + subjectSeen = true; + continue; + } + // stat summary: "N file(s) changed, N insertions(+), N deletions(-)" + if (/^\d+ file\w* changed/.test(trimmed)) { + pushLine(" " + trimmed); + continue; + } + // embedded diff header — one-line marker + if (/^diff --git /.test(trimmed)) { + pushLine(" ... diff body omitted"); + continue; + } + // everything else in commit body — drop + continue; + } + + // Not in a commit block (--oneline / --graph modes): + + // Graph decoration + sha + subject: "*|/\\ " + const graphMatch = trimmed.match(/^[*|/\\ ]+([0-9a-f]{7,40}\s+.+)/i); + if (graphMatch) { + pushLine(graphMatch[1]); + continue; + } + + // Plain oneline: " " + if (/^[0-9a-f]{7,40}\s+/.test(trimmed)) { + pushLine(trimmed); + continue; + } + + // Pure graph decoration (no sha) — drop + if (/^[*|/\\ ]+$/.test(trimmed) && /[*|/\\]/.test(trimmed)) { + continue; + } + + // catch-all pass-through + pushLine(trimmed); + } + + if (skipped > 0) out.push(`... (${skipped} more lines)`); + + const result = out.join("\n"); + if (!result && input) return input; + if (result.length > input.length) return input; + return result; +} + +gitLog.filterName = "git-log"; diff --git a/open-sse/rtk/registry.js b/open-sse/rtk/registry.js index d9d9bf56..5378aabd 100644 --- a/open-sse/rtk/registry.js +++ b/open-sse/rtk/registry.js @@ -1,6 +1,7 @@ import { FILTERS } from "./constants.js"; import { gitDiff } from "./filters/gitDiff.js"; import { gitStatus } from "./filters/gitStatus.js"; +import { gitLog } from "./filters/gitLog.js"; import { grep } from "./filters/grep.js"; import { find } from "./filters/find.js"; import { dedupLog } from "./filters/dedupLog.js"; @@ -13,6 +14,7 @@ import { searchList } from "./filters/searchList.js"; const REGISTRY = { [FILTERS.GIT_DIFF]: gitDiff, [FILTERS.GIT_STATUS]: gitStatus, + [FILTERS.GIT_LOG]: gitLog, [FILTERS.GREP]: grep, [FILTERS.FIND]: find, [FILTERS.DEDUP_LOG]: dedupLog, diff --git a/tests/unit/buildOutputFilterAdversarial.test.js b/tests/unit/buildOutputFilterAdversarial.test.js index 60193f8c..799f5033 100644 --- a/tests/unit/buildOutputFilterAdversarial.test.js +++ b/tests/unit/buildOutputFilterAdversarial.test.js @@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest"; import { autoDetectFilter } from "../../open-sse/rtk/autodetect.js"; import { buildOutput } from "../../open-sse/rtk/filters/buildOutput.js"; import { gitDiff } from "../../open-sse/rtk/filters/gitDiff.js"; +import { gitLog } from "../../open-sse/rtk/filters/gitLog.js"; import { gitStatus } from "../../open-sse/rtk/filters/gitStatus.js"; import { safeApply } from "../../open-sse/rtk/applyFilter.js"; import { compressMessages } from "../../open-sse/rtk/index.js"; @@ -279,6 +280,41 @@ describe("PR #1175 - integration with compressMessages", () => { }); }); +// ============================================================ +// 6.5. GIT-LOG PRIORITY +// ============================================================ +describe("git-log priority", () => { + it("git-log chosen over build-output when commit header present in first window", () => { + const input = [ + "commit abc1234def5678abc1234def5678abc1234def5", + "Author: Dev One ", + "Date: Sun Jul 6 10:00:00 2026 +0700", + "", + " Add auth middleware", + "", + "diff --git a/src/auth.js b/src/auth.js", + "index abc..def 100644", + "--- a/src/auth.js", + "+++ b/src/auth.js", + "@@ -1 +1 @@", + "+new line" + ].join("\n"); + expect(autoDetectFilter(input)).toBe(gitLog); + }); + + it("pure git diff still stays git-diff", () => { + const input = [ + "diff --git a/src/auth.js b/src/auth.js", + "index abc..def 100644", + "--- a/src/auth.js", + "+++ b/src/auth.js", + "@@ -1 +1 @@", + "+new line" + ].join("\n"); + expect(autoDetectFilter(input)).toBe(gitDiff); + }); +}); + // ============================================================ // 7. PORCELAIN REGRESSION DEEPER TESTS // ============================================================ diff --git a/tests/unit/rtk.test.js b/tests/unit/rtk.test.js index 17d0f1c6..c8009df6 100644 --- a/tests/unit/rtk.test.js +++ b/tests/unit/rtk.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { compressMessages, setRtkEnabled, isRtkEnabled, formatRtkLog } from "../../open-sse/rtk/index.js"; +import { compressMessages, formatRtkLog } from "../../open-sse/rtk/index.js"; import { gitDiff } from "../../open-sse/rtk/filters/gitDiff.js"; import { gitStatus } from "../../open-sse/rtk/filters/gitStatus.js"; import { grep } from "../../open-sse/rtk/filters/grep.js"; @@ -10,6 +10,7 @@ import { tree } from "../../open-sse/rtk/filters/tree.js"; import { smartTruncate } from "../../open-sse/rtk/filters/smartTruncate.js"; import { readNumbered } from "../../open-sse/rtk/filters/readNumbered.js"; import { searchList } from "../../open-sse/rtk/filters/searchList.js"; +import { gitLog } from "../../open-sse/rtk/filters/gitLog.js"; import { autoDetectFilter } from "../../open-sse/rtk/autodetect.js"; import { safeApply } from "../../open-sse/rtk/applyFilter.js"; @@ -53,13 +54,172 @@ function makeFindOutput() { return lines.join("\n"); } -describe("RTK flag", () => { - it("default off, toggle works", () => { - setRtkEnabled(false); - expect(isRtkEnabled()).toBe(false); - setRtkEnabled(true); - expect(isRtkEnabled()).toBe(true); - setRtkEnabled(false); +function makeGitLogOneline() { + return [ + "abc1234 Add auth middleware", + "def5678 Fix token refresh race", + "fedcba9 Update docs" + ].join("\n"); +} + +function makeGitLogDefault() { + return [ + "commit abc1234def5678abc1234def5678abc1234def5", + "Author: Dev One ", + "Date: Sun Jul 6 10:00:00 2026 +0700", + "", + " Add auth middleware", + "", + " More body detail should be dropped.", + " This is padding that consumes tokens." + ].join("\n"); +} + +function makeGitLogGraph() { + return [ + "* abc1234 Add auth middleware", + "| * def5678 Fix token refresh race", + "|/", + "* fedcba9 Update docs" + ].join("\n"); +} + +function makeGitLogGraphDefault() { + return [ + "* commit abc1234def5678abc1234def5678abc1234def5", + "|\\", + "| * commit def5678abc1234def5678abc1234def5678abc1", + "|/", + "|", + "* commit fedcba9abc1234fedcba9abc1234fedcba9abc1234", + "Author: Dev One ", + "Date: Sun Jul 6 10:00:00 2026 +0700", + "", + " Add auth middleware", + "" + ].join("\n"); +} + +function makeGitLogWithMerge() { + return [ + "commit abc1234def5678abc1234def5678abc1234def5", + "Merge: abc1234 def5678", + "Author: Dev One ", + "Date: Sun Jul 6 10:00:00 2026 +0700", + "", + " Merge branch 'feature'" + ].join("\n"); +} + +function makeGitLogWithStats() { + return [ + "commit abc1234def5678abc1234def5678abc1234def5", + "Author: Dev One ", + "Date: Sun Jul 6 10:00:00 2026 +0700", + "", + " Fix typo", + "", + " 2 files changed, 15 insertions(+), 3 deletions(-)" + ].join("\n"); +} + +function makeGitLogWithEmbeddedDiff() { + return [ + "commit abc1234def5678abc1234def5678abc1234def5", + "Author: Dev One ", + "Date: Sun Jul 6 10:00:00 2026 +0700", + "", + " Fix typo", + "", + "diff --git a/src/main.js b/src/main.js" + ].join("\n"); +} + +describe("gitLog filter", () => { + it("compresses git log --oneline without losing commit subjects", () => { + const input = makeGitLogOneline(); + const out = gitLog(input); + expect(out).toContain("abc1234"); + expect(out).toContain("Add auth middleware"); + expect(out.length).toBeLessThanOrEqual(input.length); + }); + + it("keeps commit header + subject in default git log, drops body detail", () => { + const input = makeGitLogDefault(); + const out = gitLog(input); + expect(out).toContain("commit abc1234def5678abc1234def5678abc1234def5"); + expect(out).toContain("Add auth middleware"); + expect(out).not.toContain("More body detail should be dropped."); + }); + + it("strips graph-only decoration but keeps commit subjects", () => { + const input = makeGitLogGraph(); + const out = gitLog(input); + expect(out).toContain("abc1234 Add auth middleware"); + expect(out).toContain("def5678 Fix token refresh race"); + expect(out).not.toContain("|/"); + }); + + it("returns empty string for empty input", () => { + expect(gitLog("")).toBe(""); + }); + + it("returns empty string for null/undefined input", () => { + expect(gitLog(null)).toBe(""); + expect(gitLog(undefined)).toBe(""); + }); + + it("handles git log --graph without --oneline (graph-prefixed commit headers)", () => { + const input = makeGitLogGraphDefault(); + const out = gitLog(input); + expect(out).toContain("commit abc1234def5678abc1234def5678abc1234def5"); + expect(out).toContain("Add auth middleware"); + // graph decoration dropped, pure-graph branch connectors dropped + expect(out).not.toContain("|\\"); + expect(out).not.toContain("|/"); + }); + + it("drops merge commit line ('Merge: abc1234 def5678')", () => { + const input = makeGitLogWithMerge(); + const out = gitLog(input); + expect(out).toContain("commit abc1234def5678abc1234def5678abc1234def5"); + expect(out).toContain("Merge branch 'feature'"); + // "Merge:" line should be dropped (not in output) + expect(out).not.toContain("Merge:"); + }); + + it("keeps stat-summary lines verbatim", () => { + const input = makeGitLogWithStats(); + const out = gitLog(input); + expect(out).toContain("2 files changed, 15 insertions(+), 3 deletions(-)"); + }); + + it("replaces embedded diff markers with '... diff body omitted'", () => { + const input = makeGitLogWithEmbeddedDiff(); + const out = gitLog(input); + expect(out).toContain("diff body omitted"); + // Original diff line replaced + expect(out).not.toContain("diff --git a/src/main.js b/src/main.js"); + }); + + it("truncates beyond maxLines and reports skipped count", () => { + // Generate 50 commit lines but cap at 20 + const lines = []; + for (let i = 0; i < 50; i++) { + lines.push(`commit ${String(i).padStart(40, "0")}`); + } + const input = lines.join("\n"); + const out = gitLog(input, 20); + const outLines = out.split("\n").filter(l => l.length > 0); + expect(outLines.length).toBeLessThanOrEqual(21); // 20 commits + optional skipped note + expect(out).toContain("more lines"); + }); + + it("preserves input when compressed output inflates", () => { + // Input shorter than output would be — e.g. tiny log + const input = "abc\ndef"; + const out = gitLog(input, 10); + expect(out).toBe(input); }); }); @@ -123,6 +283,16 @@ describe("autoDetectFilter", () => { it("detects find", () => { expect(autoDetectFilter("./a/b.js\n./a/c.js\n./a/d.js").filterName).toBe("find"); }); + it("detects git log via commit header", () => { + const input = [ + "commit abc1234def5678abc1234def5678abc1234def5", + "Author: Dev One ", + "Date: Sun Jul 6 10:00:00 2026 +0700", + "", + " Add auth middleware" + ].join("\n"); + expect(autoDetectFilter(input).filterName).toBe("git-log"); + }); it("falls back to dedupLog for generic text", () => { const txt = "line1\nline2\nline3\nline4\nline5\nline6\n"; expect(autoDetectFilter(txt).filterName).toBe("dedup-log"); @@ -245,20 +415,17 @@ describe("safeApply", () => { }); describe("compressMessages (disabled)", () => { - beforeEach(() => setRtkEnabled(false)); it("returns null when disabled", () => { const body = { messages: [{ role: "tool", tool_call_id: "x", content: makeLongDiff() }] }; - expect(compressMessages(body)).toBeNull(); + expect(compressMessages(body, false)).toBeNull(); }); }); describe("compressMessages (enabled)", () => { - beforeEach(() => setRtkEnabled(true)); - it("compresses OpenAI tool message (string content)", () => { const big = makeLongDiff(); const body = { messages: [{ role: "tool", tool_call_id: "call_1", content: big }] }; - const stats = compressMessages(body); + const stats = compressMessages(body, true); expect(stats.hits.length).toBeGreaterThan(0); expect(body.messages[0].content.length).toBeLessThan(big.length); expect(stats.bytesBefore).toBeGreaterThan(stats.bytesAfter); @@ -272,7 +439,7 @@ describe("compressMessages (enabled)", () => { content: [{ type: "tool_result", tool_use_id: "toolu_1", content: big }] }] }; - const stats = compressMessages(body); + const stats = compressMessages(body, true); expect(stats.hits.length).toBeGreaterThan(0); expect(body.messages[0].content[0].content.length).toBeLessThan(big.length); }); @@ -289,7 +456,7 @@ describe("compressMessages (enabled)", () => { }] }] }; - const stats = compressMessages(body); + const stats = compressMessages(body, true); expect(stats.hits.length).toBeGreaterThan(0); expect(body.messages[0].content[0].content[0].text.length).toBeLessThan(big.length); // short part unchanged @@ -304,7 +471,7 @@ describe("compressMessages (enabled)", () => { content: [{ type: "tool_result", tool_use_id: "toolu_1", content: big, is_error: true }] }] }; - const stats = compressMessages(body); + const stats = compressMessages(body, true); expect(stats.hits.length).toBe(0); expect(body.messages[0].content[0].content).toBe(big); }); @@ -312,7 +479,7 @@ describe("compressMessages (enabled)", () => { it("skips below MIN_COMPRESS_SIZE (<500 bytes)", () => { const small = "diff --git a/x b/x\n@@ -1 +1 @@\n+a"; const body = { messages: [{ role: "tool", tool_call_id: "x", content: small }] }; - const stats = compressMessages(body); + const stats = compressMessages(body, true); expect(stats.hits.length).toBe(0); expect(body.messages[0].content).toBe(small); }); @@ -320,7 +487,7 @@ describe("compressMessages (enabled)", () => { it("never produces empty content (R14 guard)", () => { const input = "a".repeat(1000); const body = { messages: [{ role: "tool", tool_call_id: "x", content: input }] }; - compressMessages(body); + compressMessages(body, true); expect(body.messages[0].content.length).toBeGreaterThan(0); }); @@ -339,7 +506,7 @@ describe("compressMessages (enabled)", () => { { role: "user", content: [{ type: "text", text: "next" }] } ] }; - const stats = compressMessages(body); + const stats = compressMessages(body, true); expect(stats).not.toBeNull(); expect(stats.hits.length).toBeGreaterThan(0); }); From 081c6f2aff4c14afdeec76af05f9af18e3803da5 Mon Sep 17 00:00:00 2001 From: baibiao Date: Tue, 7 Jul 2026 12:02:51 +0700 Subject: [PATCH 14/48] fix(count_tokens): count structured Anthropic blocks (#2419) Estimate tokens for tool_use, tool_result, thinking, system, and tools blocks instead of text only, so count_tokens no longer returns 0 for structured content and breaks Claude Code auto-compaction (#2337). Co-authored-by: Cursor --- src/app/api/v1/messages/count_tokens/route.js | 76 +++++++++++++---- tests/unit/count-tokens.test.js | 84 +++++++++++++++++++ 2 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 tests/unit/count-tokens.test.js diff --git a/src/app/api/v1/messages/count_tokens/route.js b/src/app/api/v1/messages/count_tokens/route.js index c5a2918f..2e08cd09 100644 --- a/src/app/api/v1/messages/count_tokens/route.js +++ b/src/app/api/v1/messages/count_tokens/route.js @@ -11,6 +11,64 @@ export async function OPTIONS() { return new Response(null, { headers: CORS_HEADERS }); } +function countValueChars(value) { + if (value == null) return 0; + if (typeof value === "string") return value.length; + if (typeof value === "number" || typeof value === "boolean") { + return String(value).length; + } + if (Array.isArray(value)) { + return value.reduce((total, item) => total + countValueChars(item), 0); + } + if (typeof value === "object") { + return Object.entries(value).reduce((total, [key, item]) => { + return total + key.length + countValueChars(item); + }, 0); + } + return 0; +} + +function countContentBlockChars(block) { + if (block == null) return 0; + if (typeof block === "string") return block.length; + if (typeof block !== "object") return countValueChars(block); + + switch (block.type) { + case "text": + return countValueChars(block.text); + case "tool_use": + return countValueChars(block.name) + countValueChars(block.input); + case "tool_result": + return countValueChars(block.content); + case "thinking": + return countValueChars(block.thinking); + default: + return countValueChars(block); + } +} + +function countMessageChars(message) { + if (!message || typeof message !== "object") return 0; + const content = message.content; + + if (typeof content === "string") return content.length; + if (Array.isArray(content)) { + return content.reduce((total, block) => total + countContentBlockChars(block), 0); + } + return countValueChars(content); +} + +export function estimateAnthropicInputTokens(body = {}) { + const messages = Array.isArray(body.messages) ? body.messages : []; + let totalChars = countValueChars(body.system) + countValueChars(body.tools); + + for (const msg of messages) { + totalChars += countMessageChars(msg); + } + + return Math.ceil(totalChars / 4); +} + /** * POST /v1/messages/count_tokens - Mock token count response */ @@ -25,23 +83,7 @@ export async function POST(request) { }); } - // Estimate token count based on content length - const messages = body.messages || []; - let totalChars = 0; - for (const msg of messages) { - if (typeof msg.content === "string") { - totalChars += msg.content.length; - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "text" && part.text) { - totalChars += part.text.length; - } - } - } - } - - // Rough estimate: ~4 chars per token - const inputTokens = Math.ceil(totalChars / 4); + const inputTokens = estimateAnthropicInputTokens(body); return new Response(JSON.stringify({ input_tokens: inputTokens diff --git a/tests/unit/count-tokens.test.js b/tests/unit/count-tokens.test.js new file mode 100644 index 00000000..92f7f9c3 --- /dev/null +++ b/tests/unit/count-tokens.test.js @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { POST } from "../../src/app/api/v1/messages/count_tokens/route.js"; + +async function countTokens(body) { + const response = await POST(new Request("https://9router.local/v1/messages/count_tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + + expect(response.status).toBe(200); + return response.json(); +} + +describe("Anthropic count_tokens estimator", () => { + it("preserves the existing plain text estimate", async () => { + const result = await countTokens({ + messages: [ + { + role: "user", + content: "hello world", + }, + ], + }); + + expect(result.input_tokens).toBe(3); + }); + + it("counts tool and thinking content blocks that carry context", async () => { + const result = await countTokens({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01", + name: "Read", + input: { file_path: "/tmp/example.txt" }, + }, + { + type: "thinking", + thinking: "Need to inspect the file before answering.", + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_01", + content: "line1 line2 line3 some file content here", + }, + ], + }, + ], + }); + + expect(result.input_tokens).toBeGreaterThan(0); + }); + + it("counts system prompts and tool definitions", async () => { + const result = await countTokens({ + system: "You are a coding assistant.", + tools: [ + { + name: "Read", + description: "Read a file", + input_schema: { + type: "object", + properties: { + file_path: { type: "string" }, + }, + }, + }, + ], + messages: [], + }); + + expect(result.input_tokens).toBeGreaterThan(0); + }); +}); From b10b8070632cbdeb3c6b26b0ccf60f433ebf5014 Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 7 Jul 2026 16:29:11 +0700 Subject: [PATCH 15/48] # v0.5.20 (2026-07-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features - **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step) - **RTK**: add JS-native git-log filter (#2423) - **Caveman**: add targeted upstream-aligned style rules (#2424) - **i18n**: add Farsi (fa) language support (#2385) ## Fixes - **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests - **Translator**: preserve developer instructions in openai-responses conversion (#2434) - **count_tokens**: count structured Anthropic blocks (#2419) - **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428) - **Kimi**: normalize reasoning_effort to backend enum (#2427) - **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381) - **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366) - **Headroom**: proxy dashboard through app (#2372) - **MITM**: recover from stale lock file on server start --- CHANGELOG.md | 19 +++++++ cli/package.json | 2 +- open-sse/config/providerModels.js | 17 ++++-- open-sse/handlers/chatCore.js | 7 ++- open-sse/providers/registry/claude.js | 6 +- open-sse/providers/thinkingLevels.js | 46 +++++++++++++++ .../translator/concerns/thinkingUnified.js | 7 +++ package.json | 2 +- .../dashboard/providers/[id]/ModelRow.js | 8 ++- .../dashboard/providers/[id]/page.js | 56 ++++++++++++------- src/shared/constants/cliTools.js | 8 ++- 11 files changed, 137 insertions(+), 41 deletions(-) create mode 100644 open-sse/providers/thinkingLevels.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b03e97..7fb9a5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# v0.5.20 (2026-07-07) + +## Features +- **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step) +- **RTK**: add JS-native git-log filter (#2423) +- **Caveman**: add targeted upstream-aligned style rules (#2424) +- **i18n**: add Farsi (fa) language support (#2385) + +## Fixes +- **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests +- **Translator**: preserve developer instructions in openai-responses conversion (#2434) +- **count_tokens**: count structured Anthropic blocks (#2419) +- **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428) +- **Kimi**: normalize reasoning_effort to backend enum (#2427) +- **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381) +- **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366) +- **Headroom**: proxy dashboard through app (#2372) +- **MITM**: recover from stale lock file on server start + # v0.5.18 (2026-07-03) ## Features diff --git a/cli/package.json b/cli/package.json index f55e7751..f42ddf88 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.18", + "version": "0.5.20", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index e1153da2..108806e8 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -63,14 +63,19 @@ export function getModelType(aliasOrId, modelId) { } export function getModelUpstreamId(aliasOrId, modelId) { + // Split off thinking suffix "(level)" so lookup hits the base id; re-append it to + // the result so downstream applyThinking still sees the suffix (body.model is stripped separately). + const sufMatch = typeof modelId === "string" ? modelId.match(/\([^()]+\)\s*$/) : null; + const suffix = sufMatch ? sufMatch[0] : ""; + const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId; const models = PROVIDER_MODELS[aliasOrId]; - const found = findModel(models, modelId, aliasOrId); - if (found?.upstreamModelId) return found.upstreamModelId; - if (found?.id) return found.id; - if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { - return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length); + const found = findModel(models, baseId, aliasOrId); + if (found?.upstreamModelId) return found.upstreamModelId + suffix; + if (found?.id) return found.id + suffix; + if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) { + return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix; } - return modelId; + return baseId + suffix; } export function getModelQuotaFamily(aliasOrId, modelId) { diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index b5cf8a84..5305aa04 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -1,5 +1,6 @@ import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js"; import { translateRequest } from "../translator/index.js"; +import { stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js"; import { FORMATS } from "../translator/formats.js"; import { normalizeClaudePassthrough } from "../translator/formats/claude.js"; import { COLORS } from "../utils/stream.js"; @@ -123,9 +124,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred let toolNameMap; if (passthrough) { log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); - translatedBody = { ...body, model: upstreamModel }; + translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) }; // Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects - if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, upstreamModel); + if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model); } else { translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool); if (!translatedBody) { @@ -134,7 +135,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } toolNameMap = translatedBody._toolNameMap; delete translatedBody._toolNameMap; - translatedBody.model = upstreamModel; + translatedBody.model = stripThinkingSuffix(upstreamModel); } // Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only). diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index 9d483d8f..a2912701 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -60,12 +60,10 @@ export default { }, }, models: [ + { id: "claude-fable-5", name: "Claude Fable 5" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" }, - { id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, ], oauth: { diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js new file mode 100644 index 00000000..ab258836 --- /dev/null +++ b/open-sse/providers/thinkingLevels.js @@ -0,0 +1,46 @@ +// Resolve valid thinking levels per model — drives UI level picker (suffix "model(level)"). +// Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY). +import { getCapabilitiesForModel } from "./capabilities.js"; +import { matchPattern } from "./pricing.js"; + +// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. +const L = { + base: ["none", "low", "medium", "high"], // qwen, step, hunyuan, gemini-budget + onOff: ["none", "thinking"], // zai (binary), minimax (adaptive) + openai: ["none", "minimal", "low", "medium", "high", "xhigh"], // GPT-5.x / o-series (no "max") + levelMax: ["none", "low", "medium", "high", "max"], // claude-adaptive, kimi + budgetX: ["none", "low", "medium", "high", "xhigh", "max"], // claude-budget + gemini: ["minimal", "low", "medium", "high"], // gemini-3 thinkingLevel (no disable) + hiMax: ["none", "high", "max"], // deepseek (low/med→high, xhigh→max) +}; + +// thinkingFormat → valid selectable levels (source of truth for UI options). +const FORMAT_LEVELS = { + openai: L.openai, + "claude-adaptive": L.levelMax, + "claude-budget": L.budgetX, + "gemini-level": L.gemini, + "gemini-budget": L.base, + zai: L.onOff, + qwen: L.base, + kimi: L.levelMax, + deepseek: L.hiMax, + minimax: L.onOff, + hunyuan: L.base, + step: L.base, +}; + +// Model-name pattern overrides (glob, first match wins) — more precise than format default. +const PATTERN_THINKING = [ + { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking +]; + +// Returns valid thinking levels for a model, or null when the model has no reasoning. +export function getThinkingLevels(provider, model) { + const caps = getCapabilitiesForModel(provider, model); + if (!caps.reasoning) return null; + const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); + let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; + if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none"); + return levels; +} diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 883e5c62..9e9235de 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -20,6 +20,13 @@ const FORMAT_TO_NATIVE = { kiro: "kiro", }; +// Strip a trailing thinking suffix "model(value)" → "model" (no-op when absent). +export function stripThinkingSuffix(model) { + if (typeof model !== "string") return model; + const m = model.match(/^(.*)\([^()]+\)\s*$/); + return m ? m[1].trim() : model; +} + // Parse model-name suffix "model(value)" → { cleanModel, override }. // value: level name (high) | number (8192) | auto | none. null override when absent. export function parseSuffix(model) { diff --git a/package.json b/package.json index 9099c90a..5dd951e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.18", + "version": "0.5.20", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js index d5b0359b..b011f58b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js @@ -1,7 +1,8 @@ import PropTypes from "prop-types"; import { CapacityBadges } from "@/shared/components"; -export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps }) { +export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps, thinkingSuffix }) { + const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel; const borderColor = testStatus === "ok" ? "border-green-500/40" : testStatus === "error" @@ -24,7 +25,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
- {fullModel} + {displayModel} {model.name && {model.name}} @@ -48,7 +49,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test )}