| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153 |
- 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) {
- tests := []struct {
- name string
- body string
- userAgent string
- wantSub string // substring expected in result
- unchanged bool // expect body to remain the same
- }{
- {
- 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." + computeClaudeCodeFingerprint([]byte(`{"messages":[]}`), "2.1.22"),
- },
- {
- name: "no billing header in system",
- body: `{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`,
- userAgent: "claude-cli/2.1.22",
- unchanged: true,
- },
- {
- name: "no system field",
- body: `{"messages":[]}`,
- userAgent: "claude-cli/2.1.22",
- unchanged: true,
- },
- {
- name: "user-agent without version",
- body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
- userAgent: "Mozilla/5.0",
- unchanged: true,
- },
- {
- name: "empty user-agent",
- body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
- userAgent: "",
- unchanged: true,
- },
- {
- name: "version already matches",
- body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.22; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
- userAgent: "claude-cli/2.1.22",
- unchanged: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := syncBillingHeaderVersion([]byte(tt.body), tt.userAgent)
- if tt.unchanged {
- assert.Equal(t, tt.body, string(result), "body should remain unchanged")
- } else {
- assert.Contains(t, string(result), tt.wantSub)
- // Ensure old semver is gone
- assert.NotContains(t, string(result), "cc_version=2.1.81")
- }
- })
- }
- }
- 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)
- })
- }
- }
- }
|