Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions backend/internal/pkg/apicompat/anthropic_responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"} {
Expand Down
134 changes: 114 additions & 20 deletions backend/internal/pkg/apicompat/responses_to_anthropic_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 36 additions & 7 deletions backend/internal/pkg/claude/effort_catalog.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package claude

import (
"slices"
"strings"
"unicode"
)
Expand Down Expand Up @@ -54,13 +55,37 @@ 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 {
entry := findEffortFamily(model)
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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/gateway_forward_as_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading