From 89cfe24a0c29d9be2f48cfc944cea4060e26abb7 Mon Sep 17 00:00:00 2001 From: alfadb Date: Thu, 18 Jun 2026 19:07:08 +0800 Subject: [PATCH] fix(openai): normalize glm reasoning effort --- backend/internal/service/gateway_request.go | 47 ++++++++++ .../internal/service/gateway_request_test.go | 93 +++++++++++++++++++ .../openai_gateway_chat_completions_raw.go | 3 + ...penai_gateway_chat_completions_raw_test.go | 27 ++++++ 4 files changed, 170 insertions(+) diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 170a630ea4..a90714ca1d 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -1269,6 +1269,53 @@ func ApplyThinkingEnabledFallback(effort *string, body []byte, mappedModel strin return DefaultEffortForThinkingEnabled(mappedModel) } +// NormalizeGLMOpenAIReasoningEffort rewrites OpenAI Chat Completions +// reasoning_effort values to the GLM native scale used by z.ai: high/max. +// It only applies to glm-* mapped models and leaves all other providers untouched. +func NormalizeGLMOpenAIReasoningEffort(body []byte, mappedModel string) ([]byte, bool) { + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(mappedModel)), "glm-") { + return body, false + } + + path := "reasoning.effort" + raw := strings.TrimSpace(gjson.GetBytes(body, path).String()) + if raw == "" { + path = "reasoning_effort" + raw = strings.TrimSpace(gjson.GetBytes(body, path).String()) + } + if raw == "" { + return body, false + } + + mapped := normalizeGLMOpenAIReasoningEffort(raw) + if mapped == "" || mapped == raw { + return body, false + } + + modified, err := sjson.SetBytes(body, path, mapped) + if err != nil { + return body, false + } + return modified, true +} + +func normalizeGLMOpenAIReasoningEffort(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return "" + } + value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) + + switch value { + case "low", "medium", "high": + return "high" + case "xhigh", "extrahigh", "max", "ultracode": + return "max" + default: + return "" + } +} + // ========================= // Thinking Budget Rectifier // ========================= diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 4f9075a99f..3172b4d086 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -1512,3 +1512,96 @@ func TestApplyThinkingEnabledFallback(t *testing.T) { }) } } + +func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) { + tests := []struct { + name string + model string + input string + wantApplied bool + wantPath string + wantValue string + wantUnchanged bool + }{ + { + name: "flat xhigh maps to max", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"xhigh","messages":[]}`, + wantApplied: true, + wantPath: "reasoning_effort", + wantValue: "max", + }, + { + name: "flat x-high maps to max", + model: "GLM-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"x-high","messages":[]}`, + wantApplied: true, + wantPath: "reasoning_effort", + wantValue: "max", + }, + { + name: "flat ultracode maps to max", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"ultracode","messages":[]}`, + wantApplied: true, + wantPath: "reasoning_effort", + wantValue: "max", + }, + { + name: "flat medium maps to high", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"medium","messages":[]}`, + wantApplied: true, + wantPath: "reasoning_effort", + wantValue: "high", + }, + { + name: "nested high case-normalizes", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning":{"effort":"HIGH"},"messages":[]}`, + wantApplied: true, + wantPath: "reasoning.effort", + wantValue: "high", + }, + { + name: "native max unchanged", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"max","messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + { + name: "non glm unchanged", + model: "deepseek-v4-pro", + input: `{"model":"deepseek-v4-pro","reasoning_effort":"xhigh","messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + { + name: "missing effort unchanged", + model: "glm-5.2", + input: `{"model":"glm-5.2","messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + { + name: "unknown effort unchanged", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"banana","messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, applied := NormalizeGLMOpenAIReasoningEffort([]byte(tt.input), tt.model) + require.Equal(t, tt.wantApplied, applied) + if tt.wantUnchanged { + require.Equal(t, tt.input, string(got)) + return + } + require.Equal(t, tt.wantValue, gjson.GetBytes(got, tt.wantPath).String()) + }) + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index a7522144c8..d03c7b6636 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -89,6 +89,9 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if upstreamModel != originalModel { upstreamBody = ReplaceModelInBody(body, upstreamModel) } + if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(upstreamBody, upstreamModel); normalized { + upstreamBody = normalizedBody + } // 4. Apply OpenAI fast policy on the CC body updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, upstreamBody) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index eeb814f16c..5a6ee3f9b9 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -224,6 +224,33 @@ func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentInRequest( require.Equal(t, "get_weather", gjson.GetBytes(upstream.lastBody, "messages.1.tool_calls.0.function.name").String()) } +func TestForwardAsRawChatCompletions_NormalizesGLMReasoningEffortForUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"glm-5.2","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh","stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_glm_effort"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"chatcmpl_glm","object":"chat.completion","model":"glm-5.2","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) +} + func TestForwardAsRawChatCompletions_SilentRefusalTriggersFailover(t *testing.T) { gin.SetMode(gin.TestMode)