sub2api/backend/internal/service/header_util.go
alfadb ddf91e9a7f fix(gateway): 按最终 anthropic-beta header 对 body.context_management 做能力维度 sanitize
上游 Anthropic 在 body 含 `context_management` 但最终发出去的
`anthropic-beta` header 不含 `context-management-2025-06-27` 时会拒收:

    {
      "type": "invalid_request_error",
      "message": "context_management: Extra inputs are not permitted"
    }
    (HTTP 400, request_id 形如 req_011C...)

该 400 在 haiku 路径上触发,因为三个 beta header 构造器有意排除了
context-management beta:

  - HaikuBetaHeader          (messages,     OAuth / mimic CC)
  - APIKeyHaikuBetaHeader    (messages,     API-key)
  - CountTokensBetaHeader    (count_tokens, 所有认证类型)

但 body 中仍然带着 `context_management` 字段,原因有二:

  1. normalizeClaudeOAuthRequestBody 在 thinking_enabled / thinking_adaptive
     打开时为 `clear_thinking_20251015` 主动注入;
  2. 客户端 (Claude Code CLI >= 2.1.87) 原样发送, 网关透传时一并转发。

修复方案: 能力维度对称约束
==========================

对齐已有的 Bedrock 模式
(`backend/internal/service/bedrock_request.go` 中的
`sanitizeBedrockFieldsForBetaTokens`):
根据 **最终** 发出的 `anthropic-beta` header 决定是否保留
`body.context_management`, 而不是按 model 名或路由分类来决定。

新增纯函数:

    sanitizeAnthropicBodyForBetaTokens(body, betaHeader) (body, changed)

如果 `betaHeader` 不含 `context-management-2025-06-27`, 用 sjson 把 body
字段 strip 掉; 否则原样返回。

在所有 Anthropic / Anthropic-兼容 上游出口都接入:

| 路径                                       | sanitize 接入点                                       |
|--------------------------------------------|-------------------------------------------------------|
| /v1/messages OAuth mimic CC                | buildUpstreamRequest                                  |
| /v1/messages OAuth 真 CC 透传              | buildUpstreamRequest                                  |
| /v1/messages API-key                       | buildUpstreamRequest                                  |
| /v1/messages API-key passthrough           | buildUpstreamRequestAnthropicAPIKeyPassthrough        |
| /v1/messages Vertex / service-account      | buildUpstreamRequestAnthropicVertex                   |
| /v1/messages/count_tokens (全部 4 条路径)  | buildCountTokensRequest,                              |
|                                            | buildCountTokensRequestAnthropicAPIKeyPassthrough     |
| Antigravity Anthropic-兼容 上游            | AntigravityGatewayService.ForwardUpstream             |
| Bedrock                                    | (已由 sanitizeBedrockFieldsForBetaTokens 处理)        |

为什么要重排 (而不是加一行调用)
================================

sanitize 必须 **在** `signBillingHeaderCCH` 之前运行。CCH 对整个 body
取 xxHash64 摘要后写入 billing header 里 5 位十六进制的 `cch` 字段;
如果先签名再 strip, 上游对发出去的 body 重算 hash 会和 `cch` 不一致,
请求被判为 third-party。这就要求在 `http.NewRequest` 之前算出最终的
`anthropic-beta` header, 所以把原本内联在 builder 里的 beta 计算逻辑
抽成了两个纯函数:

  - computeFinalAnthropicBeta              (messages 路径: mimic 不透传
                                            客户端 beta)
  - computeFinalCountTokensAnthropicBeta   (count_tokens 路径: mimic 不
                                            跳过白名单透传)

两者逐位保留原行为:

  - mimic 路径在 messages 上跳过客户端 beta, 在 count_tokens 上合并
  - API-key 路径尊重 `InjectBetaForAPIKey` 开关
  - dropSet (`defaultDroppedBetasSet` + BetaPolicy filter) 应用在主路径,
    passthrough / Vertex 路径有意不应用 —— 这条原有的不对称行为本 PR
    不动。

一条语义测试 (`TestSanitizeMustBeBeforeCCHSigning_HashConsistency`) 把
顺序约束文档化并强制守住: 它证明 `sanitize -> signBillingHeaderCCH`
产生的 `cch` 与最终 body 一致, 而 `signBillingHeaderCCH -> sanitize`
产生的 `cch` 会被上游 hash 重算判失败。

为什么是能力维度 (而不是 haiku 模型名匹配)
==========================================

最朴素的"按 model 名 strip"方案
(`strings.Contains(modelID, "haiku") -> DeleteBytes "context_management"`)
有四个真实失败模式:

  1. 过度删除。CLI >= 2.1.87 的真 Claude Code 客户端在 haiku 上同时
     发送 body 字段 **和** `anthropic-beta: context-management-2025-06-27`。
     一律 strip 会让该用户的 `clear_thinking_20251015` 静默失效。
  2. 别名漂移。未来的 haiku 别名 (`claude-3-haiku-...`,
     `claude-haiku-...` 等) 改变匹配面; 任何新别名都会悄悄绕过 strip。
  3. count_tokens 漏覆盖。count_tokens 有自己的 builder 和不同的 beta
     header 集合; 在一个地方做 model 名检查会漏掉这条路径。
  4. API-key passthrough 早退。passthrough builder 在 model 名 strip
     之前就 return 了, strip 根本不执行。

能力维度沿着 header 端到端走, 上述 4 个 case 都由构造方式保证正确,
不依赖任何 modelID 匹配。

防御项
======

  - 当 `sjson.DeleteBytes` 在 `gjson` 刚验证过字段存在的 body 上失败时,
    `sanitizeAnthropicBodyForBetaTokens` 会记 warning 日志 —— 这种情况
    现实中仅在请求中途被破坏时发生, 日志把此前会静默发生的 body / header
    不一致暴露出来。
  - `header_util.go` 新增 `deleteHeaderAllForms`: 在白名单透传已经写入
    canonical 大小写的 `Anthropic-Beta` 之后再覆盖, 否则会同时留下两条。

测试
====

`backend/internal/service` 下新增 44 个测试:

  - 纯函数: anthropicBetaTokensContains x 5, sanitize keep/strip x 6,
    computeFinal{Anthropic,CountTokens}AnthropicBeta x 12
  - normalize 回归 x 5
  - buildUpstreamRequest 端到端 x 4
      (OAuth mimic haiku strip / mimic sonnet preserve /
       真 CC haiku 带客户端 beta preserve / API-key haiku strip)
  - buildCountTokensRequest 端到端 x 2
  - buildUpstreamRequestAnthropicAPIKeyPassthrough x 2 (strip / preserve)
  - buildCountTokensRequestAnthropicAPIKeyPassthrough x 2 (strip / preserve)
  - buildUpstreamRequestAnthropicVertex x 2 (strip / preserve, 含 outgoing
    `anthropic-beta` header 对称断言)
  - CCH 顺序语义测试 x 1

unit 套件全过 (本机 88s), `golangci-lint` 0 issues。

已知局限 (本 PR 范围外)
========================

  - Vertex 路径用透传过来的客户端 `anthropic-beta` header 作为 sanitize
    依据, 而不是 Vertex 侧的能力矩阵。最坏情况是过度 strip (= 当前 main
    的行为, 主路径本来什么都不 strip); 不是 regression。完整的 Vertex
    能力模型属于单独的 PR。
  - Vertex builder 仍然不应用 BetaPolicy filter / dropSet。这是该 builder
    早 return 的既有架构决策, 本 PR 不动。
  - count_tokens mimic 在 haiku 上仍然注入 `context-management-2025-06-27`
    (因为原 count_tokens mimic 逻辑并不像 messages mimic 那样排除它)。
    本 PR 逐位保留 main 的行为; 是否要让它与 messages mimic 的排除策略
    统一是另一个问题。
  - `sanitizeAnthropicBodyForBetaTokens` 目前只处理
    `context_management <-> context-management-2025-06-27` 这一对。如果
    Anthropic 后续推出更多 beta-gated body 字段, 可以在后续 PR 重构为
    `{body 路径 -> required beta token}` 注册表的形式。
2026-05-28 00:02:50 +08:00

180 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"net/http"
"strings"
)
// headerWireCasing 定义每个白名单 header 在真实 Claude CLI 抓包中的准确大小写。
// Go 的 HTTP server 解析请求时会将所有 header key 转为 Canonical 形式(如 x-app → X-App
// 此 map 用于在转发时恢复到真实的 wire format。
//
// 来源:对真实 Claude CLI (claude-cli/2.1.81) 到 api.anthropic.com 的 HTTPS 流量抓包。
var headerWireCasing = map[string]string{
// Title case
"accept": "Accept",
"user-agent": "User-Agent",
// X-Stainless-* 保持 SDK 原始大小写
"x-stainless-retry-count": "X-Stainless-Retry-Count",
"x-stainless-timeout": "X-Stainless-Timeout",
"x-stainless-lang": "X-Stainless-Lang",
"x-stainless-package-version": "X-Stainless-Package-Version",
"x-stainless-os": "X-Stainless-OS",
"x-stainless-arch": "X-Stainless-Arch",
"x-stainless-runtime": "X-Stainless-Runtime",
"x-stainless-runtime-version": "X-Stainless-Runtime-Version",
"x-stainless-helper-method": "x-stainless-helper-method",
// Anthropic SDK 自身设置的 header全小写
"anthropic-dangerous-direct-browser-access": "anthropic-dangerous-direct-browser-access",
"anthropic-version": "anthropic-version",
"anthropic-beta": "anthropic-beta",
"x-app": "x-app",
"content-type": "content-type",
"accept-language": "accept-language",
"sec-fetch-mode": "sec-fetch-mode",
"accept-encoding": "accept-encoding",
"authorization": "authorization",
// Claude Code 2.1.87+ 新增 header
"x-claude-code-session-id": "X-Claude-Code-Session-Id",
"x-client-request-id": "x-client-request-id",
"content-length": "content-length",
}
// headerWireOrder 定义真实 Claude CLI 发送 header 的顺序(基于抓包)。
// 用于 debug log 按此顺序输出,便于与抓包结果直接对比。
var headerWireOrder = []string{
"Accept",
"X-Stainless-Retry-Count",
"X-Stainless-Timeout",
"X-Stainless-Lang",
"X-Stainless-Package-Version",
"X-Stainless-OS",
"X-Stainless-Arch",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"anthropic-dangerous-direct-browser-access",
"anthropic-version",
"authorization",
"x-app",
"User-Agent",
"X-Claude-Code-Session-Id",
"content-type",
"anthropic-beta",
"x-client-request-id",
"accept-language",
"sec-fetch-mode",
"accept-encoding",
"content-length",
"x-stainless-helper-method",
}
// headerWireOrderSet 用于快速判断某个 key 是否在 headerWireOrder 中(按 lowercase 匹配)。
var headerWireOrderSet map[string]struct{}
func init() {
headerWireOrderSet = make(map[string]struct{}, len(headerWireOrder))
for _, k := range headerWireOrder {
headerWireOrderSet[strings.ToLower(k)] = struct{}{}
}
}
// resolveWireCasing 将 Go canonical key如 X-Stainless-Os映射为真实 wire casing如 X-Stainless-OS
// 如果 map 中没有对应条目,返回原始 key 不变。
func resolveWireCasing(key string) string {
if wk, ok := headerWireCasing[strings.ToLower(key)]; ok {
return wk
}
return key
}
// setHeaderRaw sets a header bypassing Go's canonical-case normalization.
// The key is stored exactly as provided, preserving original casing.
//
// It first removes any existing value under the canonical key, the wire casing key,
// and the exact raw key, preventing duplicates from any source.
func setHeaderRaw(h http.Header, key, value string) {
h.Del(key) // remove canonical form (e.g. "Anthropic-Beta")
if wk := resolveWireCasing(key); wk != key {
delete(h, wk) // remove wire casing form if different
}
delete(h, key) // remove exact raw key if it differs from canonical
h[key] = []string{value}
}
// addHeaderRaw appends a header value bypassing Go's canonical-case normalization.
func addHeaderRaw(h http.Header, key, value string) {
h[key] = append(h[key], value)
}
// deleteHeaderAllForms removes a header in all common key forms (raw, wire casing,
// canonical) so subsequent setHeaderRaw will not coexist with a passthrough value
// written under a different casing.
func deleteHeaderAllForms(h http.Header, key string) {
if h == nil || key == "" {
return
}
h.Del(key) // canonical
delete(h, key)
if wk := resolveWireCasing(key); wk != key {
delete(h, wk)
}
}
// getHeaderRaw reads a header value, trying multiple key forms to handle the mismatch
// between Go canonical keys, wire casing keys, and raw keys:
// 1. exact key as provided
// 2. wire casing form (from headerWireCasing)
// 3. Go canonical form (via http.Header.Get)
func getHeaderRaw(h http.Header, key string) string {
// 1. exact key
if vals := h[key]; len(vals) > 0 {
return vals[0]
}
// 2. wire casing (e.g. looking up "Anthropic-Dangerous-Direct-Browser-Access" finds "anthropic-dangerous-direct-browser-access")
if wk := resolveWireCasing(key); wk != key {
if vals := h[wk]; len(vals) > 0 {
return vals[0]
}
}
// 3. canonical fallback
return h.Get(key)
}
// sortHeadersByWireOrder 按照真实 Claude CLI 的 header 顺序返回排序后的 key 列表。
// 在 headerWireOrder 中定义的 key 按其顺序排列,未定义的 key 追加到末尾。
func sortHeadersByWireOrder(h http.Header) []string {
// 构建 lowercase -> actual map key 的映射
present := make(map[string]string, len(h))
for k := range h {
present[strings.ToLower(k)] = k
}
result := make([]string, 0, len(h))
seen := make(map[string]struct{}, len(h))
// 先按 wire order 输出
for _, wk := range headerWireOrder {
lk := strings.ToLower(wk)
if actual, ok := present[lk]; ok {
if _, dup := seen[lk]; !dup {
result = append(result, actual)
seen[lk] = struct{}{}
}
}
}
// 再追加不在 wire order 中的 header
for k := range h {
lk := strings.ToLower(k)
if _, ok := seen[lk]; !ok {
result = append(result, k)
seen[lk] = struct{}{}
}
}
return result
}