History

feat: add opt-in experimental Claude cch signing

edlsh committed 7 months ago

Commit 37249331 parent c10f8ae7 files · +276 −132Browse files

Unified · Split

7 files
  1. config.example.yaml +2 −0
  2. go.mod +1 −0
  3. go.sum +2 −0
  4. internal/config/config.go +7 −2
  5. internal/runtime/executor/claude_executor.go +41 −59
  6. internal/runtime/executor/claude_executor_test.go +159 −71
  7. internal/runtime/executor/claude_signing.go +64 −0
config.example.yaml+2−0
@@ -172,6 +172,8 @@ nonstream-keepalive-interval: 0 · 171 unmodified lines
172172 # - "API"
173173 # - "proxy"
174174 # 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
175+# experimental-cch-signing: false # optional: default is false; when true, sign the final /v1/messages body using the current Claude Code cch algorithm
176+# # keep this disabled unless you explicitly need the behavior, so upstream seed changes fall back to legacy proxy behavior
175177
176178 # Default headers for Claude API requests. Update when Claude Code releases new versions.
177179 # In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks
go.mod+1−0
@@ -81,6 +81,7 @@ require ( · 80 unmodified lines
8181 github.com/muesli/cancelreader v0.2.2 // indirect
8282 github.com/muesli/termenv v0.16.0 // indirect
8383 github.com/pelletier/go-toml/v2 v2.2.2 // indirect
84+ github.com/pierrec/xxHash v0.1.5 // indirect
8485 github.com/pjbgf/sha1cd v0.5.0 // indirect
8586 github.com/rivo/uniseg v0.4.7 // indirect
8687 github.com/rs/xid v1.5.0 // indirect
go.sum+2−0
@@ -152,6 +152,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc · 151 unmodified lines
152152 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
153153 github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
154154 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
155+github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo=
156+github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I=
155157 github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
156158 github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
157159 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
internal/config/config.go+7−2
@@ -240,8 +240,8 @@ type AmpCode struct { · 239 unmodified lines
240240 UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"`
241241
242242 // UpstreamAPIKeys maps client API keys (from top-level api-keys) to upstream API keys.
243− // When a client authenticates with a key that matches an entry, that upstream key is used.
244− // If no match is found, falls back to UpstreamAPIKey (default behavior).
243+ // When a request is authenticated with one of the APIKeys, the corresponding UpstreamAPIKey
244+ // is used for the upstream Amp request.
245245 UpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:"upstream-api-keys,omitempty" json:"upstream-api-keys,omitempty"`
246246
247247 // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)
@@ -363,6 +363,11 @@ type ClaudeKey struct { · 115 unmodified lines
363363
364364 // Cloak configures request cloaking for non-Claude-Code clients.
365365 Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"`
366+
367+ // ExperimentalCCHSigning enables opt-in final-body cch signing for cloaked
368+ // Claude /v1/messages requests. It is disabled by default so upstream seed
369+ // changes do not alter the proxy's legacy behavior.
370+ ExperimentalCCHSigning bool `yaml:"experimental-cch-signing,omitempty" json:"experimental-cch-signing,omitempty"`
366371 }
367372
368373 func (k ClaudeKey) GetAPIKey() string { return k.APIKey }
internal/runtime/executor/claude_executor.go+41−59
@@ -159,6 +159,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 158 unmodified lines
159159 if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
160160 bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
161161 }
162+ if experimentalCCHSigningEnabled(e.cfg, auth) {
163+ bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
164+ }
162165
163166 url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
164167 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 · 158 unmodified lines
323326 if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
324327 bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
325328 }
329+ if experimentalCCHSigningEnabled(e.cfg, auth) {
330+ bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
331+ }
326332
327333 url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
328334 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
@@ -900,7 +906,7 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { · 571 unmodified lines
900906 }
901907
902908 func checkSystemInstructions(payload []byte) []byte {
903− return checkSystemInstructionsWithMode(payload, false)
909+ return checkSystemInstructionsWithSigningMode(payload, false, false)
904910 }
905911
906912 func isClaudeOAuthToken(apiKey string) bool {
@@ -1122,35 +1128,6 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bo · 215 unmodified lines
11221128 return cloakMode, strictMode, sensitiveWords, cacheUserID
11231129 }
11241130
1125−// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.
1126−func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig {
1127− if cfg == nil || auth == nil {
1128− return nil
1129− }
1130−
1131− apiKey, baseURL := claudeCreds(auth)
1132− if apiKey == "" {
1133− return nil
1134− }
1135−
1136− for i := range cfg.ClaudeKey {
1137− entry := &cfg.ClaudeKey[i]
1138− cfgKey := strings.TrimSpace(entry.APIKey)
1139− cfgBase := strings.TrimSpace(entry.BaseURL)
1140−
1141− // Match by API key
1142− if strings.EqualFold(cfgKey, apiKey) {
1143− // If baseURL is specified, also check it
1144− if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) {
1145− continue
1146− }
1147− return entry.Cloak
1148− }
1149− }
1150−
1151− return nil
1152−}
1153−
11541131 // injectFakeUserID generates and injects a fake user ID into the request metadata.
11551132 // When useCache is false, a new user ID is generated for every call.
11561133 func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {
@@ -1177,29 +1154,36 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { · 20 unmodified lines
11771154 // generateBillingHeader creates the x-anthropic-billing-header text block that
11781155 // real Claude Code prepends to every system prompt array.
11791156 // Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;
1180−func generateBillingHeader(payload []byte) string {
1181− // Generate a deterministic cch hash from the payload content (system + messages + tools).
1182− // Real Claude Code uses a 5-char hex hash that varies per request.
1183− h := sha256.Sum256(payload)
1184− cch := hex.EncodeToString(h[:])[:5]
1185−
1157+func generateBillingHeader(payload []byte, experimentalCCHSigning bool) string {
11861158 // Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43")
11871159 buildBytes := make([]byte, 2)
11881160 _, _ = rand.Read(buildBytes)
11891161 buildHash := hex.EncodeToString(buildBytes)[:3]
11901162
1163+ if experimentalCCHSigning {
1164+ return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=00000;", buildHash)
1165+ }
1166+
1167+ // Generate a deterministic cch hash from the payload content (system + messages + tools).
1168+ // Real Claude Code uses a 5-char hex hash that varies per request.
1169+ h := sha256.Sum256(payload)
1170+ cch := hex.EncodeToString(h[:])[:5]
11911171 return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
11921172 }
11931173
1194−// checkSystemInstructionsWithMode injects Claude Code-style system blocks:
1174+func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
1175+ return checkSystemInstructionsWithSigningMode(payload, strictMode, false)
1176+}
1177+
1178+// checkSystemInstructionsWithSigningMode injects Claude Code-style system blocks:
11951179 //
11961180 // system[0]: billing header (no cache_control)
11971181 // system[1]: agent identifier (no cache_control)
11981182 // system[2..]: user system messages (cache_control added when missing)
1199−func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
1183+func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool) []byte {
12001184 system := gjson.GetBytes(payload, "system")
12011185
1202− billingText := generateBillingHeader(payload)
1186+ billingText := generateBillingHeader(payload, experimentalCCHSigning)
12031187 billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
12041188 // No cache_control on the agent block. It is a cloaking artifact with zero cache
12051189 // 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 { · 48 unmodified lines
12541238 // Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation.
12551239 func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte {
12561240 clientUserAgent := getClientUserAgent(ctx)
1241+ useExperimentalCCHSigning := experimentalCCHSigningEnabled(cfg, auth)
12571242
12581243 // Get cloak config from ClaudeKey configuration
1244+
12591245 cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)
1246+ attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth)
12601247
12611248 // Determine cloak settings
12621249 var cloakMode string
@@ -1265,29 +1252,24 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 2 unmodified lines
12651252 var cacheUserID bool
12661253
12671254 if cloakCfg != nil {
1268− cloakMode = cloakCfg.Mode
1269− strictMode = cloakCfg.StrictMode
1270− sensitiveWords = cloakCfg.SensitiveWords
1271− if cloakCfg.CacheUserID != nil {
1272− cacheUserID = *cloakCfg.CacheUserID
1273− }
1274− }
1275−
1276− // Fallback to auth attributes if no config found
1277− if cloakMode == "" {
1278− attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth)
1279− cloakMode = attrMode
1280− if !strictMode {
1255+ cloakMode = strings.TrimSpace(cloakCfg.Mode)
1256+ if cloakMode == "" {
1257+ cloakMode = attrMode
12811258 strictMode = attrStrict
1282− }
1283− if len(sensitiveWords) == 0 {
12841259 sensitiveWords = attrWords
1260+ } else {
1261+ strictMode = cloakCfg.StrictMode
1262+ sensitiveWords = cloakCfg.SensitiveWords
12851263 }
1286− if cloakCfg == nil || cloakCfg.CacheUserID == nil {
1264+ if cloakCfg.CacheUserID != nil {
1265+ cacheUserID = *cloakCfg.CacheUserID
1266+ } else {
12871267 cacheUserID = attrCache
12881268 }
1289− } else if cloakCfg == nil || cloakCfg.CacheUserID == nil {
1290− _, _, _, attrCache := getCloakConfigFromAuth(auth)
1269+ } else {
1270+ cloakMode = attrMode
1271+ strictMode = attrStrict
1272+ sensitiveWords = attrWords
12911273 cacheUserID = attrCache
12921274 }
12931275
@@ -1298,7 +1280,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 4 unmodified lines
12981280
12991281 // Skip system instructions for claude-3-5-haiku models
13001282 if !strings.HasPrefix(model, "claude-3-5-haiku") {
1301− payload = checkSystemInstructionsWithMode(payload, strictMode)
1283+ payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning)
13021284 }
13031285
13041286 // Inject fake user ID
@@ -1317,7 +1299,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 12 unmodified lines
13171299 // According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages.
13181300 // This function adds cache_control to:
13191301 // 1. The LAST tool in the tools array (caches all tool definitions)
1320−// 2. The LAST element in the system array (caches system prompt)
1302+// 2. The LAST system prompt element
13211303 // 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn)
13221304 //
13231305 // Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints.
internal/runtime/executor/claude_executor_test.go+159−71
@@ -4,9 +4,11 @@ import ( · 3 unmodified lines
44 "bytes"
55 "compress/gzip"
66 "context"
7+ "fmt"
78 "io"
89 "net/http"
910 "net/http/httptest"
11+ "regexp"
1012 "strings"
1113 "sync"
1214 "testing"
@@ -14,6 +16,7 @@ import ( · 1 unmodified line
1416
1517 "github.com/gin-gonic/gin"
1618 "github.com/klauspost/compress/zstd"
19+ xxHash64 "github.com/pierrec/xxHash/xxHash64"
1720 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
1821 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1922 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
@@ -1418,6 +1421,35 @@ func TestDecodeResponseBody_MagicByteGzipNoHeader(t *testing.T) { · 1398 unmodified lines
14181421 }
14191422 }
14201423
1424+// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody
1425+// detects zstd-compressed content via magic bytes even when Content-Encoding is absent.
1426+func TestDecodeResponseBody_MagicByteZstdNoHeader(t *testing.T) {
1427+ const plaintext = "data: {\"type\":\"message_stop\"}\n"
1428+
1429+ var buf bytes.Buffer
1430+ enc, err := zstd.NewWriter(&buf)
1431+ if err != nil {
1432+ t.Fatalf("zstd.NewWriter: %v", err)
1433+ }
1434+ _, _ = enc.Write([]byte(plaintext))
1435+ _ = enc.Close()
1436+
1437+ rc := io.NopCloser(&buf)
1438+ decoded, err := decodeResponseBody(rc, "")
1439+ if err != nil {
1440+ t.Fatalf("decodeResponseBody error: %v", err)
1441+ }
1442+ defer decoded.Close()
1443+
1444+ got, err := io.ReadAll(decoded)
1445+ if err != nil {
1446+ t.Fatalf("ReadAll error: %v", err)
1447+ }
1448+ if string(got) != plaintext {
1449+ t.Errorf("decoded = %q, want %q", got, plaintext)
1450+ }
1451+}
1452+
14211453 // TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns
14221454 // plain text untouched when Content-Encoding is absent and no magic bytes match.
14231455 func TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) {
@@ -1489,77 +1521,6 @@ func TestClaudeExecutor_ExecuteStream_GzipNoContentEncodingHeader(t *testing.T) · 65 unmodified lines
14891521 }
14901522 }
14911523
1492−// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies
1493−// that injecting Accept-Encoding via auth.Attributes cannot override the stream
1494−// path's enforced identity encoding.
1495−func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity(t *testing.T) {
1496− var gotEncoding string
1497− server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1498− gotEncoding = r.Header.Get("Accept-Encoding")
1499− w.Header().Set("Content-Type", "text/event-stream")
1500− _, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n"))
1501− }))
1502− defer server.Close()
1503−
1504− executor := NewClaudeExecutor(&config.Config{})
1505− // Inject Accept-Encoding via the custom header attribute mechanism.
1506− auth := &cliproxyauth.Auth{Attributes: map[string]string{
1507− "api_key": "key-123",
1508− "base_url": server.URL,
1509− "header:Accept-Encoding": "gzip, deflate, br, zstd",
1510− }}
1511− payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
1512−
1513− result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
1514− Model: "claude-3-5-sonnet-20241022",
1515− Payload: payload,
1516− }, cliproxyexecutor.Options{
1517− SourceFormat: sdktranslator.FromString("claude"),
1518− })
1519− if err != nil {
1520− t.Fatalf("ExecuteStream error: %v", err)
1521− }
1522− for chunk := range result.Chunks {
1523− if chunk.Err != nil {
1524− t.Fatalf("unexpected chunk error: %v", chunk.Err)
1525− }
1526− }
1527−
1528− if gotEncoding != "identity" {
1529− t.Errorf("Accept-Encoding = %q; stream path must enforce identity regardless of auth.Attributes override", gotEncoding)
1530− }
1531−}
1532−
1533−// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody
1534−// detects zstd-compressed content via magic bytes (28 b5 2f fd) even when
1535−// Content-Encoding is absent.
1536−func TestDecodeResponseBody_MagicByteZstdNoHeader(t *testing.T) {
1537− const plaintext = "data: {\"type\":\"message_stop\"}\n"
1538−
1539− var buf bytes.Buffer
1540− enc, err := zstd.NewWriter(&buf)
1541− if err != nil {
1542− t.Fatalf("zstd.NewWriter: %v", err)
1543− }
1544− _, _ = enc.Write([]byte(plaintext))
1545− _ = enc.Close()
1546−
1547− rc := io.NopCloser(&buf)
1548− decoded, err := decodeResponseBody(rc, "")
1549− if err != nil {
1550− t.Fatalf("decodeResponseBody error: %v", err)
1551− }
1552− defer decoded.Close()
1553−
1554− got, err := io.ReadAll(decoded)
1555− if err != nil {
1556− t.Fatalf("ReadAll error: %v", err)
1557− }
1558− if string(got) != plaintext {
1559− t.Errorf("decoded = %q, want %q", got, plaintext)
1560− }
1561−}
1562−
15631524 // TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the
15641525 // error path (4xx) correctly decompresses a gzip body even when the upstream omits
15651526 // the Content-Encoding header. This closes the gap left by PR #1771, which only
@@ -1643,6 +1604,45 @@ func TestClaudeExecutor_ExecuteStream_GzipErrorBodyNoContentEncodingHeader(t *te · 77 unmodified lines
16431604 }
16441605 }
16451606
1607+// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies that the
1608+// streaming executor enforces Accept-Encoding: identity regardless of auth.Attributes override.
1609+func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity(t *testing.T) {
1610+ var gotEncoding string
1611+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1612+ gotEncoding = r.Header.Get("Accept-Encoding")
1613+ w.Header().Set("Content-Type", "text/event-stream")
1614+ _, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n"))
1615+ }))
1616+ defer server.Close()
1617+
1618+ executor := NewClaudeExecutor(&config.Config{})
1619+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
1620+ "api_key": "key-123",
1621+ "base_url": server.URL,
1622+ "header:Accept-Encoding": "gzip, deflate, br, zstd",
1623+ }}
1624+ payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
1625+
1626+ result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
1627+ Model: "claude-3-5-sonnet-20241022",
1628+ Payload: payload,
1629+ }, cliproxyexecutor.Options{
1630+ SourceFormat: sdktranslator.FromString("claude"),
1631+ })
1632+ if err != nil {
1633+ t.Fatalf("ExecuteStream error: %v", err)
1634+ }
1635+ for chunk := range result.Chunks {
1636+ if chunk.Err != nil {
1637+ t.Fatalf("unexpected chunk error: %v", chunk.Err)
1638+ }
1639+ }
1640+
1641+ if gotEncoding != "identity" {
1642+ t.Errorf("Accept-Encoding = %q; stream path must enforce identity regardless of auth.Attributes override", gotEncoding)
1643+ }
1644+}
1645+
16461646 // Test case 1: String system prompt is preserved and converted to a content block
16471647 func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
16481648 payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)
@@ -1726,3 +1726,91 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { · 77 unmodified lines
17261726 t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String())
17271727 }
17281728 }
1729+
1730+func TestClaudeExecutor_ExperimentalCCHSigningDisabledByDefaultKeepsLegacyHeader(t *testing.T) {
1731+ var seenBody []byte
1732+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1733+ body, _ := io.ReadAll(r.Body)
1734+ seenBody = bytes.Clone(body)
1735+ w.Header().Set("Content-Type", "application/json")
1736+ _, _ = 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}}`))
1737+ }))
1738+ defer server.Close()
1739+
1740+ executor := NewClaudeExecutor(&config.Config{})
1741+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
1742+ "api_key": "key-123",
1743+ "base_url": server.URL,
1744+ }}
1745+ payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
1746+
1747+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
1748+ Model: "claude-3-5-sonnet-20241022",
1749+ Payload: payload,
1750+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")})
1751+ if err != nil {
1752+ t.Fatalf("Execute() error = %v", err)
1753+ }
1754+ if len(seenBody) == 0 {
1755+ t.Fatal("expected request body to be captured")
1756+ }
1757+
1758+ billingHeader := gjson.GetBytes(seenBody, "system.0.text").String()
1759+ if !strings.HasPrefix(billingHeader, "x-anthropic-billing-header:") {
1760+ t.Fatalf("system.0.text = %q, want billing header", billingHeader)
1761+ }
1762+ if strings.Contains(billingHeader, "cch=00000;") {
1763+ t.Fatalf("legacy mode should not forward cch placeholder, got %q", billingHeader)
1764+ }
1765+}
1766+
1767+func TestClaudeExecutor_ExperimentalCCHSigningOptInSignsFinalBody(t *testing.T) {
1768+ var seenBody []byte
1769+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1770+ body, _ := io.ReadAll(r.Body)
1771+ seenBody = bytes.Clone(body)
1772+ w.Header().Set("Content-Type", "application/json")
1773+ _, _ = 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}}`))
1774+ }))
1775+ defer server.Close()
1776+
1777+ executor := NewClaudeExecutor(&config.Config{
1778+ ClaudeKey: []config.ClaudeKey{{
1779+ APIKey: "key-123",
1780+ BaseURL: server.URL,
1781+ ExperimentalCCHSigning: true,
1782+ }},
1783+ })
1784+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
1785+ "api_key": "key-123",
1786+ "base_url": server.URL,
1787+ }}
1788+ const messageText = "please keep literal cch=00000 in this message"
1789+ payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"please keep literal cch=00000 in this message"}]}]}`)
1790+
1791+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
1792+ Model: "claude-3-5-sonnet-20241022",
1793+ Payload: payload,
1794+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")})
1795+ if err != nil {
1796+ t.Fatalf("Execute() error = %v", err)
1797+ }
1798+ if len(seenBody) == 0 {
1799+ t.Fatal("expected request body to be captured")
1800+ }
1801+ if got := gjson.GetBytes(seenBody, "messages.0.content.0.text").String(); got != messageText {
1802+ t.Fatalf("message text = %q, want %q", got, messageText)
1803+ }
1804+
1805+ billingPattern := regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)([0-9a-f]{5})(;)`)
1806+ match := billingPattern.FindSubmatch(seenBody)
1807+ if match == nil {
1808+ t.Fatalf("expected signed billing header in body: %s", string(seenBody))
1809+ }
1810+ actualCCH := string(match[2])
1811+ unsignedBody := billingPattern.ReplaceAll(seenBody, []byte(`${1}00000${3}`))
1812+ wantCCH := fmt.Sprintf("%05x", xxHash64.Checksum(unsignedBody, 0x6E52736AC806831E)&0xFFFFF)
1813+ if actualCCH != wantCCH {
1814+ t.Fatalf("cch = %q, want %q\nbody: %s", actualCCH, wantCCH, string(seenBody))
1815+ }
1816+}
internal/runtime/executor/claude_signing.go+64−0
@@ -0,0 +1,64 @@
1+package executor
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "strings"
7+
8+ xxHash64 "github.com/pierrec/xxHash/xxHash64"
9+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
10+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
11+)
12+
13+const claudeCCHSeed uint64 = 0x6E52736AC806831E
14+
15+var claudeBillingHeaderPlaceholderPattern = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`)
16+
17+func signAnthropicMessagesBody(body []byte) []byte {
18+ if !claudeBillingHeaderPlaceholderPattern.Match(body) {
19+ return body
20+ }
21+
22+ cch := fmt.Sprintf("%05x", xxHash64.Checksum(body, claudeCCHSeed)&0xFFFFF)
23+ return claudeBillingHeaderPlaceholderPattern.ReplaceAll(body, []byte("${1}"+cch+"${3}"))
24+}
25+
26+func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.ClaudeKey {
27+ if cfg == nil || auth == nil {
28+ return nil
29+ }
30+
31+ apiKey, baseURL := claudeCreds(auth)
32+ if apiKey == "" {
33+ return nil
34+ }
35+
36+ for i := range cfg.ClaudeKey {
37+ entry := &cfg.ClaudeKey[i]
38+ cfgKey := strings.TrimSpace(entry.APIKey)
39+ cfgBase := strings.TrimSpace(entry.BaseURL)
40+ if !strings.EqualFold(cfgKey, apiKey) {
41+ continue
42+ }
43+ if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) {
44+ continue
45+ }
46+ return entry
47+ }
48+
49+ return nil
50+}
51+
52+// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.
53+func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig {
54+ entry := resolveClaudeKeyConfig(cfg, auth)
55+ if entry == nil {
56+ return nil
57+ }
58+ return entry.Cloak
59+}
60+
61+func experimentalCCHSigningEnabled(cfg *config.Config, auth *cliproxyauth.Auth) bool {
62+ entry := resolveClaudeKeyConfig(cfg, auth)
63+ return entry != nil && entry.ExperimentalCCHSigning
64+}