mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user