From 37249339ac026b35fa708e9cd68fec948bf60e8d Mon Sep 17 00:00:00 2001 From: edlsh Date: Wed, 1 Apr 2026 13:03:17 -0400 Subject: [PATCH] feat: add opt-in experimental Claude cch signing --- config.example.yaml | 2 + go.mod | 1 + go.sum | 2 + internal/config/config.go | 9 +- internal/runtime/executor/claude_executor.go | 102 ++++---- .../runtime/executor/claude_executor_test.go | 230 ++++++++++++------ internal/runtime/executor/claude_signing.go | 64 +++++ 7 files changed, 277 insertions(+), 133 deletions(-) create mode 100644 internal/runtime/executor/claude_signing.go diff --git a/config.example.yaml b/config.example.yaml index 1b365d87..9bae2e05 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -172,6 +172,8 @@ nonstream-keepalive-interval: 0 # - "API" # - "proxy" # cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request +# experimental-cch-signing: false # optional: default is false; when true, sign the final /v1/messages body using the current Claude Code cch algorithm +# # keep this disabled unless you explicitly need the behavior, so upstream seed changes fall back to legacy proxy behavior # Default headers for Claude API requests. Update when Claude Code releases new versions. # In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks diff --git a/go.mod b/go.mod index 34237de9..9213f736 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pierrec/xxHash v0.1.5 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.5.0 // indirect diff --git a/go.sum b/go.sum index 3c424c5e..e811b012 100644 --- a/go.sum +++ b/go.sum @@ -152,6 +152,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= +github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/config/config.go b/internal/config/config.go index c4156e97..85627776 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -240,8 +240,8 @@ type AmpCode struct { UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"` // UpstreamAPIKeys maps client API keys (from top-level api-keys) to upstream API keys. - // When a client authenticates with a key that matches an entry, that upstream key is used. - // If no match is found, falls back to UpstreamAPIKey (default behavior). + // When a request is authenticated with one of the APIKeys, the corresponding UpstreamAPIKey + // is used for the upstream Amp request. UpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:"upstream-api-keys,omitempty" json:"upstream-api-keys,omitempty"` // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) @@ -363,6 +363,11 @@ type ClaudeKey struct { // Cloak configures request cloaking for non-Claude-Code clients. Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"` + + // ExperimentalCCHSigning enables opt-in final-body cch signing for cloaked + // Claude /v1/messages requests. It is disabled by default so upstream seed + // changes do not alter the proxy's legacy behavior. + ExperimentalCCHSigning bool `yaml:"experimental-cch-signing,omitempty" json:"experimental-cch-signing,omitempty"` } func (k ClaudeKey) GetAPIKey() string { return k.APIKey } diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index cc88dd77..fed21044 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -159,6 +159,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } + if experimentalCCHSigningEnabled(e.cfg, auth) { + bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream) + } url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) @@ -323,6 +326,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } + if experimentalCCHSigningEnabled(e.cfg, auth) { + bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream) + } url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) @@ -900,7 +906,7 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { } func checkSystemInstructions(payload []byte) []byte { - return checkSystemInstructionsWithMode(payload, false) + return checkSystemInstructionsWithSigningMode(payload, false, false) } func isClaudeOAuthToken(apiKey string) bool { @@ -1122,35 +1128,6 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bo return cloakMode, strictMode, sensitiveWords, cacheUserID } -// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig. -func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig { - if cfg == nil || auth == nil { - return nil - } - - apiKey, baseURL := claudeCreds(auth) - if apiKey == "" { - return nil - } - - for i := range cfg.ClaudeKey { - entry := &cfg.ClaudeKey[i] - cfgKey := strings.TrimSpace(entry.APIKey) - cfgBase := strings.TrimSpace(entry.BaseURL) - - // Match by API key - if strings.EqualFold(cfgKey, apiKey) { - // If baseURL is specified, also check it - if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) { - continue - } - return entry.Cloak - } - } - - return nil -} - // injectFakeUserID generates and injects a fake user ID into the request metadata. // When useCache is false, a new user ID is generated for every call. func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { @@ -1177,29 +1154,36 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { // generateBillingHeader creates the x-anthropic-billing-header text block that // real Claude Code prepends to every system prompt array. // Format: x-anthropic-billing-header: cc_version=.; cc_entrypoint=cli; cch=; -func generateBillingHeader(payload []byte) string { - // Generate a deterministic cch hash from the payload content (system + messages + tools). - // Real Claude Code uses a 5-char hex hash that varies per request. - h := sha256.Sum256(payload) - cch := hex.EncodeToString(h[:])[:5] - +func generateBillingHeader(payload []byte, experimentalCCHSigning bool) string { // Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43") buildBytes := make([]byte, 2) _, _ = rand.Read(buildBytes) buildHash := hex.EncodeToString(buildBytes)[:3] + if experimentalCCHSigning { + return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=00000;", buildHash) + } + + // Generate a deterministic cch hash from the payload content (system + messages + tools). + // Real Claude Code uses a 5-char hex hash that varies per request. + h := sha256.Sum256(payload) + cch := hex.EncodeToString(h[:])[:5] return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch) } -// checkSystemInstructionsWithMode injects Claude Code-style system blocks: +func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { + return checkSystemInstructionsWithSigningMode(payload, strictMode, false) +} + +// checkSystemInstructionsWithSigningMode injects Claude Code-style system blocks: // // system[0]: billing header (no cache_control) // system[1]: agent identifier (no cache_control) // system[2..]: user system messages (cache_control added when missing) -func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { +func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool) []byte { system := gjson.GetBytes(payload, "system") - billingText := generateBillingHeader(payload) + billingText := generateBillingHeader(payload, experimentalCCHSigning) billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText) // No cache_control on the agent block. It is a cloaking artifact with zero cache // value (the last system block is what actually triggers caching of all system content). @@ -1254,9 +1238,12 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { // Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation. func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte { clientUserAgent := getClientUserAgent(ctx) + useExperimentalCCHSigning := experimentalCCHSigningEnabled(cfg, auth) // Get cloak config from ClaudeKey configuration + cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth) + attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth) // Determine cloak settings var cloakMode string @@ -1265,29 +1252,24 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A var cacheUserID bool if cloakCfg != nil { - cloakMode = cloakCfg.Mode - strictMode = cloakCfg.StrictMode - sensitiveWords = cloakCfg.SensitiveWords + cloakMode = strings.TrimSpace(cloakCfg.Mode) + if cloakMode == "" { + cloakMode = attrMode + strictMode = attrStrict + sensitiveWords = attrWords + } else { + strictMode = cloakCfg.StrictMode + sensitiveWords = cloakCfg.SensitiveWords + } if cloakCfg.CacheUserID != nil { cacheUserID = *cloakCfg.CacheUserID - } - } - - // Fallback to auth attributes if no config found - if cloakMode == "" { - attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth) - cloakMode = attrMode - if !strictMode { - strictMode = attrStrict - } - if len(sensitiveWords) == 0 { - sensitiveWords = attrWords - } - if cloakCfg == nil || cloakCfg.CacheUserID == nil { + } else { cacheUserID = attrCache } - } else if cloakCfg == nil || cloakCfg.CacheUserID == nil { - _, _, _, attrCache := getCloakConfigFromAuth(auth) + } else { + cloakMode = attrMode + strictMode = attrStrict + sensitiveWords = attrWords cacheUserID = attrCache } @@ -1298,7 +1280,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A // Skip system instructions for claude-3-5-haiku models if !strings.HasPrefix(model, "claude-3-5-haiku") { - payload = checkSystemInstructionsWithMode(payload, strictMode) + payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning) } // Inject fake user ID @@ -1317,7 +1299,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A // According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages. // This function adds cache_control to: // 1. The LAST tool in the tools array (caches all tool definitions) -// 2. The LAST element in the system array (caches system prompt) +// 2. The LAST system prompt element // 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn) // // Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints. diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index ee8e9025..c15d41cf 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -4,9 +4,11 @@ import ( "bytes" "compress/gzip" "context" + "fmt" "io" "net/http" "net/http/httptest" + "regexp" "strings" "sync" "testing" @@ -14,6 +16,7 @@ import ( "github.com/gin-gonic/gin" "github.com/klauspost/compress/zstd" + xxHash64 "github.com/pierrec/xxHash/xxHash64" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" @@ -1418,6 +1421,35 @@ func TestDecodeResponseBody_MagicByteGzipNoHeader(t *testing.T) { } } +// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody +// detects zstd-compressed content via magic bytes even when Content-Encoding is absent. +func TestDecodeResponseBody_MagicByteZstdNoHeader(t *testing.T) { + const plaintext = "data: {\"type\":\"message_stop\"}\n" + + var buf bytes.Buffer + enc, err := zstd.NewWriter(&buf) + if err != nil { + t.Fatalf("zstd.NewWriter: %v", err) + } + _, _ = enc.Write([]byte(plaintext)) + _ = enc.Close() + + rc := io.NopCloser(&buf) + decoded, err := decodeResponseBody(rc, "") + if err != nil { + t.Fatalf("decodeResponseBody error: %v", err) + } + defer decoded.Close() + + got, err := io.ReadAll(decoded) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if string(got) != plaintext { + t.Errorf("decoded = %q, want %q", got, plaintext) + } +} + // TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns // plain text untouched when Content-Encoding is absent and no magic bytes match. func TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) { @@ -1489,77 +1521,6 @@ func TestClaudeExecutor_ExecuteStream_GzipNoContentEncodingHeader(t *testing.T) } } -// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies -// that injecting Accept-Encoding via auth.Attributes cannot override the stream -// path's enforced identity encoding. -func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity(t *testing.T) { - var gotEncoding string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotEncoding = r.Header.Get("Accept-Encoding") - w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n")) - })) - defer server.Close() - - executor := NewClaudeExecutor(&config.Config{}) - // Inject Accept-Encoding via the custom header attribute mechanism. - auth := &cliproxyauth.Auth{Attributes: map[string]string{ - "api_key": "key-123", - "base_url": server.URL, - "header:Accept-Encoding": "gzip, deflate, br, zstd", - }} - payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) - - result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ - Model: "claude-3-5-sonnet-20241022", - Payload: payload, - }, cliproxyexecutor.Options{ - SourceFormat: sdktranslator.FromString("claude"), - }) - if err != nil { - t.Fatalf("ExecuteStream error: %v", err) - } - for chunk := range result.Chunks { - if chunk.Err != nil { - t.Fatalf("unexpected chunk error: %v", chunk.Err) - } - } - - if gotEncoding != "identity" { - t.Errorf("Accept-Encoding = %q; stream path must enforce identity regardless of auth.Attributes override", gotEncoding) - } -} - -// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody -// detects zstd-compressed content via magic bytes (28 b5 2f fd) even when -// Content-Encoding is absent. -func TestDecodeResponseBody_MagicByteZstdNoHeader(t *testing.T) { - const plaintext = "data: {\"type\":\"message_stop\"}\n" - - var buf bytes.Buffer - enc, err := zstd.NewWriter(&buf) - if err != nil { - t.Fatalf("zstd.NewWriter: %v", err) - } - _, _ = enc.Write([]byte(plaintext)) - _ = enc.Close() - - rc := io.NopCloser(&buf) - decoded, err := decodeResponseBody(rc, "") - if err != nil { - t.Fatalf("decodeResponseBody error: %v", err) - } - defer decoded.Close() - - got, err := io.ReadAll(decoded) - if err != nil { - t.Fatalf("ReadAll error: %v", err) - } - if string(got) != plaintext { - t.Errorf("decoded = %q, want %q", got, plaintext) - } -} - // TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the // error path (4xx) correctly decompresses a gzip body even when the upstream omits // the Content-Encoding header. This closes the gap left by PR #1771, which only @@ -1643,6 +1604,45 @@ func TestClaudeExecutor_ExecuteStream_GzipErrorBodyNoContentEncodingHeader(t *te } } +// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies that the +// streaming executor enforces Accept-Encoding: identity regardless of auth.Attributes override. +func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity(t *testing.T) { + var gotEncoding string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotEncoding = r.Header.Get("Accept-Encoding") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n")) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + "header:Accept-Encoding": "gzip, deflate, br, zstd", + }} + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("claude"), + }) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("unexpected chunk error: %v", chunk.Err) + } + } + + if gotEncoding != "identity" { + t.Errorf("Accept-Encoding = %q; stream path must enforce identity regardless of auth.Attributes override", gotEncoding) + } +} + // Test case 1: String system prompt is preserved and converted to a content block func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) @@ -1726,3 +1726,91 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String()) } } + +func TestClaudeExecutor_ExperimentalCCHSigningDisabledByDefaultKeepsLegacyHeader(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seenBody = bytes.Clone(body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-3-5-sonnet","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if len(seenBody) == 0 { + t.Fatal("expected request body to be captured") + } + + billingHeader := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.HasPrefix(billingHeader, "x-anthropic-billing-header:") { + t.Fatalf("system.0.text = %q, want billing header", billingHeader) + } + if strings.Contains(billingHeader, "cch=00000;") { + t.Fatalf("legacy mode should not forward cch placeholder, got %q", billingHeader) + } +} + +func TestClaudeExecutor_ExperimentalCCHSigningOptInSignsFinalBody(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seenBody = bytes.Clone(body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-3-5-sonnet","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-123", + BaseURL: server.URL, + ExperimentalCCHSigning: true, + }}, + }) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + const messageText = "please keep literal cch=00000 in this message" + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"please keep literal cch=00000 in this message"}]}]}`) + + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if len(seenBody) == 0 { + t.Fatal("expected request body to be captured") + } + if got := gjson.GetBytes(seenBody, "messages.0.content.0.text").String(); got != messageText { + t.Fatalf("message text = %q, want %q", got, messageText) + } + + billingPattern := regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)([0-9a-f]{5})(;)`) + match := billingPattern.FindSubmatch(seenBody) + if match == nil { + t.Fatalf("expected signed billing header in body: %s", string(seenBody)) + } + actualCCH := string(match[2]) + unsignedBody := billingPattern.ReplaceAll(seenBody, []byte(`${1}00000${3}`)) + wantCCH := fmt.Sprintf("%05x", xxHash64.Checksum(unsignedBody, 0x6E52736AC806831E)&0xFFFFF) + if actualCCH != wantCCH { + t.Fatalf("cch = %q, want %q\nbody: %s", actualCCH, wantCCH, string(seenBody)) + } +} diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go new file mode 100644 index 00000000..c52aef49 --- /dev/null +++ b/internal/runtime/executor/claude_signing.go @@ -0,0 +1,64 @@ +package executor + +import ( + "fmt" + "regexp" + "strings" + + xxHash64 "github.com/pierrec/xxHash/xxHash64" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +const claudeCCHSeed uint64 = 0x6E52736AC806831E + +var claudeBillingHeaderPlaceholderPattern = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`) + +func signAnthropicMessagesBody(body []byte) []byte { + if !claudeBillingHeaderPlaceholderPattern.Match(body) { + return body + } + + cch := fmt.Sprintf("%05x", xxHash64.Checksum(body, claudeCCHSeed)&0xFFFFF) + return claudeBillingHeaderPlaceholderPattern.ReplaceAll(body, []byte("${1}"+cch+"${3}")) +} + +func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.ClaudeKey { + if cfg == nil || auth == nil { + return nil + } + + apiKey, baseURL := claudeCreds(auth) + if apiKey == "" { + return nil + } + + for i := range cfg.ClaudeKey { + entry := &cfg.ClaudeKey[i] + cfgKey := strings.TrimSpace(entry.APIKey) + cfgBase := strings.TrimSpace(entry.BaseURL) + if !strings.EqualFold(cfgKey, apiKey) { + continue + } + if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) { + continue + } + return entry + } + + return nil +} + +// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig. +func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig { + entry := resolveClaudeKeyConfig(cfg, auth) + if entry == nil { + return nil + } + return entry.Cloak +} + +func experimentalCCHSigningEnabled(cfg *config.Config, auth *cliproxyauth.Auth) bool { + entry := resolveClaudeKeyConfig(cfg, auth) + return entry != nil && entry.ExperimentalCCHSigning +}