gateway_billing_header_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. package service
  2. import (
  3. "context"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/assert"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. "github.com/tidwall/sjson"
  16. )
  17. func TestSyncBillingHeaderVersion(t *testing.T) {
  18. tests := []struct {
  19. name string
  20. body string
  21. userAgent string
  22. wantSub string // substring expected in result
  23. unchanged bool // expect body to remain the same
  24. }{
  25. {
  26. name: "replaces cc_version and recomputes message-derived suffix",
  27. 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":[]}`,
  28. userAgent: "claude-cli/2.1.22 (external, cli)",
  29. wantSub: "cc_version=2.1.22." + computeClaudeCodeFingerprint([]byte(`{"messages":[]}`), "2.1.22"),
  30. },
  31. {
  32. name: "no billing header in system",
  33. body: `{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`,
  34. userAgent: "claude-cli/2.1.22",
  35. unchanged: true,
  36. },
  37. {
  38. name: "no system field",
  39. body: `{"messages":[]}`,
  40. userAgent: "claude-cli/2.1.22",
  41. unchanged: true,
  42. },
  43. {
  44. name: "user-agent without version",
  45. body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
  46. userAgent: "Mozilla/5.0",
  47. unchanged: true,
  48. },
  49. {
  50. name: "empty user-agent",
  51. body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
  52. userAgent: "",
  53. unchanged: true,
  54. },
  55. {
  56. name: "version already matches",
  57. body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.22; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
  58. userAgent: "claude-cli/2.1.22",
  59. unchanged: true,
  60. },
  61. }
  62. for _, tt := range tests {
  63. t.Run(tt.name, func(t *testing.T) {
  64. result := syncBillingHeaderVersion([]byte(tt.body), tt.userAgent)
  65. if tt.unchanged {
  66. assert.Equal(t, tt.body, string(result), "body should remain unchanged")
  67. } else {
  68. assert.Contains(t, string(result), tt.wantSub)
  69. // Ensure old semver is gone
  70. assert.NotContains(t, string(result), "cc_version=2.1.81")
  71. }
  72. })
  73. }
  74. }
  75. func TestSyncBillingHeaderVersion_RecomputesSuffixAndIsIdempotent(t *testing.T) {
  76. 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"}]}`)
  77. version := "2.1.22"
  78. result := syncBillingHeaderVersion(body, "claude-cli/"+version)
  79. require.Contains(t, gjson.GetBytes(result, "system.0.text").String(),
  80. "cc_version="+version+"."+computeClaudeCodeFingerprint(body, version)+";")
  81. require.Equal(t, string(result), string(syncBillingHeaderVersion(result, "claude-cli/"+version)))
  82. require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(result, "messages").Raw)
  83. }
  84. func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) {
  85. gin.SetMode(gin.TestMode)
  86. for _, endpoint := range []string{"messages", "count_tokens"} {
  87. for _, tc := range []struct {
  88. name string
  89. mimic bool
  90. identity bool
  91. disableFP bool
  92. }{
  93. {name: "mimic_overrides_cached_version", mimic: true, identity: true},
  94. {name: "mimic_without_identity", mimic: true},
  95. {name: "mimic_with_fingerprint_disabled", mimic: true, identity: true, disableFP: true},
  96. {name: "passthrough_uses_cached_version", identity: true},
  97. } {
  98. t.Run(endpoint+"/"+tc.name, func(t *testing.T) {
  99. resetGatewayForwardingSettingsCacheForTest(t)
  100. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  101. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  102. body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`)
  103. billing, err := buildBillingAttributionText(body, "2.1.81")
  104. require.NoError(t, err)
  105. body, err = sjson.SetBytes(body, "system.0.text", billing)
  106. require.NoError(t, err)
  107. cfg := &config.Config{}
  108. svc := &GatewayService{cfg: cfg}
  109. cachedUA := "claude-cli/2.9.0 (external, cli)"
  110. if tc.identity {
  111. svc.identityService = NewIdentityService(&stubIdentityCache{fingerprint: &Fingerprint{
  112. UserAgent: cachedUA, ClientID: "test-client", UpdatedAt: time.Now().Unix(),
  113. }})
  114. }
  115. if tc.disableFP {
  116. svc.settingService = NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{
  117. SettingKeyEnableFingerprintUnification: "false",
  118. }}, cfg)
  119. }
  120. account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
  121. var req *http.Request
  122. var wireBody []byte
  123. if endpoint == "messages" {
  124. req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account,
  125. body, "test-token", "oauth", "claude-haiku-4-5", false, tc.mimic)
  126. } else {
  127. req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account,
  128. body, "test-token", "oauth", "claude-haiku-4-5", tc.mimic)
  129. }
  130. require.NoError(t, err)
  131. defer func() { require.NoError(t, req.Body.Close()) }()
  132. wantUA := cachedUA
  133. if tc.mimic {
  134. wantUA = claude.DefaultHeaders["User-Agent"]
  135. }
  136. require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent"))
  137. version := ExtractCLIVersion(wantUA)
  138. require.Contains(t, gjson.GetBytes(wireBody, "system.0.text").String(),
  139. "cc_version="+version+"."+computeClaudeCodeFingerprint(wireBody, version)+";")
  140. actualBody, err := io.ReadAll(req.Body)
  141. require.NoError(t, err)
  142. require.Equal(t, wireBody, actualBody)
  143. })
  144. }
  145. }
  146. }