package executor import ( "bufio" "bytes" "compress/flate" "compress/gzip" "compress/zlib" "context" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "github.com/andybalholm/brotli" "github.com/google/uuid" "github.com/klauspost/compress/zstd" claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" "github.com/gin-gonic/gin") const ( claudeTokenCountingBeta = "token-counting-2024-11-01" claudeFastModeBeta = "fast-mode-2026-02-01" claudeOAuthBeta = "oauth-2025-04-20" claudeCodeBeta = "claude-code-20250219" claudeContext1MBeta = "context-1m-2025-08-07" claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07" claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20" claudeEffortBeta = "effort-2025-11-24" claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01" claudeFallbackCreditBeta = "fallback-credit-2026-06-01" claudeStructuredOutputsBeta = "structured-outputs-2025-12-15" claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11" claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" claudeRedactThinkingBeta = "redact-thinking-2026-02-12") // claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every// /v1/messages request from the "cli" entrypoint, in wire order, excluding the// leading claude-code-20250219.//// redact-thinking-2026-02-12 belongs here because cloaked requests always claim// cc_entrypoint=cli; the "sdk-cli" entrypoint omits it. It is still dropped for// requests that carry thinking.display, see claudeThinkingDisplaySet.var claudeCodeCLIConstantBetas = []string{ "interleaved-thinking-2025-05-14", claudeRedactThinkingBeta, "thinking-token-count-2026-05-13", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05",} // claudeCodeTrailingBetas are caller-supplied betas that real Claude Code emits// after effort-2025-11-24, in that relative order. They are forwarded when the// caller asks for them and dropped otherwise.var claudeCodeTrailingBetas = []string{ claudeServerSideFallbackBeta, claudeFallbackCreditBeta, claudeStructuredOutputsBeta,} // claudeCodeCLIBetas assembles the Anthropic-Beta baseline the way Claude Code// 2.1.220 does: the list is per-request, not a fixed string. requested holds the// betas the caller asked for, which decide the capability flags below.//// Verified against api.anthropic.com with isolated 2.1.220 profiles on both// API-key and OAuth paths. A 2026-08-03 A/B capture with two distinct OAuth// accounts confirmed the current tool beta and OAuth trailer below.// The full observed order is://// 1 claude-code-20250219// 2 oauth-2025-04-20 OAuth credentials only// 3 context-1m-2025-08-07 [1m] model variants only// 4 interleaved-thinking-2025-05-14// 5 redact-thinking-2026-02-12 cli entrypoint, no thinking.display// 6 thinking-token-count-2026-05-13// 7 context-management-2025-06-27// 8 prompt-caching-scope-2026-01-05// 9 mid-conversation-system-2026-04-07 models accepting a role=system turn// 10 advanced-tool-use-2025-11-20 requests with tools// 11 effort-2025-11-24// 12 server-side-fallback-2026-06-01// 13 fallback-credit-2026-06-01// 14 fast-mode-2026-02-01 speed:fast requests only// 15 extended-cache-ttl-2025-04-11 OAuth credentials only// 16 cache-diagnosis-2026-04-07 requests with diagnostics only//// An empty body keeps the optimistic role=system default, matching the cloaking// policy for unknown and future model IDs.func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) string { betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+7) betas = append(betas, claudeCodeBeta) if oauthToken { betas = append(betas, claudeOAuthBeta) } if requested[claudeContext1MBeta] { betas = append(betas, claudeContext1MBeta) } redactThinking := !claudeThinkingDisplaySet(body) for _, beta := range claudeCodeCLIConstantBetas { if beta == claudeRedactThinkingBeta && !redactThinking { continue } betas = append(betas, beta) } if !claudeUsesLegacySystemReminder(body) { betas = append(betas, claudeMidConvSystemBeta) } if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { betas = append(betas, claudeAdvancedToolUseBeta) } betas = append(betas, claudeEffortBeta) if oauthToken && !requested[claudeFallbackCreditBeta] { betas = append(betas, claudeFallbackCreditBeta) } for _, beta := range claudeCodeTrailingBetas { if requested[beta] { betas = append(betas, beta) } } if claudeRequestUsesFastMode(body, requested) { betas = append(betas, claudeFastModeBeta) } if oauthToken { betas = append(betas, claudeExtendedCacheTTLBeta) } if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.IsObject() { betas = append(betas, claudeCacheDiagnosisBeta) } return strings.Join(betas, ",")} // claudeThinkingDisplaySet reports whether the request carries a thinking.display// value. Claude Code 2.1.220 and redact-thinking-2026-02-12 are mutually// exclusive by construction: the beta is only appended while thinking summaries// are off, and the request builder removes it again whenever a display value is// attached. Sending both makes Anthropic honour the redaction and return thinking// blocks with an empty thinking field, so the caller's summary request would be// answered with a signature and no text. Verified on api.anthropic.com with// claude-opus-4-8: display=summarized yields thinking text only when the beta is// absent, and a native 2.1.220 CLI run with showThinkingSummaries enabled sends// display=summarized without the beta.func claudeThinkingDisplaySet(body []byte) bool { display := gjson.GetBytes(body, "thinking.display") return display.Type == gjson.String && strings.TrimSpace(display.String()) != ""} // claudeRequestUsesFastMode reports whether the request selects the fast service// tier. Anthropic rejects the body's speed field with "Extra inputs are not// permitted" unless fast-mode-2026-02-01 is declared, so the beta has to follow// the body. Deriving it here rather than at the call sites is deliberate: the// streaming and non-streaming paths previously disagreed and streaming silently// dropped the beta, turning every fast request into a 400.func claudeRequestUsesFastMode(body []byte, requested map[string]bool) bool { if requested[claudeFastModeBeta] { return true } speed := gjson.GetBytes(body, "speed") return speed.Type == gjson.String && strings.EqualFold(strings.TrimSpace(speed.String()), "fast")} // claudeCountTokensBetas is the fixed profile Claude Code 2.1.220 sends to// /v1/messages/count_tokens. It is far smaller than the inference baseline:// redact-thinking, thinking-token-count, prompt-caching-scope, effort and every// conditional beta are absent. Verified identical across 37 captured calls.var claudeCountTokensBetas = []string{ claudeCodeBeta, "interleaved-thinking-2025-05-14", "context-management-2025-06-27", claudeTokenCountingBeta,} func claudeCountTokensBetasForCredential(oauthToken bool) string { betas := make([]string, 0, len(claudeCountTokensBetas)+1) betas = append(betas, claudeCodeBeta) if oauthToken { betas = append(betas, claudeOAuthBeta) } betas = append(betas, claudeCountTokensBetas[1:]...) return strings.Join(betas, ",")} func withClaudeCountTokensOAuthBeta(betas string) string { parts := make([]string, 0, len(claudeCountTokensBetas)+1) seen := make(map[string]bool) for _, beta := range strings.Split(betas, ",") { if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] { parts = append(parts, beta) seen[beta] = true } } if seen[claudeOAuthBeta] { return strings.Join(parts, ",") } insertAt := 0 if len(parts) > 0 && parts[0] == claudeCodeBeta { insertAt = 1 } parts = append(parts, "") copy(parts[insertAt+1:], parts[insertAt:]) parts[insertAt] = claudeOAuthBeta return strings.Join(parts, ",")} // withClaudeOAuthCredentialBetas restores the credential-scoped betas that// describe the selected upstream OAuth account rather than caller capability.//// A confirmed native client authenticates to CPA with whatever key the user// configured and cannot know that CPA will select an OAuth credential upstream,// so its header never carries the OAuth betas. Passing it through verbatim ships// a Bearer request that declares neither oauth-2025-04-20 nor// extended-cache-ttl-2025-04-11, which no real OAuth client ever does. Passthrough// governs what the caller expressed; the credential is CPA's own choice and has to// be described accurately.//// Betas already present are left exactly where the caller put them.func withClaudeOAuthCredentialBetas(betas string) string { parts := make([]string, 0, 16) seen := make(map[string]bool) for _, beta := range strings.Split(betas, ",") { if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] { parts = append(parts, beta) seen[beta] = true } } if !seen[claudeOAuthBeta] { // Captured position 2, directly after claude-code-20250219. insertAt := 0 if len(parts) > 0 && parts[0] == claudeCodeBeta { insertAt = 1 } parts = append(parts, "") copy(parts[insertAt+1:], parts[insertAt:]) parts[insertAt] = claudeOAuthBeta } if !seen[claudeExtendedCacheTTLBeta] { parts = append(parts, claudeExtendedCacheTTLBeta) } return strings.Join(parts, ",")} // claudeEntitlementError marks an upstream refusal that is a property of the// request shape combined with the account's entitlements, not of the credential's// health. The auth manager must neither rotate nor cool down on these.type claudeEntitlementError struct { statusErr} func (claudeEntitlementError) IsRequestScoped() bool { return true} func (claudeEntitlementError) IsCredentialScoped() bool { return false} type claudeRateLimitError struct { statusErr credentialScoped bool} func (e claudeRateLimitError) IsCredentialScoped() bool { return e.credentialScoped} func (e claudeRateLimitError) IsRequestScoped() bool { return false} // classifyClaudeUpstreamError promotes upstream refusals that no other credential// can satisfy into request-scoped errors.//// Anthropic answers a fast-mode request from an account without the matching// usage credits with 429 rate_limit_error "Usage credits are required for fast// mode". The generic pipeline reads 429 as quota exhaustion: it marks the// credential Quota.Exceeded, applies an exponential cooldown and rotates to the// next one, which returns the same 429. A single speed:"fast" request would walk// the whole Claude pool and cool down every credential, all of which remain// perfectly healthy for ordinary traffic. The refusal belongs to the request.func classifyClaudeUpstreamError(statusCode int, headers http.Header, body []byte) error { var retryAfter *time.Duration if statusCode == http.StatusTooManyRequests || (statusCode >= 400 && statusCode < 600) { retryAfter = helps.ParseClaudeRateLimitReset(headers, time.Now()) } err := statusErr{code: statusCode, msg: string(body), retryAfter: retryAfter} if statusCode == http.StatusTooManyRequests { if helps.ClaudeHeadersIndicateUnifiedRateLimitRejection(headers) { return claudeRateLimitError{statusErr: err, credentialScoped: true} } if claudeBodyIndicatesFastModeCredits(body) { return claudeEntitlementError{err} } // Ordinary model-level Claude 429 (not a unified 5h/7d rejection) return claudeRateLimitError{statusErr: err, credentialScoped: false} } return err} // claudeBodyIndicatesFastModeCredits matches Anthropic's fast-mode entitlement// refusal without matching a genuine rate limit, which never mentions fast mode.func claudeBodyIndicatesFastModeCredits(body []byte) bool { message := strings.ToLower(gjson.GetBytes(body, "error.message").String()) if message == "" { message = strings.ToLower(string(body)) } return strings.Contains(message, "fast request rejected") || (strings.Contains(message, "fast") && (strings.Contains(message, "usage credits") || strings.Contains(message, "credits are required")))} // claudeRequestedBetas collects every beta the caller asked for, from the// Anthropic-Beta header and from betas lifted out of the request body.func claudeRequestedBetas(incomingBetas string, extraBetas []string) map[string]bool { requested := make(map[string]bool) for _, beta := range strings.Split(incomingBetas, ",") { if beta = strings.TrimSpace(beta); beta != "" { requested[beta] = true } } for _, beta := range extraBetas { if beta = strings.TrimSpace(beta); beta != "" { requested[beta] = true } } return requested} // isAnthropicUpstreamURL reports whether a resolved request targets Anthropic's// first-party API.//// Every rule that reconstructs Claude Code's identity must key on this rather// than on the cloaked flag. Kimi rewrites base_url to api.kimi.com and custom// gateways set their own host, yet both delegate to ClaudeExecutor and are// therefore cloaked; a cloak-keyed rule silently rewrites their traffic too.func isAnthropicUpstreamURL(u *url.URL) bool { return helps.IsAnthropicUpstreamURL(u)} // isAnthropicUpstreamBase reports whether a configured base URL targets Anthropic's// first-party API. Used before the outgoing request exists.func isAnthropicUpstreamBase(baseURL string) bool { parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil { return false } return isAnthropicUpstreamURL(parsed)} // extractAndRemoveBetas extracts the "betas" array from the body and removes it.// Returns the extracted betas as a string slice and the modified body.func extractAndRemoveBetas(body []byte) ([]string, []byte) { betasResult := gjson.GetBytes(body, "betas") if !betasResult.Exists() { return nil, body } var betas []string if betasResult.IsArray() { for _, item := range betasResult.Array() { if s := strings.TrimSpace(item.String()); s != "" { betas = append(betas, s) } } } else if s := strings.TrimSpace(betasResult.String()); s != "" { betas = append(betas, s) } body, _ = sjson.DeleteBytes(body, "betas") return betas, body} // disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerationsfunc disableThinkingIfToolChoiceForced(body []byte) []byte { toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String() // "auto" is allowed with thinking, but "any" or "tool" (specific tool) are not if toolChoiceType == "any" || toolChoiceType == "tool" { // Remove thinking configuration entirely to avoid API error body, _ = sjson.DeleteBytes(body, "thinking") // Adaptive thinking may also set output_config.effort; remove it to avoid // leaking thinking controls when tool_choice forces tool use. body, _ = sjson.DeleteBytes(body, "output_config.effort") if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { body, _ = sjson.DeleteBytes(body, "output_config") } } return body} // normalizeClaudeSamplingForUpstream keeps Anthropic message requests valid.//// Translated and cloaked callers keep the conservative normalization: their// sampling knobs come from a protocol that was not written for Anthropic, and// Anthropic rejects several combinations outright, so neither temperature nor// top_p is worth forwarding.//// A confirmed native Claude Code client owns its own wire, exactly like// cache_control placement. The measured structured Haiku helper sends// "temperature":1 and claudeCodeHelperShapeStructured keys on it, so stripping// it would emit a shape no native client ever produces. Keep what the caller// sent and drop only what Anthropic actually rejects (verified live):// - thinking active: temperature must be 1, top_p must be >= 0.95, top_k unset// - otherwise: temperature and top_p cannot both be specifiedfunc normalizeClaudeSamplingForUpstream(body []byte, nativeOwned bool) []byte { thinkingActive := false switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) { case "enabled", "adaptive", "auto": thinkingActive = true } if !nativeOwned { body, _ = sjson.DeleteBytes(body, "temperature") body, _ = sjson.DeleteBytes(body, "top_p") if thinkingActive { body, _ = sjson.DeleteBytes(body, "top_k") } return body } if thinkingActive { if temperature := gjson.GetBytes(body, "temperature"); temperature.Exists() && temperature.Num != 1 { body, _ = sjson.DeleteBytes(body, "temperature") } if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && topP.Num < 0.95 { body, _ = sjson.DeleteBytes(body, "top_p") } body, _ = sjson.DeleteBytes(body, "top_k") return body } // Anthropic accepts either one but not both; temperature is the knob native // Claude Code actually sends, so top_p is the one that gives way. if gjson.GetBytes(body, "temperature").Exists() && gjson.GetBytes(body, "top_p").Exists() { body, _ = sjson.DeleteBytes(body, "top_p") } return body} type compositeReadCloser struct { io.Reader closers []func() error} func (c *compositeReadCloser) Close() error { var firstErr error for i := range c.closers { if c.closers[i] == nil { continue } if err := c.closers[i](); err != nil && firstErr == nil { firstErr = err } } return firstErr} // peekableBody wraps a bufio.Reader around the original ReadCloser so that// magic bytes can be inspected without consuming them from the stream.type peekableBody struct { *bufio.Reader closer io.Closer} func (p *peekableBody) Close() error { return p.closer.Close()} func claudeResponseContentEncoding(header http.Header) string { return strings.Join(header.Values("Content-Encoding"), ",")} func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) { if body == nil { return nil, fmt.Errorf("response body is nil") } if contentEncoding == "" { // No Content-Encoding header. Attempt best-effort magic-byte detection to // handle misbehaving upstreams that compress without setting the header. // Only gzip (1f 8b) and zstd (28 b5 2f fd) have reliable magic sequences; // br and deflate have none and are left as-is. // The bufio wrapper preserves unread bytes so callers always see the full // stream regardless of whether decompression was applied. pb := &peekableBody{Reader: bufio.NewReader(body), closer: body} magic, peekErr := pb.Peek(4) if peekErr == nil || (peekErr == io.EOF && len(magic) >= 2) { switch { case len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b: gzipReader, gzErr := gzip.NewReader(pb)Lines 1–500 of 2120. Load more
package executor import ( "bufio" "bytes" "compress/flate" "compress/gzip" "compress/zlib" "context" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "github.com/andybalholm/brotli" "github.com/google/uuid" "github.com/klauspost/compress/zstd" claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" "github.com/gin-gonic/gin" ) const ( claudeTokenCountingBeta = "token-counting-2024-11-01" claudeFastModeBeta = "fast-mode-2026-02-01" claudeOAuthBeta = "oauth-2025-04-20" claudeCodeBeta = "claude-code-20250219" claudeContext1MBeta = "context-1m-2025-08-07" claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07" claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20" claudeEffortBeta = "effort-2025-11-24" claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01" claudeFallbackCreditBeta = "fallback-credit-2026-06-01" claudeStructuredOutputsBeta = "structured-outputs-2025-12-15" claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11" claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" claudeRedactThinkingBeta = "redact-thinking-2026-02-12" ) // claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every // /v1/messages request from the "cli" entrypoint, in wire order, excluding the // leading claude-code-20250219. // // redact-thinking-2026-02-12 belongs here because cloaked requests always claim // cc_entrypoint=cli; the "sdk-cli" entrypoint omits it. It is still dropped for // requests that carry thinking.display, see claudeThinkingDisplaySet. var claudeCodeCLIConstantBetas = []string{ "interleaved-thinking-2025-05-14", claudeRedactThinkingBeta, "thinking-token-count-2026-05-13", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", } // claudeCodeTrailingBetas are caller-supplied betas that real Claude Code emits // after effort-2025-11-24, in that relative order. They are forwarded when the // caller asks for them and dropped otherwise. var claudeCodeTrailingBetas = []string{ claudeServerSideFallbackBeta, claudeFallbackCreditBeta, claudeStructuredOutputsBeta, } // claudeCodeCLIBetas assembles the Anthropic-Beta baseline the way Claude Code // 2.1.220 does: the list is per-request, not a fixed string. requested holds the // betas the caller asked for, which decide the capability flags below. // // Verified against api.anthropic.com with isolated 2.1.220 profiles on both // API-key and OAuth paths. A 2026-08-03 A/B capture with two distinct OAuth // accounts confirmed the current tool beta and OAuth trailer below. // The full observed order is: // // 1 claude-code-20250219 // 2 oauth-2025-04-20 OAuth credentials only // 3 context-1m-2025-08-07 [1m] model variants only // 4 interleaved-thinking-2025-05-14 // 5 redact-thinking-2026-02-12 cli entrypoint, no thinking.display // 6 thinking-token-count-2026-05-13 // 7 context-management-2025-06-27 // 8 prompt-caching-scope-2026-01-05 // 9 mid-conversation-system-2026-04-07 models accepting a role=system turn // 10 advanced-tool-use-2025-11-20 requests with tools // 11 effort-2025-11-24 // 12 server-side-fallback-2026-06-01 // 13 fallback-credit-2026-06-01 // 14 fast-mode-2026-02-01 speed:fast requests only // 15 extended-cache-ttl-2025-04-11 OAuth credentials only // 16 cache-diagnosis-2026-04-07 requests with diagnostics only // // An empty body keeps the optimistic role=system default, matching the cloaking // policy for unknown and future model IDs. func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) string { betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+7) betas = append(betas, claudeCodeBeta) if oauthToken { betas = append(betas, claudeOAuthBeta) } if requested[claudeContext1MBeta] { betas = append(betas, claudeContext1MBeta) } redactThinking := !claudeThinkingDisplaySet(body) for _, beta := range claudeCodeCLIConstantBetas { if beta == claudeRedactThinkingBeta && !redactThinking { continue } betas = append(betas, beta) } if !claudeUsesLegacySystemReminder(body) { betas = append(betas, claudeMidConvSystemBeta) } if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { betas = append(betas, claudeAdvancedToolUseBeta) } betas = append(betas, claudeEffortBeta) if oauthToken && !requested[claudeFallbackCreditBeta] { betas = append(betas, claudeFallbackCreditBeta) } for _, beta := range claudeCodeTrailingBetas { if requested[beta] { betas = append(betas, beta) } } if claudeRequestUsesFastMode(body, requested) { betas = append(betas, claudeFastModeBeta) } if oauthToken { betas = append(betas, claudeExtendedCacheTTLBeta) } if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.IsObject() { betas = append(betas, claudeCacheDiagnosisBeta) } return strings.Join(betas, ",") } // claudeThinkingDisplaySet reports whether the request carries a thinking.display // value. Claude Code 2.1.220 and redact-thinking-2026-02-12 are mutually // exclusive by construction: the beta is only appended while thinking summaries // are off, and the request builder removes it again whenever a display value is // attached. Sending both makes Anthropic honour the redaction and return thinking // blocks with an empty thinking field, so the caller's summary request would be // answered with a signature and no text. Verified on api.anthropic.com with // claude-opus-4-8: display=summarized yields thinking text only when the beta is // absent, and a native 2.1.220 CLI run with showThinkingSummaries enabled sends // display=summarized without the beta. func claudeThinkingDisplaySet(body []byte) bool { display := gjson.GetBytes(body, "thinking.display") return display.Type == gjson.String && strings.TrimSpace(display.String()) != "" } // claudeRequestUsesFastMode reports whether the request selects the fast service // tier. Anthropic rejects the body's speed field with "Extra inputs are not // permitted" unless fast-mode-2026-02-01 is declared, so the beta has to follow // the body. Deriving it here rather than at the call sites is deliberate: the // streaming and non-streaming paths previously disagreed and streaming silently // dropped the beta, turning every fast request into a 400. func claudeRequestUsesFastMode(body []byte, requested map[string]bool) bool { if requested[claudeFastModeBeta] { return true } speed := gjson.GetBytes(body, "speed") return speed.Type == gjson.String && strings.EqualFold(strings.TrimSpace(speed.String()), "fast") } // claudeCountTokensBetas is the fixed profile Claude Code 2.1.220 sends to // /v1/messages/count_tokens. It is far smaller than the inference baseline: // redact-thinking, thinking-token-count, prompt-caching-scope, effort and every // conditional beta are absent. Verified identical across 37 captured calls. var claudeCountTokensBetas = []string{ claudeCodeBeta, "interleaved-thinking-2025-05-14", "context-management-2025-06-27", claudeTokenCountingBeta, } func claudeCountTokensBetasForCredential(oauthToken bool) string { betas := make([]string, 0, len(claudeCountTokensBetas)+1) betas = append(betas, claudeCodeBeta) if oauthToken { betas = append(betas, claudeOAuthBeta) } betas = append(betas, claudeCountTokensBetas[1:]...) return strings.Join(betas, ",") } func withClaudeCountTokensOAuthBeta(betas string) string { parts := make([]string, 0, len(claudeCountTokensBetas)+1) seen := make(map[string]bool) for _, beta := range strings.Split(betas, ",") { if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] { parts = append(parts, beta) seen[beta] = true } } if seen[claudeOAuthBeta] { return strings.Join(parts, ",") } insertAt := 0 if len(parts) > 0 && parts[0] == claudeCodeBeta { insertAt = 1 } parts = append(parts, "") copy(parts[insertAt+1:], parts[insertAt:]) parts[insertAt] = claudeOAuthBeta return strings.Join(parts, ",") } // withClaudeOAuthCredentialBetas restores the credential-scoped betas that // describe the selected upstream OAuth account rather than caller capability. // // A confirmed native client authenticates to CPA with whatever key the user // configured and cannot know that CPA will select an OAuth credential upstream, // so its header never carries the OAuth betas. Passing it through verbatim ships // a Bearer request that declares neither oauth-2025-04-20 nor // extended-cache-ttl-2025-04-11, which no real OAuth client ever does. Passthrough // governs what the caller expressed; the credential is CPA's own choice and has to // be described accurately. // // Betas already present are left exactly where the caller put them. func withClaudeOAuthCredentialBetas(betas string) string { parts := make([]string, 0, 16) seen := make(map[string]bool) for _, beta := range strings.Split(betas, ",") { if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] { parts = append(parts, beta) seen[beta] = true } } if !seen[claudeOAuthBeta] { // Captured position 2, directly after claude-code-20250219. insertAt := 0 if len(parts) > 0 && parts[0] == claudeCodeBeta { insertAt = 1 } parts = append(parts, "") copy(parts[insertAt+1:], parts[insertAt:]) parts[insertAt] = claudeOAuthBeta } if !seen[claudeExtendedCacheTTLBeta] { parts = append(parts, claudeExtendedCacheTTLBeta) } return strings.Join(parts, ",") } // claudeEntitlementError marks an upstream refusal that is a property of the // request shape combined with the account's entitlements, not of the credential's // health. The auth manager must neither rotate nor cool down on these. type claudeEntitlementError struct { statusErr } func (claudeEntitlementError) IsRequestScoped() bool { return true } func (claudeEntitlementError) IsCredentialScoped() bool { return false } type claudeRateLimitError struct { statusErr credentialScoped bool } func (e claudeRateLimitError) IsCredentialScoped() bool { return e.credentialScoped } func (e claudeRateLimitError) IsRequestScoped() bool { return false } // classifyClaudeUpstreamError promotes upstream refusals that no other credential // can satisfy into request-scoped errors. // // Anthropic answers a fast-mode request from an account without the matching // usage credits with 429 rate_limit_error "Usage credits are required for fast // mode". The generic pipeline reads 429 as quota exhaustion: it marks the // credential Quota.Exceeded, applies an exponential cooldown and rotates to the // next one, which returns the same 429. A single speed:"fast" request would walk // the whole Claude pool and cool down every credential, all of which remain // perfectly healthy for ordinary traffic. The refusal belongs to the request. func classifyClaudeUpstreamError(statusCode int, headers http.Header, body []byte) error { var retryAfter *time.Duration if statusCode == http.StatusTooManyRequests || (statusCode >= 400 && statusCode < 600) { retryAfter = helps.ParseClaudeRateLimitReset(headers, time.Now()) } err := statusErr{code: statusCode, msg: string(body), retryAfter: retryAfter} if statusCode == http.StatusTooManyRequests { if helps.ClaudeHeadersIndicateUnifiedRateLimitRejection(headers) { return claudeRateLimitError{statusErr: err, credentialScoped: true} } if claudeBodyIndicatesFastModeCredits(body) { return claudeEntitlementError{err} } // Ordinary model-level Claude 429 (not a unified 5h/7d rejection) return claudeRateLimitError{statusErr: err, credentialScoped: false} } return err } // claudeBodyIndicatesFastModeCredits matches Anthropic's fast-mode entitlement // refusal without matching a genuine rate limit, which never mentions fast mode. func claudeBodyIndicatesFastModeCredits(body []byte) bool { message := strings.ToLower(gjson.GetBytes(body, "error.message").String()) if message == "" { message = strings.ToLower(string(body)) } return strings.Contains(message, "fast request rejected") || (strings.Contains(message, "fast") && (strings.Contains(message, "usage credits") || strings.Contains(message, "credits are required"))) } // claudeRequestedBetas collects every beta the caller asked for, from the // Anthropic-Beta header and from betas lifted out of the request body. func claudeRequestedBetas(incomingBetas string, extraBetas []string) map[string]bool { requested := make(map[string]bool) for _, beta := range strings.Split(incomingBetas, ",") { if beta = strings.TrimSpace(beta); beta != "" { requested[beta] = true } } for _, beta := range extraBetas { if beta = strings.TrimSpace(beta); beta != "" { requested[beta] = true } } return requested } // isAnthropicUpstreamURL reports whether a resolved request targets Anthropic's // first-party API. // // Every rule that reconstructs Claude Code's identity must key on this rather // than on the cloaked flag. Kimi rewrites base_url to api.kimi.com and custom // gateways set their own host, yet both delegate to ClaudeExecutor and are // therefore cloaked; a cloak-keyed rule silently rewrites their traffic too. func isAnthropicUpstreamURL(u *url.URL) bool { return helps.IsAnthropicUpstreamURL(u) } // isAnthropicUpstreamBase reports whether a configured base URL targets Anthropic's // first-party API. Used before the outgoing request exists. func isAnthropicUpstreamBase(baseURL string) bool { parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil { return false } return isAnthropicUpstreamURL(parsed) } // extractAndRemoveBetas extracts the "betas" array from the body and removes it. // Returns the extracted betas as a string slice and the modified body. func extractAndRemoveBetas(body []byte) ([]string, []byte) { betasResult := gjson.GetBytes(body, "betas") if !betasResult.Exists() { return nil, body } var betas []string if betasResult.IsArray() { for _, item := range betasResult.Array() { if s := strings.TrimSpace(item.String()); s != "" { betas = append(betas, s) } } } else if s := strings.TrimSpace(betasResult.String()); s != "" { betas = append(betas, s) } body, _ = sjson.DeleteBytes(body, "betas") return betas, body } // disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking. // Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool. // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations func disableThinkingIfToolChoiceForced(body []byte) []byte { toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String() // "auto" is allowed with thinking, but "any" or "tool" (specific tool) are not if toolChoiceType == "any" || toolChoiceType == "tool" { // Remove thinking configuration entirely to avoid API error body, _ = sjson.DeleteBytes(body, "thinking") // Adaptive thinking may also set output_config.effort; remove it to avoid // leaking thinking controls when tool_choice forces tool use. body, _ = sjson.DeleteBytes(body, "output_config.effort") if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { body, _ = sjson.DeleteBytes(body, "output_config") } } return body } // normalizeClaudeSamplingForUpstream keeps Anthropic message requests valid. // // Translated and cloaked callers keep the conservative normalization: their // sampling knobs come from a protocol that was not written for Anthropic, and // Anthropic rejects several combinations outright, so neither temperature nor // top_p is worth forwarding. // // A confirmed native Claude Code client owns its own wire, exactly like // cache_control placement. The measured structured Haiku helper sends // "temperature":1 and claudeCodeHelperShapeStructured keys on it, so stripping // it would emit a shape no native client ever produces. Keep what the caller // sent and drop only what Anthropic actually rejects (verified live): // - thinking active: temperature must be 1, top_p must be >= 0.95, top_k unset // - otherwise: temperature and top_p cannot both be specified func normalizeClaudeSamplingForUpstream(body []byte, nativeOwned bool) []byte { thinkingActive := false switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) { case "enabled", "adaptive", "auto": thinkingActive = true } if !nativeOwned { body, _ = sjson.DeleteBytes(body, "temperature") body, _ = sjson.DeleteBytes(body, "top_p") if thinkingActive { body, _ = sjson.DeleteBytes(body, "top_k") } return body } if thinkingActive { if temperature := gjson.GetBytes(body, "temperature"); temperature.Exists() && temperature.Num != 1 { body, _ = sjson.DeleteBytes(body, "temperature") } if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && topP.Num < 0.95 { body, _ = sjson.DeleteBytes(body, "top_p") } body, _ = sjson.DeleteBytes(body, "top_k") return body } // Anthropic accepts either one but not both; temperature is the knob native // Claude Code actually sends, so top_p is the one that gives way. if gjson.GetBytes(body, "temperature").Exists() && gjson.GetBytes(body, "top_p").Exists() { body, _ = sjson.DeleteBytes(body, "top_p") } return body } type compositeReadCloser struct { io.Reader closers []func() error } func (c *compositeReadCloser) Close() error { var firstErr error for i := range c.closers { if c.closers[i] == nil { continue } if err := c.closers[i](); err != nil && firstErr == nil { firstErr = err } } return firstErr } // peekableBody wraps a bufio.Reader around the original ReadCloser so that // magic bytes can be inspected without consuming them from the stream. type peekableBody struct { *bufio.Reader closer io.Closer } func (p *peekableBody) Close() error { return p.closer.Close() } func claudeResponseContentEncoding(header http.Header) string { return strings.Join(header.Values("Content-Encoding"), ",") } func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) { if body == nil { return nil, fmt.Errorf("response body is nil") } if contentEncoding == "" { // No Content-Encoding header. Attempt best-effort magic-byte detection to // handle misbehaving upstreams that compress without setting the header. // Only gzip (1f 8b) and zstd (28 b5 2f fd) have reliable magic sequences; // br and deflate have none and are left as-is. // The bufio wrapper preserves unread bytes so callers always see the full // stream regardless of whether decompression was applied. pb := &peekableBody{Reader: bufio.NewReader(body), closer: body} magic, peekErr := pb.Peek(4) if peekErr == nil || (peekErr == io.EOF && len(magic) >= 2) { switch { case len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b: gzipReader, gzErr := gzip.NewReader(pb)