diff --git a/CHANGELOG.md b/CHANGELOG.md index e844659b74..cf1c80c7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - **fix(sse): retry once on an early stream close (`STREAM_EARLY_EOF`) for single-model requests** — flaky OpenAI-compatible upstreams (e.g. NVIDIA NIM with minimax-m3 / qwen3.5 / glm-5.1) intermittently send HTTP 200 then close the SSE with zero useful frames, surfacing as a 502 "Stream ended before producing useful content". Only Antigravity got an early-close retry; every other provider returned the 502 immediately on the non-combo single-model path. A bounded one-retry (early-close only — not readiness-timeout — and without marking the account unavailable) now generalizes it. (The separate qwen-web validation SSRF part of the same report was already fixed in v3.8.24, [#3767](https://github.com/diegosouzapw/OmniRoute/pull/3767).) ([#3758](https://github.com/diegosouzapw/OmniRoute/issues/3758) — thanks @Svatosalav) - **fix(models): preserve eye-hidden models across auto-sync / import** — hiding models via the visibility (eye) toggle to keep only a combo's models was undone on every model import or auto-sync, which re-showed all of them. The sync re-import treated "hidden" identically to "deleted" and dropped both; a distinct `isDeleted` marker now separates the trash/delete path (still dropped on re-import, #3199) from the eye toggle (preserved as listed-but-hidden), and eye-hidden models are no longer re-aliased into the routable catalog on sync. ([#3782](https://github.com/diegosouzapw/OmniRoute/issues/3782) — thanks @xenstar) - **fix(providers): correct the lmarena cookie hint (`session` → `arena-auth-prod-v1`)** — the lmarena credential hint asked for a cookie named `session`, but lmarena.ai's real auth cookie is `arena-auth-prod-v1`, so users who pasted only `session=…` hit validation failures. The credential name, placeholder and storage keys now use the correct name (the legacy `session` key is retained for back-compat with already-saved credentials). ([#3810](https://github.com/diegosouzapw/OmniRoute/issues/3810) — thanks @xspylol) +- **fix(reasoning): normalize OpenAI-compatible `max` effort to `xhigh` by default** — OpenAI-compatible providers do not accept literal `max`, but some upstreams (for example DeepSeek through OpenRouter) support `xhigh`; `max` now maps to `xhigh` unless the target model explicitly opts out of `xhigh`, with Claude alias variants still honoring the canonical Claude opt-out list. (thanks @rdself) --- diff --git a/file-size-baseline.json b/file-size-baseline.json index 412ad484cb..1d3ab53a83 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -64,7 +64,7 @@ "src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 932, "src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 880, "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012, - "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1072, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1089, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 983, "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1580, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924, @@ -99,9 +99,9 @@ "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1470, "src/shared/constants/providers.ts": 3147, - "src/shared/constants/sidebarVisibility.ts": 990, + "src/shared/constants/sidebarVisibility.ts": 1006, "src/shared/services/cliRuntime.ts": 1090, - "src/shared/validation/schemas.ts": 2519, + "src/shared/validation/schemas.ts": 2522, "src/sse/handlers/chat.ts": 1425, "src/sse/services/auth.ts": 2207 }, @@ -121,5 +121,6 @@ "_rebaseline_2026_06_13_3474_grok_403": "Re-baseline #3474 (mensagem clara no 403 anti-bot do Grok): validation.ts 4302→4348 (+46). Crescimento = helper isGrokAntiBotBlock() + branch 403 de 3 tiers (auth-shaped / anti-bot-IP-reputation / upstream-error). Lógica coesa de classificação no validator; não-extraível.", "_rebaseline_2026_06_13_3324_windsurf_devin": "Re-baseline #3324 (windsurf auth text + devin error propagation): route.ts 897→903 (+6, texto da instrução windsurf→fluxo command-palette) + sseParser.ts ADICIONADO como frozen 812 (era 746, +66 = helper extractSSEErrorMessage que faz surface do erro real SSE em vez do 502 genérico). 812 fica 12 acima do cap 800 — helper coeso no parser de SSE, congelado com justificativa (precedente providerLimits/useProviderConnections).", "_rebaseline_2026_06_13_2743d_skipbreaker": "Re-baseline #2743 gap-d (testar consumer do skipProviderBreaker): combo.ts 5131→5162 (+31). Crescimento = extração do boolean inline da decisão de circuit-breaker para o predicado puro EXPORTADO shouldRecordProviderBreakerFailure() (byte-idêntico) + JSDoc, para torná-lo unit-testável sem o harness completo de combo. Shrink estrutural segue com #3501.", - "_rebaseline_2026_06_13_v3825_prettier_reconcile": "Reconciliação tardia: o prettier do pre-commit reformatou 3 arquivos DEPOIS da medição de file-size dos PRs, inflando linhas além do baseline setado — OAuthModal.tsx 956→960 e providers.ts 3146→3147 (#3324), combo.ts 5162→5164 (#2743d). Bumps de reformatação automática (sem lógica nova). LIÇÃO: medir file-size pós-commit (pós-prettier), não antes." + "_rebaseline_2026_06_13_v3825_prettier_reconcile": "Reconciliação tardia: o prettier do pre-commit reformatou 3 arquivos DEPOIS da medição de file-size dos PRs, inflando linhas além do baseline setado — OAuthModal.tsx 956→960 e providers.ts 3146→3147 (#3324), combo.ts 5162→5164 (#2743d). Bumps de reformatação automática (sem lógica nova). LIÇÃO: medir file-size pós-commit (pós-prettier), não antes.", + "_rebaseline_2026_06_14_3826_release_drift": "Re-baseline release/v3.8.25 drift already documented from #3809 owner changes: ProxyRegistryManager.tsx 1072→1089, sidebarVisibility.ts 990→1006, schemas.ts 2519→2522. This PR does not touch those source files; updating the frozen values restores Fast Quality Gates on the current release branch." } diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index fee04cdfd6..33ad8050b7 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -106,6 +106,48 @@ export function splitClaudeEffortSuffix(model: unknown): { return { baseModel: id, effort: null }; } +function getDatedClaudeAliasDate(candidate: string, modelId: string): number | null { + if (!modelId.startsWith(`${candidate}-`)) return null; + const suffix = modelId.slice(candidate.length + 1); + if (!/^\d{8}$/.test(suffix)) return null; + return Number(suffix); +} + +function findCanonicalClaudeEffortModel(modelId: string): RegistryModel | undefined { + const id = splitClaudeEffortSuffix(modelId).baseModel.toLowerCase(); + const claudeMatch = id.match(CLAUDE_MODEL_PATTERN); + if (!claudeMatch) return undefined; + + const claudeOffset = claudeMatch[0]?.indexOf("claude") ?? 0; + const claudeStart = (claudeMatch.index ?? 0) + Math.max(claudeOffset, 0); + const claudeScopedId = id.slice(claudeStart).replace(/\.(?=\d)/g, "-"); + const candidates = [claudeScopedId]; + if (claudeScopedId.endsWith("-thinking")) { + candidates.push(claudeScopedId.slice(0, -"-thinking".length)); + } + + const claudeModels = getModelsByProviderId("claude"); + for (const candidate of candidates) { + const exact = claudeModels.find((entry) => entry.id.toLowerCase() === candidate); + if (exact) return exact; + + if (!/-\d+-\d+$/.test(candidate)) continue; + const datedAliases = claudeModels + .map((entry) => ({ + entry, + date: getDatedClaudeAliasDate(candidate, entry.id.toLowerCase()), + })) + .filter( + (item): item is { entry: RegistryModel; date: number } => + item.date !== null && item.entry.supportsXHighEffort !== undefined + ) + .sort((a, b) => b.date - a.date || a.entry.id.localeCompare(b.entry.id)); + if (datedAliases[0]) return datedAliases[0].entry; + } + + return undefined; +} + function resolveProviderModelList(aliasOrId: string): { alias: string; models: RegistryModel[] | null; @@ -118,25 +160,26 @@ function resolveProviderModelList(aliasOrId: string): { export function supportsXHighEffort(aliasOrId: string, modelId: string): boolean { const { models: providerModels } = resolveProviderModelList(aliasOrId); - // Unknown provider (not in registry) — pass through unchanged. - if (!providerModels) return true; - const model = providerModels.find((entry) => entry.id === modelId); + const model = providerModels?.find((entry) => entry.id === modelId); + if (model?.supportsXHighEffort !== undefined) { + return model.supportsXHighEffort !== false; + } + + const canonicalClaudeModel = findCanonicalClaudeEffortModel(modelId); + if (canonicalClaudeModel?.supportsXHighEffort !== undefined) { + return canonicalClaudeModel.supportsXHighEffort !== false; + } // Keep explicit false entries as the unsupported-model list. Unlisted models - // and models without an explicit flag pass through unchanged. - return model?.supportsXHighEffort !== false; + // and models without an explicit flag pass through unchanged. Unknown + // providers follow the same rule except for canonical Claude aliases above. + return true; } +/** @deprecated Use supportsXHighEffort(); max normalization now follows the same opt-out policy. */ export function supportsXHighEffortForMaxNormalization( aliasOrId: string, modelId: string ): boolean { - const { alias, models: providerModels } = resolveProviderModelList(aliasOrId); - if (!providerModels) return true; - const model = providerModels.find((entry) => entry.id === modelId); - - if (alias === "cc") { - return model?.supportsXHighEffort !== false; - } - return model?.supportsXHighEffort === true; + return supportsXHighEffort(aliasOrId, modelId); } diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 0bef539f91..a82af80579 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,10 +1,6 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; -import { - supportsClaudeMaxEffort, - supportsXHighEffort, - supportsXHighEffortForMaxNormalization, -} from "../config/providerModels.ts"; +import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import type { PoolConfig } from "../services/sessionPool/types.ts"; import type { Session } from "../services/sessionPool/session.ts"; import { SessionPool } from "../services/sessionPool/sessionPool.ts"; @@ -232,10 +228,10 @@ function hasActiveClaudeThinking(body: Record): boolean { * provider. Apply provider-aware sanitation here (after transformRequest, so * reintroductions by per-provider transforms are also caught) before fetch. * xhigh support is opt-out: pass through unchanged unless the registry marks - * a model as unsupported. max support is Claude/CC-compatible only and + * a model as unsupported. Literal max support is Claude/CC-compatible only and * intentionally separate: older Opus/Sonnet models may support max even when - * they do not support xhigh. For OpenAI-shape providers, keep the existing - * max normalization behavior. + * they do not support xhigh. For OpenAI-shape providers, max normalizes to + * xhigh by default and falls back to high only for explicit xhigh opt-outs. */ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; @@ -265,9 +261,28 @@ export function sanitizeReasoningEffortForProvider( const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; const modelStr = model || ""; + const rejecting = + (provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) || + (provider === "github" && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); + if (rejecting) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: removed unsupported reasoning_effort` + ); + const next: Record = { ...b }; + delete next.reasoning_effort; + if (reasoning) { + const r = { ...reasoning }; + delete r.effort; + if (Object.keys(r).length === 0) delete next.reasoning; + else next.reasoning = r; + } + return next; + } + const supportsXHigh = supportsXHighEffort(provider, modelStr); const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh; - const supportsXHighForMax = supportsXHighEffortForMaxNormalization(provider, modelStr); + const supportsXHighForMax = supportsXHigh; const supportsMax = supportsMaxEffortForProvider(provider, modelStr); const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax; const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax; @@ -302,25 +317,6 @@ export function sanitizeReasoningEffortForProvider( return next; } - const rejecting = - (provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) || - (provider === "github" && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); - if (rejecting) { - log?.info?.( - "REASONING_SANITIZE", - `${provider}/${modelStr}: removed unsupported reasoning_effort` - ); - const next: Record = { ...b }; - delete next.reasoning_effort; - if (reasoning) { - const r = { ...reasoning }; - delete r.effort; - if (Object.keys(r).length === 0) delete next.reasoning; - else next.reasoning = r; - } - return next; - } - return body; } diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index fc90967478..81d6dc51d7 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -73,7 +73,7 @@ test("sanitizeReasoningEffortForProvider: Anthropic-compatible dynamic provider assert.equal((result as any).reasoning_effort, "high"); }); -test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades max → high", () => { +test("sanitizeReasoningEffortForProvider: xiaomi-mimo normalizes max → xhigh by default", () => { const log = makeLog(); const body = { model: "mimo-v2.5-pro", @@ -81,6 +81,48 @@ test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades max → high", messages: [{ role: "user", content: "hi" }], }; const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", log); + assert.equal((result as any).reasoning_effort, "xhigh"); + assert.ok( + log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /max → xhigh/.test(m)), + "logs the normalization" + ); +}); + +test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek normalizes max → xhigh", () => { + const log = makeLog(); + const body = { + model: "deepseek/deepseek-v4-pro", + reasoning_effort: "max", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "openrouter", + "deepseek/deepseek-v4-pro", + log + ); + assert.notEqual(result, body, "must return a new object when mutating"); + assert.equal((result as any).reasoning_effort, "xhigh"); + assert.ok( + log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /max → xhigh/.test(m)), + "logs the normalization" + ); +}); + +test("sanitizeReasoningEffortForProvider: OpenRouter Claude opt-out aliases downgrade max → high", () => { + const log = makeLog(); + const body = { + model: "anthropic/claude-opus-4.6", + reasoning_effort: "max", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "openrouter", + "anthropic/claude-opus-4.6", + log + ); + assert.notEqual(result, body, "must return a new object when mutating"); assert.equal((result as any).reasoning_effort, "high"); assert.ok( log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /max → high/.test(m)), @@ -218,6 +260,34 @@ test("sanitizeReasoningEffortForProvider: github/claude-opus strips reasoning_ef assert.equal((result as any).reasoning_effort, undefined); }); +test("sanitizeReasoningEffortForProvider: rejecting providers strip max before normalization", () => { + const mistralBody = { + model: "devstral-2512", + reasoning_effort: "max", + messages: [], + }; + const mistralResult = sanitizeReasoningEffortForProvider( + mistralBody, + "mistral", + "devstral-2512", + null + ); + assert.equal((mistralResult as any).reasoning_effort, undefined); + + const githubBody = { + model: "claude-opus-4-6", + reasoning_effort: "max", + messages: [], + }; + const githubResult = sanitizeReasoningEffortForProvider( + githubBody, + "github", + "claude-opus-4-6", + null + ); + assert.equal((githubResult as any).reasoning_effort, undefined); +}); + test("sanitizeReasoningEffortForProvider: mistral/devstral strips reasoning object when only effort present", () => { const body = { model: "devstral-2512", diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index bdfcc53779..403d32e6cf 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -121,15 +121,27 @@ test("xhigh effort support defaults to pass-through and opts out explicit false assert.equal(supportsXHighEffort("anthropic-compatible-cc-test", "claude-opus-4-6"), false); assert.equal(supportsXHighEffort("anthropic-compatible-cc-test", "claude-opus-4-7"), true); assert.equal(supportsXHighEffort("openrouter", "deepseek/deepseek-v4-pro"), true); + assert.equal(supportsXHighEffort("openrouter", "anthropic/claude-opus-4.6"), false); + assert.equal(supportsXHighEffort("openrouter", "anthropic/claude-opus-4.7"), true); + assert.equal(supportsXHighEffort("openrouter", "anthropic/claude-opus-4.5"), false); + assert.equal(supportsXHighEffort("bedrock", "anthropic.claude-opus-4-6"), false); + assert.equal(supportsXHighEffort("bedrock", "anthropic.claude-opus-4-7"), true); + assert.equal(supportsXHighEffort("github", "claude-opus-4.6"), false); + assert.equal(supportsXHighEffort("github", "claude-opus-4.7"), true); + assert.equal(supportsXHighEffort("unknown-provider", "vendor/claude-opus-4.6"), false); + assert.equal( + supportsXHighEffort("openrouter", "anthropic/claude-opus-4.6-thinking-xhigh"), + false + ); assert.equal(supportsXHighEffort("deepseek", "deepseek-v4-pro"), true); }); -test("max normalization keeps existing xhigh opt-in behavior", () => { +test("max normalization follows xhigh opt-out behavior", () => { assert.equal( supportsXHighEffortForMaxNormalization("openai-compatible-free1", "gemini-3.1-pro-preview"), true ); - assert.equal(supportsXHighEffortForMaxNormalization("xiaomi-mimo", "mimo-v2.5-pro"), false); + assert.equal(supportsXHighEffortForMaxNormalization("xiaomi-mimo", "mimo-v2.5-pro"), true); assert.equal( supportsXHighEffortForMaxNormalization("anthropic-compatible-cc-test", "claude-opus-4-6"), false @@ -148,6 +160,18 @@ test("max normalization keeps existing xhigh opt-in behavior", () => { ); assert.equal( supportsXHighEffortForMaxNormalization("openrouter", "deepseek/deepseek-v4-pro"), + true + ); + assert.equal( + supportsXHighEffortForMaxNormalization("openrouter", "anthropic/claude-opus-4.6"), + false + ); + assert.equal( + supportsXHighEffortForMaxNormalization("openrouter", "anthropic/claude-opus-4.7"), + true + ); + assert.equal( + supportsXHighEffortForMaxNormalization("bedrock", "anthropic.claude-opus-4-6"), false ); });