Unified · Split
| @@ -172,6 +172,8 @@ nonstream-keepalive-interval: 0 · 171 unmodified lines | ||
| 172 | 172 | # - "API" |
| 173 | 173 | # - "proxy" |
| 174 | 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 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 | 178 | # Default headers for Claude API requests. Update when Claude Code releases new versions. |
| 177 | 179 | # In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks |
| @@ -81,6 +81,7 @@ require ( · 80 unmodified lines | ||
| 81 | 81 | github.com/muesli/cancelreader v0.2.2 // indirect |
| 82 | 82 | github.com/muesli/termenv v0.16.0 // indirect |
| 83 | 83 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect |
| 84 | + github.com/pierrec/xxHash v0.1.5 // indirect | |
| 84 | 85 | github.com/pjbgf/sha1cd v0.5.0 // indirect |
| 85 | 86 | github.com/rivo/uniseg v0.4.7 // indirect |
| 86 | 87 | github.com/rs/xid v1.5.0 // indirect |
| @@ -152,6 +152,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc · 151 unmodified lines | ||
| 152 | 152 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= |
| 153 | 153 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= |
| 154 | 154 | 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= | |
| 155 | 157 | github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= |
| 156 | 158 | github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= |
| 157 | 159 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| @@ -240,8 +240,8 @@ type AmpCode struct { · 239 unmodified lines | ||
| 240 | 240 | UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"` |
| 241 | 241 | |
| 242 | 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. | |
| 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. | |
| 245 | 245 | UpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:"upstream-api-keys,omitempty" json:"upstream-api-keys,omitempty"` |
| 246 | 246 | |
| 247 | 247 | // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) |
| @@ -363,6 +363,11 @@ type ClaudeKey struct { · 115 unmodified lines | ||
| 363 | 363 | |
| 364 | 364 | // Cloak configures request cloaking for non-Claude-Code clients. |
| 365 | 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 | |
| 368 | 373 | func (k ClaudeKey) GetAPIKey() string { return k.APIKey } |
| @@ -159,6 +159,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 158 unmodified lines | ||
| 159 | 159 | if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { |
| 160 | 160 | bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) |
| 161 | 161 | } |
| 162 | + if experimentalCCHSigningEnabled(e.cfg, auth) { | |
| 163 | + bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream) | |
| 164 | + } | |
| 162 | 165 | |
| 163 | 166 | url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) |
| 164 | 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 | 326 | if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() { |
| 324 | 327 | bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) |
| 325 | 328 | } |
| 329 | + if experimentalCCHSigningEnabled(e.cfg, auth) { | |
| 330 | + bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream) | |
| 331 | + } | |
| 326 | 332 | |
| 327 | 333 | url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) |
| 328 | 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 | |
| 902 | 908 | func checkSystemInstructions(payload []byte) []byte { |
| 903 | − return checkSystemInstructionsWithMode(payload, false) | |
| 909 | + return checkSystemInstructionsWithSigningMode(payload, false, false) | |
| 904 | 910 | } |
| 905 | 911 | |
| 906 | 912 | func isClaudeOAuthToken(apiKey string) bool { |
| @@ -1122,35 +1128,6 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bo · 215 unmodified lines | ||
| 1122 | 1128 | return cloakMode, strictMode, sensitiveWords, cacheUserID |
| 1123 | 1129 | } |
| 1124 | 1130 | |
| 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 | − | |
| 1154 | 1131 | // injectFakeUserID generates and injects a fake user ID into the request metadata. |
| 1155 | 1132 | // When useCache is false, a new user ID is generated for every call. |
| 1156 | 1133 | 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 | ||
| 1177 | 1154 | // generateBillingHeader creates the x-anthropic-billing-header text block that |
| 1178 | 1155 | // real Claude Code prepends to every system prompt array. |
| 1179 | 1156 | // 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 { | |
| 1186 | 1158 | // Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43") |
| 1187 | 1159 | buildBytes := make([]byte, 2) |
| 1188 | 1160 | _, _ = rand.Read(buildBytes) |
| 1189 | 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 | 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: | |
| 1174 | +func 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 | 1180 | // system[0]: billing header (no cache_control) |
| 1197 | 1181 | // system[1]: agent identifier (no cache_control) |
| 1198 | 1182 | // 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 { | |
| 1200 | 1184 | system := gjson.GetBytes(payload, "system") |
| 1201 | 1185 | |
| 1202 | − billingText := generateBillingHeader(payload) | |
| 1186 | + billingText := generateBillingHeader(payload, experimentalCCHSigning) | |
| 1203 | 1187 | billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText) |
| 1204 | 1188 | // No cache_control on the agent block. It is a cloaking artifact with zero cache |
| 1205 | 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 | 1238 | // Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation. |
| 1255 | 1239 | func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte { |
| 1256 | 1240 | clientUserAgent := getClientUserAgent(ctx) |
| 1241 | + useExperimentalCCHSigning := experimentalCCHSigningEnabled(cfg, auth) | |
| 1257 | 1242 | |
| 1258 | 1243 | // Get cloak config from ClaudeKey configuration |
| 1244 | + | |
| 1259 | 1245 | cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth) |
| 1246 | + attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth) | |
| 1260 | 1247 | |
| 1261 | 1248 | // Determine cloak settings |
| 1262 | 1249 | var cloakMode string |
| @@ -1265,29 +1252,24 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 2 unmodified lines | ||
| 1265 | 1252 | var cacheUserID bool |
| 1266 | 1253 | |
| 1267 | 1254 | 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 | |
| 1281 | 1258 | strictMode = attrStrict |
| 1282 | − } | |
| 1283 | − if len(sensitiveWords) == 0 { | |
| 1284 | 1259 | 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 | 1267 | cacheUserID = attrCache |
| 1288 | 1268 | } |
| 1289 | − } else if cloakCfg == nil || cloakCfg.CacheUserID == nil { | |
| 1290 | − _, _, _, attrCache := getCloakConfigFromAuth(auth) | |
| 1269 | + } else { | |
| 1270 | + cloakMode = attrMode | |
| 1271 | + strictMode = attrStrict | |
| 1272 | + sensitiveWords = attrWords | |
| 1291 | 1273 | 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 | 1281 | // Skip system instructions for claude-3-5-haiku models |
| 1300 | 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 | 1286 | // Inject fake user ID |
| @@ -1317,7 +1299,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 12 unmodified lines | ||
| 1317 | 1299 | // According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages. |
| 1318 | 1300 | // This function adds cache_control to: |
| 1319 | 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 | 1303 | // 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn) |
| 1322 | 1304 | // |
| 1323 | 1305 | // Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints. |
| @@ -4,9 +4,11 @@ import ( · 3 unmodified lines | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "compress/gzip" |
| 6 | 6 | "context" |
| 7 | + "fmt" | |
| 7 | 8 | "io" |
| 8 | 9 | "net/http" |
| 9 | 10 | "net/http/httptest" |
| 11 | + "regexp" | |
| 10 | 12 | "strings" |
| 11 | 13 | "sync" |
| 12 | 14 | "testing" |
| @@ -14,6 +16,7 @@ import ( · 1 unmodified line | ||
| 14 | 16 | |
| 15 | 17 | "github.com/gin-gonic/gin" |
| 16 | 18 | "github.com/klauspost/compress/zstd" |
| 19 | + xxHash64 "github.com/pierrec/xxHash/xxHash64" | |
| 17 | 20 | "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| 18 | 21 | "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" |
| 19 | 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. | |
| 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 | + | |
| 1421 | 1453 | // TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns |
| 1422 | 1454 | // plain text untouched when Content-Encoding is absent and no magic bytes match. |
| 1423 | 1455 | func 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. | |
| 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 | − | |
| 1563 | 1524 | // TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the |
| 1564 | 1525 | // error path (4xx) correctly decompresses a gzip body even when the upstream omits |
| 1565 | 1526 | // 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. | |
| 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 | + | |
| 1646 | 1646 | // Test case 1: String system prompt is preserved and converted to a content block |
| 1647 | 1647 | func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { |
| 1648 | 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 | 1726 | t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String()) |
| 1727 | 1727 | } |
| 1728 | 1728 | } |
| 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 | +} | |
| @@ -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 | +} | |