Merge pull request #6507 from wucm667/fix/issue-6501-glm53-thinking-effort

fix(gateway): normalize GLM-5.3 thinking effort
This commit is contained in:
Wesley Liddick
2026-09-05 16:04:16 +08:00
committed by GitHub
4 changed files with 143 additions and 2 deletions
+49 -2
View File
@@ -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
// =========================
@@ -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",
@@ -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
@@ -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))
})
}
}