From 2fc91663d25ae31e2d0f06a979ab4c0cd0c15381 Mon Sep 17 00:00:00 2001 From: touhouqing Date: Wed, 23 Sep 2026 16:48:15 +0800 Subject: [PATCH] fix(apicompat): honor disabled thinking in OpenAI bridges --- .../pkg/apicompat/anthropic_responses_test.go | 19 +++++++- .../pkg/apicompat/anthropic_to_responses.go | 43 ++++++++++--------- .../chatcompletions_anthropic_bridge.go | 9 +--- .../chatcompletions_anthropic_bridge_test.go | 14 ++++++ .../internal/service/openai_compat_model.go | 3 ++ .../service/openai_compat_model_test.go | 25 +++++++++++ ...nai_gateway_messages_chat_fallback_test.go | 7 +++ 7 files changed, 90 insertions(+), 30 deletions(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index d823393cb2..00ca2daadd 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1136,9 +1136,24 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) - // Default effort applies (medium) even when thinking is disabled. require.NotNil(t, resp.Reasoning) - assert.Equal(t, "medium", resp.Reasoning.Effort) + assert.Equal(t, "none", resp.Reasoning.Effort) + assert.Empty(t, resp.Reasoning.Summary) +} + +func TestAnthropicToResponses_ThinkingDisabledOverridesOutputEffort(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.6-sol", + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Thinking: &AnthropicThinking{Type: "disabled"}, + OutputConfig: &AnthropicOutputConfig{Effort: "max"}, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Reasoning) + assert.Equal(t, "none", resp.Reasoning.Effort) } func TestAnthropicToResponses_NoThinking(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index 23acc0bea8..695e9bd7ec 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -57,17 +57,13 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { out.Tools = convertAnthropicToolsToResponses(req.Tools) } - // Determine reasoning effort: only output_config.effort controls the - // level; thinking.type is ignored. Default follows Codex CLI / airgate's - // Anthropic bridge shape, which uses medium when unset. - // Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh. - effort := "medium" - if req.OutputConfig != nil && req.OutputConfig.Effort != "" { - effort = req.OutputConfig.Effort - } + // An explicit thinking disable takes precedence over output_config.effort. + effort := anthropicReasoningEffort(req) out.Reasoning = &ResponsesReasoning{ - Effort: mapAnthropicEffortToResponses(effort), - Summary: "auto", + Effort: effort, + } + if effort != "none" { + out.Reasoning.Summary = "auto" } // Convert tool_choice @@ -423,17 +419,22 @@ func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string { return strings.Join(parts, "\n\n") } -// mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to -// OpenAI Responses API effort levels. -// -// Both APIs default to "high". The mapping is 1:1 for shared levels; -// only Anthropic's "max" (Opus 4.6 exclusive) maps to OpenAI's "xhigh" -// (GPT-5.2+ exclusive) as both represent the highest reasoning tier. -// -// low → low -// medium → medium -// high → high -// max → xhigh +// anthropicReasoningEffort resolves the Anthropic request preference for both +// OpenAI bridges. Explicitly disabled thinking overrides output_config.effort; +// otherwise the bridge keeps its medium default. +func anthropicReasoningEffort(req *AnthropicRequest) string { + if req.Thinking != nil && req.Thinking.Type == "disabled" { + return "none" + } + effort := "medium" + if req.OutputConfig != nil && req.OutputConfig.Effort != "" { + effort = req.OutputConfig.Effort + } + return mapAnthropicEffortToResponses(effort) +} + +// mapAnthropicEffortToResponses maps shared effort levels directly and maps +// Anthropic's max to OpenAI's xhigh. func mapAnthropicEffortToResponses(effort string) string { if effort == "max" { return "xhigh" diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index 47d4601c24..fc73afa3a4 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -99,13 +99,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR } } - // Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type - // itself is ignored (the Responses bridge behaves identically). - effort := "medium" - if req.OutputConfig != nil && req.OutputConfig.Effort != "" { - effort = req.OutputConfig.Effort - } - out.ReasoningEffort = mapAnthropicEffortToResponses(effort) + // Match the Responses bridge, including an explicit thinking disable. + out.ReasoningEffort = anthropicReasoningEffort(req) parallelToolCalls := true out.ParallelToolCalls = ¶llelToolCalls diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 8ebd5092fa..b278c28a83 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -150,6 +150,20 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) { require.Empty(t, out.Messages[0].ReasoningContent) } +func TestAnthropicToChatCompletionsRequest_ThinkingDisabledOverridesOutputEffort(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.6-luna", + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Thinking: &AnthropicThinking{Type: "disabled"}, + OutputConfig: &AnthropicOutputConfig{Effort: "max"}, + } + + out, err := AnthropicToChatCompletionsRequest(req) + require.NoError(t, err) + require.Equal(t, "none", out.ReasoningEffort) +} + func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", diff --git a/backend/internal/service/openai_compat_model.go b/backend/internal/service/openai_compat_model.go index 6a237b6b81..8a0a6bbdd6 100644 --- a/backend/internal/service/openai_compat_model.go +++ b/backend/internal/service/openai_compat_model.go @@ -107,6 +107,9 @@ func openAIReasoningEffortToClaudeOutputEffort(effort string) string { // normally translated to OpenAI xhigh, but GPT-5.6 accepts the original max // value on Responses and Chat Completions. func openAICompatAnthropicReasoningEffort(req *apicompat.AnthropicRequest, upstreamModel, convertedEffort string) string { + if convertedEffort == "none" { + return convertedEffort + } if req == nil || req.OutputConfig == nil || !strings.EqualFold(strings.TrimSpace(req.OutputConfig.Effort), "max") { return convertedEffort } diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 945615bf57..7bd000b4b8 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -313,6 +313,31 @@ func TestForwardAsAnthropic_PreservesMaxForFinalGPT56ResponsesModel(t *testing.T } } +func TestForwardAsAnthropic_DisabledThinkingOverridesMaxForResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"sol","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"thinking":{"type":"disabled"},"output_config":{"effort":"max"},"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_no_thinking", "gpt-5.6-sol")} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := rawGPT56ResponsesOAuthAccount("sol", "gpt-5.6-sol") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "none", *result.ReasoningEffort) +} + func rawGPT56ResponsesAPIKeyAccount(requestedModel, mappedModel string) *Account { return &Account{ ID: 501, diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go index 60594026ab..68a20852f8 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go @@ -91,6 +91,13 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo mapped: "gpt-5.6-luna", wantEffort: "medium", }, + { + name: "disabled thinking overrides max", + model: "gpt-5.6-luna", + mapped: "gpt-5.6-luna", + effortJSON: `,"output_config":{"effort":"max"},"thinking":{"type":"disabled"}`, + wantEffort: "none", + }, } for _, tt := range tests {