1250 lines
53 KiB
Go
1250 lines
53 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestResolveClaudeFingerprintPolicy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
apiKey string
|
|
attrs map[string]string
|
|
metadata map[string]any
|
|
cfg *config.Config
|
|
wantAuthOAuth bool
|
|
wantProfileOAuth bool
|
|
wantSynthesize bool
|
|
wantMCP bool
|
|
wantDiagnostics bool
|
|
wantCancellation bool
|
|
}{
|
|
{
|
|
name: "real oauth token",
|
|
apiKey: "sk-ant-oat-real",
|
|
attrs: map[string]string{"api_key": "sk-ant-oat-real"},
|
|
wantAuthOAuth: true,
|
|
wantProfileOAuth: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
wantCancellation: true,
|
|
},
|
|
{
|
|
name: "api key default",
|
|
apiKey: "key-default",
|
|
attrs: map[string]string{"api_key": "key-default"},
|
|
wantAuthOAuth: false,
|
|
wantProfileOAuth: false,
|
|
},
|
|
{
|
|
name: "official anthropic api key opts in via claude-code-cli attribute",
|
|
apiKey: "key-attr",
|
|
attrs: map[string]string{"api_key": "key-attr", "fingerprint_profile": "claude-code-cli"},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "official anthropic api key opts in via oauth-cli alias",
|
|
apiKey: "key-attr-legacy",
|
|
attrs: map[string]string{"api_key": "key-attr-legacy", "fingerprint_profile": "oauth-cli"},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "official anthropic explicit 443 api key opts in via profile",
|
|
apiKey: "key-official-443",
|
|
attrs: map[string]string{"api_key": "key-official-443", "base_url": "https://api.anthropic.com:443", "fingerprint_profile": "claude-code-cli"},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "api key claude-code-cli attribute on gateway",
|
|
apiKey: "key-attr-gateway",
|
|
attrs: map[string]string{
|
|
"api_key": "key-attr-gateway",
|
|
"base_url": "https://gateway.example",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "api key claude-code-cli config entry",
|
|
apiKey: "key-config",
|
|
attrs: map[string]string{"api_key": "key-config", "base_url": "https://gateway.example"},
|
|
cfg: &config.Config{ClaudeKey: []config.ClaudeKey{{
|
|
APIKey: "key-config",
|
|
BaseURL: "https://gateway.example",
|
|
FingerprintProfile: "claude-code-cli",
|
|
}}},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "official anthropic api key opts in via metadata profile",
|
|
apiKey: "key-metadata",
|
|
attrs: map[string]string{"api_key": "key-metadata"},
|
|
metadata: map[string]any{"fingerprint_profile": "claude-code-cli"},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "kimi default token has no fingerprint",
|
|
provider: "kimi",
|
|
apiKey: "kimi-access-token",
|
|
metadata: map[string]any{"access_token": "kimi-access-token"},
|
|
},
|
|
{
|
|
name: "kimi with claude-code-cli profile opts in",
|
|
provider: "kimi",
|
|
apiKey: "kimi-access-token",
|
|
metadata: map[string]any{
|
|
"access_token": "kimi-access-token",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "kimi oauth json hyphenated fingerprint-profile opts in",
|
|
provider: "kimi",
|
|
apiKey: "kimi-access-token",
|
|
metadata: map[string]any{
|
|
"access_token": "kimi-access-token",
|
|
"fingerprint-profile": "claude-code-cli",
|
|
},
|
|
wantProfileOAuth: true,
|
|
wantSynthesize: true,
|
|
wantMCP: true,
|
|
wantDiagnostics: true,
|
|
},
|
|
{
|
|
name: "unknown profile ignored",
|
|
apiKey: "key-unknown",
|
|
attrs: map[string]string{"api_key": "key-unknown", "fingerprint_profile": "not-a-profile"},
|
|
wantAuthOAuth: false,
|
|
wantProfileOAuth: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: tt.provider,
|
|
Attributes: tt.attrs,
|
|
Metadata: tt.metadata,
|
|
}
|
|
fp := resolveClaudeFingerprintPolicy(tt.cfg, auth, tt.apiKey)
|
|
if fp.AuthIsOAuthToken != tt.wantAuthOAuth {
|
|
t.Fatalf("AuthIsOAuthToken = %v, want %v", fp.AuthIsOAuthToken, tt.wantAuthOAuth)
|
|
}
|
|
if fp.ProfileClaudeCodeCLI != tt.wantProfileOAuth {
|
|
t.Fatalf("ProfileClaudeCodeCLI = %v, want %v", fp.ProfileClaudeCodeCLI, tt.wantProfileOAuth)
|
|
}
|
|
if fp.UseOAuthBetas != tt.wantProfileOAuth || fp.ApplyCLIIdentity != tt.wantProfileOAuth {
|
|
t.Fatalf("UseOAuthBetas/ApplyCLIIdentity = %v/%v, want %v", fp.UseOAuthBetas, fp.ApplyCLIIdentity, tt.wantProfileOAuth)
|
|
}
|
|
if fp.SynthesizeIdentity != tt.wantSynthesize {
|
|
t.Fatalf("SynthesizeIdentity = %v, want %v", fp.SynthesizeIdentity, tt.wantSynthesize)
|
|
}
|
|
if fp.MCPAlias != tt.wantMCP {
|
|
t.Fatalf("MCPAlias = %v, want %v", fp.MCPAlias, tt.wantMCP)
|
|
}
|
|
if fp.InjectDiagnostics != tt.wantDiagnostics {
|
|
t.Fatalf("InjectDiagnostics = %v, want %v", fp.InjectDiagnostics, tt.wantDiagnostics)
|
|
}
|
|
if fp.OAuthCancellation != tt.wantCancellation {
|
|
t.Fatalf("OAuthCancellation = %v, want %v", fp.OAuthCancellation, tt.wantCancellation)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClaudeFingerprintProfileFromAuthConcurrentMetadata(t *testing.T) {
|
|
auth := &cliproxyauth.Auth{Metadata: map[string]any{
|
|
claudeFingerprintProfileAttr: "claude-code-cli",
|
|
}}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
for range 1_000 {
|
|
if got := claudeFingerprintProfileFromAuth(auth); got != claudeFingerprintProfileClaudeCodeCLI {
|
|
t.Errorf("claudeFingerprintProfileFromAuth() = %q, want %q", got, claudeFingerprintProfileClaudeCodeCLI)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
for range 1_000 {
|
|
claudeauth.StoreMetadataString(
|
|
&auth.Metadata,
|
|
"account_uuid",
|
|
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
)
|
|
}
|
|
}()
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestApplyClaudeHeaders_ClaudeCodeCLIProfileUsesOAuthBetasWithoutPretendingToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
|
"api_key": "key-third-party",
|
|
"base_url": "https://gateway.example",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
}}
|
|
req, errReq := http.NewRequest(http.MethodPost, "https://gateway.example/v1/messages?beta=true", nil)
|
|
if errReq != nil {
|
|
t.Fatalf("NewRequest() error = %v", errReq)
|
|
}
|
|
if errHeaders := applyClaudeHeaders(req, auth, "key-third-party", false, nil, []byte(`{"model":"claude-sonnet-5"}`), &config.Config{}, nil, false, "11111111-2222-4333-8444-555555555555"); errHeaders != nil {
|
|
t.Fatalf("applyClaudeHeaders() error = %v", errHeaders)
|
|
}
|
|
if got := req.Header.Get("Authorization"); got != "Bearer key-third-party" {
|
|
t.Fatalf("Authorization = %q, want API key bearer", got)
|
|
}
|
|
if got := req.Header.Get("x-api-key"); got != "" {
|
|
t.Fatalf("x-api-key = %q, want empty on third-party gateway", got)
|
|
}
|
|
betas := req.Header.Get("Anthropic-Beta")
|
|
if !strings.Contains(betas, "oauth-2025-04-20") {
|
|
t.Fatalf("Anthropic-Beta = %q, want oauth beta", betas)
|
|
}
|
|
if !strings.Contains(betas, "extended-cache-ttl-2025-04-11") {
|
|
t.Fatalf("Anthropic-Beta = %q, want extended-cache-ttl", betas)
|
|
}
|
|
if !strings.Contains(betas, "fallback-credit-2026-06-01") {
|
|
t.Fatalf("Anthropic-Beta = %q, want fallback-credit", betas)
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeHeaders_OfficialAPIKeyDefaultRespectsClient(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
|
"api_key": "key-official",
|
|
}}
|
|
req, errReq := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil)
|
|
if errReq != nil {
|
|
t.Fatalf("NewRequest() error = %v", errReq)
|
|
}
|
|
incoming := http.Header{}
|
|
incoming.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
|
|
if errHeaders := applyClaudeHeaders(req, auth, "key-official", false, nil, []byte(`{"model":"claude-sonnet-5"}`), &config.Config{}, incoming, false); errHeaders != nil {
|
|
t.Fatalf("applyClaudeHeaders() error = %v", errHeaders)
|
|
}
|
|
if got := req.Header.Get("Authorization"); got != "" {
|
|
t.Fatalf("Authorization = %q, want empty on official Anthropic API key", got)
|
|
}
|
|
if got := req.Header.Get("x-api-key"); got != "key-official" {
|
|
t.Fatalf("x-api-key = %q, want API key", got)
|
|
}
|
|
betas := req.Header.Get("Anthropic-Beta")
|
|
if strings.Contains(betas, "oauth-2025-04-20") {
|
|
t.Fatalf("Anthropic-Beta = %q, default official API key must not add oauth beta", betas)
|
|
}
|
|
if strings.Contains(betas, "fallback-credit-2026-06-01") {
|
|
t.Fatalf("Anthropic-Beta = %q, default official API key must not add fallback-credit", betas)
|
|
}
|
|
if !strings.Contains(betas, "interleaved-thinking-2025-05-14") {
|
|
t.Fatalf("Anthropic-Beta = %q, want caller interleaved-thinking beta", betas)
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeHeaders_OfficialAPIKeyClaudeCodeCLIProfileUsesOAuthBetas(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
|
"api_key": "key-official-fp",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
}}
|
|
req, errReq := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", nil)
|
|
if errReq != nil {
|
|
t.Fatalf("NewRequest() error = %v", errReq)
|
|
}
|
|
if errHeaders := applyClaudeHeaders(req, auth, "key-official-fp", false, nil, []byte(`{"model":"claude-sonnet-5"}`), &config.Config{}, nil, false, "11111111-2222-4333-8444-555555555555"); errHeaders != nil {
|
|
t.Fatalf("applyClaudeHeaders() error = %v", errHeaders)
|
|
}
|
|
if got := req.Header.Get("Authorization"); got != "" {
|
|
t.Fatalf("Authorization = %q, want empty on official Anthropic API key", got)
|
|
}
|
|
if got := req.Header.Get("x-api-key"); got != "key-official-fp" {
|
|
t.Fatalf("x-api-key = %q, want API key", got)
|
|
}
|
|
betas := req.Header.Get("Anthropic-Beta")
|
|
if !strings.Contains(betas, "oauth-2025-04-20") {
|
|
t.Fatalf("Anthropic-Beta = %q, want oauth beta after fingerprint-profile opt-in", betas)
|
|
}
|
|
if !strings.Contains(betas, "extended-cache-ttl-2025-04-11") {
|
|
t.Fatalf("Anthropic-Beta = %q, want extended-cache-ttl after fingerprint-profile opt-in", betas)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_ClaudeCodeCLIFingerprintOnThirdPartyGateway(t *testing.T) {
|
|
var seenBody []byte
|
|
var seenHeaders http.Header
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seenBody, _ = io.ReadAll(r.Body)
|
|
seenHeaders = r.Header.Clone()
|
|
upstreamToolName := gjson.GetBytes(seenBody, "tools.0.name").String()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(
|
|
`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"` +
|
|
upstreamToolName +
|
|
`","input":{}}],"stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":1}}`,
|
|
))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{
|
|
ClaudeKey: []config.ClaudeKey{{
|
|
APIKey: "key-claude-code-cli-fp",
|
|
BaseURL: server.URL,
|
|
FingerprintProfile: "claude-code-cli",
|
|
Cloak: &config.CloakConfig{Mode: "always"},
|
|
}},
|
|
}
|
|
executor := NewClaudeExecutor(cfg)
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-code-cli-api-key",
|
|
Attributes: map[string]string{
|
|
"api_key": "key-claude-code-cli-fp",
|
|
"base_url": server.URL,
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
}
|
|
payload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":[{"type":"text","text":"What can you do?"}]}],"tools":[{"name":"read_file","description":"Read a file","input_schema":{"type":"object"}}]}`)
|
|
|
|
response, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-5",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute() error = %v", errExecute)
|
|
}
|
|
|
|
if got := seenHeaders.Get("Authorization"); got != "Bearer key-claude-code-cli-fp" {
|
|
t.Fatalf("Authorization = %q, want API key bearer", got)
|
|
}
|
|
wantBetas := claudeCodeCLIBetas(payload, nil, true)
|
|
if got := seenHeaders.Get("Anthropic-Beta"); got != wantBetas {
|
|
t.Fatalf("Anthropic-Beta = %q, want %q", got, wantBetas)
|
|
}
|
|
|
|
billing := gjson.GetBytes(seenBody, "system.0.text").String()
|
|
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") {
|
|
t.Fatalf("system.0.text = %q, want billing header", billing)
|
|
}
|
|
// Native only emits cch for firstParty on api.anthropic.com or for vertex. A
|
|
// third-party gateway therefore gets the billing header without a per-request
|
|
// hash, which is both the measured shape and what keeps the gateway's prompt
|
|
// cache stable.
|
|
if strings.Contains(billing, "cch=") {
|
|
t.Fatalf("billing = %q, want no cch on a third-party gateway", billing)
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
|
|
t.Fatalf("system.1.text = %q, want CLI identity", got)
|
|
}
|
|
|
|
userID := gjson.GetBytes(seenBody, "metadata.user_id").String()
|
|
if !helps.IsValidUserID(userID) {
|
|
t.Fatalf("metadata.user_id = %q, want valid", userID)
|
|
}
|
|
if got := gjson.Get(userID, "account_uuid").String(); got == "" {
|
|
t.Fatal("account_uuid is empty for claude-code-cli fingerprint identity")
|
|
}
|
|
sessionHeader := seenHeaders.Get("X-Claude-Code-Session-Id")
|
|
if sessionHeader == "" {
|
|
t.Fatal("missing X-Claude-Code-Session-Id")
|
|
}
|
|
if got := gjson.Get(userID, "session_id").String(); got != sessionHeader {
|
|
t.Fatalf("metadata session_id = %q, header = %q", got, sessionHeader)
|
|
}
|
|
|
|
upstreamToolName := gjson.GetBytes(seenBody, "tools.0.name").String()
|
|
if !strings.HasPrefix(upstreamToolName, "mcp__") {
|
|
t.Fatalf("upstream tool name = %q, want OAuth CLI MCP alias", upstreamToolName)
|
|
}
|
|
if got := gjson.GetBytes(response.Payload, "content.0.name").String(); got != "read_file" {
|
|
t.Fatalf("downstream tool name = %q, want restored caller name", got)
|
|
}
|
|
}
|
|
|
|
type claudeFingerprintRoundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f claudeFingerprintRoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func TestClaudeExecutor_OfficialAPIKeyDefaultRespectsClient(t *testing.T) {
|
|
var seenBody []byte
|
|
var seenHeaders http.Header
|
|
transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
seenHeaders = req.Header.Clone()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
|
)),
|
|
}, nil
|
|
})
|
|
ctx := context.WithValue(
|
|
context.Background(),
|
|
"cliproxy.roundtripper",
|
|
http.RoundTripper(transport),
|
|
)
|
|
cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{
|
|
APIKey: "key-official-default",
|
|
}}}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "official-api-key-default",
|
|
Attributes: map[string]string{
|
|
"api_key": "key-official-default",
|
|
},
|
|
}
|
|
payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"read_file","input_schema":{"type":"object"}}]}`)
|
|
|
|
_, errExecute := NewClaudeExecutor(cfg).Execute(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-5",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatClaude,
|
|
Headers: http.Header{
|
|
"Anthropic-Beta": []string{"caller-private-beta-2099-01-01"},
|
|
"User-Agent": []string{"caller-agent/1.0"},
|
|
},
|
|
})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute() error = %v", errExecute)
|
|
}
|
|
if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() {
|
|
t.Fatalf("diagnostics = %s, default official API key must not inject CLI diagnostics", diagnostics.Raw)
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "tools.0.name").String(); got != "read_file" {
|
|
t.Fatalf("tools.0.name = %q, want caller name", got)
|
|
}
|
|
if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") {
|
|
t.Fatalf("default official API key must not inject billing/CCH: %s", seenBody)
|
|
}
|
|
userID := gjson.GetBytes(seenBody, "metadata.user_id").String()
|
|
if userID != "" && gjson.Get(userID, "account_uuid").String() != "" {
|
|
t.Fatalf("metadata.user_id = %q, default official API key must not synthesize CLI account_uuid", userID)
|
|
}
|
|
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
|
|
if strings.Contains(betas, "oauth-2025-04-20") {
|
|
t.Fatalf("Anthropic-Beta = %q, default official API key must not add oauth beta", betas)
|
|
}
|
|
if betas != "caller-private-beta-2099-01-01" {
|
|
t.Fatalf("Anthropic-Beta = %q, want exact caller beta", betas)
|
|
}
|
|
if got := claudeFingerprintHeaderValue(seenHeaders, "User-Agent"); got != "caller-agent/1.0" {
|
|
t.Fatalf("User-Agent = %q, want caller value", got)
|
|
}
|
|
if got := claudeFingerprintHeaderValue(seenHeaders, "x-api-key"); got != "key-official-default" {
|
|
t.Fatalf("x-api-key = %q, want API key auth", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_OfficialAPIKeyDefaultPreservesCallerCCH(t *testing.T) {
|
|
var seenBody []byte
|
|
transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
|
}, nil
|
|
})
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
|
|
auth := &cliproxyauth.Auth{ID: "official-caller-cch", Attributes: map[string]string{"api_key": "key-official-caller-cch"}}
|
|
payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=caller; cch=abcde;"},{"type":"text","text":"Keep this rule."}],"messages":[{"role":"user","content":"hello"}]}`)
|
|
if _, errExecute := NewClaudeExecutor(&config.Config{}).Execute(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-5", Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errExecute != nil {
|
|
t.Fatalf("Execute() error = %v", errExecute)
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "x-anthropic-billing-header: cc_version=caller; cch=abcde;" {
|
|
t.Fatalf("caller billing/CCH = %q, want byte-preserved text", got)
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != "Keep this rule." {
|
|
t.Fatalf("caller system text = %q, want preserved", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_OfficialAPIKeyClaudeCodeCLIFingerprintIncludesDiagnostics(t *testing.T) {
|
|
var seenBody []byte
|
|
var seenHeaders http.Header
|
|
transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
seenHeaders = req.Header.Clone()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
|
)),
|
|
}, nil
|
|
})
|
|
ctx := context.WithValue(
|
|
context.Background(),
|
|
"cliproxy.roundtripper",
|
|
http.RoundTripper(transport),
|
|
)
|
|
cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{
|
|
APIKey: "key-official-fp",
|
|
FingerprintProfile: "claude-code-cli",
|
|
}}}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "official-api-key-fp",
|
|
Attributes: map[string]string{
|
|
"api_key": "key-official-fp",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
}
|
|
payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"hello"}]}`)
|
|
|
|
_, errExecute := NewClaudeExecutor(cfg).Execute(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-5",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute() error = %v", errExecute)
|
|
}
|
|
if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); !diagnostics.IsObject() {
|
|
t.Fatalf("diagnostics = %s, want object after fingerprint-profile opt-in", diagnostics.Raw)
|
|
}
|
|
// api.anthropic.com is the one API-key origin where native emits cch, so the
|
|
// opt-in must produce a finalized signature here.
|
|
billing := gjson.GetBytes(seenBody, "system.0.text").String()
|
|
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") || !strings.Contains(billing, "cch=") {
|
|
t.Fatalf("billing = %q, want signed cch on api.anthropic.com", billing)
|
|
}
|
|
if strings.Contains(billing, "cch=00000") {
|
|
t.Fatalf("billing = %q, want finalized cch signature", billing)
|
|
}
|
|
userID := gjson.GetBytes(seenBody, "metadata.user_id").String()
|
|
if !helps.IsValidUserID(userID) || gjson.Get(userID, "account_uuid").String() == "" {
|
|
t.Fatalf("metadata.user_id = %q, want synthesized CLI identity", userID)
|
|
}
|
|
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
|
|
if !strings.Contains(betas, "oauth-2025-04-20") {
|
|
t.Fatalf("Anthropic-Beta = %q, want oauth beta after fingerprint-profile opt-in", betas)
|
|
}
|
|
if !strings.Contains(betas, claudeCacheDiagnosisBeta) {
|
|
t.Fatalf("Anthropic-Beta = %q, want %q", betas, claudeCacheDiagnosisBeta)
|
|
}
|
|
if got := claudeFingerprintHeaderValue(seenHeaders, "x-api-key"); got != "key-official-fp" {
|
|
t.Fatalf("x-api-key = %q, want API key auth", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_ClaudeCodeCLIFingerprintStreamMatchesWirePolicy(t *testing.T) {
|
|
var seenBody []byte
|
|
var seenHeaders http.Header
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seenBody, _ = io.ReadAll(r.Body)
|
|
seenHeaders = r.Header.Clone()
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte(
|
|
"event: message_start\n" +
|
|
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_1\"}}\n\n" +
|
|
"event: message_stop\n" +
|
|
"data: {\"type\":\"message_stop\"}\n\n",
|
|
))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{
|
|
APIKey: "key-claude-code-cli-stream",
|
|
BaseURL: server.URL,
|
|
FingerprintProfile: "claude-code-cli",
|
|
Cloak: &config.CloakConfig{Mode: "always"},
|
|
}}}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-code-cli-stream",
|
|
Attributes: map[string]string{
|
|
"api_key": "key-claude-code-cli-stream",
|
|
"base_url": server.URL,
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
}
|
|
payload := []byte(`{"model":"claude-sonnet-5","max_tokens":64,"thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"read_file","input_schema":{"type":"object"}}]}`)
|
|
|
|
result, errStream := NewClaudeExecutor(cfg).ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-5",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errStream != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", errStream)
|
|
}
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
}
|
|
if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() {
|
|
t.Fatalf("diagnostics = %s, custom gateway must not inherit official diagnostics", diagnostics.Raw)
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") {
|
|
t.Fatalf("stream tool name = %q, want OAuth CLI MCP alias", got)
|
|
}
|
|
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
|
|
if !strings.Contains(betas, "oauth-2025-04-20") {
|
|
t.Fatalf("Anthropic-Beta = %q, want oauth beta on custom gateway", betas)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExecutor_ClaudeCodeCLIFingerprintCountTokensKeepsNativeShape(t *testing.T) {
|
|
var seenBody []byte
|
|
var seenHeaders http.Header
|
|
transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
seenHeaders = req.Header.Clone()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"input_tokens":12}`)),
|
|
}, nil
|
|
})
|
|
ctx := context.WithValue(
|
|
context.Background(),
|
|
"cliproxy.roundtripper",
|
|
http.RoundTripper(transport),
|
|
)
|
|
cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{
|
|
APIKey: "key-claude-code-cli-count",
|
|
FingerprintProfile: "claude-code-cli",
|
|
}}}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "claude-code-cli-count",
|
|
Attributes: map[string]string{
|
|
"api_key": "key-claude-code-cli-count",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
}
|
|
payload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"hello"}],"tools":[{"name":"read_file","input_schema":{"type":"object"}}],"metadata":{"user_id":"remove"},"diagnostics":{"previous_message_id":"remove"}}`)
|
|
|
|
_, errCount := NewClaudeExecutor(cfg).countTokensUpstream(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "claude-sonnet-5",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if errCount != nil {
|
|
t.Fatalf("countTokensUpstream() error = %v", errCount)
|
|
}
|
|
for _, field := range []string{"system", "metadata", "context_management", "diagnostics"} {
|
|
if got := gjson.GetBytes(seenBody, field); got.Exists() {
|
|
t.Fatalf("count_tokens %s = %s, want absent", field, got.Raw)
|
|
}
|
|
}
|
|
if strings.Contains(string(seenBody), "cch=") {
|
|
t.Fatalf("count_tokens body contains CCH: %s", seenBody)
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") {
|
|
t.Fatalf("count_tokens tool name = %q, want OAuth CLI MCP alias after fingerprint-profile opt-in", got)
|
|
}
|
|
if got, want := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta"), claudeCountTokensBetasForCredential(true); got != want {
|
|
t.Fatalf("Anthropic-Beta = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutor_ClaudeMessagesWithAndWithoutClaudeCodeCLIFingerprint(t *testing.T) {
|
|
var seenBodies [][]byte
|
|
var seenHeaders []http.Header
|
|
var mu sync.Mutex
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
body, _ := io.ReadAll(req.Body)
|
|
mu.Lock()
|
|
seenBodies = append(seenBodies, body)
|
|
seenHeaders = append(seenHeaders, req.Header.Clone())
|
|
mu.Unlock()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"msg_test","type":"message","role":"assistant","model":"k2.5","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`,
|
|
)),
|
|
}, nil
|
|
}))
|
|
|
|
executor := NewKimiExecutor(&config.Config{})
|
|
payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`)
|
|
|
|
// 1. Default Kimi OAuth: no fingerprint injection.
|
|
defaultAuth := &cliproxyauth.Auth{
|
|
ID: "kimi-auth-default",
|
|
Provider: "kimi",
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}
|
|
_, errDefault := executor.Execute(ctx, defaultAuth, cliproxyexecutor.Request{
|
|
Model: "kimi-k2.5(max)",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatClaude,
|
|
OriginalRequest: payload,
|
|
Headers: http.Header{
|
|
"Anthropic-Beta": []string{"kimi-caller-beta"},
|
|
"User-Agent": []string{"kimi-caller/1.0"},
|
|
},
|
|
})
|
|
if errDefault != nil {
|
|
t.Fatalf("default Execute() error = %v", errDefault)
|
|
}
|
|
|
|
// 2. Kimi OAuth with fingerprint_profile: "claude-code-cli": opts into Claude Code CLI fingerprint.
|
|
fpAuth := &cliproxyauth.Auth{
|
|
ID: "kimi-auth-profile",
|
|
Provider: "kimi",
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{
|
|
"access_token": "test-token",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
}
|
|
_, errFP := executor.Execute(ctx, fpAuth, cliproxyexecutor.Request{
|
|
Model: "kimi-k2.5(max)",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatClaude,
|
|
OriginalRequest: payload,
|
|
})
|
|
if errFP != nil {
|
|
t.Fatalf("fingerprint Execute() error = %v", errFP)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(seenBodies) != 2 {
|
|
t.Fatalf("expected 2 captured requests, got %d", len(seenBodies))
|
|
}
|
|
|
|
// Default Kimi preserves caller fingerprint headers and strips billing/CCH.
|
|
if got := gjson.GetBytes(seenBodies[0], "metadata.user_id").String(); got != "" {
|
|
t.Fatalf("default Kimi request should not have metadata.user_id: %q", got)
|
|
}
|
|
if strings.Contains(string(seenBodies[0]), "cch=") || strings.Contains(string(seenBodies[0]), "x-anthropic-billing-header:") {
|
|
t.Fatalf("default Kimi request should not have billing/CCH: %s", seenBodies[0])
|
|
}
|
|
if got := claudeFingerprintHeaderValue(seenHeaders[0], "Anthropic-Beta"); got != "kimi-caller-beta" {
|
|
t.Fatalf("default Kimi Anthropic-Beta = %q, want caller beta", got)
|
|
}
|
|
if got := claudeFingerprintHeaderValue(seenHeaders[0], "User-Agent"); got != "kimi-caller/1.0" {
|
|
t.Fatalf("default Kimi User-Agent = %q, want caller value", got)
|
|
}
|
|
|
|
// Opt-in Kimi requests use the complete CLI fingerprint. Kimi is not a native
|
|
// cch origin, so the billing header goes out unsigned, exactly as native does
|
|
// against a non-first-party base URL.
|
|
userID := gjson.GetBytes(seenBodies[1], "metadata.user_id").String()
|
|
if !helps.IsValidUserID(userID) {
|
|
t.Fatalf("opt-in Kimi metadata.user_id = %q, want valid synthesized user_id", userID)
|
|
}
|
|
if got := gjson.Get(userID, "account_uuid").String(); got == "" {
|
|
t.Fatal("opt-in Kimi request should have non-empty synthesized account_uuid")
|
|
}
|
|
billing := gjson.GetBytes(seenBodies[1], "system.0.text").String()
|
|
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") {
|
|
t.Fatalf("opt-in Kimi request must carry Claude billing attribution: %s", seenBodies[1])
|
|
}
|
|
if strings.Contains(billing, "cch=") {
|
|
t.Fatalf("opt-in Kimi billing = %q, want no cch off first-party origins", billing)
|
|
}
|
|
betas := claudeFingerprintHeaderValue(seenHeaders[1], "Anthropic-Beta")
|
|
if !strings.Contains(betas, "oauth-2025-04-20") || !strings.Contains(betas, "extended-cache-ttl-2025-04-11") {
|
|
t.Fatalf("opt-in Kimi Anthropic-Beta = %q, want full OAuth CLI beta set", betas)
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutor_ClaudeCodeCLIProfileKeepsUnsignedBillingAndNativeCountTokens(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
run func(context.Context, *KimiExecutor, *cliproxyauth.Auth, []byte) error
|
|
}{
|
|
{name: "stream", run: func(ctx context.Context, executor *KimiExecutor, auth *cliproxyauth.Auth, payload []byte) error {
|
|
result, errStream := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "kimi-k2.5(max)", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload})
|
|
if errStream != nil {
|
|
return errStream
|
|
}
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
return chunk.Err
|
|
}
|
|
}
|
|
return nil
|
|
}},
|
|
{name: "count tokens", run: func(ctx context.Context, executor *KimiExecutor, auth *cliproxyauth.Auth, payload []byte) error {
|
|
_, errCount := executor.CountTokens(ctx, auth, cliproxyexecutor.Request{Model: "kimi-k2.5(max)", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload})
|
|
return errCount
|
|
}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var seenBody []byte
|
|
var seenHeaders http.Header
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
seenHeaders = req.Header.Clone()
|
|
if strings.Contains(req.URL.Path, "count_tokens") {
|
|
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"input_tokens":7}`))}, nil
|
|
}
|
|
stream := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"k2.5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
|
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream))}, nil
|
|
}))
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "kimi-profile-" + test.name,
|
|
Provider: "kimi",
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{
|
|
"access_token": "test-token",
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
}
|
|
payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`)
|
|
if errRun := test.run(ctx, NewKimiExecutor(&config.Config{}), auth, payload); errRun != nil {
|
|
t.Fatalf("request error = %v", errRun)
|
|
}
|
|
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
|
|
if test.name == "count tokens" {
|
|
for _, field := range []string{"system", "metadata", "context_management", "diagnostics"} {
|
|
if got := gjson.GetBytes(seenBody, field); got.Exists() {
|
|
t.Fatalf("count_tokens %s = %s, want absent", field, got.Raw)
|
|
}
|
|
}
|
|
if strings.Contains(string(seenBody), "cch=") || strings.Contains(string(seenBody), "currentDate") {
|
|
t.Fatalf("count_tokens must keep the native shape without CCH/currentDate: %s", seenBody)
|
|
}
|
|
if want := claudeCountTokensBetasForCredential(true); betas != want {
|
|
t.Fatalf("Anthropic-Beta = %q, want count_tokens CLI set %q", betas, want)
|
|
}
|
|
return
|
|
}
|
|
billing := gjson.GetBytes(seenBody, "system.0.text").String()
|
|
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") {
|
|
t.Fatalf("upstream body is missing opt-in billing attribution: %s", seenBody)
|
|
}
|
|
if strings.Contains(billing, "cch=") {
|
|
t.Fatalf("opt-in Kimi billing = %q, want no cch off first-party origins", billing)
|
|
}
|
|
if !strings.Contains(betas, "oauth-2025-04-20") || !strings.Contains(betas, "extended-cache-ttl-2025-04-11") {
|
|
t.Fatalf("Anthropic-Beta = %q, want full OAuth CLI beta set", betas)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The custom-header escape hatch must have the same scope in caller-owned mode as
|
|
// it has on the CLI path: a non-streaming third-party gateway keeps operator
|
|
// overrides, while api.anthropic.com and streaming requests claw them back.
|
|
func TestApplyClaudeHeaders_CallerOwnedScopesOperatorHeaderOverrides(t *testing.T) {
|
|
newAuth := func() *cliproxyauth.Auth {
|
|
return &cliproxyauth.Auth{
|
|
ID: "caller-owned-operator-headers",
|
|
Attributes: map[string]string{
|
|
"api_key": "key-operator-headers",
|
|
"header:Accept": "application/vnd.gateway+json",
|
|
"header:Accept-Encoding": "identity",
|
|
},
|
|
}
|
|
}
|
|
body := []byte(`{"model":"claude-opus-4-6"}`)
|
|
// The caller deliberately sends neither header; only the operator configured them.
|
|
incoming := http.Header{}
|
|
|
|
// Non-streaming custom gateway: the documented escape hatch wins. This is the
|
|
// case the caller-owned branch used to break by resetting on "caller sent none"
|
|
// instead of on upstream/stream scope.
|
|
gatewayReq := httptest.NewRequest(http.MethodPost, "https://gateway.example/v1/messages", nil)
|
|
if err := applyClaudeHeaders(gatewayReq, newAuth(), "key-operator-headers", false, nil, body, nil, incoming, false); err != nil {
|
|
t.Fatalf("applyClaudeHeaders(gateway) error = %v", err)
|
|
}
|
|
if got := gatewayReq.Header.Get("Accept"); got != "application/vnd.gateway+json" {
|
|
t.Fatalf("gateway Accept = %q, want the operator override preserved", got)
|
|
}
|
|
if got := gatewayReq.Header.Get("Accept-Encoding"); got != "identity" {
|
|
t.Fatalf("gateway Accept-Encoding = %q, want the operator override preserved", got)
|
|
}
|
|
|
|
// Streaming custom gateway: transport negotiation is restored so an Accept
|
|
// override cannot silently disable SSE.
|
|
streamReq := httptest.NewRequest(http.MethodPost, "https://gateway.example/v1/messages", nil)
|
|
if err := applyClaudeHeaders(streamReq, newAuth(), "key-operator-headers", true, nil, body, nil, incoming, false); err != nil {
|
|
t.Fatalf("applyClaudeHeaders(stream) error = %v", err)
|
|
}
|
|
if got := streamReq.Header.Get("Accept"); got != "text/event-stream" {
|
|
t.Fatalf("stream Accept = %q, want event-stream negotiation restored", got)
|
|
}
|
|
|
|
// api.anthropic.com: first-party identity is never operator-overridable.
|
|
directReq := newClaudeHeaderTestRequest(t, nil)
|
|
if err := applyClaudeHeaders(directReq, newAuth(), "key-operator-headers", false, nil, body, nil, incoming, false); err != nil {
|
|
t.Fatalf("applyClaudeHeaders(direct) error = %v", err)
|
|
}
|
|
if got := directReq.Header.Get("Accept-Encoding"); got != "gzip, deflate, br, zstd" {
|
|
t.Fatalf("direct Accept-Encoding = %q, want the operator override clawed back", got)
|
|
}
|
|
}
|
|
|
|
// Restoring transport negotiation must restore the caller's own choice, not CPA's
|
|
// default: this mode is caller-owned.
|
|
func TestApplyClaudeHeaders_CallerOwnedRestoreKeepsCallerAccept(t *testing.T) {
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "caller-owned-restore",
|
|
Attributes: map[string]string{
|
|
"api_key": "key-restore",
|
|
"header:Accept": "application/vnd.operator+json",
|
|
},
|
|
}
|
|
incoming := http.Header{"Accept": {"application/vnd.caller+json"}}
|
|
req := newClaudeHeaderTestRequest(t, incoming)
|
|
if err := applyClaudeHeaders(req, auth, "key-restore", false, nil,
|
|
[]byte(`{"model":"claude-opus-4-6"}`), nil, incoming, false); err != nil {
|
|
t.Fatalf("applyClaudeHeaders() error = %v", err)
|
|
}
|
|
if got := req.Header.Get("Accept"); got != "application/vnd.caller+json" {
|
|
t.Fatalf("Accept = %q, want the caller value restored rather than a CPA default", got)
|
|
}
|
|
}
|
|
|
|
// A caller that sends no User-Agent must not reach the upstream as Go's transport
|
|
// default, which reads as a bot signature. Uses a real socket because that default
|
|
// is added by the transport, not by the header builder.
|
|
func TestClaudeExecutor_CallerOwnedNeverSendsGoTransportUserAgent(t *testing.T) {
|
|
var seen http.Header
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r.Header.Clone()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","content":[],"usage":{"input_tokens":1,"output_tokens":1}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
auth := &cliproxyauth.Auth{ID: "caller-owned-ua", Attributes: map[string]string{
|
|
"api_key": "key-caller-owned-ua",
|
|
"base_url": server.URL,
|
|
}}
|
|
if _, err := NewClaudeExecutor(&config.Config{}).Execute(context.Background(), auth,
|
|
cliproxyexecutor.Request{Model: "claude-opus-4-6", Payload: []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`)},
|
|
cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
got := seen.Get("User-Agent")
|
|
if strings.HasPrefix(got, "Go-http-client") {
|
|
t.Fatalf("User-Agent = %q, want CPA's own identity rather than Go's transport default", got)
|
|
}
|
|
if !strings.HasPrefix(got, "CLIProxyAPI/") {
|
|
t.Fatalf("User-Agent = %q, want a CLIProxyAPI/<version> fallback", got)
|
|
}
|
|
}
|
|
|
|
// A caller that does send a User-Agent keeps it verbatim.
|
|
func TestClaudeExecutor_CallerOwnedForwardsCallerUserAgent(t *testing.T) {
|
|
var seen http.Header
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r.Header.Clone()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","content":[],"usage":{"input_tokens":1,"output_tokens":1}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
auth := &cliproxyauth.Auth{ID: "caller-owned-ua-keep", Attributes: map[string]string{
|
|
"api_key": "key-caller-owned-ua-keep",
|
|
"base_url": server.URL,
|
|
}}
|
|
if _, err := NewClaudeExecutor(&config.Config{}).Execute(context.Background(), auth,
|
|
cliproxyexecutor.Request{Model: "claude-opus-4-6", Payload: []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`)},
|
|
cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatClaude,
|
|
Headers: http.Header{"User-Agent": {"my-sdk/1.2.3"}},
|
|
}); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if got := seen.Get("User-Agent"); got != "my-sdk/1.2.3" {
|
|
t.Fatalf("User-Agent = %q, want the caller value forwarded verbatim", got)
|
|
}
|
|
}
|
|
|
|
// Without a profile opt-in the caller owns its count_tokens body. A caller that
|
|
// deliberately sends context_management expects the returned count to reflect it,
|
|
// so CPA must not quietly reshape the request into the CLI contract.
|
|
func TestKimiExecutor_DefaultCountTokensRespectsCallerBody(t *testing.T) {
|
|
var seenBody []byte
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"input_tokens":7}`)),
|
|
}, nil
|
|
}))
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "kimi-default-count-tokens",
|
|
Provider: "kimi",
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}
|
|
payload := []byte(`{"model":"kimi-k2.5(max)","system":"caller system","messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":"caller-user"},"context_management":{"edits":[]}}`)
|
|
if _, errCount := NewKimiExecutor(&config.Config{}).CountTokens(ctx, auth,
|
|
cliproxyexecutor.Request{Model: "kimi-k2.5(max)", Payload: payload},
|
|
cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errCount != nil {
|
|
t.Fatalf("CountTokens() error = %v", errCount)
|
|
}
|
|
if len(seenBody) == 0 {
|
|
t.Fatal("expected an upstream count_tokens request")
|
|
}
|
|
// Positive pins first: assert the request really arrived for this model, so the
|
|
// preservation assertions below cannot pass vacuously.
|
|
if got := gjson.GetBytes(seenBody, "model").String(); got == "" {
|
|
t.Fatalf("upstream model is empty: %s", seenBody)
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
|
|
t.Fatalf("upstream messages length = %d, want 1: %s", got, seenBody)
|
|
}
|
|
for _, field := range []string{"system", "metadata", "context_management"} {
|
|
if !gjson.GetBytes(seenBody, field).Exists() {
|
|
t.Fatalf("default count_tokens dropped caller-owned %q: %s", field, seenBody)
|
|
}
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != "caller-user" {
|
|
t.Fatalf("metadata.user_id = %q, want the caller value preserved", got)
|
|
}
|
|
// Default mode must not add the CLI billing/CCH attribution either.
|
|
if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") {
|
|
t.Fatalf("default count_tokens must not inject billing/CCH: %s", seenBody)
|
|
}
|
|
}
|
|
|
|
// api.anthropic.com rejects metadata/context_management/diagnostics on
|
|
// count_tokens regardless of profile, so upstream compatibility still strips them
|
|
// for an unprofiled first-party API key.
|
|
func TestClaudeExecutor_DefaultCountTokensStillStripsAnthropicRejectedFields(t *testing.T) {
|
|
var seenBody []byte
|
|
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"input_tokens":11}`)),
|
|
Request: req,
|
|
}, nil
|
|
})
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
|
|
auth := &cliproxyauth.Auth{ID: "anthropic-default-count", Attributes: map[string]string{"api_key": "key-default-count"}}
|
|
payload := []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":"caller-user"},"context_management":{"edits":[]},"diagnostics":{"previous_message_id":null}}`)
|
|
if _, errCount := NewClaudeExecutor(&config.Config{}).CountTokens(ctx, auth,
|
|
cliproxyexecutor.Request{Model: "claude-opus-4-6", Payload: payload},
|
|
cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errCount != nil {
|
|
t.Fatalf("CountTokens() error = %v", errCount)
|
|
}
|
|
if len(seenBody) == 0 {
|
|
t.Fatal("expected an upstream count_tokens request")
|
|
}
|
|
if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
|
|
t.Fatalf("upstream messages length = %d, want 1: %s", got, seenBody)
|
|
}
|
|
for _, field := range []string{"metadata", "context_management", "diagnostics"} {
|
|
if got := gjson.GetBytes(seenBody, field); got.Exists() {
|
|
t.Fatalf("api.anthropic.com count_tokens %s = %s, want stripped", field, got.Raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutor_ClaudeCodeCLIIdentitySurvivesAccessTokenRotation(t *testing.T) {
|
|
var seenBodies [][]byte
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
body, _ := io.ReadAll(req.Body)
|
|
seenBodies = append(seenBodies, body)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"id":"msg_test","type":"message","role":"assistant","model":"k2.5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)),
|
|
}, nil
|
|
}))
|
|
payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`)
|
|
for _, token := range []string{"token-before-refresh", "token-after-refresh"} {
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "stable-kimi-auth",
|
|
Provider: "kimi",
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{
|
|
"access_token": token,
|
|
"fingerprint_profile": "claude-code-cli",
|
|
},
|
|
}
|
|
if _, errExecute := NewKimiExecutor(&config.Config{}).Execute(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "kimi-k2.5(max)",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errExecute != nil {
|
|
t.Fatalf("Execute(%q) error = %v", token, errExecute)
|
|
}
|
|
}
|
|
if len(seenBodies) != 2 {
|
|
t.Fatalf("captured %d requests, want 2", len(seenBodies))
|
|
}
|
|
firstUserID := gjson.GetBytes(seenBodies[0], "metadata.user_id").String()
|
|
secondUserID := gjson.GetBytes(seenBodies[1], "metadata.user_id").String()
|
|
for _, field := range []string{"account_uuid", "device_id"} {
|
|
if first, second := gjson.Get(firstUserID, field).String(), gjson.Get(secondUserID, field).String(); first == "" || first != second {
|
|
t.Fatalf("%s changed across access token refresh: %q vs %q", field, first, second)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStripDefaultKimiClaudeCodeAttributionRespectsProfile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"},{"type":"text","text":"Keep this rule."}],"messages":[]}`)
|
|
kimiAuth := &cliproxyauth.Auth{Provider: "kimi"}
|
|
if got := stripDefaultKimiClaudeCodeAttribution(kimiAuth, "https://api.kimi.com/coding/v1/messages", false, body); strings.Contains(string(got), "cch=") || !strings.Contains(string(got), "Keep this rule.") {
|
|
t.Fatalf("default Kimi stripping produced %s", got)
|
|
}
|
|
if got := stripDefaultKimiClaudeCodeAttribution(kimiAuth, "https://api.kimi.com/coding/v1/messages", true, body); !strings.Contains(string(got), "cch=abcde") {
|
|
t.Fatalf("profiled Kimi request lost caller CCH: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutor_StripsCallerClaudeCodeCCH(t *testing.T) {
|
|
var seenBody []byte
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
seenBody, _ = io.ReadAll(req.Body)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"msg_test","type":"message","role":"assistant","model":"k2.5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`,
|
|
)),
|
|
}, nil
|
|
}))
|
|
payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220; cch=abcde;"},{"type":"text","text":"Keep this rule."}],"messages":[{"role":"user","content":"hello"}]}`)
|
|
_, errExecute := NewKimiExecutor(&config.Config{}).Execute(ctx, &cliproxyauth.Auth{
|
|
Provider: "kimi",
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}, cliproxyexecutor.Request{
|
|
Model: "kimi-k2.5(max)",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatClaude,
|
|
OriginalRequest: payload,
|
|
})
|
|
if errExecute != nil {
|
|
t.Fatalf("Execute() error = %v", errExecute)
|
|
}
|
|
if strings.Contains(string(seenBody), "cch=") || strings.Contains(string(seenBody), "x-anthropic-billing-header:") {
|
|
t.Fatalf("Kimi upstream body still has Claude CCH attribution: %s", seenBody)
|
|
}
|
|
if !strings.Contains(string(seenBody), "Keep this rule.") {
|
|
t.Fatalf("Kimi upstream body dropped caller system text: %s", seenBody)
|
|
}
|
|
}
|
|
|
|
// Profile resolution runs several times per request, so an unrecognized value must
|
|
// not turn one config typo into a per-request log flood.
|
|
func TestNormalizeClaudeFingerprintProfileWarnsOncePerValue(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
previous := log.StandardLogger().Out
|
|
log.SetOutput(&buf)
|
|
defer log.SetOutput(previous)
|
|
|
|
const (
|
|
firstTypo = "claude-code-cli-test-typo-a"
|
|
secondTypo = "claude-code-cli-test-typo-b"
|
|
)
|
|
defer claudeFingerprintProfileWarned.Delete(firstTypo)
|
|
defer claudeFingerprintProfileWarned.Delete(secondTypo)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if got := normalizeClaudeFingerprintProfile(firstTypo); got != claudeFingerprintProfileDefault {
|
|
t.Fatalf("normalizeClaudeFingerprintProfile(%q) = %q, want default", firstTypo, got)
|
|
}
|
|
}
|
|
normalizeClaudeFingerprintProfile(secondTypo)
|
|
for i := 0; i < 3; i++ {
|
|
normalizeClaudeFingerprintProfile("claude-code-cli")
|
|
normalizeClaudeFingerprintProfile("")
|
|
}
|
|
|
|
if got := strings.Count(buf.String(), firstTypo); got != 1 {
|
|
t.Fatalf("warnings for %q = %d, want exactly 1: %s", firstTypo, got, buf.String())
|
|
}
|
|
if got := strings.Count(buf.String(), secondTypo); got != 1 {
|
|
t.Fatalf("warnings for %q = %d, want exactly 1: %s", secondTypo, got, buf.String())
|
|
}
|
|
if got := strings.Count(buf.String(), "unrecognized claude fingerprint-profile"); got != 2 {
|
|
t.Fatalf("total warnings = %d, want 2 (one per distinct value): %s", got, buf.String())
|
|
}
|
|
}
|
|
|
|
// The CLI profile is credential-scoped: the same credential resolves the same
|
|
// policy regardless of which upstream URL the request is being built for. Origin
|
|
// only decides CCH signing, through claudeCCHSigningEnabled.
|
|
func TestResolveClaudeFingerprintPolicyIsOriginIndependent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{
|
|
APIKey: "key-origin-independent",
|
|
FingerprintProfile: "claude-code-cli",
|
|
}}}
|
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-origin-independent"}}
|
|
|
|
policy := resolveClaudeFingerprintPolicy(cfg, auth, "key-origin-independent")
|
|
if !policy.ProfileClaudeCodeCLI || policy.AuthIsOAuthToken {
|
|
t.Fatalf("policy = %+v, want opted-in non-OAuth profile", policy)
|
|
}
|
|
for _, origin := range []string{
|
|
"https://api.anthropic.com/v1/messages?beta=true",
|
|
"https://gateway.example/v1/messages?beta=true",
|
|
"https://api.kimi.com/v1/messages",
|
|
} {
|
|
wantCCH := origin == "https://api.anthropic.com/v1/messages?beta=true"
|
|
if got := claudeCCHSigningEnabled("key-origin-independent", claudeCCHUpstreamAnthropic, policy.ProfileClaudeCodeCLI, origin); got != wantCCH {
|
|
t.Fatalf("claudeCCHSigningEnabled(%q) = %t, want %t", origin, got, wantCCH)
|
|
}
|
|
}
|
|
}
|
|
|
|
func claudeFingerprintHeaderValue(headers http.Header, name string) string {
|
|
for key, values := range headers {
|
|
if strings.EqualFold(key, name) {
|
|
return strings.Join(values, ",")
|
|
}
|
|
}
|
|
return ""
|
|
}
|