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 )}