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
172# - "API"172# - "API"
173# - "proxy"173# - "proxy"
174# 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 request174# 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
175 177
176# Default headers for Claude API requests. Update when Claude Code releases new versions.178# Default headers for Claude API requests. Update when Claude Code releases new versions.
177# In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks179# 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
81 github.com/muesli/cancelreader v0.2.2 // indirect81 github.com/muesli/cancelreader v0.2.2 // indirect
82 github.com/muesli/termenv v0.16.0 // indirect82 github.com/muesli/termenv v0.16.0 // indirect
83 github.com/pelletier/go-toml/v2 v2.2.2 // indirect83 github.com/pelletier/go-toml/v2 v2.2.2 // indirect
84 github.com/pierrec/xxHash v0.1.5 // indirect
84 github.com/pjbgf/sha1cd v0.5.0 // indirect85 github.com/pjbgf/sha1cd v0.5.0 // indirect
85 github.com/rivo/uniseg v0.4.7 // indirect86 github.com/rivo/uniseg v0.4.7 // indirect
86 github.com/rs/xid v1.5.0 // indirect87 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
152github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=152github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
153github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=153github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
154github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=154github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
155github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo=
156github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I=
155github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=157github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
156github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=158github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
157github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=159github.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
240 UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"`240 UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"`
241 241
242 // UpstreamAPIKeys maps client API keys (from top-level api-keys) to upstream API keys.242 // 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.243 // When a request is authenticated with one of the APIKeys, the corresponding UpstreamAPIKey
244 // If no match is found, falls back to UpstreamAPIKey (default behavior).244 // is used for the upstream Amp request.
245 UpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:"upstream-api-keys,omitempty" json:"upstream-api-keys,omitempty"`245 UpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:"upstream-api-keys,omitempty" json:"upstream-api-keys,omitempty"`
246 246
247 // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)247 // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)
@@ -363,6 +363,11 @@ type ClaudeKey struct { · 115 unmodified lines
363 363
364 // Cloak configures request cloaking for non-Claude-Code clients.364 // Cloak configures request cloaking for non-Claude-Code clients.
365 Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"`365 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"`
366}371}
367 372
368func (k ClaudeKey) GetAPIKey() string { return k.APIKey }373func (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
159 if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {159 if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
160 bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)160 bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
161 }161 }
162 if experimentalCCHSigningEnabled(e.cfg, auth) {
163 bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
164 }
162 165
163 url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)166 url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
164 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))167 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
323 if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {326 if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
324 bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)327 bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
325 }328 }
329 if experimentalCCHSigningEnabled(e.cfg, auth) {
330 bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
331 }
326 332
327 url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)333 url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
328 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))334 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
900}906}
901 907
902func checkSystemInstructions(payload []byte) []byte {908func checkSystemInstructions(payload []byte) []byte {
903 return checkSystemInstructionsWithMode(payload, false)909 return checkSystemInstructionsWithSigningMode(payload, false, false)
904}910}
905 911
906func isClaudeOAuthToken(apiKey string) bool {912func isClaudeOAuthToken(apiKey string) bool {
@@ -1122,35 +1128,6 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bo · 215 unmodified lines
1122 return cloakMode, strictMode, sensitiveWords, cacheUserID1128 return cloakMode, strictMode, sensitiveWords, cacheUserID
1123}1129}
1124 1130
1125// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.
1126func 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
1154// injectFakeUserID generates and injects a fake user ID into the request metadata.1131// injectFakeUserID generates and injects a fake user ID into the request metadata.
1155// When useCache is false, a new user ID is generated for every call.1132// When useCache is false, a new user ID is generated for every call.
1156func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {1133func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {
@@ -1177,29 +1154,36 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { · 20 unmodified lines
1177// generateBillingHeader creates the x-anthropic-billing-header text block that1154// generateBillingHeader creates the x-anthropic-billing-header text block that
1178// real Claude Code prepends to every system prompt array.1155// real Claude Code prepends to every system prompt array.
1179// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;1156// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;
1180func generateBillingHeader(payload []byte) string {1157func generateBillingHeader(payload []byte, experimentalCCHSigning bool) 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
1186 // Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43")1158 // Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43")
1187 buildBytes := make([]byte, 2)1159 buildBytes := make([]byte, 2)
1188 _, _ = rand.Read(buildBytes)1160 _, _ = rand.Read(buildBytes)
1189 buildHash := hex.EncodeToString(buildBytes)[:3]1161 buildHash := hex.EncodeToString(buildBytes)[:3]
1190 1162
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]
1191 return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)1171 return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
1192}1172}
1193 1173
1194// checkSystemInstructionsWithMode injects Claude Code-style system blocks:1174func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
1175 return checkSystemInstructionsWithSigningMode(payload, strictMode, false)
1176}
1177
1178// checkSystemInstructionsWithSigningMode injects Claude Code-style system blocks:
1195//1179//
1196// system[0]: billing header (no cache_control)1180// system[0]: billing header (no cache_control)
1197// system[1]: agent identifier (no cache_control)1181// system[1]: agent identifier (no cache_control)
1198// system[2..]: user system messages (cache_control added when missing)1182// system[2..]: user system messages (cache_control added when missing)
1199func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {1183func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool) []byte {
1200 system := gjson.GetBytes(payload, "system")1184 system := gjson.GetBytes(payload, "system")
1201 1185
1202 billingText := generateBillingHeader(payload)1186 billingText := generateBillingHeader(payload, experimentalCCHSigning)
1203 billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)1187 billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
1204 // No cache_control on the agent block. It is a cloaking artifact with zero cache1188 // No cache_control on the agent block. It is a cloaking artifact with zero cache
1205 // value (the last system block is what actually triggers caching of all system content).1189 // 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
1254// Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation.1238// Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation.
1255func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte {1239func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte {
1256 clientUserAgent := getClientUserAgent(ctx)1240 clientUserAgent := getClientUserAgent(ctx)
1241 useExperimentalCCHSigning := experimentalCCHSigningEnabled(cfg, auth)
1257 1242
1258 // Get cloak config from ClaudeKey configuration1243 // Get cloak config from ClaudeKey configuration
1244
1259 cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)1245 cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)
1246 attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth)
1260 1247
1261 // Determine cloak settings1248 // Determine cloak settings
1262 var cloakMode string1249 var cloakMode string
@@ -1265,29 +1252,24 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 2 unmodified lines
1265 var cacheUserID bool1252 var cacheUserID bool
1266 1253
1267 if cloakCfg != nil {1254 if cloakCfg != nil {
1268 cloakMode = cloakCfg.Mode1255 cloakMode = strings.TrimSpace(cloakCfg.Mode)
1269 strictMode = cloakCfg.StrictMode1256 if cloakMode == "" {
1270 sensitiveWords = cloakCfg.SensitiveWords1257 cloakMode = attrMode
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 {
1281 strictMode = attrStrict1258 strictMode = attrStrict
1282 }
1283 if len(sensitiveWords) == 0 {
1284 sensitiveWords = attrWords1259 sensitiveWords = attrWords
1260 } else {
1261 strictMode = cloakCfg.StrictMode
1262 sensitiveWords = cloakCfg.SensitiveWords
1285 }1263 }
1286 if cloakCfg == nil || cloakCfg.CacheUserID == nil {1264 if cloakCfg.CacheUserID != nil {
1265 cacheUserID = *cloakCfg.CacheUserID
1266 } else {
1287 cacheUserID = attrCache1267 cacheUserID = attrCache
1288 }1268 }
1289 } else if cloakCfg == nil || cloakCfg.CacheUserID == nil {1269 } else {
1290 _, _, _, attrCache := getCloakConfigFromAuth(auth)1270 cloakMode = attrMode
1271 strictMode = attrStrict
1272 sensitiveWords = attrWords
1291 cacheUserID = attrCache1273 cacheUserID = attrCache
1292 }1274 }
1293 1275
@@ -1298,7 +1280,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 4 unmodified lines
1298 1280
1299 // Skip system instructions for claude-3-5-haiku models1281 // Skip system instructions for claude-3-5-haiku models
1300 if !strings.HasPrefix(model, "claude-3-5-haiku") {1282 if !strings.HasPrefix(model, "claude-3-5-haiku") {
1301 payload = checkSystemInstructionsWithMode(payload, strictMode)1283 payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning)
1302 }1284 }
1303 1285
1304 // Inject fake user ID1286 // Inject fake user ID
@@ -1317,7 +1299,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 12 unmodified lines
1317// According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages.1299// According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages.
1318// This function adds cache_control to:1300// This function adds cache_control to:
1319// 1. The LAST tool in the tools array (caches all tool definitions)1301// 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
1321// 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn)1303// 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn)
1322//1304//
1323// Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints.1305// 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
4 "bytes"4 "bytes"
5 "compress/gzip"5 "compress/gzip"
6 "context"6 "context"
7 "fmt"
7 "io"8 "io"
8 "net/http"9 "net/http"
9 "net/http/httptest"10 "net/http/httptest"
11 "regexp"
10 "strings"12 "strings"
11 "sync"13 "sync"
12 "testing"14 "testing"
@@ -14,6 +16,7 @@ import ( · 1 unmodified line
14 16
15 "github.com/gin-gonic/gin"17 "github.com/gin-gonic/gin"
16 "github.com/klauspost/compress/zstd"18 "github.com/klauspost/compress/zstd"
19 xxHash64 "github.com/pierrec/xxHash/xxHash64"
17 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"20 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
18 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"21 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
19 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"22 cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
@@ -1418,6 +1421,35 @@ func TestDecodeResponseBody_MagicByteGzipNoHeader(t *testing.T) { · 1398 unmodified lines
1418 }1421 }
1419}1422}
1420 1423
1424// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody
1425// detects zstd-compressed content via magic bytes even when Content-Encoding is absent.
1426func 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
1421// TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns1453// TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns
1422// plain text untouched when Content-Encoding is absent and no magic bytes match.1454// plain text untouched when Content-Encoding is absent and no magic bytes match.
1423func TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) {1455func TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) {
@@ -1489,77 +1521,6 @@ func TestClaudeExecutor_ExecuteStream_GzipNoContentEncodingHeader(t *testing.T) · 65 unmodified lines
1489 }1521 }
1490}1522}
1491 1523
1492// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies
1493// that injecting Accept-Encoding via auth.Attributes cannot override the stream
1494// path's enforced identity encoding.
1495func 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.
1536func 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
1563// TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the1524// TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the
1564// error path (4xx) correctly decompresses a gzip body even when the upstream omits1525// error path (4xx) correctly decompresses a gzip body even when the upstream omits
1565// the Content-Encoding header. This closes the gap left by PR #1771, which only1526// 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
1643 }1604 }
1644}1605}
1645 1606
1607// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies that the
1608// streaming executor enforces Accept-Encoding: identity regardless of auth.Attributes override.
1609func 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
1646// Test case 1: String system prompt is preserved and converted to a content block1646// Test case 1: String system prompt is preserved and converted to a content block
1647func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {1647func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
1648 payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)1648 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
1726 t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String())1726 t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String())
1727 }1727 }
1728}1728}
1729
1730func 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
1767func 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 @@
1package executor
2
3import (
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
13const claudeCCHSeed uint64 = 0x6E52736AC806831E
14
15var claudeBillingHeaderPlaceholderPattern = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`)
16
17func 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
26func 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.
53func 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
61func experimentalCCHSigningEnabled(cfg *config.Config, auth *cliproxyauth.Auth) bool {
62 entry := resolveClaudeKeyConfig(cfg, auth)
63 return entry != nil && entry.ExperimentalCCHSigning
64}