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}` 注册表的形式。
This commit is contained in:
alfadb 2026-05-27 15:46:05 +08:00
parent 89d96f4b25
commit ddf91e9a7f
6 changed files with 1024 additions and 74 deletions

View File

@ -4209,6 +4209,14 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
// 构建上游请求 URL
upstreamURL := baseURL + "/v1/messages"
// 能力维度 sanitizeAnthropic-compatible 上游透传路径也需要保证 body↔beta header
// 对称。客户端 anthropic-beta header 不含 context-management-2025-06-27 但 body 带
// context_management 时 strip与 Anthropic 直连 / Bedrock / Vertex 路径保持一致。
clientBeta := c.GetHeader("anthropic-beta")
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
// 创建请求
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil {
@ -4224,7 +4232,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.
if v := c.GetHeader("anthropic-version"); v != "" {
req.Header.Set("anthropic-version", v)
}
if v := c.GetHeader("anthropic-beta"); v != "" {
if v := clientBeta; v != "" {
req.Header.Set("anthropic-beta", v)
}

View File

@ -66,3 +66,67 @@ func readRequestBodyForTest(t *testing.T, req *http.Request) []byte {
require.NoError(t, err)
return body
}
// Vertex 路径回归保护:同样需要
// body↔beta header 能力维度对称。客户端 header 不带 context-management beta
// 但 body 带 context_management 字段 → Vertex builder 必须 strip 字段,与 Anthropic
// 直连 / Bedrock 路径保持一致。
func TestGatewayService_BuildAnthropicVertexServiceAccount_StripsContextManagementWhenBetaMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
// 客户端 header 只带 interleaved-thinking不带 context-management-2025-06-27
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
account := &Account{
ID: 302, Platform: PlatformAnthropic, Type: AccountTypeServiceAccount,
Credentials: map[string]any{"project_id": "vertex-proj", "location": "us-east5"},
}
// body 带了 context_management 字段(客户端透传 / normalize 补齐 / mimicry 注入等场景都可能导致)
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[{"role":"user","content":"hi"}]}`)
svc := &GatewayService{}
req, err := svc.buildUpstreamRequest(
context.Background(), c, account, body,
"vertex-token", "service_account", "claude-haiku-4-5@20251001", false, false,
)
require.NoError(t, err)
got := readRequestBodyForTest(t, req)
require.False(t, gjson.GetBytes(got, "context_management").Exists(),
"Vertex 路径下客户端 header 缺 context-management beta 时,必须 strip body 同名字段")
// header 对称断言:覆盖未来某人在 Vertex builder 里加入与 sanitize 不一致的 header 处理。
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.False(t, anthropicBetaTokensContains(outBeta, "context-management-2025-06-27"),
"与 body 对称outgoing anthropic-beta header 也不含 context-management beta")
}
// Vertex 路径反面:客户端 header 含 context-management beta 时保留字段。
func TestGatewayService_BuildAnthropicVertexServiceAccount_PreservesContextManagementWhenBetaPresent(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14,context-management-2025-06-27")
account := &Account{
ID: 303, Platform: PlatformAnthropic, Type: AccountTypeServiceAccount,
Credentials: map[string]any{"project_id": "vertex-proj", "location": "us-east5"},
}
body := []byte(`{"model":"claude-sonnet-4-6","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
svc := &GatewayService{}
req, err := svc.buildUpstreamRequest(
context.Background(), c, account, body,
"vertex-token", "service_account", "claude-sonnet-4-6@20260218", false, false,
)
require.NoError(t, err)
got := readRequestBodyForTest(t, req)
require.True(t, gjson.GetBytes(got, "context_management").Exists(),
"Vertex + 客户端 header 包含 context-management beta 时字段必须保留")
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.True(t, anthropicBetaTokensContains(outBeta, "context-management-2025-06-27"),
"与 body 对称outgoing anthropic-beta header 同步含 context-management beta")
}

View File

@ -0,0 +1,667 @@
//go:build unit
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// ============================================================================
// 背景
// ============================================================================
//
// Anthropic 上游对 body.context_management 字段实施 Pydantic schema 校验:
// 当且仅当 anthropic-beta header 含 context-management-2025-06-27 时接受。
// 否则报:
// "context_management: Extra inputs are not permitted"
//
// 本仓采用能力维度对称约束(与 Bedrock 路径的 sanitizeBedrockFieldsForBetaTokens
// 对称):在所有 Anthropic 直连出口,按最终 anthropic-beta header 是否含上述 token
// 决定 body 是否保留同名字段。
//
// 本文件覆盖:
// 1) sanitizeAnthropicBodyForBetaTokens 纯函数
// 2) anthropicBetaTokensContains 解析辅助函数
// 3) computeFinalAnthropicBeta / computeFinalCountTokensAnthropicBeta 各路径
// 4) normalizeClaudeOAuthRequestBody 的 context_management 补齐行为(不再按 model 短路)
// ============================================================================
// anthropicBetaTokensContains
// ============================================================================
func TestAnthropicBetaTokensContains_EmptyInputs(t *testing.T) {
require.False(t, anthropicBetaTokensContains("", "context-management-2025-06-27"))
require.False(t, anthropicBetaTokensContains("oauth-2025-04-20", ""))
}
func TestAnthropicBetaTokensContains_SingleToken(t *testing.T) {
require.True(t, anthropicBetaTokensContains("context-management-2025-06-27", "context-management-2025-06-27"))
}
func TestAnthropicBetaTokensContains_MultiTokenComma(t *testing.T) {
header := "oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14"
require.True(t, anthropicBetaTokensContains(header, "context-management-2025-06-27"))
require.True(t, anthropicBetaTokensContains(header, "oauth-2025-04-20"))
require.False(t, anthropicBetaTokensContains(header, "fast-mode-2026-02-01"))
}
func TestAnthropicBetaTokensContains_ToleratesWhitespace(t *testing.T) {
header := "oauth-2025-04-20 , context-management-2025-06-27 , interleaved-thinking-2025-05-14"
require.True(t, anthropicBetaTokensContains(header, "context-management-2025-06-27"))
}
func TestAnthropicBetaTokensContains_SubstringNotMatched(t *testing.T) {
// 严格 token 比较,不应被子串误匹配
require.False(t, anthropicBetaTokensContains("context-management-2025-06-27-rev2", "context-management-2025-06-27"),
"必须按 token 边界匹配,不允许 prefix 子串误命中")
}
// ============================================================================
// sanitizeAnthropicBodyForBetaTokens
// ============================================================================
func TestSanitizeAnthropicBodyForBetaTokens_NoFieldNoChange(t *testing.T) {
body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
require.False(t, changed)
require.Equal(t, string(body), string(out))
}
func TestSanitizeAnthropicBodyForBetaTokens_FieldKeptWhenBetaPresent(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
"oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14")
require.False(t, changed)
require.True(t, gjson.GetBytes(out, "context_management").Exists())
require.Equal(t, "clear_thinking_20251015",
gjson.GetBytes(out, "context_management.edits.0.type").String())
}
func TestSanitizeAnthropicBodyForBetaTokens_FieldStrippedWhenBetaMissing(t *testing.T) {
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
require.True(t, changed)
require.False(t, gjson.GetBytes(out, "context_management").Exists(),
"header 不含 context-management beta 时必须 strip 同名字段")
}
func TestSanitizeAnthropicBodyForBetaTokens_FieldStrippedWhenBetaEmpty(t *testing.T) {
body := []byte(`{"context_management":{"edits":[]},"messages":[]}`)
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
require.True(t, changed)
require.False(t, gjson.GetBytes(out, "context_management").Exists())
}
func TestSanitizeAnthropicBodyForBetaTokens_EmptyBody(t *testing.T) {
out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "")
require.False(t, changed)
require.Empty(t, out)
out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "")
require.False(t, changed)
require.Empty(t, out)
}
// ★ 关键回归断言:能力维度 sanitize 解决了 "真 CC + haiku" 路径的过度删除问题。
// 真实 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta
// 即使 model 是 haikusanitize 也不应剥离功能字段。
func TestSanitizeAnthropicBodyForBetaTokens_HaikuRealCCClientPreservesField(t *testing.T) {
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
// 真 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta
clientBeta := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27"
out, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta)
require.False(t, changed,
"真 CC 客户端 header 含 context-management beta 时haiku body 字段必须保留(功能不丢)")
require.True(t, gjson.GetBytes(out, "context_management").Exists())
}
// ============================================================================
// computeFinalAnthropicBeta — 关键路径
// ============================================================================
func newTestGatewayServiceForBeta(injectBetaForAPIKey bool) *GatewayService {
cfg := &config.Config{}
cfg.Gateway.InjectBetaForAPIKey = injectBetaForAPIKey
return &GatewayService{cfg: cfg}
}
func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil)
require.True(t, ok)
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
"OAuth mimic non-haiku 必须注入完整 CC mimicry beta含 context-management-2025-06-27")
require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth))
require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode))
}
func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_ExcludesContextManagement(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
require.True(t, ok)
require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
"OAuth mimic haiku 仅注入 oauth + interleaved-thinking不含 context-management")
require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth))
require.True(t, anthropicBetaTokensContains(final, claude.BetaInterleavedThinking))
}
func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBeta(t *testing.T) {
// mimic 路径下原代码白名单透传被跳过client beta 应被忽略
s := newTestGatewayServiceForBeta(false)
hdr := http.Header{}
hdr.Set("anthropic-beta", "custom-experimental-beta")
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
require.True(t, ok)
require.False(t, strings.Contains(final, "custom-experimental-beta"),
"mimic 路径必须忽略客户端 anthropic-beta header")
}
func TestComputeFinalAnthropicBeta_OAuthTransparent_NonHaiku_PreservesClientContextManagement(t *testing.T) {
// 真 CC 客户端透传:客户端 header 中的 context-management beta 必须保留
s := newTestGatewayServiceForBeta(false)
hdr := http.Header{}
hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27")
final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
require.True(t, ok)
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement))
}
func TestComputeFinalAnthropicBeta_OAuthTransparent_Haiku_RealCCPreservesContextManagement(t *testing.T) {
// haiku 透传 + 客户端带 context-management beta → 必须保留
// (能力维度核心场景:避免 model-name 误删客户端透传的功能 beta
s := newTestGatewayServiceForBeta(false)
hdr := http.Header{}
hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14")
final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-haiku-4-5", hdr, []byte(`{}`), nil)
require.True(t, ok)
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
"真 CC + haiku + 客户端带 context-management beta → 透传必须保留")
}
func TestComputeFinalAnthropicBeta_APIKey_PassesClientBetaThroughDropSet(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
hdr := http.Header{}
hdr.Set("anthropic-beta", "oauth-2025-04-20,custom-beta")
final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
require.True(t, ok)
require.True(t, anthropicBetaTokensContains(final, "oauth-2025-04-20"))
require.True(t, anthropicBetaTokensContains(final, "custom-beta"))
}
func TestComputeFinalAnthropicBeta_APIKey_NoClientBetaInjectOff_ShouldNotSet(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil)
require.False(t, ok, "API-key + 客户端未传 + InjectBetaForAPIKey 关 → 不应主动设置 anthropic-beta")
require.Equal(t, "", final)
}
// ============================================================================
// computeFinalCountTokensAnthropicBeta
// ============================================================================
func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_AlwaysIncludesContextManagement(t *testing.T) {
// count_tokens 路径下 mimic 不按 haiku 排除:始终注入完整 mimicry beta
s := newTestGatewayServiceForBeta(false)
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
require.True(t, ok)
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
"count_tokens + mimic 即使 haiku 也注入 context-management beta与 messages 不同)")
require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
"count_tokens 路径必须含 token-counting beta")
}
// 重构等价性回归:
// 原 main buildCountTokensRequest 在 count_tokens mimic 分支上不跳过白名单透传
// (与 messages mimic 不同incomingBeta 取自客户端透传。重构后必须从 clientHeaders
// 拿同一个值并 merge否则会丢失客户端 beta。
func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_PreservesClientBeta(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
hdr := http.Header{}
hdr.Set("anthropic-beta", "custom-experimental-beta,context-1m-2025-08-07")
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", hdr, []byte(`{}`), nil)
require.True(t, ok)
require.True(t, anthropicBetaTokensContains(final, "custom-experimental-beta"),
"count_tokens mimic 不同于 messages mimic原代码会保留客户端透传的 beta")
require.True(t, anthropicBetaTokensContains(final, "context-1m-2025-08-07"),
"客户端透传的其他 beta token 同样需要保留")
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
"同时 FullClaudeCodeMimicryBetas 不打折扣")
require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
"同时补齐 token-counting beta")
}
// messages mimic 路径反向验证:原代码会跳过白名单透传,
// 客户端 beta 不会进入 mimic 计算。重构后 messages computeFinalAnthropicBeta
// mimic 分支依然不该使用 clientBeta。
func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBetaExplicit(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
hdr := http.Header{}
hdr.Set("anthropic-beta", "custom-experimental-beta")
final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
require.True(t, ok)
require.False(t, anthropicBetaTokensContains(final, "custom-experimental-beta"),
"messages mimic 原代码跳过白名单透传 → 客户端 beta 不进入计算。"+
"与 count_tokens mimic 是不同的设计,不能合并为同一函数。")
}
func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_NoClientBetaInjectsDefault(t *testing.T) {
// 真 CC 客户端透传 + 客户端未传 anthropic-beta → 用 CountTokensBetaHeader 兜底
s := newTestGatewayServiceForBeta(false)
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil)
require.True(t, ok)
require.Equal(t, claude.CountTokensBetaHeader, final)
// CountTokensBetaHeader 不含 context-management beta
require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement))
}
func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_AppendsBetaTokenCounting(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
hdr := http.Header{}
hdr.Set("anthropic-beta", "oauth-2025-04-20,context-management-2025-06-27")
final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil)
require.True(t, ok)
require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting),
"客户端未带 token-counting beta 时必须补齐")
require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement),
"客户端带的 context-management beta 必须保留")
}
// ============================================================================
// normalizeClaudeOAuthRequestBody — 回归context_management 补齐恢复原行为
// ============================================================================
//
// 重构后该函数不再按 model 名短路thinking=enabled/adaptive 时补齐 context_management
// 与 model 无关。strip 责任移交 sanitizeAnthropicBodyForBetaTokens
// buildUpstreamRequest 层按最终 beta header 执行)。
func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingEnabled(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-6","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
require.True(t, gjson.GetBytes(out, "context_management").Exists())
require.Equal(t, "clear_thinking_20251015",
gjson.GetBytes(out, "context_management.edits.0.type").String())
}
func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingAdaptive(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-7","thinking":{"type":"adaptive"},"messages":[]}`)
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-4-7", claudeOAuthNormalizeOptions{})
require.True(t, gjson.GetBytes(out, "context_management").Exists())
}
func TestNormalizeClaudeOAuthRequestBody_HaikuStillInjects_StripDeferredToSanitize(t *testing.T) {
// haiku + thinking=enablednormalize 阶段仍按 CLI mimicry 行为补齐字段;
// strip 由 buildUpstreamRequest 层的 sanitize 兜底(如果 final beta 不含 token
body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{})
require.True(t, gjson.GetBytes(out, "context_management").Exists(),
"normalize 不再按 model 名短路strip 责任移交 sanitize 层")
}
func TestNormalizeClaudeOAuthRequestBody_PreservesClientContextManagement(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"custom_strategy"}]},"thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`)
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-4-7", claudeOAuthNormalizeOptions{})
require.Equal(t, "custom_strategy",
gjson.GetBytes(out, "context_management.edits.0.type").String(),
"客户端透传的 context_management 内容必须原样保留")
}
func TestNormalizeClaudeOAuthRequestBody_NoThinking_NoInject(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-6","messages":[]}`)
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
require.False(t, gjson.GetBytes(out, "context_management").Exists())
}
// ============================================================================
// passthrough 集成测试buildUpstreamRequest-
// AnthropicAPIKeyPassthrough 与 buildCountTokensRequestAnthropicAPIKeyPassthrough
// 路径上 sanitize 是否生效。
// ============================================================================
// passthrough 集成测试不设 base_url避开 validateUpstreamBaseURL 对 cfg.Security 的依赖。
// targetURL 会走默认 claudeAPIURLsanitize 逻辑与 baseURL 是否存在无关。
func newAnthropicAPIKeyPassthroughAccountForBetaTest() *Account {
return &Account{
ID: 501,
Name: "anthropic-apikey-passthrough-ctxmgmt-test",
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "upstream-key",
},
Extra: map[string]any{"anthropic_passthrough": true},
Status: StatusActive,
Schedulable: true,
}
}
func readUpstreamBodyForTest(t *testing.T, req *http.Request) []byte {
t.Helper()
require.NotNil(t, req.Body)
b, err := io.ReadAll(req.Body)
require.NoError(t, err)
return b
}
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsContextManagementWhenClientHeaderMissingBeta(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
// 客户端仅带 oauth beta不带 context-management-2025-06-27
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
)
require.NoError(t, err)
require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
"API-key passthrough + 客户端未带 context-management beta → strip body 字段")
}
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,context-management-2025-06-27")
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
)
require.NoError(t, err)
require.True(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
"API-key passthrough + 客户端带 context-management beta → 字段保留(不过度删除)")
}
func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_StripsContextManagementWhenClientHeaderMissingBeta(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,token-counting-2024-11-01")
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
)
require.NoError(t, err)
require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
"count_tokens passthrough + 客户端未带 context-management beta → strip")
}
// ============================================================================
// 集成测试buildUpstreamRequest
// 全路径验证上游 outgoing body 与 anthropic-beta header 严格对称。
// 这个测试能挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
// ============================================================================
func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
account := &Account{ID: 401, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "oauth-tok"},
Status: StatusActive,
Schedulable: true,
}
// haiku + mimic CC → final beta = HaikuBetaHeader不含 context-management
// body 必须 strip。
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildUpstreamRequest(
context.Background(), c, account, body,
"oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
"OAuth mimic + haiku 端到端outgoing body 不应含 context_management")
require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
"对称约束outgoing anthropic-beta header 也不带 context-management beta")
}
func TestBuildUpstreamRequest_OAuthMimicNonHaiku_PreservesContextManagementEndToEnd(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
account := &Account{ID: 402, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "oauth-tok"},
Status: StatusActive,
Schedulable: true,
}
// sonnet + mimic CC → final beta = FullClaudeCodeMimicryBetas含 context-management
// body 保留。
body := []byte(`{"model":"claude-sonnet-4-6","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildUpstreamRequest(
context.Background(), c, account, body,
"oauth-tok", "oauth", "claude-sonnet-4-6", false, true,
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
"OAuth mimic + non-haikuoutgoing body 必须保留 context_management。")
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
"对称约束outgoing anthropic-beta header 同时含 context-management beta")
}
func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField(t *testing.T) {
// 端到端验证:真 CC 客户端 + haiku + 客户端 header 带 context-management beta
// → final beta 透传 → 不应该过度删除 body 字段
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Anthropic-Beta",
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27")
account := &Account{ID: 403, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "oauth-tok"},
Status: StatusActive, Schedulable: true,
}
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildUpstreamRequest(
context.Background(), c, account, body,
"oauth-tok", "oauth", "claude-haiku-4-5", false, false, // mimicClaudeCode=false真 CC
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
"真 CC 透传路径:客户端 header 中的 context-management beta 必须保留")
require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
"回归保护:真 CC + haiku + 客户端带 beta token 时clear_thinking_20251015 功能不能静默失效")
}
// CCH 顺序语义测试sanitize 必须在 signBillingHeaderCCH 之前,
// 否则签名的 hash 与最终发送的 body 不一致,被 Anthropic 判 third-party。
//
// 该测试不走 buildUpstreamRequest 完整路径(需要 mock SettingService 成本高),
// 而是直接验证两个顺序产生的 cch 不同,证明二者不可交换。
// 测试名本身是语义约束的文档化 marker。
func TestSanitizeMustBeBeforeCCHSigning_HashConsistency(t *testing.T) {
// 构造 body含 context_management + cch=00000 占位符
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92; cch=00000;"}],"messages":[]}`)
// 最终发送场景final beta 不含 context-management beta → sanitize 会 strip
finalBeta := "oauth-2025-04-20,interleaved-thinking-2025-05-14"
extractCCH := func(t *testing.T, b []byte) string {
t.Helper()
m := regexp.MustCompile(`\bcch=([0-9a-fA-F]{5})\b`).FindSubmatch(b)
require.NotNil(t, m, "body 里找不到 cch=<5hex> %s", string(b))
return string(m[1])
}
// === 正确顺序sanitize → signBillingHeaderCCH ===
// 1. strip context_management
sanitizedFirst, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBeta)
require.True(t, changed)
require.False(t, gjson.GetBytes(sanitizedFirst, "context_management").Exists())
// 2. 基于“strip 后的 body”算 hash
correctFinal := signBillingHeaderCCH(sanitizedFirst)
correctCCH := extractCCH(t, correctFinal)
require.NotEqual(t, "00000", correctCCH, "placeholder 应被替换")
// === 错误顺序signBillingHeaderCCH → sanitize未来 regression 场景)===
// 1. 先基于“含 context_management 的 body”算 hash → cch=H_with
signedFirst := signBillingHeaderCCH(body)
wrongCCH := extractCCH(t, signedFirst)
require.NotEqual(t, "00000", wrongCCH)
// 2. 后 strip context_management → body 变化但 cch 仍是 H_with
wrongFinal, _ := sanitizeAnthropicBodyForBetaTokens(signedFirst, finalBeta)
wrongFinalCCH := extractCCH(t, wrongFinal)
// === 关键断言 ===
// 上游验证逻辑:将 outgoing body 的 cch 还原为 00000、重算 hash、与 cch 字段比较。
// 模拟上游验证:用发送 body 算出“期望的 cch”与发送 body 里的 cch 字段比。
recomputeExpected := func(b []byte, currentCCH string) string {
t.Helper()
// 把 cch=<currentCCH> 还原为 cch=00000
re := regexp.MustCompile(`(\bcch=)` + currentCCH + `(\b)`)
restored := re.ReplaceAll(b, []byte("${1}00000${2}"))
return extractCCH(t, signBillingHeaderCCH(restored))
}
// 正确顺序:发送 body 的 cch == 重算 hash → 上游验证过
require.Equal(t, correctCCH, recomputeExpected(correctFinal, correctCCH),
"正确顺序final body 里的 cch 与重算 hash 一致 → 上游验证通过")
// 错误顺序:发送 body 的 cch 是“含 ctx 算的”,但最终 body 不含 ctx → 重算 hash 不同
require.NotEqual(t, wrongFinalCCH, recomputeExpected(wrongFinal, wrongFinalCCH),
"错误顺序final body 里的 cch 是基于含 ctx 的 body 算的,"+
"但发送 body 已 strip ctx → 上游重算 hash 与 cch 不一致 → 被判 third-party。"+
"这是 buildUpstreamRequest / buildCountTokensRequest 里 sanitize 必须在 "+
"signBillingHeaderCCH 之前的原因。")
}
// count_tokens 主路径 E2E 集成测试
func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) {
// count_tokens 路径下 mimic 不按 haiku 排除,始终注入 BetaContextManagement
// → sanitize 看到最终 beta header 含 context-management beta → 字段保留。
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
account := &Account{ID: 411, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "oauth-tok"},
Status: StatusActive, Schedulable: true,
}
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildCountTokensRequest(
context.Background(), c, account, body,
"oauth-tok", "oauth", "claude-haiku-4-5", true, // mimicClaudeCode=true
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
"count_tokens mimic 始终注入 context-management beta")
require.True(t, gjson.GetBytes(outBody, "context_management").Exists(),
"对称约束final beta 含 token 时 body 字段保留")
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaTokenCounting),
"count_tokens 路径必须含 token-counting beta")
}
func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t *testing.T) {
// API-key + haiku + 客户端 header 不带 context-management beta → final beta 不含 → strip
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
account := &Account{ID: 412, Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-ant-xxx"},
Status: StatusActive, Schedulable: true,
}
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildCountTokensRequest(
context.Background(), c, account, body,
"sk-ant-xxx", "apikey", "claude-haiku-4-5", false,
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
"count_tokens API-key + 客户端未带 beta token → body strip")
}
// count_tokens passthrough preserve 测试
func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,context-management-2025-06-27,token-counting-2024-11-01")
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
)
require.NoError(t, err)
require.True(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "context_management").Exists(),
"count_tokens passthrough + 客户端带 context-management beta → 字段保留")
}
func TestBuildUpstreamRequest_APIKeyHaikuWithContextManagement_StripsField(t *testing.T) {
// API-key + haiku + body 带 context_management + 客户端 header 未带 context-management beta
// → final beta 不含 → body 字段被 strip
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
account := &Account{ID: 404, Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-ant-xxx"},
Status: StatusActive, Schedulable: true,
}
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[]},"messages":[]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, err := svc.buildUpstreamRequest(
context.Background(), c, account, body,
"sk-ant-xxx", "apikey", "claude-haiku-4-5", false, false,
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
require.False(t, gjson.GetBytes(outBody, "context_management").Exists(),
"API-key + haiku + 客户端未带 beta token → body 字段必须被 strip")
}

View File

@ -12,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@ -665,6 +666,69 @@ func removeThinkingDependentContextStrategies(body []byte) []byte {
return body
}
// anthropicBetaContextManagementToken 是 context_management 字段受的 beta token。
// 与 claude.BetaContextManagement 保持一致;在本文件本地定义以避免震荡
// claude package 的该常量含义。
const anthropicBetaContextManagementToken = "context-management-2025-06-27"
// sanitizeAnthropicBodyForBetaTokens 是对 Anthropic 直连路径上 body↔beta header
// **能力维度**对称约束的统一实现,与 Bedrock 路径的
// `sanitizeBedrockFieldsForBetaTokens` 对称。
//
// 问题场景:
// - context_management 是 Claude Code CLI 2.1.87+ 默认携带的 beta 字段
// (含 clear_thinking_20251015 等清理策略)
// - 其被 Anthropic 上游接受的前提是 anthropic-beta header 含
// `context-management-2025-06-27`
// - 若两侧不一致上游 Pydantic schema 拒收:
// "context_management: Extra inputs are not permitted"
//
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的
// context_management 字段:缺 beta token → strip。这将限制完全建立在
// "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
//
// 调用约束:必须在 CCH 签名之前调用,否则签名 hash 与最终 body
// 不一致,上游会以 third-party 拒收。
//
// 返回 (sanitized, changed)changed 表示是否发生实际删除,供调用方决定
// 是否重用原 body 引用。
func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string) ([]byte, bool) {
if len(body) == 0 {
return body, false
}
if !gjson.GetBytes(body, "context_management").Exists() {
return body, false
}
if anthropicBetaTokensContains(anthropicBetaHeader, anthropicBetaContextManagementToken) {
return body, false
}
if b, err := sjson.DeleteBytes(body, "context_management"); err == nil {
return b, true
} else {
// 不应发生gjson 刚验证过字段存在 + body 是合法 JSON。如果 sjson 仍报错,
// 调用方会拿到 (body, false),但此前 computeFinalAnthropicBeta 已按“strip 后”
// 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
logger.LegacyPrintf("service.gateway",
"[CtxMgmtSanitize] sjson.DeleteBytes failed unexpectedly: %v (body len=%d). "+
"body and final anthropic-beta header may be out of sync.", err, len(body))
}
return body, false
}
// anthropicBetaTokensContains 检测逗号分隔的 anthropic-beta header 是否含指定 token。
// 宋体空格宽容区分大小写Anthropic beta token 始终是小写)。
func anthropicBetaTokensContains(header, token string) bool {
if header == "" || token == "" {
return false
}
for _, part := range strings.Split(header, ",") {
if strings.TrimSpace(part) == token {
return true
}
}
return false
}
// FilterSignatureSensitiveBlocksForRetry is a stronger retry filter for cases where upstream errors indicate
// signature/thought_signature validation issues involving tool blocks.
//

View File

@ -1155,6 +1155,12 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
// context_managementthinking.type 为 enabled/adaptive 时,真实 CLI 会自动
// 附带 {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}。
// 客户端显式传了就透传;否则按 CLI 行为补齐。
//
// 注:本函数不按 model 名决定是否保留 context_management。“最终 beta
// header 不含 context-management-2025-06-27 时 strip 字段”的能力维度
// 对称约束由 sanitizeAnthropicBodyForBetaTokens 在 buildUpstreamRequest /
// buildCountTokensRequest 层统一执行,与 Bedrock 路径的
// sanitizeBedrockFieldsForBetaTokens 对称。
if !gjson.GetBytes(out, "context_management").Exists() {
thinkingType := gjson.GetBytes(out, "thinking.type").String()
if thinkingType == "enabled" || thinkingType == "adaptive" {
@ -5248,6 +5254,17 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
targetURL = validatedURL + "/v1/messages?beta=true"
}
// 能力维度 body sanitize透传路径上 anthropic-beta header 原样透传客户端值,
// 依此决定是否保留 body 中的 context_management。避免“客户端 body 带字段但
// header 忘记带 beta token”的客户端 bug 在透传场景下让上游 400。
clientBeta := ""
if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
@ -6106,6 +6123,29 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
if fingerprint != nil {
body = syncBillingHeaderVersion(body, fingerprint.UserAgent)
}
// === 计算最终 anthropic-beta header先于 body sanitize 与 CCH 签名)===
//
// 顺序约束:
// 1) 算 finalBeta纯函数不依赖 req.Headermimicry 路径会忽略客户端 beta
// 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐)
// 2) 按 finalBeta 做能力维度 body sanitize如 context-management beta 缺失 →
// strip body.context_management与 Bedrock 路径对称)
// 3) CCH 签名(必须使用 strip 后的 body否则 hash 与最终 body 不一致 →
// 被 Anthropic 判 third-party
// 4) NewRequestbody 至此最终敲定)
// 5) 透传白名单 / fingerprint / mimic header / 写入 finalBeta
policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
effectiveDropSet := mergeDropSets(policyFilterSet)
finalBetaHeader, finalBetaShouldSet := s.computeFinalAnthropicBeta(
tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet,
)
// 能力维度 body sanitize与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized
}
// CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后)
if enableCCH {
body = signBillingHeaderCCH(body)
@ -6156,46 +6196,18 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
applyClaudeOAuthHeaderDefaults(req)
}
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
effectiveDropSet := mergeDropSets(policyFilterSet)
// OAuth + mimic Claude Code强制注入 CLI 指纹相关 header
// user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id
if tokenType == "oauth" && mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, reqStream)
}
// 处理 anthropic-beta headerOAuth 账号需要包含 oauth beta
if tokenType == "oauth" {
if mimicClaudeCode {
// 非 Claude Code 客户端:按 opencode 的策略处理:
// - 强制 Claude Code 指纹相关请求头(尤其是 user-agent/x-stainless/x-app
// - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在
applyClaudeCodeMimicHeaders(req, reqStream)
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
// Claude Code OAuth credentials are scoped to Claude Code.
// Non-haiku models MUST include claude-code beta for Anthropic to recognize
// this as a legitimate Claude Code request; without it, the request is
// rejected as third-party ("out of extra usage").
// Haiku models are exempt from third-party detection and don't need it.
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
if !strings.Contains(strings.ToLower(modelID), "haiku") {
requiredBetas = claude.FullClaudeCodeMimicryBetas()
}
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet))
} else {
// Claude Code 客户端:尽量透传原始 header仅补齐 oauth beta
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBetaHeader), effectiveDropSet))
}
} else {
// API-key accounts: apply beta policy filter to strip controlled tokens
if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, effectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
// 写入最终 anthropic-beta header
// 注:透传分支白名单可能写入了客户端 anthropic-beta无条件 Del 一次再按 finalBeta
// 决定是否 set确保 dropSet 过滤后的结果一定覆盖客户端原始值。
deleteHeaderAllForms(req.Header, "anthropic-beta")
if finalBetaShouldSet {
setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader)
}
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
@ -6242,6 +6254,16 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
if err != nil {
return nil, err
}
// 能力维度 sanitizeVertex 路径上 anthropic-beta header 原样透传客户端值
// (下面白名单跳过 anthropic-version 但保留 anthropic-beta依此决定是否
// 保留 body 中的 context_management与 Anthropic 直连 / Bedrock 路径对称。
if c != nil && c.Request != nil {
clientBeta := getHeaderRaw(c.Request.Header, "anthropic-beta")
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, clientBeta); changed {
vertexBody = sanitized
}
}
fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream)
if err != nil {
return nil, err
@ -6410,6 +6432,121 @@ func mergeAnthropicBetaDropping(required []string, incoming string, drop map[str
return strings.Join(out, ",")
}
// computeFinalAnthropicBeta 计算发往上游的最终 anthropic-beta header 值。
//
// 设计动机:将原本在 buildUpstreamRequest 内联在一起、依赖 req.Header 的
// anthropic-beta 计算逻辑抽成纯函数。这样调用方可以在 NewRequest 之前
// 就提前拿到最终 beta header进而能按它对 body 做能力维度 sanitize 后再做
// CCH 签名——一举修复了以下之前由顺序依赖导致的能力维度 sanitize
// 无法部署的问题(签名与最终 body 不一致可以被判 third-party
//
// 返回 (value, shouldSet)
// - shouldSet=false 意为“不主动设置 anthropic-beta header”与原代码“
// API-key 账号 + 客户端未传 anthropic-beta + InjectBetaForAPIKey 未开启或
// requestNeedsBetaFeatures=false”的行为对齐。
// - shouldSet=true 时 value 可能为空字符串(例如客户端透传的 beta 被 dropSet
// 全部过滤掉),这与原代码中 setHeaderRaw 的结果一致。
//
// clientHeaders 是客户端原始 HTTP header通常为 c.Request.Headernil 时按“客户端
// 未传”处理。body 是已经 metadata 重写 / billing version sync 之后但未 sanitize 上游
// 不兼容字段之前的版本。
func (s *GatewayService) computeFinalAnthropicBeta(
tokenType string,
mimicClaudeCode bool,
modelID string,
clientHeaders http.Header,
body []byte,
effectiveDropSet map[string]struct{},
) (string, bool) {
clientBeta := ""
if clientHeaders != nil {
clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
}
if tokenType == "oauth" {
if mimicClaudeCode {
// mimic 路径原代码跳过白名单透传incomingBeta 总是空字符串。
// 这里传空 string 以严格对齐原行为。
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
if !strings.Contains(strings.ToLower(modelID), "haiku") {
requiredBetas = claude.FullClaudeCodeMimicryBetas()
}
return mergeAnthropicBetaDropping(requiredBetas, "", effectiveDropSet), true
}
// 真 Claude Code 客户端透传路径
return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true
}
// API-key accounts
if clientBeta != "" {
return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
}
if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
return beta, true
}
}
}
return "", false
}
// computeFinalCountTokensAnthropicBeta 是 count_tokens 路径上 anthropic-beta header 的
// 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的
// 两条特殊规则:
//
// - OAuth mimicrequiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting
// (与 messages 不同的是:不按 haiku 排除count_tokens 始终携带 token-counting beta
// - OAuth 透传 + 客户端未传 anthropic-beta补齐 CountTokensBetaHeader
// - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting如果未含
//
// 返回语义同 computeFinalAnthropicBeta。
func (s *GatewayService) computeFinalCountTokensAnthropicBeta(
tokenType string,
mimicClaudeCode bool,
modelID string,
clientHeaders http.Header,
body []byte,
effectiveDropSet map[string]struct{},
) (string, bool) {
clientBeta := ""
if clientHeaders != nil {
clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
}
if tokenType == "oauth" {
if mimicClaudeCode {
// 与原代码严格等价original buildCountTokensRequest 在 count_tokens mimic
// 分支上**不**会跳过白名单透传(与 messages mimic 路径不同),所以
// incomingBeta = req.Header[anthropic-beta] = 客户端透传过来的 client beta。
// 重构后直接从 clientHeaders 拿同一个值,保持行为一致。
requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting)
return mergeAnthropicBetaDropping(requiredBetas, clientBeta, effectiveDropSet), true
}
if clientBeta == "" {
return claude.CountTokensBetaHeader, true
}
beta := s.getBetaHeader(modelID, clientBeta)
if !strings.Contains(beta, claude.BetaTokenCounting) {
beta = beta + "," + claude.BetaTokenCounting
}
return stripBetaTokensWithSet(beta, effectiveDropSet), true
}
// API-key accounts
if clientBeta != "" {
return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
}
if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
return beta, true
}
}
}
return "", false
}
// stripBetaTokens removes the given beta tokens from a comma-separated header value.
func stripBetaTokens(header string, tokens []string) string {
if header == "" || len(tokens) == 0 {
@ -9312,6 +9449,15 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
targetURL = validatedURL + "/v1/messages/count_tokens?beta=true"
}
// 同 buildUpstreamRequestAnthropicAPIKeyPassthrough能力维度 sanitize。
clientBeta := ""
if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
@ -9402,6 +9548,19 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if ctFingerprint != nil && ctEnableFP {
body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent)
}
// === 计算最终 anthropic-beta header先于 body sanitize 与 CCH 签名)===
// 顺序约束同 buildUpstreamRequest。
ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID))
finalBetaHeader, finalBetaShouldSet := s.computeFinalCountTokensAnthropicBeta(
tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet,
)
// 能力维度 body sanitize与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized
}
if ctEnableCCH {
body = signBillingHeaderCCH(body)
}
@ -9445,41 +9604,15 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
applyClaudeOAuthHeaderDefaults(req)
}
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID))
// OAuth + mimic Claude Code强制注入 CLI 指纹 header
if tokenType == "oauth" && mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, false)
}
// OAuth 账号:处理 anthropic-beta header
if tokenType == "oauth" {
if mimicClaudeCode {
applyClaudeCodeMimicHeaders(req, false)
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting)
setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
} else {
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
if clientBetaHeader == "" {
setHeaderRaw(req.Header, "anthropic-beta", claude.CountTokensBetaHeader)
} else {
beta := s.getBetaHeader(modelID, clientBetaHeader)
if !strings.Contains(beta, claude.BetaTokenCounting) {
beta = beta + "," + claude.BetaTokenCounting
}
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
}
}
} else {
// API-key accounts: apply beta policy filter to strip controlled tokens
if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, ctEffectiveDropSet))
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
// API-key与 messages 同步的按需 beta 注入(默认关闭)
if requestNeedsBetaFeatures(body) {
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
// 写入最终 anthropic-beta headerDel 一次避免白名单透传值残留)
deleteHeaderAllForms(req.Header, "anthropic-beta")
if finalBetaShouldSet {
setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader)
}
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖

View File

@ -109,6 +109,20 @@ 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