From f5ae32e53f6d1b42f4da6c78ff06fbd3d870f156 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 2 Sep 2026 17:00:13 +0800 Subject: [PATCH] fix(gateway): normalize GLM-5.3 thinking effort --- backend/internal/service/gateway_request.go | 51 ++++++++++++- .../internal/service/gateway_request_test.go | 15 ++++ ...penai_gateway_messages_anthropic_native.go | 3 + ..._gateway_messages_anthropic_native_test.go | 76 +++++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index f5e4a4d500..9a3ca9fb88 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -1388,6 +1388,9 @@ func NormalizeGLMOpenAIReasoningEffort(body []byte, mappedModel string) ([]byte, } mapped := normalizeGLMOpenAIReasoningEffort(raw) + if isGLM53Model(mappedModel) && mapped == "high" && normalizeEffortToken(raw) == "low" { + mapped = "low" + } if mapped == "" || mapped == raw { return body, false } @@ -1399,12 +1402,20 @@ func NormalizeGLMOpenAIReasoningEffort(body []byte, mappedModel string) ([]byte, return modified, true } -func normalizeGLMOpenAIReasoningEffort(raw string) string { +func normalizeEffortToken(raw string) string { value := strings.ToLower(strings.TrimSpace(raw)) + return strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) +} + +func isGLM53Model(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), "glm-5.3") +} + +func normalizeGLMOpenAIReasoningEffort(raw string) string { + value := normalizeEffortToken(raw) if value == "" { return "" } - value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) switch value { case "low", "medium", "high": @@ -1416,6 +1427,42 @@ func normalizeGLMOpenAIReasoningEffort(raw string) string { } } +// NormalizeGLM53AnthropicThinking maps explicit client thinking effort onto the +// GLM-5.3 Anthropic-compatible scale. Requests without an effort or thinking +// preference are left unchanged so the upstream default remains in effect. +func NormalizeGLM53AnthropicThinking(body []byte, mappedModel string) ([]byte, bool) { + if !isGLM53Model(mappedModel) { + return body, false + } + + raw := gjson.GetBytes(body, "output_config.effort").String() + if strings.TrimSpace(raw) == "" { + raw = gjson.GetBytes(body, "thinking.type").String() + } + + var effort string + switch normalizeEffortToken(raw) { + case "disabled", "off", "none", "minimal", "low": + effort = "low" + case "enabled", "adaptive", "medium", "high": + effort = "high" + case "xhigh", "max", "ultra": + effort = "max" + default: + return body, false + } + + modified, err := sjson.SetBytes(body, "thinking.type", "enabled") + if err != nil { + return body, false + } + modified, err = sjson.SetBytes(modified, "output_config.effort", effort) + if err != nil { + return body, false + } + return modified, true +} + // ========================= // Thinking Budget Rectifier // ========================= diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 6074c02991..27b71c31fe 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -1605,6 +1605,14 @@ func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) { wantPath: "reasoning_effort", wantValue: "high", }, + { + name: "glm 5.2 low maps to high", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"low","messages":[]}`, + wantApplied: true, + wantPath: "reasoning_effort", + wantValue: "high", + }, { name: "nested high case-normalizes", model: "glm-5.2", @@ -1613,6 +1621,13 @@ func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) { wantPath: "reasoning.effort", wantValue: "high", }, + { + name: "glm 5.3 native low unchanged", + model: "glm-5.3", + input: `{"model":"glm-5.3","reasoning_effort":"low","messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, { name: "native max unchanged", model: "glm-5.2", diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index f7addc90b0..fbed8c34a7 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -57,6 +57,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( } body = rewritten } + if normalized, changed := NormalizeGLM53AnthropicThinking(body, upstreamModel); changed { + body = normalized + } // 记录客户端请求的推理强度:优先 Claude 协议的 output_config.effort; // 缺失且 thinking 已启用时,按国产 passback-required 模型兜底为 high diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go index 2f68982094..bea8ab1cef 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "fmt" "io" "net/http" "strings" @@ -11,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // 国产供应商原生 Anthropic 直通路径(api_protocol=anthropic)的 reasoning_effort 记录。 @@ -35,6 +37,13 @@ func nativeAnthropicTestAccount() *Account { } } +func nativeAnthropicGLMTestAccount() *Account { + account := nativeAnthropicTestAccount() + account.Name = "zhipu-native" + account.Platform = PlatformZhipu + return account +} + func nativeAnthropicBufferedResponse() *http.Response { return &http.Response{ StatusCode: http.StatusOK, @@ -137,3 +146,70 @@ func TestNativeAnthropicPassthroughNoEffortStaysNil(t *testing.T) { require.NotNil(t, result) require.Nil(t, result.ReasoningEffort) } + +func TestNativeAnthropicPassthroughNormalizesGLM53Thinking(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + stream bool + preference string + wantEffort string + }{ + {name: "disabled buffered", preference: `"thinking":{"type":"disabled"},`, wantEffort: "low"}, + {name: "off buffered", preference: `"thinking":{"type":"off"},`, wantEffort: "low"}, + {name: "none buffered", preference: `"thinking":{"type":"none"},`, wantEffort: "low"}, + {name: "enabled buffered", preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"}, + {name: "enabled streaming", stream: true, preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"}, + {name: "adaptive buffered", preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"}, + {name: "adaptive streaming", stream: true, preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"}, + {name: "minimal buffered", preference: `"output_config":{"effort":"minimal"},`, wantEffort: "low"}, + {name: "low buffered", preference: `"output_config":{"effort":"low"},`, wantEffort: "low"}, + {name: "medium streaming", stream: true, preference: `"output_config":{"effort":"medium"},`, wantEffort: "high"}, + {name: "high buffered", preference: `"output_config":{"effort":"high"},`, wantEffort: "high"}, + {name: "xhigh buffered", preference: `"output_config":{"effort":"xhigh"},`, wantEffort: "max"}, + {name: "max buffered", preference: `"output_config":{"effort":"max"},`, wantEffort: "max"}, + {name: "ultra buffered", preference: `"output_config":{"effort":"ultra"},`, wantEffort: "max"}, + {name: "output effort wins over thinking", preference: `"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},`, wantEffort: "low"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"glm-5.3","max_tokens":32,"stream":%t,%s"messages":[{"role":"user","content":"hi"}]}`, tt.stream, tt.preference)) + response := nativeAnthropicBufferedResponse() + if tt.stream { + response = nativeAnthropicStreamResponse() + } + upstream := &httpUpstreamRecorder{resp: response} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "") + require.NoError(t, err) + require.Equal(t, "enabled", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) + require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + }) + } +} + +func TestNativeAnthropicPassthroughLeavesOtherThinkingUntouched(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + body string + }{ + {name: "glm 5.3 unspecified", body: `{"model":"glm-5.3","max_tokens":32,"stream":false,"messages":[]}`}, + {name: "glm 5.2 disabled", body: `{"model":"glm-5.2","max_tokens":32,"stream":false,"thinking":{"type":"disabled"},"messages":[]}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(tt.body) + upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "") + require.NoError(t, err) + require.JSONEq(t, tt.body, string(upstream.lastBody)) + }) + } +}