mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
fix(claude): align billing fingerprint with outbound user agent
This commit is contained in:
@@ -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 签名)===
|
||||
|
||||
Reference in New Issue
Block a user