From 56c6325d157f90342d2bcad67b01b2dac72b3a54 Mon Sep 17 00:00:00 2001 From: alfadb Date: Fri, 12 Jun 2026 11:39:40 +0800 Subject: [PATCH] fix(gateway): rewrite thinking.type=enabled to adaptive for MiniMax M-series \u95ee\u9898\uff1aAnthropic-SDK \u5ba2\u6237\u7aef\uff08\u5982 pi-ai\uff09\u5728 budget-based thinking \u8def\u5f84\u4e0a\u9ed8\u8ba4\u53d1 `thinking: { type: 'enabled', budget_tokens: ... }` \u7ed9\u4efb\u4f55 Anthropic \u517c\u5bb9\u4e0a\u6e38\uff0c\u5305\u62ec MiniMax M3 / M2.x \u3002\u4f46 MiniMax \u5b98\u65b9\u6587\u6863\u660e\u786e\u5217\u51fa\uff1athinking.type \u53ea\u63a5\u53d7 "adaptive" \u6216 "disabled"\uff0c\u4e0d\u63a5\u53d7 "enabled"\u3002\u9020\u6210\u4e0a\u6e38\u53ef\u80fd 400 / \u9759\u9ed8\u5ffd\u7565\u3002 \u4fee\u590d\uff1a\u5728 Anthropic forward \u8def\u5f84\u4e0a\u3001StripEmptyTextBlocks \u4e4b\u540e\u3001\u91cd\u8bd5\u5faa\u73af\u4e4b\u524d\uff0c \u52a0\u4e00\u4e2a NormalizeChineseLLMThinking \u9884\u6539\u5199\u6b65\u9aa4\uff1a - \u4ec5\u5bf9 ResolveThinkingProtocol == PassbackRequired \u4e0a\u6e38\u751f\u6548\uff08claude-* \u4e0d\u4f1a\u8fdb\u6765\uff09 - \u4ec5\u5339\u914d minimax-m* \u524d\u7f00\u7684\u6620\u5c04\u540e\u6a21\u578b ID - \u4ec5\u5f53 thinking.type == "enabled" \u65f6\u6539\u5199\u4e3a "adaptive" - Kimi/GLM/DeepSeek \u7684 enabled \u4e0d\u53d7\u5f71\u54cd\uff08\u8fd9\u4e9b\u4e0a\u6e38\u63a5\u53d7 enabled\uff09 \u8bbe\u8ba1\u539f\u5219\uff1a 1. "\u767d\u540d\u5355\u8bed\u4e49"\uff1a\u53ea\u5bf9\u5df2\u77e5\u9700\u8981\u5904\u7406\u7684\u4e0a\u6e38\u6539\u5199\uff0c\u4e0d\u505a\u9690\u5f0f\u5e7f\u8c31\u8865\u4e01\u3002 2. "\u4f4e\u4fb5\u5165\u6027"\uff1aMiniMax \u4e4b\u5916\u7684\u6a21\u578b\u96f6\u6539\u52a8\uff0c\u73b0\u6709 4 \u4e2a\u4e0a\u6e38\u7684\u884c\u4e3a\u4e0d\u53d8\u3002 3. "\u53ef\u8bca\u65ad"\uff1a\u6539\u5199\u65f6 LegacyPrintf \u8f93\u51fa\u4e00\u884c\u65e5\u5fd7\uff0c\u4fbf\u4e8e\u8c03\u8bd5\u3002 \u6d4b\u8bd5\uff1a9 \u4e2a\u65b0\u7528\u4f8b\u8986\u76d6 MiniMax M3/M2.7\u3001adaptive/disabled \u4e0d\u53d8\u3001\u65e0 thinking \u5b57\u6bb5\u3001Kimi/GLM/DeepSeek/Claude \u4e0d\u53d7\u5f71\u54cd\u3001invalid JSON fail-safe\u3002 \u4f9d\u8d56\uff1a\u672c\u5206\u652f\u57fa\u4e8e fix/thinking-block-protocol-aware-filter\uff0c\u7528\u5176 ResolveThinkingProtocol \u4f5c\u4e3a passback-required \u5224\u522b\u6e90\u3002\u4e0a\u6e38 PR \u987a\u5e8f\u9700\u5148\u5408 fix/thinking-block-protocol-aware-filter\uff08PR #2136\uff09\u518d\u5408\u672c PR\u3002 --- backend/internal/service/gateway_request.go | 32 ++++++ .../internal/service/gateway_request_test.go | 107 ++++++++++++++++++ backend/internal/service/gateway_service.go | 10 ++ 3 files changed, 149 insertions(+) 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