fix(claude): align billing fingerprint with outbound user agent

This commit is contained in:
cyh
2026-09-05 16:34:00 +08:00
parent cc52c93ad7
commit 994ca26e92
4 changed files with 117 additions and 11 deletions
@@ -5,16 +5,30 @@ import (
"regexp"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z), preserving
// the trailing message-derived suffix (e.g. ".c02") if present.
// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z).
var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`)
var ccVersionWithFingerprintInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+\.[0-9a-fA-F]{3}\b`)
// OAuth mimicry forces the built-in User-Agent after applying account fingerprints.
func effectiveBillingUserAgent(tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string {
if tokenType == "oauth" && mimicClaudeCode {
return claude.DefaultHeaders["User-Agent"]
}
if fingerprint == nil {
return ""
}
return fingerprint.UserAgent
}
// syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header
// system text blocks to match the version extracted from userAgent.
// Recompute any recognized fingerprint suffix because its input includes the version.
// Only touches system array blocks whose text starts with "x-anthropic-billing-header".
func syncBillingHeaderVersion(body []byte, userAgent string) []byte {
version := ExtractCLIVersion(userAgent)
@@ -33,7 +47,9 @@ func syncBillingHeaderVersion(body []byte, userAgent string) []byte {
text := item.Get("text")
if text.Exists() && text.Type == gjson.String &&
strings.HasPrefix(text.String(), "x-anthropic-billing-header") {
newText := ccVersionInBillingRe.ReplaceAllString(text.String(), replacement)
fingerprintedReplacement := replacement + "." + computeClaudeCodeFingerprint(body, version)
newText := ccVersionWithFingerprintInBillingRe.ReplaceAllString(text.String(), fingerprintedReplacement)
newText = ccVersionInBillingRe.ReplaceAllString(newText, replacement)
if newText != text.String() {
if updated, err := sjson.SetBytes(body, fmt.Sprintf("system.%d.text", idx), newText); err == nil {
body = updated
@@ -1,9 +1,20 @@
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func TestSyncBillingHeaderVersion(t *testing.T) {
@@ -15,10 +26,10 @@ func TestSyncBillingHeaderVersion(t *testing.T) {
unchanged bool // expect body to remain the same
}{
{
name: "replaces cc_version preserving message-derived suffix",
name: "replaces cc_version and recomputes message-derived suffix",
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`,
userAgent: "claude-cli/2.1.22 (external, cli)",
wantSub: "cc_version=2.1.22.df2",
wantSub: "cc_version=2.1.22." + computeClaudeCodeFingerprint([]byte(`{"messages":[]}`), "2.1.22"),
},
{
name: "no billing header in system",
@@ -65,3 +76,78 @@ func TestSyncBillingHeaderVersion(t *testing.T) {
})
}
}
func TestSyncBillingHeaderVersion_RecomputesSuffixAndIsIdempotent(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli;"}],"messages":[{"role":"user","content":"hello world"}]}`)
version := "2.1.22"
result := syncBillingHeaderVersion(body, "claude-cli/"+version)
require.Contains(t, gjson.GetBytes(result, "system.0.text").String(),
"cc_version="+version+"."+computeClaudeCodeFingerprint(body, version)+";")
require.Equal(t, string(result), string(syncBillingHeaderVersion(result, "claude-cli/"+version)))
require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(result, "messages").Raw)
}
func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, endpoint := range []string{"messages", "count_tokens"} {
for _, tc := range []struct {
name string
mimic bool
identity bool
disableFP bool
}{
{name: "mimic_overrides_cached_version", mimic: true, identity: true},
{name: "mimic_without_identity", mimic: true},
{name: "mimic_with_fingerprint_disabled", mimic: true, identity: true, disableFP: true},
{name: "passthrough_uses_cached_version", identity: true},
} {
t.Run(endpoint+"/"+tc.name, func(t *testing.T) {
resetGatewayForwardingSettingsCacheForTest(t)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`)
billing, err := buildBillingAttributionText(body, "2.1.81")
require.NoError(t, err)
body, err = sjson.SetBytes(body, "system.0.text", billing)
require.NoError(t, err)
cfg := &config.Config{}
svc := &GatewayService{cfg: cfg}
cachedUA := "claude-cli/2.9.0 (external, cli)"
if tc.identity {
svc.identityService = NewIdentityService(&stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: cachedUA, ClientID: "test-client", UpdatedAt: time.Now().Unix(),
}})
}
if tc.disableFP {
svc.settingService = NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{
SettingKeyEnableFingerprintUnification: "false",
}}, cfg)
}
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
var req *http.Request
var wireBody []byte
if endpoint == "messages" {
req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", false, tc.mimic)
} else {
req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", tc.mimic)
}
require.NoError(t, err)
defer req.Body.Close()
wantUA := cachedUA
if tc.mimic {
wantUA = claude.DefaultHeaders["User-Agent"]
}
require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent"))
version := ExtractCLIVersion(wantUA)
require.Contains(t, gjson.GetBytes(wireBody, "system.0.text").String(),
"cc_version="+version+"."+computeClaudeCodeFingerprint(wireBody, version)+";")
actualBody, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, wireBody, actualBody)
})
}
}
}
@@ -485,9 +485,13 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
if ctFingerprint != nil && ctEnableFP {
body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent)
// Disabled fingerprint unification does not disable forced mimicry headers.
var billingFingerprint *Fingerprint
if ctEnableFP {
billingFingerprint = ctFingerprint
}
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" {
body = syncBillingHeaderVersion(body, billingUA)
}
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
@@ -84,9 +84,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
if fingerprint != nil {
body = syncBillingHeaderVersion(body, fingerprint.UserAgent)
// Mimicry may override the cached User-Agent later, even without a fingerprint.
if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" {
body = syncBillingHeaderVersion(body, billingUA)
}
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===