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>
107 lines
4.9 KiB
Go
107 lines
4.9 KiB
Go
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
|
||
}
|