diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c00449fd3..234367bff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ _In development — bullets added per PR; finalized at release._ - **fix(capabilities): resolve models.dev-synced vision metadata for Mistral `-latest` aliases** — root cause behind the #4071 heuristic: `getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision` resolved `null` (vision came only from the #4071 model-id heuristic, with `attachment` still `null`) even though models.dev exposes the model as multimodal. Confirmed against the live models.dev API: it catalogs Pixtral 12B under the **short** id `pixtral-12b` (with `attachment: true`, `modalities.input: ["text","image"]`), while requests use the Mistral API alias `pixtral-12b-latest`. The synced lookup tried the exact / raw / static-spec-canonical ids — all of which miss the short form — so it fell through to the heuristic. `getSyncedCapabilityForResolved` now adds a last-resort fallback that retries with a trailing `-latest` stripped, so synced metadata (`attachment` / image modalities) wins for these aliases; models whose `-latest` id is stored verbatim (e.g. `pixtral-large-latest`) keep resolving directly. Note: the models.dev sync is currently manual-only (Settings → models.dev) with no scheduled refresh, so a fresh instance still relies on the #4071 heuristic until that sync runs — a periodic-refresh cadence is left as a separate follow-up. ([#4073](https://github.com/diegosouzapw/OmniRoute/issues/4073) — thanks @diego-anselmo) - **fix(sse): map Xiaomi MiMo reasoning control to its native `thinking:{type}` shape** — MiMo (`api.xiaomimimo.com`) controls chain-of-thought **only** via top-level `thinking:{type:"enabled"|"disabled"}` and does not understand OpenAI's `reasoning_effort`/`reasoning`, while its request validator is strict (`400 Param Incorrect`). OmniRoute's OpenAI path carried reasoning intent as `reasoning_effort`, and the claude→openai translator can leave a Claude-shaped `thinking:{type, budget_tokens}` — so the client's on/off choice was silently dropped and `budget_tokens`/`reasoning_effort` rode along as extra params the validator can reject. New `open-sse/services/mimoThinking.ts::normalizeMimoThinking` (wired in `chatCore` for `provider==="xiaomi-mimo"`) reduces any thinking object to just `{type}` (`disabled` stays; `enabled`/`adaptive`/other → `enabled`) and drops `reasoning_effort`/`reasoning`. It deliberately does **not** synthesize thinking from a bare `reasoning_effort` — `mimo-v2-omni` is non-thinking, so that could turn a silently-ignored param into a hard error. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224)) - **fix(capabilities): Xiaomi MiMo `*-pro` chat models are text-only (no vision)** — only `mimo-v2.5` and `mimo-v2-omni` accept images per Xiaomi's docs; `mimo-v2.5-pro`/`mimo-v2-pro` are text-only, but `modelSpecs` marked them vision-capable and models.dev mislabels them ([hermes-agent#18884](https://github.com/NousResearch/hermes-agent/issues/18884)). Since `resolveVisionCapability` lets a synced `attachment:true` win first, an image request could be routed to a blind model (the #4071 failure mode). Corrected the specs **and** added a hard override in `resolveVisionCapability` (checked before the synced branch, anchored so `mimo-v2.5-pro` never matches the multimodal `mimo-v2.5`) that beats the wrong synced attachment. Also registered the missing native `mimo-v2-pro` chat model and the missing `mimo-v2-tts` speech model. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224)) +- **fix(sse): Claude Opus 4.7+/Fable 5 use adaptive thinking only (no more manual-budget 400s)** — Opus 4.7 and later (Opus 4.7/4.8, Fable 5) removed manual extended thinking: `thinking.type:"enabled"` or **any** `thinking.budget_tokens` now returns `400` ("Any request that tries to set a fixed thinking budget gets a 400" — Anthropic migration guide). Reasoning is adaptive-only, steered by `output_config.effort`. OmniRoute's OpenAI→Claude translator mapped `reasoning_effort` low/medium/high to a manual `thinking:{type:"enabled", budget_tokens}`, so those requests hard-400'd on the most-used provider (and a Claude-native passthrough client sending the legacy shape did too). A new `adaptiveThinkingOnly` model flag now drives two fixes: the translator maps `reasoning_effort` of **every** level to `{type:"adaptive"}` + `output_config.effort` (preserving the requested level, never a budget) for these models, and a `normalizeClaudeAdaptiveThinking` catch-all at the existing post-translation thinking-normalization chokepoint collapses any residual manual thinking (passthrough legacy shape, per-model defaults) to `{type:"adaptive"}`, keyed on the resolved upstream model so it covers every routing mode. Pre-4.7 models (Opus 4.6/4.5, Sonnet, Haiku) keep manual budgets unchanged. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230)) +- **fix(providers): strip non-default temperature/top_p/top_k for Claude Opus 4.7+/Fable 5 (fixed sampling → no 400)** — Opus 4.7 and later reject non-default `temperature`/`top_p`/`top_k` with a `400` (sampling is fixed; reasoning moved to `output_config.effort`). The translator forwarded client-supplied `temperature`/`top_p` unconditionally and the Claude registry models carried no `unsupportedParams`, so a plain OpenAI-format request with `temperature: 0.7` to `claude-opus-4-8` hard-400'd. Added `unsupportedParams: ["temperature","top_p","top_k"]` to the Opus 4.7+/Fable 5 ids in both the `claude` (dashed `claude-opus-4-8`) and `anthropic` (dotted `claude-opus-4.7`) registries, so they're stripped at the existing `getUnsupportedParams` dispatch chokepoint. Pre-4.7 Claude models still accept sampling params. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230)) ### 🧪 Tests diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 0feefb9ebc..224d689a65 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_18_claude_adaptive_thinking": "PR (Claude Opus 4.7+ adaptive-thinking flow) own growth: chatCore.ts 5095->5102 (+7 = wire normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream) at the existing post-translation thinking-normalization chokepoint, right after normalizeThinkingForModel — its import line + a 5-line explanatory comment + the call). Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:\"enabled\"` or any `thinking.budget_tokens` is a hard 400, so this collapses any manual thinking that reached dispatch (passthrough legacy shape, reasoning_effort buckets, per-model defaults) to `{type:\"adaptive\"}`, keyed on the resolved upstream model. The reusable guard lives in the new pure leaf open-sse/services/claudeAdaptiveThinking.ts (1468 (+10 at the existing registerTool override in createMcpServer = F4.3 opt-in tool-cardinality wiring). Reads readMcpToolProfileFromEnv(process.env) once (MCP_TOOL_DENY/MCP_TOOL_ALLOW; null = no filter, the default), and when a registered tool is denied by the profile calls registered.disable() so it is not announced in tools/list (token savings). The default (null) profile never enters the branch — existing behavior byte-identical. The reusable parser + reduceToolManifest decision live in the (non-frozen) toolCardinality.ts. Cohesive opt-in feature at the registration chokepoint; not extractable without hiding the register boundary.", "_rebaseline_2026_06_18_4217_compression_step_streaming": "PR #4217 own growth: chatCore.ts 5063->5086 (+23 at the existing compression-apply chokepoint = the best-effort onEngineStep callback threaded into applyCompressionAsync). The callback builds a compression.step payload and fires emit(\"compression.step\", …) + forwardDashboardEventToLiveWs(…) once per stacked engine as it completes (F3.3 live per-engine streaming), wrapped in try/catch so it never fails the request. It closes over the same emit/traceId/mode locals as the compression.completed emit right below it (line 1749); the reusable per-engine emission lives in strategySelector.ts (reportEngineStep + StackedCompressionStep) and the studio reducers in compressionFlowModel.ts (both 5063 (+3 = wire ensureEngineBreakdown(result.stats) into the existing compression.completed emit + its import line). Single-engine modes (rtk/lite/standard/aggressive/ultra) leave stats.engineBreakdown empty, which made the dashboard studio render an empty Input->Output pipeline (no engine node); the synthesized 1-entry breakdown lives in the new pure leaf open-sse/services/compression/engineBreakdown.ts (; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +/** + * Collapse manual extended thinking to adaptive for Claude models that no longer accept it. + * + * Claude Opus 4.7 and later (Opus 4.7/4.8, Fable 5) removed manual extended thinking: the + * Messages API returns HTTP 400 for `thinking.type:"enabled"` and for ANY + * `thinking.budget_tokens`. Reasoning is steered exclusively by `output_config.effort` + * (Anthropic migration guide, 2026-05-19). OmniRoute can still produce a manual thinking + * block on these models from several paths — a Claude-native passthrough client sending the + * legacy shape, the OpenAI→Claude translator's reasoning_effort buckets, or a per-model + * thinking default — so this is the final, provider-agnostic guard keyed on the target model. + * + * Returns a NEW object only when it changes the body: + * - `thinking.type:"enabled"` → `"adaptive"` (the only supported mode); + * - `thinking.budget_tokens` / `thinking.max_tokens` → dropped (rejected extras). + * `thinking.type:"adaptive"` is left as-is (just stripped of any stray budget), and + * `thinking.type:"disabled"` is left untouched — that's handled separately by + * `normalizeThinkingForModel` for the models that reject `disabled` (#3554). + * + * No-op (returns the same reference) when the model is not adaptive-only, when there is no + * thinking object, or when the thinking object already carries no manual-budget signal — + * so adaptive defaults and effort hints reach the model unchanged. + */ +export function normalizeClaudeAdaptiveThinking>( + body: T, + model: string | null | undefined +): T { + if (!isAdaptiveThinkingOnly(model)) return body; + const record = asRecord(body); + if (!record) return body; + + const thinking = asRecord(record.thinking); + if (!thinking) return body; + + const isManualType = thinking.type === "enabled"; + const hasBudget = thinking.budget_tokens !== undefined || thinking.max_tokens !== undefined; + if (!isManualType && !hasBudget) return body; + + const nextThinking: JsonRecord = { ...thinking }; + if (nextThinking.type === "enabled") nextThinking.type = "adaptive"; + delete nextThinking.budget_tokens; + delete nextThinking.max_tokens; + + return { ...record, thinking: nextThinking } as T; +} diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 7db6062331..b770a80bfd 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -6,6 +6,11 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; +import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; + +// Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer +// adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. +const ADAPTIVE_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); // Prefix for Claude OAuth tool names to avoid conflicts // Can be disabled per-request via body._disableToolPrefix = true @@ -458,7 +463,22 @@ export function openaiToClaudeRequest(model, body, stream) { : requestedEffort === "xhigh" && !supportsXHighEffort("claude", model) ? "high" : requestedEffort; - if (normalizedEffort === "max" || normalizedEffort === "xhigh") { + if (isAdaptiveThinkingOnly(model)) { + // Opus 4.7+/Fable 5 removed manual extended thinking: a fixed `budget_tokens` + // (or `type:"enabled"`) is a hard 400. Steer EVERY level via adaptive + + // output_config.effort instead of the budget buckets below. Unrecognized levels + // leave thinking unset so the model keeps its adaptive default rather than 400ing + // on an invalid effort value. + if (ADAPTIVE_EFFORT_LEVELS.has(normalizedEffort)) { + result.thinking = { + type: "adaptive", + }; + result.output_config = { + ...(result.output_config || {}), + effort: normalizedEffort, + }; + } + } else if (normalizedEffort === "max" || normalizedEffort === "xhigh") { result.thinking = { type: "adaptive", }; diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index c046f5bc4e..5787d7d1d5 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -19,6 +19,12 @@ export interface ModelSpec { // (upstream returns 400). Used to normalize the request when a combo/route substitutes // this model after the client already chose `disabled`. See issue #3554. rejectsThinkingDisabled?: boolean; + // Model ONLY supports adaptive thinking: manual extended thinking was removed. Sending + // `thinking.type:"enabled"` or any `thinking.budget_tokens` returns HTTP 400; reasoning + // is steered exclusively by `output_config.effort` (low/medium/high/xhigh/max). True for + // Claude Opus 4.7 and later (Opus 4.7/4.8, Fable 5). Per Anthropic's migration guide + // (2026-05-19): "Any request that tries to set a fixed thinking budget gets a 400 error." + adaptiveThinkingOnly?: boolean; // Explicit operator override for the no-thinking gateway alias (Fase 8.1). When unset, // the catalog auto-advertises a `claude-3-omniroute-no-thinking/…` variant for // Claude-family thinking-capable models that honor `disabled`. Set `true` to force the @@ -211,16 +217,17 @@ export const MODEL_SPECS: Record = { "claude-opus-4-7": { maxOutputTokens: 128000, contextWindow: 1000000, - // Anthropic accepts thinking.budget_tokens in [1024, 128000]; cap - // a bit below to leave headroom for the visible response within - // max_tokens. Without this cap, adaptive scaling on top of an - // `output_config.effort=max` request can push past 128000 and - // trigger a 400 "budget out of range" from Anthropic. + // Opus 4.7 removed manual extended thinking: a fixed `thinking.budget_tokens` + // (or `thinking.type:"enabled"`) returns 400. Reasoning is adaptive-only and + // steered by `output_config.effort`. defaultThinkingBudget/thinkingBudgetCap + // are retained only as caps for any legacy budget path; the request flow + // collapses manual thinking to adaptive before dispatch (see adaptiveThinkingOnly). defaultThinkingBudget: 32000, thinkingBudgetCap: 120000, supportsThinking: true, supportsTools: true, supportsVision: true, + adaptiveThinkingOnly: true, aliases: BEDROCK_CLAUDE_ALIASES("claude-opus-4-7", "claude-opus-4.7"), }, @@ -235,6 +242,8 @@ export const MODEL_SPECS: Record = { supportsVision: true, // Fable 5 defaults to adaptive thinking and rejects `thinking.type:"disabled"` (#3554). rejectsThinkingDisabled: true, + // …and, like Opus 4.7+, rejects manual budgets/`type:"enabled"` (adaptive-only). + adaptiveThinkingOnly: true, aliases: BEDROCK_CLAUDE_ALIASES("claude-fable-5"), }, @@ -249,6 +258,7 @@ export const MODEL_SPECS: Record = { supportsThinking: true, supportsTools: true, supportsVision: true, + adaptiveThinkingOnly: true, aliases: BEDROCK_CLAUDE_ALIASES("claude-opus-4-8", "claude-opus-4.8"), }, @@ -515,6 +525,18 @@ export function getDefaultThinkingBudget(modelId: string): number { return getModelSpec(modelId)?.defaultThinkingBudget ?? 0; } +/** + * True when the resolved model only supports adaptive thinking and rejects manual + * extended thinking. For these models (Claude Opus 4.7+/Fable 5) a `thinking.type:"enabled"` + * or any `thinking.budget_tokens` is a hard 400 — reasoning must be steered via + * `output_config.effort`. Used by the request flow to collapse manual thinking to + * `{type:"adaptive"}` before dispatch. Matches dated/Bedrock aliases via getModelSpec. + */ +export function isAdaptiveThinkingOnly(modelId: string | null | undefined): boolean { + if (typeof modelId !== "string" || modelId.length === 0) return false; + return getModelSpec(modelId)?.adaptiveThinkingOnly === true; +} + export function capThinkingBudget(modelId: string, budget: number): number { const cap = getModelSpec(modelId)?.thinkingBudgetCap ?? budget; return Math.min(budget, cap); diff --git a/tests/unit/claude-adaptive-sampling-params.test.ts b/tests/unit/claude-adaptive-sampling-params.test.ts new file mode 100644 index 0000000000..753d019490 --- /dev/null +++ b/tests/unit/claude-adaptive-sampling-params.test.ts @@ -0,0 +1,72 @@ +/** + * Claude Opus 4.7+/Fable 5 sampling-param strip + adaptive-only flag. + * + * Anthropic's Opus 4.7+ generation rejects non-default `temperature`/`top_p`/`top_k` with a + * 400 (sampling is fixed; reasoning is steered by output_config.effort). These tests pin both + * the registry `unsupportedParams` that drive the strip at the chatCore dispatch point and + * the `isAdaptiveThinkingOnly` model flag — with regression guards that pre-4.7 models keep + * accepting sampling params and manual thinking. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getUnsupportedParams } from "../../open-sse/config/providerRegistry.ts"; +import { isAdaptiveThinkingOnly } from "../../src/shared/constants/modelSpecs.ts"; + +const SAMPLING = ["temperature", "top_p", "top_k"]; + +test("claude registry strips temperature/top_p/top_k for Opus 4.7+/Fable 5", () => { + for (const model of ["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5"]) { + const unsupported = getUnsupportedParams("claude", model); + for (const param of SAMPLING) { + assert.ok( + unsupported.includes(param), + `${model} must list ${param} as unsupported (400 on Anthropic otherwise)` + ); + } + } +}); + +test("anthropic registry (dotted ids) strips sampling params for Opus 4.7", () => { + const unsupported = getUnsupportedParams("anthropic", "claude-opus-4.7"); + for (const param of SAMPLING) { + assert.ok(unsupported.includes(param), `claude-opus-4.7 must list ${param} as unsupported`); + } +}); + +test("pre-4.7 Claude models still accept sampling params (regression guard)", () => { + for (const [provider, model] of [ + ["claude", "claude-opus-4-6"], + ["claude", "claude-opus-4-5-20251101"], + ["claude", "claude-sonnet-4-5-20250929"], + ["claude", "claude-haiku-4-5-20251001"], + ["anthropic", "claude-opus-4.6"], + ] as const) { + const unsupported = getUnsupportedParams(provider, model); + for (const param of SAMPLING) { + assert.ok( + !unsupported.includes(param), + `${provider}/${model} must NOT strip ${param} — it still accepts sampling` + ); + } + } +}); + +test("isAdaptiveThinkingOnly is true only for Opus 4.7+/Fable 5", () => { + for (const model of ["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5"]) { + assert.equal(isAdaptiveThinkingOnly(model), true, `${model} is adaptive-only`); + } + for (const model of [ + "claude-opus-4-6", + "claude-opus-4-5-20251101", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + ]) { + assert.equal(isAdaptiveThinkingOnly(model), false, `${model} still supports manual thinking`); + } + assert.equal(isAdaptiveThinkingOnly(null), false); + assert.equal(isAdaptiveThinkingOnly(""), false); +}); + +test("isAdaptiveThinkingOnly resolves Bedrock/dated aliases", () => { + assert.equal(isAdaptiveThinkingOnly("anthropic.claude-opus-4-8"), true); +}); diff --git a/tests/unit/claude-adaptive-thinking-normalize.test.ts b/tests/unit/claude-adaptive-thinking-normalize.test.ts new file mode 100644 index 0000000000..3b8cf18e33 --- /dev/null +++ b/tests/unit/claude-adaptive-thinking-normalize.test.ts @@ -0,0 +1,105 @@ +/** + * Claude adaptive-thinking normalization — `normalizeClaudeAdaptiveThinking`. + * + * Claude Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:"enabled"` and + * any `thinking.budget_tokens` return HTTP 400 (Anthropic migration guide, 2026-05-19). + * These tests pin the final guard that collapses any manual thinking that reached the + * dispatch point to `{type:"adaptive"}`, while leaving non-adaptive-only models and + * already-adaptive bodies untouched. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeClaudeAdaptiveThinking } from "../../open-sse/services/claudeAdaptiveThinking.ts"; + +test("manual thinking:{type:'enabled', budget_tokens} → adaptive, budget dropped (Opus 4.8)", () => { + const body = { + model: "claude-opus-4-8", + messages: [], + thinking: { type: "enabled", budget_tokens: 131072 }, + }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8"); + assert.deepEqual(result.thinking, { type: "adaptive" }); +}); + +test("Claude-shaped thinking:{type:'enabled', max_tokens} → adaptive, max_tokens dropped", () => { + const body = { + model: "claude-opus-4-7", + messages: [], + thinking: { type: "enabled", budget_tokens: 4096, max_tokens: 8000 }, + }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-7"); + assert.deepEqual(result.thinking, { type: "adaptive" }); +}); + +test("type:'enabled' with no budget still flips to adaptive (manual mode is gone)", () => { + const body = { model: "claude-fable-5", messages: [], thinking: { type: "enabled" } }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-fable-5"); + assert.deepEqual(result.thinking, { type: "adaptive" }); +}); + +test("thinking:{type:'adaptive'} is returned UNTOUCHED (same reference)", () => { + const body = { model: "claude-opus-4-8", messages: [], thinking: { type: "adaptive" } }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8"); + assert.equal(result, body, "already-adaptive body must not be reallocated"); +}); + +test("adaptive thinking carrying a stray budget_tokens has it stripped", () => { + const body = { + model: "claude-opus-4-8", + messages: [], + thinking: { type: "adaptive", budget_tokens: 5 }, + }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8"); + assert.deepEqual(result.thinking, { type: "adaptive" }); +}); + +test("thinking:{type:'disabled'} is left untouched (handled by normalizeThinkingForModel)", () => { + const body = { model: "claude-opus-4-8", messages: [], thinking: { type: "disabled" } }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8"); + assert.equal(result, body, "disabled is a separate concern; do not touch it here"); +}); + +test("NON-adaptive-only model keeps its manual budget (regression guard for Opus 4.6)", () => { + const body = { + model: "claude-opus-4-6", + messages: [], + thinking: { type: "enabled", budget_tokens: 96000 }, + }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-6"); + assert.equal(result, body, "Opus 4.6 still supports manual extended thinking"); + assert.deepEqual(result.thinking, { type: "enabled", budget_tokens: 96000 }); +}); + +test("body without a thinking object is returned UNTOUCHED (same reference)", () => { + const body = { model: "claude-opus-4-8", messages: [{ role: "user", content: "hi" }] }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8"); + assert.equal(result, body); +}); + +test("non-object body / empty model are returned unchanged", () => { + const body = null as unknown as Record; + assert.equal(normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8"), body); + const ok = { model: "claude-opus-4-8", thinking: { type: "enabled" } }; + assert.equal(normalizeClaudeAdaptiveThinking(ok, ""), ok, "empty model → no-op"); +}); + +test("Bedrock/dated alias still resolves the adaptive-only spec", () => { + const body = { thinking: { type: "enabled", budget_tokens: 1000 } }; + // BEDROCK_CLAUDE_ALIASES generates `anthropic.claude-opus-4-8` etc.; getModelSpec resolves it. + const result = normalizeClaudeAdaptiveThinking(body, "anthropic.claude-opus-4-8"); + assert.deepEqual(result.thinking, { type: "adaptive" }); +}); + +test("unrelated fields are preserved when collapsing thinking", () => { + const body = { + model: "claude-opus-4-8", + messages: [{ role: "user", content: "hi" }], + output_config: { effort: "high" }, + thinking: { type: "enabled", budget_tokens: 32000 }, + }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8") as Record; + assert.equal(result.model, "claude-opus-4-8"); + assert.deepEqual(result.messages, [{ role: "user", content: "hi" }]); + assert.deepEqual(result.output_config, { effort: "high" }); + assert.deepEqual(result.thinking, { type: "adaptive" }); +}); diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index cd819c574f..6b18b85681 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -349,15 +349,17 @@ test("OpenAI -> Claude preserves max effort except for Haiku models", () => { assert.equal(haiku.max_tokens, 64000); }); -test("OpenAI -> Claude fits thinking budget within Opus 4.7 output cap (regression)", () => { +test("OpenAI -> Claude fits thinking budget within a 128k 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 - // `budget + 8192 = 139264` exceeded Opus 4.7's 128000 output cap and caused + // `budget + 8192 = 139264` exceeded the 128000 output cap and caused // HTTP 400 "max_tokens > 128000". // fitThinkingToMaxTokens must preserve caller's 32000 response room and // shrink budget to (128000 - 32000) = 96000. + // Pinned on Opus 4.6 — a model that still uses manual budgets. Opus 4.7+/Fable 5 are + // adaptive-only now (no budget_tokens), so their effort path is covered separately below. const result = openaiToClaudeRequest( - "claude-opus-4-7", + "claude-opus-4-6", { messages: [{ role: "user", content: "Reason about something hard" }], max_tokens: 32000, @@ -376,6 +378,56 @@ test("OpenAI -> Claude fits thinking budget within Opus 4.7 output cap (regressi ); }); +test("OpenAI -> Claude steers adaptive-only models via output_config.effort for EVERY level", () => { + // Opus 4.7+/Fable 5 reject a manual `thinking.budget_tokens`/`type:"enabled"` with 400. + // reasoning_effort low/medium/high must therefore map to adaptive + output_config.effort + // (preserving the requested level), NOT to the budget buckets older models use. + for (const effort of ["low", "medium", "high", "xhigh", "max"]) { + for (const model of ["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5"]) { + const result = openaiToClaudeRequest( + model, + { + messages: [{ role: "user", content: "Reason" }], + max_tokens: 4000, + reasoning_effort: effort, + }, + false + ); + assert.deepEqual( + result.thinking, + { type: "adaptive" }, + `${model} @ ${effort} must use adaptive thinking, never a manual budget` + ); + assert.deepEqual( + result.output_config, + { effort }, + `${model} @ ${effort} must carry the effort on output_config` + ); + assert.equal( + (result.thinking as Record).budget_tokens, + undefined, + `${model} @ ${effort} must NOT emit budget_tokens (hard 400 on adaptive-only models)` + ); + } + } +}); + +test("OpenAI -> Claude keeps manual budgets for low/medium/high on pre-4.7 models (regression)", () => { + // Opus 4.6 still supports manual extended thinking: the budget buckets must be untouched. + const result = openaiToClaudeRequest( + "claude-opus-4-6", + { + messages: [{ role: "user", content: "Reason" }], + max_tokens: 20000, + reasoning_effort: "medium", + }, + false + ); + assert.equal((result.thinking as { type: string }).type, "enabled"); + assert.equal((result.thinking as { budget_tokens: number }).budget_tokens, 10240); + assert.equal(result.output_config, undefined); +}); + test("OpenAI -> Claude can disable OAuth prefixes and Antigravity strips Claude-only prompting", () => { const baseBody = { messages: [