diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index e8154f302c..1cf5728e0c 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -1290,3 +1290,35 @@ func RectifyThinkingBudget(body []byte) ([]byte, bool) { return modified, changed } + +// NormalizeChineseLLMThinking rewrites the top-level `thinking` object for Chinese +// LLM providers that use Anthropic-compatible endpoints but have different accepted +// values for `thinking.type`. Currently scoped to: +// - MiniMax M3 / M3.x (`MiniMax-m*`): official docs accept only `thinking.type` +// of "adaptive" or "disabled"; "enabled" is not a valid value and may be +// rejected/ignored. Pi-ai and other Anthropic-SDK clients default to "enabled" +// (Anthropic-original) and never auto-rewrite for non-Anthropic models. +// +// Non-MiniMax models (Kimi/GLM/DeepSeek) currently accept "enabled" as-is, so this +// function is intentionally a no-op for them. New Chinese LLM quirks should be +// added here as separate case branches. +// +// Returns (modified body, true) if a rewrite was applied, or (original body, false) +// if no rewrite was needed. Caller should be on the Anthropic forward path AFTER +// FilterThinkingBlocks and BEFORE building the upstream request, only for +// passback-required models (ResolveThinkingProtocol == PassbackRequired). +func NormalizeChineseLLMThinking(body []byte, mappedModel string) ([]byte, bool) { + modelLower := strings.ToLower(mappedModel) + if !strings.HasPrefix(modelLower, "minimax-m") { + return body, false + } + thinkingType := gjson.GetBytes(body, "thinking.type").String() + if thinkingType != "enabled" { + return body, false + } + modified, err := sjson.SetBytes(body, "thinking.type", "adaptive") + if err != nil { + return body, false + } + return modified, true +} diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 07b5b37ff1..c03f3c3e60 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -1237,3 +1237,110 @@ func BenchmarkParseGatewayRequest_New_Large(b *testing.B) { _, _ = ParseGatewayRequest(NewRequestBodyRef(data), "") } } + +func TestNormalizeChineseLLMThinking(t *testing.T) { + tests := []struct { + name string + model string + input string + wantApplied bool + wantTypeValue string // expected thinking.type after rewrite; "" = must not exist + wantUnchanged bool // body must be byte-for-byte unchanged + }{ + // MiniMax M3 / M2.x — passback-required path: rewrite enabled -> adaptive + { + name: "minimax m3 enabled -> adaptive", + model: "MiniMax-M3", + input: `{"model":"MiniMax-M3","thinking":{"type":"enabled","budget_tokens":8192},"messages":[]}`, + wantApplied: true, + wantTypeValue: "adaptive", + }, + { + name: "minimax m2.7 enabled -> adaptive", + model: "MiniMax-M2.7", + input: `{"model":"MiniMax-M2.7","thinking":{"type":"enabled","budget_tokens":4096},"messages":[]}`, + wantApplied: true, + wantTypeValue: "adaptive", + }, + { + name: "minimax m3 adaptive is left alone", + model: "MiniMax-M3", + input: `{"model":"MiniMax-M3","thinking":{"type":"adaptive","budget_tokens":8192},"messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + { + name: "minimax m3 disabled is left alone", + model: "MiniMax-M3", + input: `{"model":"MiniMax-M3","thinking":{"type":"disabled"},"messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + { + name: "minimax m3 with no thinking field is no-op", + model: "MiniMax-M3", + input: `{"model":"MiniMax-M3","messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + // Non-MiniMax Chinese LLMs: no-op (Kimi/GLM/DeepSeek accept enabled as-is) + { + name: "kimi k2.6 with enabled left alone", + model: "kimi-k2.6", + input: `{"model":"kimi-k2.6","thinking":{"type":"enabled","budget_tokens":8192},"messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + { + name: "glm-5.1 with enabled left alone", + model: "glm-5.1", + input: `{"model":"glm-5.1","thinking":{"type":"enabled"},"messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + { + name: "deepseek v4-pro with enabled left alone", + model: "deepseek-v4-pro", + input: `{"model":"deepseek-v4-pro","thinking":{"type":"enabled"},"messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + // Anthropic-strict model: never rewritten even though prefix would not match anyway + { + name: "claude opus 4.6 with enabled left alone", + model: "claude-opus-4.6-20260201", + input: `{"model":"claude-opus-4.6-20260201","thinking":{"type":"enabled","budget_tokens":8192},"messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, + // Edge case: invalid JSON — fail-safe return original + { + name: "invalid json returned unchanged", + model: "MiniMax-M3", + input: `{not json`, + wantApplied: false, + wantUnchanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, applied := NormalizeChineseLLMThinking([]byte(tt.input), tt.model) + require.Equal(t, tt.wantApplied, applied, "applied mismatch") + + if tt.wantUnchanged { + require.Equal(t, tt.input, string(got), "body must be byte-for-byte unchanged") + return + } + + // Parsed-back validation: output must be valid JSON with the expected thinking.type + var parsed struct { + Thinking struct { + Type string `json:"type"` + } `json:"thinking"` + } + require.NoError(t, json.Unmarshal(got, &parsed), "output must be valid JSON") + require.Equal(t, tt.wantTypeValue, parsed.Thinking.Type) + }) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5a4af000da..dd63bcbbbd 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4970,6 +4970,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if err := replaceBody(FilterThinkingBlocks(body, reqModel)); err != nil { return nil, err } + // Chinese LLM thinking.type 协议差异补正(如 MiniMax 只接受 adaptive;Anthropic-SDK + // 客户端默认发 enabled)。仅对 passback-required 上游生效(claude-* 不会进来)。 + if ResolveThinkingProtocol(reqModel) == ThinkingProtocolPassbackRequired { + if rewritten, applied := NormalizeChineseLLMThinking(body, reqModel); applied { + if err := replaceBody(rewritten); err != nil { + return nil, err + } + logger.LegacyPrintf("service.gateway", "Account %d: rewrote thinking.type for %s (Anthropic-SDK default 'enabled' -> vendor-specific)", account.ID, reqModel) + } + } // 重试循环 var resp *http.Response