Merge pull request #3375 from StarryKira/fix/3358-vertex-beta-and-cch

fix(gateway): filter anthropic-beta on the Vertex Anthropic path + drop cch sign (#3358)
This commit is contained in:
Wesley Liddick
2026-06-21 21:13:14 +08:00
committed by GitHub
10 changed files with 343 additions and 217 deletions
@@ -222,6 +222,65 @@ func TestClaudeCodeValidator_BillingBlockStillRequiresClaudeCodeUA(t *testing.T)
require.False(t, ok)
}
// 新版 Claude Code CLI 已取消 cch=... 签名字段,billing block 形如
// `x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli;`(无 cch)。
// 检测依赖前缀 + cc_entrypoint=cli,不依赖 cch,故无身份 prose 的子请求仍应被识别。
// 这同时覆盖了本仓 mimicry 注入的新格式 block(见 buildBillingAttributionText)。
func TestClaudeCodeValidator_BillingBlockRecognizedWithoutCCH(t *testing.T) {
monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
require.NoError(t, err)
validator := NewClaudeCodeValidator()
require.Less(t, validator.bestSimilarityScore(string(monitorPrompt)), systemPromptThreshold)
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.162 (external, cli)")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-3-5-haiku-20241022",
"system": []any{
map[string]any{
"type": "text",
// 注意:无 cch 段,对齐新版 CLI 与本仓新的注入格式。
"text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli;",
},
map[string]any{
"type": "text",
"text": string(monitorPrompt),
},
},
"metadata": map[string]any{
"user_id": claudeCodeMetadataUserIDJSON,
},
})
require.True(t, ok, "无 cch 的新版 billing block 仍应被识别为 Claude Code")
}
// 安全回归:去掉 cch 后检测并未放松——非 claude-cli UA 即便携带无 cch 的 billing block
// 仍在 Step 1 被拒,ClaudeCodeOnly group 不会因此被仿冒绕过。
func TestClaudeCodeValidator_NoCCHBlockStillRequiresClaudeCodeUA(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "curl/8.0.0")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-3-5-haiku-20241022",
"system": []any{
map[string]any{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli;",
},
},
})
require.False(t, ok)
}
func TestClaudeCodeValidator_MessagesPathRejectsNonClaudeCodeUA(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
+3 -1
View File
@@ -421,7 +421,9 @@ const (
SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification"
// SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false
SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough"
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false
// SettingKeyEnableCCHSigning 已废弃(no-op):新版 Claude Code CLI 已取消 cch 签名字段,
// 网关随之不再注入/签名 cch(见 buildBillingAttributionText)。保留该 key 仅为向后兼容,
// 开关不再产生任何效果。
SettingKeyEnableCCHSigning = "enable_cch_signing"
// SettingKeyEnableClaudeOAuthSystemPromptInjection 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true
SettingKeyEnableClaudeOAuthSystemPromptInjection = "enable_claude_oauth_system_prompt_injection"
@@ -0,0 +1,194 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func newVertexBetaTestContext(t *testing.T, anthropicBeta string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
if anthropicBeta != "" {
c.Request.Header.Set("Anthropic-Beta", anthropicBeta)
}
return c
}
func newVertexServiceAccount(id int64) *Account {
return &Account{
ID: id,
Platform: PlatformAnthropic,
Type: AccountTypeServiceAccount,
Credentials: map[string]any{
"project_id": "vertex-proj",
"location": "us-east5",
},
}
}
// 复刻线上 400:近期 Claude Code CLI 透传的整份 anthropic-beta header 里含 Vertex
// 不接受的 tokenadvisor-tool / prompt-caching-scope / redact-thinking /
// thinking-token-count)。Vertex builder 必须剥掉它们,否则上游 HTTP 400issue #3358)。
// 本用例在 Commit 1 之前 FAIL、之后 PASS。
func TestVertexBetaFilter_StripsUnsupportedClaudeCodeTokens(t *testing.T) {
c := newVertexBetaTestContext(t,
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,"+
"advisor-tool-2026-03-01,prompt-caching-scope-2026-01-05,"+
"redact-thinking-2026-02-12,thinking-token-count-2026-05-13,"+
"context-management-2025-06-27")
body := []byte(`{"model":"claude-opus-4-7","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`)
svc := &GatewayService{}
req, _, err := svc.buildUpstreamRequest(
context.Background(), c, newVertexServiceAccount(401), body,
"vertex-token", "service_account", "claude-opus-4-7@20260417", false, false,
)
require.NoError(t, err)
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
// Vertex 拒绝的 token 必须全部剥掉。
for _, bad := range []string{
"advisor-tool-2026-03-01",
"prompt-caching-scope-2026-01-05",
"redact-thinking-2026-02-12",
"thinking-token-count-2026-05-13",
// 客户端身份 betaVertex service_account 不需要,亦不在白名单。
"claude-code-20250219",
"oauth-2025-04-20",
} {
require.False(t, anthropicBetaTokensContains(outBeta, bad),
"token %q 必须被剥离;实际 outgoing beta=%q", bad, outBeta)
}
// 白名单内的 token 必须保留。
for _, keep := range []string{
"interleaved-thinking-2025-05-14",
"context-management-2025-06-27",
} {
require.True(t, anthropicBetaTokensContains(outBeta, keep),
"token %q 应保留;实际 outgoing beta=%q", keep, outBeta)
}
}
// 全部 token 都不受 Vertex 支持时,outgoing header 不应下发 anthropic-beta。
func TestVertexBetaFilter_DropsHeaderWhenAllUnsupported(t *testing.T) {
c := newVertexBetaTestContext(t,
"prompt-caching-scope-2026-01-05,redact-thinking-2026-02-12")
body := []byte(`{"model":"claude-opus-4-7","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`)
svc := &GatewayService{}
req, _, err := svc.buildUpstreamRequest(
context.Background(), c, newVertexServiceAccount(402), body,
"vertex-token", "service_account", "claude-opus-4-7@20260417", false, false,
)
require.NoError(t, err)
require.Empty(t, getHeaderRaw(req.Header, "anthropic-beta"),
"所有 token 被剥离后不应残留 anthropic-beta header")
}
// 能力维度 sanitize 以「最终 beta」为准:客户端只带不支持的 prompt-caching-scope(会被剥光),
// body 又带 context_management → 因最终 header 不含 context-management betabody 字段必须 strip。
// 证明 sanitize 不再以原始 client 值为准(修复前用 clientBeta,会错误保留 context_management)。
func TestVertexBetaFilter_BodySanitizeKeysOnFinalBeta(t *testing.T) {
c := newVertexBetaTestContext(t, "prompt-caching-scope-2026-01-05")
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[{"role":"user","content":"hi"}]}`)
svc := &GatewayService{}
req, _, err := svc.buildUpstreamRequest(
context.Background(), c, newVertexServiceAccount(403), body,
"vertex-token", "service_account", "claude-opus-4-7@20260417", false, false,
)
require.NoError(t, err)
got := readRequestBodyForTest(t, req)
require.False(t, gjson.GetBytes(got, "context_management").Exists(),
"最终 beta 不含 context-management 时 body.context_management 必须被 strip")
require.Empty(t, getHeaderRaw(req.Header, "anthropic-beta"))
}
// BetaPolicy block 规则在 Vertex 路径同样生效:管理员 block 某 token,客户端带它 → 直接报错。
func TestVertexBetaFilter_BlocksViaBetaPolicy(t *testing.T) {
settings := &BetaPolicySettings{
Rules: []BetaPolicyRule{
{
BetaToken: "context-management-2025-06-27",
Action: BetaPolicyActionBlock,
Scope: BetaPolicyScopeAll,
ErrorMessage: "context management is blocked",
},
},
}
raw, err := json.Marshal(settings)
require.NoError(t, err)
svc := &GatewayService{
settingService: NewSettingService(
&betaPolicySettingRepoStub{values: map[string]string{
SettingKeyBetaPolicySettings: string(raw),
}},
&config.Config{},
),
}
c := newVertexBetaTestContext(t,
"interleaved-thinking-2025-05-14,context-management-2025-06-27")
body := []byte(`{"model":"claude-opus-4-7","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`)
_, _, err = svc.buildUpstreamRequest(
context.Background(), c, newVertexServiceAccount(404), body,
"vertex-token", "service_account", "claude-opus-4-7@20260417", false, false,
)
require.Error(t, err)
var blocked *BetaBlockedError
require.True(t, errors.As(err, &blocked), "expected *BetaBlockedError, got %T", err)
require.Equal(t, "context management is blocked", err.Error())
}
// filterVertexBetaTokens 单元测试:白名单过滤 + drop 集合 + 去重 + 空输入。
func TestFilterVertexBetaTokens(t *testing.T) {
t.Run("whitelist filters unsupported", func(t *testing.T) {
out := filterVertexBetaTokens(
"interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,context-management-2025-06-27",
nil,
)
require.Equal(t, "interleaved-thinking-2025-05-14,context-management-2025-06-27", out)
})
t.Run("drop set strips before whitelist", func(t *testing.T) {
out := filterVertexBetaTokens(
"interleaved-thinking-2025-05-14,context-management-2025-06-27",
map[string]struct{}{"context-management-2025-06-27": {}},
)
require.Equal(t, "interleaved-thinking-2025-05-14", out)
})
t.Run("dedupe", func(t *testing.T) {
out := filterVertexBetaTokens(
"context-1m-2025-08-07,context-1m-2025-08-07",
nil,
)
require.Equal(t, "context-1m-2025-08-07", out)
})
t.Run("empty input", func(t *testing.T) {
require.Empty(t, filterVertexBetaTokens("", nil))
require.Empty(t, filterVertexBetaTokens("prompt-caching-scope-2026-01-05", nil))
})
}
@@ -72,12 +72,14 @@ func extractFirstUserText(body []byte) string {
// buildBillingAttributionText 构造 system 数组的 billing attribution 文本。
//
// 形态严格对齐真实 Claude Code CLI
// 形态对齐真实 Claude Code CLI
//
// x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; cch=00000;
// x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli;
//
// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段
// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。
// 注意:新版 Claude Code CLI 已不再发送 cch=... 签名字段(见 issue #3358)。我们
// 随之去掉了 cch 段——继续注入它反而会让伪装请求偏离真实 CLI 流量。cc_version +
// cc_entrypoint=cli 仍保留:它们是客户端识别(claude_code_validator)与 Anthropic
// 第一方判定都依赖的稳定信号。
//
// 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的
// Claude Code prompt block 承担)。
@@ -87,7 +89,7 @@ func buildBillingAttributionText(body []byte, cliVersion string) (string, error)
}
fp := computeClaudeCodeFingerprint(body, cliVersion)
return fmt.Sprintf(
"x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;",
"x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli;",
cliVersion, fp,
), nil
}
@@ -5,7 +5,6 @@ import (
"regexp"
"strings"
"github.com/cespare/xxhash/v2"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -14,12 +13,6 @@ import (
// the trailing message-derived suffix (e.g. ".c02") if present.
var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`)
// cchPlaceholderRe matches the cch=00000 placeholder in billing header text,
// scoped to x-anthropic-billing-header to avoid touching user content.
var cchPlaceholderRe = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`)
const cchSeed uint64 = 0x6E52736AC806831E
// syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header
// system text blocks to match the version extracted from userAgent.
// Only touches system array blocks whose text starts with "x-anthropic-billing-header".
@@ -53,21 +46,3 @@ func syncBillingHeaderVersion(body []byte, userAgent string) []byte {
return body
}
// signBillingHeaderCCH computes the xxHash64-based CCH signature for the request
// body and replaces the cch=00000 placeholder with the computed 5-hex-char hash.
// The body must contain the placeholder when this function is called.
func signBillingHeaderCCH(body []byte) []byte {
if !cchPlaceholderRe.Match(body) {
return body
}
cch := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF)
return cchPlaceholderRe.ReplaceAll(body, []byte("${1}"+cch+"${3}"))
}
// xxHash64Seeded computes xxHash64 of data with a custom seed.
func xxHash64Seeded(data []byte, seed uint64) uint64 {
d := xxhash.NewWithSeed(seed)
_, _ = d.Write(data)
return d.Sum64()
}
@@ -1,13 +1,9 @@
package service
import (
"fmt"
"testing"
"github.com/cespare/xxhash/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSyncBillingHeaderVersion(t *testing.T) {
@@ -69,97 +65,3 @@ func TestSyncBillingHeaderVersion(t *testing.T) {
})
}
}
func TestSignBillingHeaderCCH(t *testing.T) {
t.Run("replaces placeholder with hash", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
result := signBillingHeaderCCH(body)
// Should not have the placeholder anymore
assert.NotContains(t, string(result), "cch=00000")
// Should have a 5 hex-char cch value
billingText := gjson.GetBytes(result, "system.0.text").String()
require.Contains(t, billingText, "cch=")
assert.Regexp(t, `cch=[0-9a-f]{5};`, billingText)
})
t.Run("no placeholder - body unchanged", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=abcde;"}],"messages":[]}`)
result := signBillingHeaderCCH(body)
assert.Equal(t, string(body), string(result))
})
t.Run("no billing header - body unchanged", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`)
result := signBillingHeaderCCH(body)
assert.Equal(t, string(body), string(result))
})
t.Run("cch=00000 in user content is not touched", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"keep literal cch=00000 in this message"}]}]}`)
result := signBillingHeaderCCH(body)
// Billing header should be signed
billingText := gjson.GetBytes(result, "system.0.text").String()
assert.NotContains(t, billingText, "cch=00000")
// User message should keep its literal cch=00000
userText := gjson.GetBytes(result, "messages.0.content.0.text").String()
assert.Contains(t, userText, "cch=00000")
})
t.Run("signing is deterministic", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
r1 := signBillingHeaderCCH(body)
body2 := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
r2 := signBillingHeaderCCH(body2)
assert.Equal(t, string(r1), string(r2))
})
t.Run("matches reference algorithm", func(t *testing.T) {
// Verify: signBillingHeaderCCH(body) produces cch = xxHash64(body_with_placeholder, seed) & 0xFFFFF
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
expectedCCH := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF)
result := signBillingHeaderCCH(body)
billingText := gjson.GetBytes(result, "system.0.text").String()
assert.Contains(t, billingText, "cch="+expectedCCH+";")
})
}
func TestXXHash64Seeded(t *testing.T) {
t.Run("matches cespare/xxhash for seed 0", func(t *testing.T) {
inputs := []string{"", "a", "hello world", "The quick brown fox jumps over the lazy dog"}
for _, s := range inputs {
data := []byte(s)
expected := xxhash.Sum64(data)
got := xxHash64Seeded(data, 0)
assert.Equal(t, expected, got, "mismatch for input %q", s)
}
})
t.Run("large input matches cespare", func(t *testing.T) {
data := make([]byte, 256)
for i := range data {
data[i] = byte(i)
}
expected := xxhash.Sum64(data)
got := xxHash64Seeded(data, 0)
assert.Equal(t, expected, got)
})
t.Run("deterministic with custom seed", func(t *testing.T) {
data := []byte("hello world")
h1 := xxHash64Seeded(data, cchSeed)
h2 := xxHash64Seeded(data, cchSeed)
assert.Equal(t, h1, h2)
})
t.Run("different seeds produce different results", func(t *testing.T) {
data := []byte("test data for hashing")
h1 := xxHash64Seeded(data, 0)
h2 := xxHash64Seeded(data, cchSeed)
assert.NotEqual(t, h1, h2)
})
}
@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
@@ -503,68 +502,6 @@ func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField
"回归保护:真 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
@@ -417,7 +417,8 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
require.Contains(t, billingBlock["text"], "x-anthropic-billing-header:")
require.Contains(t, billingBlock["text"], "cc_version=")
require.Contains(t, billingBlock["text"], "cc_entrypoint=cli")
require.Contains(t, billingBlock["text"], "cch=00000")
// 新版 CLI 已取消 cch=... 签名字段,注入的 billing block 不应再带 cch。
require.NotContains(t, billingBlock["text"], "cch=")
systemBlock, ok := systemArr[1].(map[string]any)
require.True(t, ok)
+77 -23
View File
@@ -4444,7 +4444,7 @@ func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expa
}
// 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态:
// [0] billing attribution blockcc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;
// [0] billing attribution blockcc_version={cliVer}.{fp}; cc_entrypoint=cli;
// [1] "You are Claude Code..." 身份前缀 block(默认不带 cache_control
// [2] 工具无关的通用提示词扩充 block(带 cache_control 作为稳定缓存断点)
//
@@ -4452,9 +4452,9 @@ func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expa
// 区别于真实 CLI。这里注入 claudeCodeSystemPromptExpansion(中性段落)把形态做到
// 接近真实,同时不注入会污染被代理用户行为的工具专属指令。
//
// billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的
// signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload
// 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。
// 缺失 billing block 的系统 payload 是 Anthropic 判定第三方的关键信号之一
// (真实 CLI 每个请求都带)。新版 CLI 已取消 cch=... 签名字段,故 block 不再注入
// cch(见 buildBillingAttributionText)。
systemBlocks, blockErr := buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, blocksConfig)
if blockErr != nil {
logger.LegacyPrintf("service.gateway", "Warning: failed to build configured Claude OAuth system blocks: %v", blockErr)
@@ -6679,9 +6679,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
var fingerprint *Fingerprint
enableFP, enableMPT, enableCCH := true, false, false
enableFP, enableMPT := true, false
if s.settingService != nil {
enableFP, enableMPT, enableCCH = s.settingService.GetGatewayForwardingSettings(ctx)
enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx)
}
if account.IsOAuth() && s.identityService != nil {
// 1. 获取或创建指纹(包含随机生成的ClientID)
@@ -6735,11 +6735,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
body = sanitized
}
// CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后)
if enableCCH {
body = signBillingHeaderCCH(body)
}
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, nil, err
@@ -6830,6 +6825,48 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
return req, body, nil
}
// vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta
// 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的
// bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未
// 支持的 token 天然被剥离。当 Vertex 新增支持某 beta 时在此补充。
//
// 明确排除(issue #3358 中 Vertex 报 400 的 token):advisor-tool-2026-03-01、
// prompt-caching-scope-2026-01-05、redact-thinking-2026-02-12、
// thinking-token-count-2026-05-13;以及 claude-code-20250219 / oauth-2025-04-20 等
// 客户端身份 beta——Vertex service_account 走 Bearer 鉴权,不需要它们。
var vertexSupportedBetaTokens = map[string]bool{
"context-1m-2025-08-07": true,
"context-management-2025-06-27": true,
"fine-grained-tool-streaming-2025-05-14": true,
"interleaved-thinking-2025-05-14": true,
}
// filterVertexBetaTokens 解析 client 的 anthropic-beta header,先剔除 drop 集合中的
// tokenBetaPolicy filter + 默认 drop),再只保留 Vertex 支持的 token,去重后逗号拼接。
// 返回最终 header(可能为空字符串)。
func filterVertexBetaTokens(header string, drop map[string]struct{}) string {
tokens := parseAnthropicBetaHeader(header)
if len(tokens) == 0 {
return ""
}
out := make([]string, 0, len(tokens))
seen := make(map[string]bool, len(tokens))
for _, t := range tokens {
if _, dropped := drop[t]; dropped {
continue
}
if !vertexSupportedBetaTokens[t] {
continue
}
if seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return strings.Join(out, ",")
}
func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
ctx context.Context,
c *gin.Context,
@@ -6844,14 +6881,27 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
return nil, err
}
// 能力维度 sanitizeVertex 路径上 anthropic-beta header 原样透传客户端值
// (下面白名单跳过 anthropic-version 但保留 anthropic-beta),依此决定是否
// 保留 body 中的 context_management,与 Anthropic 直连 / Bedrock 路径对称。
// 计算最终 outgoing anthropic-beta。Vertex AI 的 Anthropic 端点只接受一小撮
// beta token,未知 token 会直接 HTTP 400——近期 Claude Code CLI 透传的
// advisor-tool-2026-03-01 / prompt-caching-scope-2026-01-05 /
// redact-thinking-2026-02-12 / thinking-token-count-2026-05-13 都不被 Vertex 接受
// issue #3358)。这里复用 BetaPolicy 的 block 检查(与 Bedrock 的
// resolveBedrockBetaTokensForRequest 对称),再按 vertexSupportedBetaTokens 白名单
// 剥离其余 token,使该路径与 Anthropic 直连 / Bedrock 路径行为一致。
clientBeta := ""
if c != nil && c.Request != nil {
clientBeta := getHeaderRaw(c.Request.Header, "anthropic-beta")
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, clientBeta); changed {
vertexBody = sanitized
}
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
policy := s.evaluateBetaPolicy(ctx, clientBeta, account, modelID)
if policy.blockErr != nil {
return nil, policy.blockErr
}
finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet))
// 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的
// context_management,与 Anthropic 直连 / Bedrock 路径对称。
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, finalBeta); changed {
vertexBody = sanitized
}
fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream)
if err != nil {
@@ -6883,6 +6933,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
setHeaderRaw(req.Header, "content-type", "application/json")
// 覆盖上面白名单 loop 写入的原始 client anthropic-beta,使用过滤后的最终值。
// finalBeta 为空(全部被剥离)时不下发该 header,与 Vertex 无 beta 请求一致。
deleteHeaderAllForms(req.Header, "anthropic-beta")
if finalBeta != "" {
setHeaderRaw(req.Header, "anthropic-beta", finalBeta)
}
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{
"url": req.URL.String(),
"token_type": "service_account",
@@ -10181,9 +10238,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
// OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
ctEnableFP, ctEnableMPT, ctEnableCCH := true, false, false
ctEnableFP, ctEnableMPT := true, false
if s.settingService != nil {
ctEnableFP, ctEnableMPT, ctEnableCCH = s.settingService.GetGatewayForwardingSettings(ctx)
ctEnableFP, ctEnableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx)
}
var ctFingerprint *Fingerprint
if account.IsOAuth() && s.identityService != nil {
@@ -10218,9 +10275,6 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
body = sanitized
}
if ctEnableCCH {
body = signBillingHeaderCCH(body)
}
body = sanitizeCountTokensRequestBody(body)
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
+1 -1
View File
@@ -192,7 +192,7 @@ type SystemSettings struct {
// Gateway forwarding behavior
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false
EnableCCHSigning bool // 已废弃 no-op:新版 CLI 取消 cch 签名后网关不再注入/签名 cch,开关无效果
EnableClaudeOAuthSystemPromptInjection bool // 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true
ClaudeOAuthSystemPrompt string // Claude OAuth mimic 路径注入的通用扩展 system prompt;空值使用内置默认
ClaudeOAuthSystemPromptBlocks string // Claude OAuth mimic 路径注入的 system blocks JSON 配置;空值使用内置默认