From 73ebc27c03d9e2a97a3e3658e4ff435d04d5f471 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 2 Oct 2026 21:27:41 +0800 Subject: [PATCH] fix(compat): convert OpenAI reasoning effort to each Claude model's own contract OpenAI-format requests (Responses and Chat Completions, including the native Anthropic-protocol paths) forwarded to Claude models other than Opus/Sonnet 5.5 mapped every reasoning effort the same way: xhigh became max and every effort but low sent thinking {type:enabled, budget_tokens} against a default max_tokens of 8192. That is rejected by Opus 4.7/4.8/5, Sonnet 5 and Fable (budget_tokens), by Opus 4.6/Sonnet 4.6/Opus 4.5 at high and above (budget >= max_tokens), by Opus 4.5 at max, and by Haiku/Sonnet 4.5 (no effort). The effort is now converted with the effort catalog of the upstream model: - adaptive families think adaptively at the requested level or the nearest level they list (xhigh stays xhigh where listed, becomes max on 4.6); none/minimal use the lowest level with adaptive thinking, as Anthropic recommends over disabling it; - budget families (Opus 4.5, Mythos Preview) and Claude models without effort levels think on a budget from medium up, with 1024 <= budget < max_tokens guaranteed: a client max_tokens is kept and the budget shrinks, the default grows by the budget up to 32000; no budget thinking with a forced tool choice; models without levels never get output_config.effort; - a request that thinks drops temperature != 1 and top_p < 0.95; - dotted OpenRouter IDs (claude-opus-4.7) normalise to their family; - non-Claude upstreams and the 5.5 branch are unchanged. Production had no such traffic in the last 30 days (read-only ops check). Co-Authored-By: Claude Opus 5.5 --- .../pkg/apicompat/anthropic_responses_test.go | 69 ++++++++- .../responses_to_anthropic_request.go | 134 +++++++++++++++--- backend/internal/pkg/claude/effort_catalog.go | 43 +++++- .../gateway_compat_reasoning_pricing_test.go | 2 +- .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- 6 files changed, 220 insertions(+), 32 deletions(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index a3d7a9a32f57..9dd586ad7841 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1858,8 +1858,8 @@ func TestOpus55ResponsesAdaptiveThinkingAndToolChoice(t *testing.T) { require.ErrorContains(t, err, "reasoning effort") old, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "xhigh"}}) require.NoError(t, err) - require.Equal(t, "max", old.OutputConfig.Effort) - require.Equal(t, "enabled", old.Thinking.Type) + require.Equal(t, "xhigh", old.OutputConfig.Effort) + require.Equal(t, &AnthropicThinking{Type: "adaptive"}, old.Thinking) } func TestOpus55SignedThinkingResponsesRoundTrip(t *testing.T) { @@ -1944,6 +1944,71 @@ func TestSonnet55SignedThinkingResponsesRoundTrip(t *testing.T) { require.Equal(t, "tool_use", blocks[2].Type) } +func TestResponsesToAnthropicReasoningEffortByModelFamily(t *testing.T) { + for _, tc := range []struct { + model, effort string + maxOutputTokens int // 0: the client sets no limit + thinking *AnthropicThinking + outputEffort string // "": no output_config + maxTokens int + }{ + {"claude-opus-4-7", "xhigh", 0, &AnthropicThinking{Type: "adaptive"}, "xhigh", 8192}, + {"anthropic/claude-opus-4.7", "xhigh", 0, &AnthropicThinking{Type: "adaptive"}, "xhigh", 8192}, + {"claude-opus-4-6", "xhigh", 0, &AnthropicThinking{Type: "adaptive"}, "max", 8192}, + {"claude-opus-4-8", "none", 0, &AnthropicThinking{Type: "adaptive"}, "low", 8192}, + {"claude-opus-5", "none", 0, &AnthropicThinking{Type: "adaptive"}, "low", 8192}, + {"claude-fable-5-1", "none", 0, &AnthropicThinking{Type: "adaptive"}, "low", 8192}, + {"claude-opus-4-5-20251101", "max", 0, &AnthropicThinking{Type: "enabled", BudgetTokens: 23808}, "high", 32000}, + {"claude-opus-4-5", "high", 1024, nil, "high", 1024}, + {"claude-haiku-4-5", "high", 0, &AnthropicThinking{Type: "enabled", BudgetTokens: 10240}, "", 18432}, + {"claude-opus-4-0", "xhigh", 0, &AnthropicThinking{Type: "enabled", BudgetTokens: 23808}, "", 32000}, + {"glm-5.3", "xhigh", 0, &AnthropicThinking{Type: "enabled", BudgetTokens: 32768}, "max", 8192}, + } { + t.Run(tc.model+"/"+tc.effort, func(t *testing.T) { + req := &ResponsesRequest{Model: tc.model, Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: tc.effort}} + if tc.maxOutputTokens > 0 { + req.MaxOutputTokens = &tc.maxOutputTokens + } + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + require.Equal(t, tc.thinking, out.Thinking) + if tc.outputEffort == "" { + require.Nil(t, out.OutputConfig) + } else { + require.Equal(t, tc.outputEffort, out.OutputConfig.Effort) + } + require.Equal(t, tc.maxTokens, out.MaxTokens) + }) + } + + // Chat Completions reaches the same conversion; its max_tokens caps + // reasoning and answer together, so the budget shrinks below it. + maxTokens := 4096 + chatReq, err := ChatCompletionsToResponses(&ChatCompletionsRequest{Model: "claude-haiku-4-5", ReasoningEffort: "high", MaxTokens: &maxTokens, Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"hello"`)}}}) + require.NoError(t, err) + out, err := ResponsesToAnthropicRequest(chatReq) + require.NoError(t, err) + require.Equal(t, &AnthropicThinking{Type: "enabled", BudgetTokens: 4095}, out.Thinking) + require.Nil(t, out.OutputConfig) + require.Equal(t, 4096, out.MaxTokens) + + // Thinking takes temperature only at 1, and chat clients send their own. + temperature := 0.7 + chatReq, err = ChatCompletionsToResponses(&ChatCompletionsRequest{Model: "claude-sonnet-4-6", ReasoningEffort: "low", Temperature: &temperature, Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"hello"`)}}}) + require.NoError(t, err) + out, err = ResponsesToAnthropicRequest(chatReq) + require.NoError(t, err) + require.Equal(t, &AnthropicThinking{Type: "adaptive"}, out.Thinking) + require.Equal(t, "low", out.OutputConfig.Effort) + require.Nil(t, out.Temperature) + + // Budget thinking rejects forced tool use; the effort level still applies. + out, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-4-5", Input: json.RawMessage(`"hello"`), Tools: []ResponsesTool{{Type: "function", Name: "lookup"}}, ToolChoice: json.RawMessage(`"required"`), Reasoning: &ResponsesReasoning{Effort: "medium"}}) + require.NoError(t, err) + require.Nil(t, out.Thinking) + require.Equal(t, "medium", out.OutputConfig.Effort) +} + func TestGPT6ChatSamplingAndCacheFields(t *testing.T) { temperature := 0.7 for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 34d90518875a..8da8cc6dc727 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -34,12 +34,12 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro } // max_output_tokens → max_tokens - if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 { + clientMaxTokens := req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 + if clientMaxTokens { out.MaxTokens = *req.MaxOutputTokens - } - if out.MaxTokens == 0 { + } else { // Anthropic requires max_tokens; default to a sensible value. - out.MaxTokens = 8192 + out.MaxTokens = defaultMaxTokens } // Convert tools @@ -106,40 +106,134 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro return out, nil } - // reasoning.effort → output_config.effort + thinking + // reasoning.effort → thinking + output_config.effort under the effort rules + // of the upstream model, resolved into req.Model as for the 5.5 models. if req.Reasoning != nil && req.Reasoning.Effort != "" { - effort := mapResponsesEffortToAnthropic(req.Reasoning.Effort) + applyReasoningEffort(out, req.Reasoning.Effort, clientMaxTokens) + } + + return out, nil +} + +// applyReasoningEffort sets out's thinking and output_config.effort for an +// OpenAI reasoning effort under the effort rules of out.Model +// (claude.EffortLevelsForModel): +// +// - none and minimal ask for no reasoning: adaptive families think +// adaptively at their lowest effort, which Anthropic recommends over +// disabling thinking (Opus 5 then may write tool calls or thinking tags +// into the answer, Opus 4.8 longer reasoning; Fable and Opus 5.5 cannot +// disable it at all); budget families do not think; +// - adaptive families think adaptively at the requested level, or at the +// nearest level the model lists, deeper first; +// - Opus 4.5, Mythos Preview and Claude models without effort levels think +// from medium up, on a budget sized by the requested effort even where +// output_config.effort is capped; budget thinking takes tool_choice auto +// or none only, so a forced tool call runs without it; models without +// levels reject output_config.effort and never receive it; +// - values that are no effort level pass through as output_config.effort, +// so the upstream names the levels it accepts; +// - thinking takes temperature only at its default 1 and top_p only from +// 0.95, so a request that thinks drops other values, as OpenAI reasoning +// models do; +// - third-party models behind Anthropic-compatible endpoints keep the +// generic mapping: the effort passes through (xhigh as max) and every +// effort but low thinks on a budget. +func applyReasoningEffort(out *AnthropicRequest, effort string, clientMaxTokens bool) { + if !claude.IsClaudeModel(out.Model) { + effort = mapResponsesEffortToAnthropic(effort) out.OutputConfig = &AnthropicOutputConfig{Effort: effort} - // Enable thinking for non-low efforts if effort != "low" { - out.Thinking = &AnthropicThinking{ - Type: "enabled", - BudgetTokens: defaultThinkingBudget(effort), - } + out.Thinking = &AnthropicThinking{Type: "enabled", BudgetTokens: defaultThinkingBudget(effort)} } + return } - return out, nil + noReasoning := effort == "none" || effort == "minimal" + if levels := claude.EffortLevelsForModel(out.Model); len(levels) > 0 { + level := claude.EffortLevelForModel(out.Model, effort) + switch { + case noReasoning: + level = levels[0] + case level == "": + level = effort + } + out.OutputConfig = &AnthropicOutputConfig{Effort: level} + } + switch { + case claude.EffortUsesAdaptiveThinking(out.Model): + out.Thinking = &AnthropicThinking{Type: "adaptive"} + case forcesToolUse(out.ToolChoice): + // Budget thinking rejects tool_choice any and tool. + case effort == "medium" || effort == "high" || effort == "xhigh" || effort == "max": + enableBudgetThinking(out, defaultThinkingBudget(effort), clientMaxTokens) + } + if out.Thinking != nil { + if out.Temperature != nil && *out.Temperature != 1 { + out.Temperature = nil + } + if out.TopP != nil && *out.TopP < 0.95 { + out.TopP = nil + } + } +} + +// forcesToolUse reports whether a converted tool_choice forces a tool call. +func forcesToolUse(toolChoice json.RawMessage) bool { + var choice struct { + Type string `json:"type"` + } + return json.Unmarshal(toolChoice, &choice) == nil && (choice.Type == "any" || choice.Type == "tool") +} + +const ( + // defaultMaxTokens is the max_tokens of a request whose client sets no + // max_output_tokens. + defaultMaxTokens = 8192 + // budgetThinkingMaxTokens caps a default max_tokens grown for budget + // thinking at the smallest output cap among the Claude models that think + // on a budget: Opus 4 and Opus 4.1 take 32000 output tokens. + budgetThinkingMaxTokens = 32000 + // minThinkingBudget is the smallest budget_tokens the Messages API accepts. + minThinkingBudget = 1024 +) + +// enableBudgetThinking turns on budget thinking, which the Messages API takes +// only with 1024 <= budget_tokens < max_tokens. A client max_tokens (OpenAI's +// max_output_tokens) already caps reasoning and answer together, so it is kept +// and the budget shrinks below it; thinking stays off when not even the +// minimum budget fits. The default max_tokens instead grows by the budget, +// which keeps the default answer allowance next to the thinking; the budget +// shrinks where the grown default would pass budgetThinkingMaxTokens. +func enableBudgetThinking(out *AnthropicRequest, budget int, clientMaxTokens bool) { + if clientMaxTokens { + budget = min(budget, out.MaxTokens-1) + if budget < minThinkingBudget { + return + } + } else { + budget = min(budget, budgetThinkingMaxTokens-out.MaxTokens) + out.MaxTokens += budget + } + out.Thinking = &AnthropicThinking{Type: "enabled", BudgetTokens: budget} } -// defaultThinkingBudget returns a sensible thinking budget based on effort level. +// defaultThinkingBudget returns the budget_tokens of enabled thinking at an +// effort; high also covers the values third-party models pass through. func defaultThinkingBudget(effort string) int { switch effort { - case "low": - return 1024 case "medium": return 4096 - case "high": - return 10240 - case "max": + case "xhigh", "max": return 32768 default: return 10240 } } -// mapResponsesEffortToAnthropic converts OpenAI Responses reasoning effort to -// Anthropic effort levels. Reverse of mapAnthropicEffortToResponses. +// mapResponsesEffortToAnthropic converts an OpenAI Responses reasoning effort +// for third-party models behind Anthropic-compatible endpoints, which have no +// effort catalog. Reverse of mapAnthropicEffortToResponses. // // low → low // medium → medium diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index 9cad2571b1b5..c8b13539a79f 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -1,6 +1,7 @@ package claude import ( + "slices" "strings" "unicode" ) @@ -54,6 +55,24 @@ func EffortLevelsForModel(model string) []string { return nil } +// EffortLevelForModel returns the output_config.effort level a Claude model +// accepts for effort: effort itself when the model lists it, otherwise the +// nearest deeper level, otherwise the model's deepest level. It returns "" +// when the model has no effort levels or effort is not an effort level. +func EffortLevelForModel(model, effort string) string { + entry := findEffortFamily(model) + rank := slices.Index(effortLowMediumHighXHighMax, effort) + if entry == nil || rank < 0 { + return "" + } + for _, level := range entry.levels { + if slices.Index(effortLowMediumHighXHighMax, level) >= rank { + return level + } + } + return entry.levels[len(entry.levels)-1] +} + // EffortUsesAdaptiveThinking reports whether a Claude model pairs // output_config.effort with thinking {type: "adaptive"}. func EffortUsesAdaptiveThinking(model string) bool { @@ -61,6 +80,12 @@ func EffortUsesAdaptiveThinking(model string) bool { return entry != nil && entry.adaptive } +// IsClaudeModel reports whether model is a Claude model ID after +// provider/local suffix normalization, including models without effort levels. +func IsClaudeModel(model string) bool { + return strings.HasPrefix(normalizeEffortModelID(model), "claude-") +} + // DefaultEffortForModel returns the effort a Claude model applies when // output_config.effort is omitted, or "" when the model has no effort levels. func DefaultEffortForModel(model string) string { @@ -97,13 +122,17 @@ func normalizeEffortModelID(model string) string { } id = strings.TrimPrefix(id, "anthropic.") id = strings.TrimSuffix(id, "-thinking") - // OpenRouter uses dotted minor versions for some models. Normalize them - // before effort, thinking, and billing family lookups. - if id == "claude-opus-5.5" { - id = "claude-opus-5-5" - } - if id == "claude-sonnet-5.5" { - id = "claude-sonnet-5-5" + // OpenRouter spells minor versions with a dot (claude-opus-4.7) where + // Anthropic IDs use a hyphen. Normalize them before effort, thinking, and + // billing family lookups. + if strings.Contains(id, ".") { + b := []byte(id) + for i := 1; i+1 < len(b); i++ { + if b[i] == '.' && '0' <= b[i-1] && b[i-1] <= '9' && '0' <= b[i+1] && b[i+1] <= '9' { + b[i] = '-' + } + } + id = string(b) } if mapped, ok := ModelIDReverseOverrides[id]; ok { id = mapped diff --git a/backend/internal/service/gateway_compat_reasoning_pricing_test.go b/backend/internal/service/gateway_compat_reasoning_pricing_test.go index d06c623d06ad..df9349ddcf19 100644 --- a/backend/internal/service/gateway_compat_reasoning_pricing_test.go +++ b/backend/internal/service/gateway_compat_reasoning_pricing_test.go @@ -41,7 +41,7 @@ func TestGatewayAnthropicCompatReasoningPricingUsesForwardedEffort(t *testing.T) wantEffort string multiplier float64 }{ - {"xhigh_converts_to_max", "claude-fable-5-1", "xhigh", "", "max", 3}, + {"xhigh_converts_to_max", "claude-opus-4-6", "xhigh", "", "max", 3}, {"native_max", "claude-fable-5-1", "max", "", "max", 3}, {"missing_effort", "kimi-k3", "", "", "", 1}, {"disabled_thinking", "kimi-k3", "", "disabled", "", 1}, diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index b1e1cbba6336..5fb09ac3a1d6 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -131,7 +131,7 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("build upstream request: %w", err) } // Bill the final Anthropic effort after conversion and account normalization. - // For example, OpenAI xhigh is forwarded as output_config.effort=max. + // For example, OpenAI xhigh is forwarded to Opus 4.6 as output_config.effort=max. reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, mappedModel) diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 507730d38ad1..96de9cdda764 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -142,7 +142,7 @@ func (s *GatewayService) ForwardAsResponses( return nil, fmt.Errorf("build upstream request: %w", err) } // Bill the final Anthropic effort after conversion and account normalization. - // For example, OpenAI xhigh is forwarded as output_config.effort=max. + // For example, OpenAI xhigh is forwarded to Opus 4.6 as output_config.effort=max. reasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(forwardedBody, "output_config.effort").String()) reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, mappedModel)