diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index 5dd0474780..dd1f16b2c7 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -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 diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index 0d5930996a..6de6b4bfec 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -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 func() { require.NoError(t, 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) + }) + } + } +} diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index b3c7286c3f..fb745d69b3 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -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 签名)=== diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 0a8efbdd5e..675cf40001 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -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 签名)===