mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(claude): preserve max effort for supported models (#2875)
Integrated into release/v3.8.6.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown> = { ...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<string, unknown>).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<string, unknown>)
|
||||
|
||||
@@ -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<string, unknown> | null,
|
||||
normalizedBody?: Record<string, unknown> | 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<string, unknown> | null;
|
||||
normalizedBody?: Record<string, unknown> | null;
|
||||
model?: string | null;
|
||||
effort: "low" | "medium" | "high" | "xhigh";
|
||||
effort: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
}) {
|
||||
const outputConfig =
|
||||
readRecord(cloneValue(claudeBody?.output_config)) ||
|
||||
|
||||
@@ -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<string, number> = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user