mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(gateway): protocol-aware thinking-block filtering for Anthropic-compatible upstreams
The gateway's thinking-block handling was designed for Anthropic's strict
semantics (drop blocks with missing/invalid signature), but third-party
Anthropic-compatible upstreams have INVERTED semantics:
* DeepSeek `/anthropic`, Kimi `/coding`, GLM, Moonshot, qwen-*-thinking
require ALL historical thinking blocks to round-trip verbatim.
* Stripping any of them produces:
400 "The content[].thinking in the thinking mode must be passed back
to the API"
Without this fix, every multi-turn request from a thinking-capable client
(Claude Code, pi, etc.) to such upstreams loses its thinking blocks and
fails. This becomes especially painful when an account's model_mapping
maps `claude-sonnet-4-6 → deepseek-v4-pro` — `reqModel` looks Anthropic
but the upstream contract is the opposite.
Approach
--------
Branch all thinking-block transforms by the *mapped* model id (after
account model_mapping is applied), classifying into three families:
* `anthropic-strict` claude-/opus-/sonnet-/haiku- → existing behaviour
* `passback-required` deepseek-/kimi-/moonshot-/glm-/ → preserve verbatim
qwen-*-thinking
* `unknown` other models → conservative
(preserve, no retry)
Affected entry points (all guarded):
* Pre-filter on outbound: `FilterThinkingBlocks`
Previously dropped blocks with missing/invalid signature; now skips
entirely for non-strict families. Pre-filter is needed because the
post-error retry path can run out of budget on long conversations
(maxRetryElapsed = 10 s).
* 400 retry rectifier: `FilterThinkingBlocksForRetry`
Disables top-level thinking and converts thinking → text. Now skips
for passback-required (those 400s aren't signature errors and any
transformation breaks the round-trip contract).
* 400 retry rectifier (tools): `FilterSignatureSensitiveBlocksForRetry`
Same family-aware short-circuit.
* 400 detector: `shouldRectifySignatureError`
Returns false for passback-required, so the retry path doesn't even
fire.
Tests
-----
* `thinking_protocol_test.go` — classifier across all known vendor
prefixes plus edge cases (empty, case, qwen non-thinking).
* `thinking_protocol_filter_integration_test.go` — locks in that the
three filter entry points return the body byte-for-byte unchanged
when the model id is passback-required or unknown, and still strip
invalid blocks for anthropic-strict.
This PR supersedes #1350 (which only added the pre-filter without the
upstream-family awareness, and would have made third-party upstreams
worse). Once merged, please close #1350.
Reference issues:
- NousResearch/hermes-agent#16748 — DeepSeek /anthropic strip behaviour
- NousResearch/hermes-agent#15700 — DeepSeek thinking:disabled requirement
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
34b1e56e29
commit
6baf00d784
@@ -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"`)) &&
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user