Unified · Split
| @@ -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 request | 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 | # 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 fallbacks | 179 | # In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks |
| @@ -81,6 +81,7 @@ require ( · 80 unmodified lines | |||
| 81 | github.com/muesli/cancelreader v0.2.2 // indirect | 81 | github.com/muesli/cancelreader v0.2.2 // indirect |
| 82 | github.com/muesli/termenv v0.16.0 // indirect | 82 | github.com/muesli/termenv v0.16.0 // indirect |
| 83 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect | 83 | 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 // indirect | 85 | github.com/pjbgf/sha1cd v0.5.0 // indirect |
| 85 | github.com/rivo/uniseg v0.4.7 // indirect | 86 | github.com/rivo/uniseg v0.4.7 // indirect |
| 86 | github.com/rs/xid v1.5.0 // indirect | 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 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= | 152 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= |
| 153 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= | 153 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= |
| 154 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= | 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 | github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= | 157 | github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= |
| 156 | github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= | 158 | github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= |
| 157 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | 159 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| @@ -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 | ||
| 368 | func (k ClaudeKey) GetAPIKey() string { return k.APIKey } | 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 | 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 | ||
| 902 | func checkSystemInstructions(payload []byte) []byte { | 908 | func checkSystemInstructions(payload []byte) []byte { |
| 903 | return checkSystemInstructionsWithMode(payload, false) | 909 | return checkSystemInstructionsWithSigningMode(payload, false, false) |
| 904 | } | 910 | } |
| 905 | 911 | ||
| 906 | func isClaudeOAuthToken(apiKey string) bool { | 912 | func 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, cacheUserID | 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 | // 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. |
| 1156 | func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte { | 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 | // generateBillingHeader creates the x-anthropic-billing-header text block that | 1154 | // 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>; |
| 1180 | func generateBillingHeader(payload []byte) string { | 1157 | func 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: | 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 | // 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) |
| 1199 | func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { | 1183 | func 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 cache | 1188 | // 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. |
| 1255 | func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte { | 1239 | func 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 configuration | 1243 | // 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 settings | 1248 | // Determine cloak settings |
| 1262 | var cloakMode string | 1249 | var cloakMode string |
| @@ -1265,29 +1252,24 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A · 2 unmodified lines | |||
| 1265 | var cacheUserID bool | 1252 | var cacheUserID bool |
| 1266 | 1253 | ||
| 1267 | if cloakCfg != nil { | 1254 | if cloakCfg != nil { |
| 1268 | cloakMode = cloakCfg.Mode | 1255 | cloakMode = strings.TrimSpace(cloakCfg.Mode) |
| 1269 | strictMode = cloakCfg.StrictMode | 1256 | if cloakMode == "" { |
| 1270 | sensitiveWords = cloakCfg.SensitiveWords | 1257 | 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 = attrStrict | 1258 | strictMode = attrStrict |
| 1282 | } | ||
| 1283 | if len(sensitiveWords) == 0 { | ||
| 1284 | sensitiveWords = attrWords | 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 | cacheUserID = attrCache | 1267 | 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 = attrCache | 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 | // Skip system instructions for claude-3-5-haiku models | 1281 | // 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 ID | 1286 | // 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. |
| @@ -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. | ||
| 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 | // TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns | 1453 | // 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. |
| 1423 | func TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) { | 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 | // TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the | 1524 | // TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the |
| 1564 | // error path (4xx) correctly decompresses a gzip body even when the upstream omits | 1525 | // 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 only | 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 | // Test case 1: String system prompt is preserved and converted to a content block | 1646 | // Test case 1: String system prompt is preserved and converted to a content block |
| 1647 | func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { | 1647 | func 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 | |||
| 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 | } | ||