diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 59442fcbca..e8154f302c 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -516,13 +516,22 @@ func StripEmptyTextBlocks(body []byte) []byte { // FilterThinkingBlocks removes thinking blocks from request body // Returns filtered body or original body if filtering fails (fail-safe) -// This prevents 400 errors from invalid thinking block signatures +// This prevents 400 errors from invalid thinking block signatures. // -// 策略: +// mappedModel 是「实际发给上游的模型 ID」(after account model mapping),用于按 +// 协议族分流。仅 anthropic-strict 走原过滤逻辑;passback-required 与 unknown +// 一律保留全部 thinking block,避免误伤第三方兼容上游 +// (DeepSeek `/anthropic`、Kimi `/coding`、GLM、Moonshot 等),详见 +// .pensieve/short-term/knowledge/thinking-block-filter-third-party-upstream-inversion/。 +// +// 策略 (anthropic-strict only): // - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块 // - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块(避免 400) // (blocks with missing/empty/dummy signatures that would cause 400 errors) -func FilterThinkingBlocks(body []byte) []byte { +func FilterThinkingBlocks(body []byte, mappedModel string) []byte { + if !ShouldPreFilterThinkingBlocks(mappedModel) { + return body + } return filterThinkingBlocksInternal(body, false) } @@ -540,7 +549,17 @@ func FilterThinkingBlocks(body []byte) []byte { // - Convert `thinking` blocks to `text` blocks (preserve the thinking content). // - Remove `redacted_thinking` blocks (cannot be converted to text). // - Ensure no message ends up with empty content. -func FilterThinkingBlocksForRetry(body []byte) []byte { +// +// mappedModel 用于按协议族分流:仅 anthropic-strict 执行上述变形; +// passback-required (DeepSeek/Kimi/GLM 等) 与 unknown 一律返回原 body, +// 因为这类上游的契约就是「thinking block 原样回传」(或我们不了解), +// retry 任何变形都不会修好 400,反而破坏契约。详见 thinking_protocol.go。 +func FilterThinkingBlocksForRetry(body []byte, mappedModel string) []byte { + // 仅 anthropic-strict 走整流;passback-required 与 unknown 都返回原 body。 + if !ShouldApplyRetryFilters(mappedModel) { + return body + } + hasThinkingContent := bytes.Contains(body, patternTypeThinking) || bytes.Contains(body, patternTypeThinkingSpaced) || bytes.Contains(body, patternTypeRedactedThinking) || @@ -883,7 +902,14 @@ func anthropicBetaTokensContains(header, token string) bool { // // Use this only when needed: converting tool blocks to text changes model behaviour and can increase the // risk of prompt injection (tool output becomes plain conversation text). -func FilterSignatureSensitiveBlocksForRetry(body []byte) []byte { +// +// mappedModel 同 FilterThinkingBlocksForRetry:仅 anthropic-strict 执行变形; +// passback-required 与 unknown 都返回原 body,避免在不熟悉的上游上盲目变形。 +func FilterSignatureSensitiveBlocksForRetry(body []byte, mappedModel string) []byte { + if !ShouldApplyRetryFilters(mappedModel) { + return body + } + // Fast path: only run when we see likely relevant constructs. if !bytes.Contains(body, []byte(`"type":"thinking"`)) && !bytes.Contains(body, []byte(`"type": "thinking"`)) && diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 7a1b6ef948..68ab6ceb60 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -277,7 +277,7 @@ func TestFilterThinkingBlocks(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := FilterThinkingBlocks([]byte(tt.input)) + result := FilterThinkingBlocks([]byte(tt.input), "claude-sonnet-4-5") if tt.expectError { // For invalid JSON, should return original @@ -313,7 +313,7 @@ func TestFilterThinkingBlocksForRetry_DisablesThinkingAndPreservesAsText(t *test ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -346,7 +346,7 @@ func TestFilterThinkingBlocksForRetry_DisablesThinkingEvenWithoutThinkingBlocks( ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -365,7 +365,7 @@ func TestFilterThinkingBlocksForRetry_RemovesRedactedThinkingAndKeepsValidConten ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -424,7 +424,7 @@ func TestFilterThinkingBlocksForRetry_EmptyContentGetsPlaceholder(t *testing.T) ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -450,7 +450,7 @@ func TestFilterThinkingBlocksForRetry_StripsEmptyTextBlocks(t *testing.T) { ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -485,7 +485,7 @@ func TestFilterThinkingBlocksForRetry_StripsNestedEmptyTextInToolResult(t *testi ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -513,7 +513,7 @@ func TestFilterThinkingBlocksForRetry_NestedAllEmptyGetsEmptySlice(t *testing.T) ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -596,7 +596,7 @@ func TestFilterThinkingBlocksForRetry_PreservesNonEmptyTextBlocks(t *testing.T) ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") // Fast path: no thinking content, no empty content, no empty text blocks → unchanged require.Equal(t, input, out) @@ -613,7 +613,7 @@ func TestFilterSignatureSensitiveBlocksForRetry_DowngradesTools(t *testing.T) { ] }`) - out := FilterSignatureSensitiveBlocksForRetry(input) + out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -700,7 +700,7 @@ func TestFilterThinkingBlocksForRetry_RemovesClearThinkingStrategy_FastPath(t *t ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -726,7 +726,7 @@ func TestFilterThinkingBlocksForRetry_RemovesClearThinkingStrategy_WithThinkingB ] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -750,7 +750,7 @@ func TestFilterThinkingBlocksForRetry_NoContextManagement_Unaffected(t *testing. "messages":[{"role":"user","content":[{"type":"text","text":"Hi"}]}] }`) - out := FilterThinkingBlocksForRetry(input) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -773,7 +773,7 @@ func TestFilterSignatureSensitiveBlocksForRetry_RemovesClearThinkingStrategy(t * ] }`) - out := FilterSignatureSensitiveBlocksForRetry(input) + out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -804,7 +804,7 @@ func TestFilterSignatureSensitiveBlocksForRetry_PreservesNonThinkingStrategies(t ] }`) - out := FilterSignatureSensitiveBlocksForRetry(input) + out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) @@ -830,7 +830,7 @@ func TestFilterSignatureSensitiveBlocksForRetry_NoThinkingField_ContextManagemen ] }`) - out := FilterSignatureSensitiveBlocksForRetry(input) + out := FilterSignatureSensitiveBlocksForRetry(input, "claude-sonnet-4-5") var req map[string]any require.NoError(t, json.Unmarshal(out, &req)) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5e333ae7e4..5a4af000da 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4957,6 +4957,19 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if err := replaceBody(StripEmptyTextBlocks(body)); err != nil { return nil, err } + // Pre-filter: remove thinking blocks with missing/invalid signatures before forwarding. + // Clients (e.g. Claude Code) sometimes send multi-turn conversations where a historical + // assistant message contains a thinking block that is missing the required "signature" field, + // causing upstream to reject the request with 400 "thinking.signature: Field required". + // FilterThinkingBlocks removes only the invalid blocks; thinking blocks with valid signatures + // are preserved. This avoids relying solely on the post-error retry path, which can time out + // (maxRetryElapsed = 10s) for long conversations before the retry budget is exhausted. + // + // 仅 anthropic-strict 模型族执行此过滤;passback-required 上游 (DeepSeek/Kimi/GLM 等) + // 要求历史 thinking block 原样回传,过滤反而制造 400。reqModel 此时已是映射后的模型 ID。 + if err := replaceBody(FilterThinkingBlocks(body, reqModel)); err != nil { + return nil, err + } // 重试循环 var resp *http.Response @@ -5007,7 +5020,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if readErr == nil { _ = resp.Body.Close() - if s.shouldRectifySignatureError(ctx, account, respBody) { + if s.shouldRectifySignatureError(ctx, account, respBody, reqModel) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, AccountID: account.ID, @@ -5047,7 +5060,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 2) Only if upstream still errors AND error message points to tool/function signature issues: // also downgrade tool_use/tool_result blocks to text. - filteredBody := FilterThinkingBlocksForRetry(body) + filteredBody := FilterThinkingBlocksForRetry(body, reqModel) retryCtx, releaseRetryCtx := detachStreamUpstreamContext(ctx, reqStream) retryReq, retryWireBody, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) releaseRetryCtx() @@ -5088,7 +5101,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A msg2 := extractUpstreamErrorMessage(retryRespBody) if looksLikeToolSignatureError(msg2) && time.Since(retryStart) < maxRetryElapsed { logger.LegacyPrintf("service.gateway", "Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID) - filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body) + filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body, reqModel) retryCtx2, releaseRetryCtx2 := detachStreamUpstreamContext(ctx, reqStream) retryReq2, retryWireBody2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) releaseRetryCtx2() @@ -7379,7 +7392,14 @@ func truncateForLog(b []byte, maxBytes int) string { // shouldRectifySignatureError 统一判断是否应触发签名整流(strip thinking blocks 并重试)。 // 根据账号类型检查对应的开关和匹配模式。 -func (s *GatewayService) shouldRectifySignatureError(ctx context.Context, account *Account, respBody []byte) bool { +// +// mappedModel 用于按 thinking 协议族分流:passback-required (DeepSeek/Kimi/GLM 等) 上游 +// 的 400 不是签名缺失问题,retry 任何 thinking 变形都会破坏「原样回传」契约——直接透传 +// 错误给客户端。详见 thinking_protocol.go。 +func (s *GatewayService) shouldRectifySignatureError(ctx context.Context, account *Account, respBody []byte, mappedModel string) bool { + if !ShouldRectifyThinkingSignatureError(mappedModel) { + return false + } if account.Type == AccountTypeAPIKey { // API Key 账号:独立开关,一次读取配置 settings, err := s.settingService.GetRectifierSettings(ctx) @@ -9814,10 +9834,10 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } // 检测 thinking block 签名错误(400)并重试一次(过滤 thinking blocks) - if resp.StatusCode == 400 && s.shouldRectifySignatureError(ctx, account, respBody) { + if resp.StatusCode == 400 && s.shouldRectifySignatureError(ctx, account, respBody, reqModel) { logger.LegacyPrintf("service.gateway", "Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID) - filteredBody := FilterThinkingBlocksForRetry(body) + filteredBody := FilterThinkingBlocksForRetry(body, reqModel) retryReq, retryWireBody, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode) if buildErr == nil { retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 375fb05326..f041292e2f 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -835,12 +835,12 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex switch signatureRetryStage { case 0: // Stage 1: disable thinking + thinking->text - strippedClaudeBody = FilterThinkingBlocksForRetry(originalClaudeBody) + strippedClaudeBody = FilterThinkingBlocksForRetry(originalClaudeBody, originalModel) stageName = "thinking-only" signatureRetryStage = 1 default: // Stage 2: additionally downgrade tool_use/tool_result blocks to text - strippedClaudeBody = FilterSignatureSensitiveBlocksForRetry(originalClaudeBody) + strippedClaudeBody = FilterSignatureSensitiveBlocksForRetry(originalClaudeBody, originalModel) stageName = "thinking+tools" signatureRetryStage = 2 } diff --git a/backend/internal/service/thinking_protocol.go b/backend/internal/service/thinking_protocol.go new file mode 100644 index 0000000000..b7c0f2bc01 --- /dev/null +++ b/backend/internal/service/thinking_protocol.go @@ -0,0 +1,106 @@ +package service + +import "strings" + +// ThinkingProtocol 描述上游对 thinking block 的处理契约。 +// 不同上游对历史 thinking block 的语义要求是相反的: +// - Anthropic 官方:要求 thinking block 携带有效 signature,否则 400 +// "thinking.signature: Field required" +// - DeepSeek `/anthropic`、Kimi `/coding` 等第三方 Anthropic 兼容上游: +// 要求历史 thinking block 原样回传,否则 400 +// "The content[].thinking in the thinking mode must be passed back to the API" +// +// 见 .pensieve/short-term/knowledge/thinking-block-filter-third-party-upstream-inversion/ +type ThinkingProtocol int + +const ( + // ThinkingProtocolUnknown 表示无法识别协议族(默认保守不剥离)。 + ThinkingProtocolUnknown ThinkingProtocol = iota + + // ThinkingProtocolAnthropicStrict 表示 Anthropic 官方语义: + // 历史 thinking block 必须携带有效 signature,缺失/非法签名应剥离。 + ThinkingProtocolAnthropicStrict + + // ThinkingProtocolPassbackRequired 表示第三方兼容上游语义: + // 所有历史 thinking block 必须原样回传,预过滤会破坏契约。 + ThinkingProtocolPassbackRequired +) + +// ResolveThinkingProtocol 根据「实际发给上游的模型 ID」推断 thinking 协议族。 +// 必须传入映射后的模型 ID(mappedModel),不要传客户端原始请求模型 ID(reqModel), +// 否则用户配置「claude-sonnet-4-6 → deepseek-v4-pro」时会判错。 +// +// 匹配规则按厂商前缀硬编码: +// - anthropic-strict: claude-* / opus-* / sonnet-* / haiku-* +// - passback-required: deepseek-* / kimi-* / moonshot-* / glm-* / +// minimax-* / minimax-m* / (qwen-|qwen2-|qwen3-|qwen4-)*-thinking +// - unknown: 其他模型(保守不剥离) +// +// 已知局限:前缀贪婪匹配(如 `claudette-`、`claude-foreign-relay-` 也会被分类为 +// strict)。当遇到伪装命名时改成显式名单匹配,但现实场景几乎不会出现。 +// +// 不覆盖的厂商(截至 2026-04): +// - Doubao / Seed (ByteDance):走 Volcano Engine OpenAI 协议,非 Anthropic 路径 +// - Hunyuan T1 (Tencent):未提供 Anthropic 兼容端点 +// - 若未来出现这些厂商的 Anthropic 兼容代理,需扩展前缀列表 +func ResolveThinkingProtocol(modelID string) ThinkingProtocol { + if modelID == "" { + return ThinkingProtocolUnknown + } + id := strings.ToLower(modelID) + + // Passback-required 优先匹配(特定厂商前缀),避免误判 claude-* 时也命中。 + switch { + case strings.HasPrefix(id, "deepseek-"), + strings.HasPrefix(id, "kimi-"), + strings.HasPrefix(id, "moonshot-"), + strings.HasPrefix(id, "glm-"): + return ThinkingProtocolPassbackRequired + } + // MiniMax M 系列:走 https://api.minimax.io/anthropic 端点, + // 官方明文要求 thinking block round-trip(interleaved thinking 协议)。 + // 实例:MiniMax-M2、MiniMax-M2.1、MiniMax-M2.5、MiniMax-M2.7、MiniMax-M2.7-highspeed + // 大小写在 ToLower 后统一为 minimax-。 + if strings.HasPrefix(id, "minimax-m") { + return ThinkingProtocolPassbackRequired + } + // Qwen thinking 变体:覆盖 qwen-/qwen2-/qwen3-/qwen4- 前缀 + 包含 -thinking + // 实例:qwen3-235b-a22b-thinking-2507、qwen3-next-80b-a3b-thinking、qwen-3-72b-thinking + if (strings.HasPrefix(id, "qwen-") || + strings.HasPrefix(id, "qwen2-") || + strings.HasPrefix(id, "qwen3-") || + strings.HasPrefix(id, "qwen4-")) && strings.Contains(id, "-thinking") { + return ThinkingProtocolPassbackRequired + } + + switch { + case strings.HasPrefix(id, "claude-"), + strings.HasPrefix(id, "opus-"), + strings.HasPrefix(id, "sonnet-"), + strings.HasPrefix(id, "haiku-"): + return ThinkingProtocolAnthropicStrict + } + + return ThinkingProtocolUnknown +} + +// ShouldPreFilterThinkingBlocks 判断是否应在转发前剥离无效 thinking block。 +// 仅 anthropic-strict 协议族需要预过滤;passback-required/unknown 都跳过, +// 因为「保留 thinking block」对 anthropic-strict 之外的上游一律更安全。 +func ShouldPreFilterThinkingBlocks(modelID string) bool { + return ResolveThinkingProtocol(modelID) == ThinkingProtocolAnthropicStrict +} + +// ShouldRectifyThinkingSignatureError 判断是否应在 400 后触发 thinking 签名整流 retry。 +// 仅 anthropic-strict 触发;passback-required 路径的 400 一般不是签名缺失问题, +// retry 任何 thinking 变形都不会修好,反而会破坏契约。unknown 同理保守不 retry。 +func ShouldRectifyThinkingSignatureError(modelID string) bool { + return ResolveThinkingProtocol(modelID) == ThinkingProtocolAnthropicStrict +} + +// ShouldApplyRetryFilters 判断是否应执行 retry 路径的 thinking/tool block 整流。 +// 与预过滤保持对称:仅 anthropic-strict 走变形;passback-required 与 unknown +// 一律返回原 body 不变形——避免在不熟悉的上游上做出可能破坏契约的猜测。 +func ShouldApplyRetryFilters(modelID string) bool { + return ResolveThinkingProtocol(modelID) == ThinkingProtocolAnthropicStrict +} diff --git a/backend/internal/service/thinking_protocol_filter_integration_test.go b/backend/internal/service/thinking_protocol_filter_integration_test.go new file mode 100644 index 0000000000..989a0d509d --- /dev/null +++ b/backend/internal/service/thinking_protocol_filter_integration_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +// 第三方 Claude 兼容上游 (DeepSeek/Kimi/GLM 等) 要求历史 thinking block 原样回传, +// 任何过滤都会破坏「thinking 必须 round-trip」契约。这些测试锁住「mappedModel 命中 +// passback-required 时,3 个过滤函数都返回原 body」的行为,避免回归。 +// 详见 .pensieve/short-term/knowledge/thinking-block-filter-third-party-upstream-inversion/ + +const passbackThinkingBody = `{ + "model":"deepseek-v4-pro", + "thinking":{"type":"enabled","budget_tokens":1024}, + "messages":[ + {"role":"user","content":[{"type":"text","text":"Hi"}]}, + {"role":"assistant","content":[ + {"type":"thinking","thinking":"Let me think..."}, + {"type":"text","text":"Answer"} + ]} + ] +}` + +func TestFilterThinkingBlocks_SkipsForPassbackRequired(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterThinkingBlocks(in, "deepseek-v4-pro") + // passback-required: 原样回传 body(byte-for-byte 不变) + require.True(t, bytes.Equal(in, out), "passback-required 上游不应过滤 thinking block") +} + +func TestFilterThinkingBlocksForRetry_SkipsForPassbackRequired(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterThinkingBlocksForRetry(in, "kimi-coding") + require.True(t, bytes.Equal(in, out), "passback-required 上游 retry 不应剥离 thinking block") +} + +func TestFilterSignatureSensitiveBlocksForRetry_SkipsForPassbackRequired(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterSignatureSensitiveBlocksForRetry(in, "glm-5.1") + require.True(t, bytes.Equal(in, out), "passback-required 上游不应降级 thinking/tool block") +} + +// 反向验证:anthropic-strict 路径仍然按原逻辑剥离无 signature 的 thinking block +func TestFilterThinkingBlocks_StripsForAnthropicStrict(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterThinkingBlocks(in, "claude-sonnet-4-5") + // anthropic-strict: thinking.type=enabled 且 thinking block 无 signature → 应剥离 + require.False(t, bytes.Equal(in, out), "anthropic-strict 上游应剥离无 signature 的 thinking block") + require.NotContains(t, string(out), `"type":"thinking"`) +} + +// Unknown 协议族保守不过滤(与 passback-required 一致) +func TestFilterThinkingBlocks_SkipsForUnknownModel(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterThinkingBlocks(in, "yi-large") + require.True(t, bytes.Equal(in, out), "unknown 协议族保守不过滤") +} + +func TestFilterThinkingBlocks_SkipsForEmptyModel(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterThinkingBlocks(in, "") + require.True(t, bytes.Equal(in, out), "空 model 保守不过滤") +} + +// retry 路径上 unknown 与 passback-required 行为对称:都返回原 body。 +// 避免「预过滤跳过但 retry 仍剥离」的语义裂缝。 +func TestFilterThinkingBlocksForRetry_SkipsForUnknownModel(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterThinkingBlocksForRetry(in, "yi-large") + require.True(t, bytes.Equal(in, out), "unknown 协议族 retry 不应剥离 thinking block") +} + +func TestFilterSignatureSensitiveBlocksForRetry_SkipsForUnknownModel(t *testing.T) { + in := []byte(passbackThinkingBody) + out := FilterSignatureSensitiveBlocksForRetry(in, "gpt-5.1") + require.True(t, bytes.Equal(in, out), "unknown 协议族不应降级 thinking/tool block") +} diff --git a/backend/internal/service/thinking_protocol_test.go b/backend/internal/service/thinking_protocol_test.go new file mode 100644 index 0000000000..3ca860bb1c --- /dev/null +++ b/backend/internal/service/thinking_protocol_test.go @@ -0,0 +1,120 @@ +package service + +import "testing" + +func TestResolveThinkingProtocol(t *testing.T) { + tests := []struct { + name string + modelID string + want ThinkingProtocol + }{ + // Anthropic 官方 + {"claude-sonnet-4-5", "claude-sonnet-4-5", ThinkingProtocolAnthropicStrict}, + {"claude-opus-4-5", "claude-opus-4-5-20251101", ThinkingProtocolAnthropicStrict}, + {"claude-haiku full id", "claude-haiku-4-5-20251001", ThinkingProtocolAnthropicStrict}, + {"opus short", "opus-4-5", ThinkingProtocolAnthropicStrict}, + {"sonnet short", "sonnet-4-5", ThinkingProtocolAnthropicStrict}, + {"haiku short", "haiku-4-5", ThinkingProtocolAnthropicStrict}, + {"upper case Claude", "Claude-Sonnet-4-5", ThinkingProtocolAnthropicStrict}, + + // 第三方兼容上游 + {"deepseek-v4-pro", "deepseek-v4-pro", ThinkingProtocolPassbackRequired}, + {"deepseek-r2-thinking", "deepseek-r2-thinking", ThinkingProtocolPassbackRequired}, + {"kimi-coding", "kimi-coding-v2", ThinkingProtocolPassbackRequired}, + {"kimi-k2-thinking", "kimi-k2-thinking", ThinkingProtocolPassbackRequired}, + {"moonshot-v1", "moonshot-v1-32k", ThinkingProtocolPassbackRequired}, + {"glm-5.1", "glm-5.1", ThinkingProtocolPassbackRequired}, + {"qwen-2 thinking variant", "qwen-2-72b-thinking", ThinkingProtocolPassbackRequired}, + {"qwen3 thinking (real Alibaba naming)", "qwen3-235b-a22b-thinking-2507", ThinkingProtocolPassbackRequired}, + {"qwen3-next thinking", "qwen3-next-80b-a3b-thinking", ThinkingProtocolPassbackRequired}, + {"upper case Deepseek", "DeepSeek-V4-Pro", ThinkingProtocolPassbackRequired}, + + // MiniMax M 系列(Anthropic 兼容端点要求 thinking round-trip) + {"MiniMax-M2 (case-sensitive original)", "MiniMax-M2", ThinkingProtocolPassbackRequired}, + {"MiniMax-M2.1", "MiniMax-M2.1", ThinkingProtocolPassbackRequired}, + {"MiniMax-M2.5", "MiniMax-M2.5", ThinkingProtocolPassbackRequired}, + {"MiniMax-M2.7", "MiniMax-M2.7", ThinkingProtocolPassbackRequired}, + {"MiniMax-M2.7-highspeed", "MiniMax-M2.7-highspeed", ThinkingProtocolPassbackRequired}, + {"minimax-m2 lowercase", "minimax-m2", ThinkingProtocolPassbackRequired}, + + // 未知 / 保守 + {"empty", "", ThinkingProtocolUnknown}, + {"gpt-5", "gpt-5.1", ThinkingProtocolUnknown}, + {"gemini", "gemini-3-pro-preview", ThinkingProtocolUnknown}, + {"qwen3 non-thinking", "qwen3-32b", ThinkingProtocolUnknown}, + {"qwen2 non-thinking", "qwen-2-72b", ThinkingProtocolUnknown}, + {"random vendor", "yi-large", ThinkingProtocolUnknown}, + // MiniMax 非 M 系列(如 abab、speech 等其他产品线)—— unknown + {"minimax abab non-M", "abab6.5-chat", ThinkingProtocolUnknown}, + // Doubao 走 OpenAI 协议,不属于本网关 Anthropic 路径——归 unknown + {"doubao goes via openai", "doubao-1-5-thinking-vision-pro-250428", ThinkingProtocolUnknown}, + // Hunyuan T1 未暴露 Anthropic 端点——归 unknown + {"hunyuan t1 no anthropic endpoint", "hunyuan-t1", ThinkingProtocolUnknown}, + {"hy-t1 short alias", "hy-t1", ThinkingProtocolUnknown}, + // claude-something 但不是 anthropic 官方命名风格——也归 strict(前缀匹配优先) + {"weird claude prefix", "claude-experimental-fork", ThinkingProtocolAnthropicStrict}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveThinkingProtocol(tt.modelID) + if got != tt.want { + t.Errorf("ResolveThinkingProtocol(%q) = %v, want %v", tt.modelID, got, tt.want) + } + }) + } +} + +func TestShouldPreFilterThinkingBlocks(t *testing.T) { + tests := []struct { + modelID string + want bool + }{ + {"claude-sonnet-4-5", true}, + {"deepseek-v4-pro", false}, + {"kimi-coding", false}, + {"glm-5.1", false}, + {"gpt-5.1", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.modelID, func(t *testing.T) { + if got := ShouldPreFilterThinkingBlocks(tt.modelID); got != tt.want { + t.Errorf("ShouldPreFilterThinkingBlocks(%q) = %v, want %v", tt.modelID, got, tt.want) + } + }) + } +} + +func TestShouldRectifyThinkingSignatureError(t *testing.T) { + if !ShouldRectifyThinkingSignatureError("claude-sonnet-4-5") { + t.Error("anthropic-strict should rectify signature error") + } + if ShouldRectifyThinkingSignatureError("deepseek-v4-pro") { + t.Error("passback-required must NOT rectify (would break protocol contract)") + } + if ShouldRectifyThinkingSignatureError("gpt-5.1") { + t.Error("unknown should NOT rectify (conservative default)") + } + if ShouldRectifyThinkingSignatureError("") { + t.Error("empty model id should NOT rectify") + } +} + +// ShouldApplyRetryFilters 与 ShouldPreFilterThinkingBlocks 必须语义一致: +// 仅 anthropic-strict 走变形,避免预过滤跳过但 retry 路径反而剥离的语义裂缝。 +func TestShouldApplyRetryFiltersMirrorsPreFilter(t *testing.T) { + models := []string{ + "claude-sonnet-4-5", "claude-opus-4-5-20251101", "haiku-4-5", + "deepseek-v4-pro", "kimi-coding", "glm-5.1", + "qwen3-235b-a22b-thinking-2507", "qwen3-32b", + "gpt-5.1", "gemini-3-pro-preview", "yi-large", "", + } + for _, m := range models { + t.Run(m, func(t *testing.T) { + if got := ShouldApplyRetryFilters(m); got != ShouldPreFilterThinkingBlocks(m) { + t.Errorf("ShouldApplyRetryFilters(%q)=%v but ShouldPreFilterThinkingBlocks=%v — must match", + m, got, ShouldPreFilterThinkingBlocks(m)) + } + }) + } +}