diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 5a919867e7..dc0a51f747 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -144,6 +144,10 @@ Models: **Pro Tip:** Use Opus for complex tasks, Sonnet for speed. OmniRoute tracks quota per model! +Claude and Claude Code-compatible routes preserve `max` thinking effort for Opus and Sonnet +models. Haiku models do not accept the `max` effort tier, so OmniRoute downgrades that +request to a high thinking budget before sending it upstream. + #### OpenAI Codex (Plus/Pro) ```bash diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index c124984c91..99a42ea019 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -59,6 +59,20 @@ export function getModelsByProviderId(providerId: string): RegistryModel[] { return PROVIDER_MODELS[alias] || []; } +const CLAUDE_MODEL_PATTERN = /(?:^|[\/._-])claude(?:[._-]|$)/; +const CLAUDE_MAX_EFFORT_UNSUPPORTED_FAMILY_PATTERNS = [/(?:^|[\/._-])haiku(?:[._-]|$)/] as const; + +export function supportsClaudeMaxEffort(modelId: string | null | undefined): boolean { + if (typeof modelId !== "string" || modelId.length === 0) return false; + const normalized = modelId.toLowerCase(); + const claudeMatch = normalized.match(CLAUDE_MODEL_PATTERN); + if (!claudeMatch) return false; + const claudeScopedId = normalized.slice(claudeMatch.index ?? 0); + return !CLAUDE_MAX_EFFORT_UNSUPPORTED_FAMILY_PATTERNS.some((pattern) => + pattern.test(claudeScopedId) + ); +} + export function supportsXHighEffort(aliasOrId: string, modelId: string): boolean { const alias = PROVIDER_ID_TO_ALIAS[aliasOrId] || aliasOrId; const providerModels = PROVIDER_MODELS[alias] || PROVIDER_MODELS[aliasOrId]; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 388f85e0f7..f45c12fe68 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,6 +1,6 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; -import { supportsXHighEffort } from "../config/providerModels.ts"; +import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import { getRotatingApiKey, getValidApiKey, @@ -205,8 +205,8 @@ function hasActiveClaudeThinking(body: Record): boolean { /** * Sanitize reasoning_effort for providers that don't accept all values. * - * The claude→openai translator emits reasoning_effort=xhigh when the client - * sends output_config.effort=max on a Claude-shape request. Combined with + * The claude→openai translator may emit reasoning_effort=max/xhigh when the + * client sends output_config.effort=max on a Claude-shape request. Combined with * runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this * routes xhigh to OpenAI-shape providers that don't accept the value: * @@ -217,11 +217,22 @@ function hasActiveClaudeThinking(body: Record): boolean { * Each rejection burns a combo fallback attempt before reaching a working * provider. Apply provider-aware sanitation here (after transformRequest, so * reintroductions by per-provider transforms are also caught) before fetch. - * Models that genuinely support xhigh pass through unchanged. Claude models - * default to xhigh support unless they are marked as legacy unsupported entries. + * xhigh support is registry-gated: models that genuinely support xhigh pass + * through unchanged, and Claude models default to xhigh support unless marked + * as legacy unsupported entries. max support is Claude/CC-compatible only and + * intentionally separate: older Opus/Sonnet models may support max even when + * they do not support xhigh. */ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; + +function supportsMaxEffortForProvider(provider: string, model: string): boolean { + return ( + (provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) && + supportsClaudeMaxEffort(model) + ); +} + export function sanitizeReasoningEffortForProvider( body: unknown, provider: string, @@ -240,10 +251,14 @@ export function sanitizeReasoningEffortForProvider( const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; const modelStr = model || ""; - if (effortStr === "xhigh" && !supportsXHighEffort(provider, modelStr)) { + const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHighEffort(provider, modelStr); + const shouldDowngradeMax = + effortStr === "max" && !supportsMaxEffortForProvider(provider, modelStr); + + if (shouldDowngradeXHigh || shouldDowngradeMax) { log?.info?.( "REASONING_SANITIZE", - `${provider}/${modelStr}: downgraded reasoning_effort xhigh → high` + `${provider}/${modelStr}: downgraded reasoning_effort ${effortStr} → high` ); const next: Record = { ...b }; if (hasTopLevelReasoningEffort) { @@ -760,7 +775,7 @@ export class BaseExecutor { } // Per-request behavior overrides via custom client headers. - // x-omniroute-effort: low | medium | high | xhigh | off + // x-omniroute-effort: low | medium | high | xhigh | max | off // x-omniroute-thinking: adaptive | off // A header value applies only when the corresponding body field is // not already set; "off" force-strips the field. @@ -782,7 +797,7 @@ export class BaseExecutor { delete (tb.output_config as Record).effort; } appliedEffort = "off"; - } else if (headerEffort && ["low", "medium", "high", "xhigh"].includes(headerEffort)) { + } else if (headerEffort && ["low", "medium", "high", "xhigh", "max"].includes(headerEffort)) { const oc = tb.output_config && typeof tb.output_config === "object" ? (tb.output_config as Record) diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 4972f21835..c7c6800687 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { getStainlessTimeoutSeconds } from "@/shared/utils/runtimeTimeouts"; import { ANTHROPIC_VERSION_HEADER } from "../config/anthropicHeaders.ts"; -import { supportsXHighEffort } from "../config/providerModels.ts"; +import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts"; import { signRequestBody } from "./claudeCodeCCH.ts"; import { remapToolNamesInRequest } from "./claudeCodeToolRemapper.ts"; @@ -451,7 +451,7 @@ export function resolveClaudeCodeCompatibleEffort( sourceBody?: Record | null, normalizedBody?: Record | null, model?: string | null -): "low" | "medium" | "high" | "xhigh" { +): "low" | "medium" | "high" | "xhigh" | "max" { const raw = readNestedString(sourceBody, ["output_config", "effort"]) || readNestedString(sourceBody, ["reasoning", "effort"]) || @@ -474,7 +474,7 @@ export function resolveClaudeCodeCompatibleEffort( return supportsClaudeXHighEffort(model) ? "xhigh" : "high"; } if (normalizedEffort === "max") { - return supportsClaudeXHighEffort(model) ? "xhigh" : "high"; + return supportsClaudeMaxEffort(model) ? "max" : "high"; } return supportsClaudeXHighEffort(model) ? "xhigh" : "high"; } @@ -1102,7 +1102,7 @@ function resolveClaudeCodeCompatibleOutputConfig({ sourceBody?: Record | null; normalizedBody?: Record | null; model?: string | null; - effort: "low" | "medium" | "high" | "xhigh"; + effort: "low" | "medium" | "high" | "xhigh" | "max"; }) { const outputConfig = readRecord(cloneValue(claudeBody?.output_config)) || diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index bbdc3a894c..2671d7a5e8 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -1,7 +1,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; // CLAUDE_SYSTEM_PROMPT import removed — no longer injected unconditionally (#1966/#2130) -import { supportsXHighEffort } from "../../config/providerModels.ts"; +import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/providerModels.ts"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; @@ -453,16 +453,18 @@ export function openaiToClaudeRequest(model, body, stream) { // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible const requestedEffort = String(body.reasoning_effort).toLowerCase(); const normalizedEffort = - requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) + requestedEffort === "max" && !supportsClaudeMaxEffort(model) ? "high" - : requestedEffort; - if (normalizedEffort === "xhigh") { + : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) + ? "high" + : requestedEffort; + if (normalizedEffort === "max" || normalizedEffort === "xhigh") { result.thinking = { type: "adaptive", }; result.output_config = { ...(result.output_config || {}), - effort: "xhigh", + effort: normalizedEffort, }; } else { const effortBudgetMap: Record = { diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index abb0930595..910e1c8fd6 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -28,6 +28,65 @@ test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh → high" ); }); +test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades max → high", () => { + const log = makeLog(); + const body = { + model: "mimo-v2.5-pro", + reasoning_effort: "max", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", log); + assert.equal((result as any).reasoning_effort, "high"); + assert.ok( + log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /max → high/.test(m)), + "logs the downgrade" + ); +}); + +test("sanitizeReasoningEffortForProvider: claude preserves max for Opus/Sonnet and downgrades Haiku", () => { + const sonnetBody = { + model: "claude-sonnet-4-6", + reasoning_effort: "max", + messages: [{ role: "user", content: "hi" }], + }; + const sonnetResult = sanitizeReasoningEffortForProvider( + sonnetBody, + "claude", + "claude-sonnet-4-6", + null + ); + assert.equal(sonnetResult, sonnetBody); + assert.equal((sonnetResult as any).reasoning_effort, "max"); + + const opusBody = { + model: "claude-opus-4-6", + reasoning: { effort: "max", summary: "auto" }, + input: [], + }; + const opusResult = sanitizeReasoningEffortForProvider( + opusBody, + "anthropic-compatible-cc-test", + "claude-opus-4-6", + null + ); + assert.equal(opusResult, opusBody); + assert.equal((opusResult as any).reasoning.effort, "max"); + + const haikuBody = { + model: "claude-haiku-4-5-20251001", + reasoning_effort: "max", + messages: [{ role: "user", content: "hi" }], + }; + const haikuResult = sanitizeReasoningEffortForProvider( + haikuBody, + "claude", + "claude-haiku-4-5-20251001", + null + ); + assert.notEqual(haikuResult, haikuBody); + assert.equal((haikuResult as any).reasoning_effort, "high"); +}); + test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh in nested reasoning.effort", () => { const body = { model: "mimo-v2.5-pro", diff --git a/tests/unit/claude-code-compatible-request.test.ts b/tests/unit/claude-code-compatible-request.test.ts index 13ece0f7b3..895693b65f 100644 --- a/tests/unit/claude-code-compatible-request.test.ts +++ b/tests/unit/claude-code-compatible-request.test.ts @@ -53,6 +53,10 @@ test("Claude Code compatible effort and max token helpers cover priority fallbac resolveClaudeCodeCompatibleEffort({ output_config: { effort: "xhigh" } }, null, xhighModel.id), "xhigh" ); + assert.equal( + resolveClaudeCodeCompatibleEffort({ output_config: { effort: "max" } }, null, xhighModel.id), + "max" + ); assert.equal( resolveClaudeCodeCompatibleEffort( { output_config: { effort: "xhigh" } }, @@ -61,6 +65,18 @@ test("Claude Code compatible effort and max token helpers cover priority fallbac ), "high" ); + assert.equal( + resolveClaudeCodeCompatibleEffort({ output_config: { effort: "max" } }, null, standardModel.id), + "max" + ); + assert.equal( + resolveClaudeCodeCompatibleEffort( + { output_config: { effort: "max" } }, + null, + "claude-haiku-4-5-20251001" + ), + "high" + ); assert.equal( resolveClaudeCodeCompatibleEffort({ output_config: { effort: "unexpected" } }), "high" diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index d992860f3e..0ebd754469 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -10,6 +10,7 @@ import { getModelsByProviderId, getProviderModels, isValidModel, + supportsClaudeMaxEffort, supportsXHighEffort, } from "../../open-sse/config/providerModels.ts"; @@ -92,6 +93,19 @@ test("Kiro registry exposes the current CLI model lineup with context windows", assert.equal(byId.has("claude-haiku-4-5"), false); }); +test("Claude max effort support excludes Haiku family and non-Claude IDs", () => { + assert.equal(supportsClaudeMaxEffort("claude-opus-4-7"), true); + assert.equal(supportsClaudeMaxEffort("claude-opus-4-6"), true); + assert.equal(supportsClaudeMaxEffort("claude-sonnet-4-6"), true); + assert.equal(supportsClaudeMaxEffort("claude-sonnet-4-5-20250929"), true); + assert.equal(supportsClaudeMaxEffort("claude-haiku-4-5-20251001"), false); + assert.equal(supportsClaudeMaxEffort("claude-3-5-haiku-20241022"), false); + assert.equal(supportsClaudeMaxEffort("anthropic/claude-haiku-4.5"), false); + assert.equal(supportsClaudeMaxEffort("vendor/haiku-compatible-claude-sonnet-4-6"), true); + assert.equal(supportsClaudeMaxEffort("gpt-5"), false); + assert.equal(supportsClaudeMaxEffort("claude-future-5-0"), true); +}); + test("Claude xhigh effort support defaults on for new models and opts out legacy models", () => { const claudeModels = new Set(getModelsByProviderId("claude").map((model) => model.id)); diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 36ab95f3f2..1bf5ca456a 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -307,6 +307,32 @@ test("OpenAI -> Claude preserves xhigh only for Claude models that expose it", ( assert.equal(downgraded.max_tokens, 128000); }); +test("OpenAI -> Claude preserves max effort except for Haiku models", () => { + const preserved = openaiToClaudeRequest( + "claude-sonnet-4-6", + { + messages: [{ role: "user", content: "Think at max" }], + reasoning_effort: "max", + }, + false + ); + const haiku = openaiToClaudeRequest( + "claude-haiku-4-5-20251001", + { + messages: [{ role: "user", content: "Think at max" }], + max_tokens: 10, + reasoning_effort: "max", + }, + false + ); + + assert.deepEqual(preserved.thinking, { type: "adaptive" }); + assert.deepEqual(preserved.output_config, { effort: "max" }); + assert.equal(haiku.output_config, undefined); + assert.deepEqual(haiku.thinking, { type: "enabled", budget_tokens: 62976 }); + assert.equal(haiku.max_tokens, 64000); +}); + test("OpenAI -> Claude fits thinking budget within Opus 4.7 output cap (regression)", () => { // Real-world OpenCode scenario: caller asks for max_tokens=32000 with high effort. // High effort maps to budget=131072. The previous naive