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) if gzErr != nil { _ = pb.Close() return nil, fmt.Errorf("magic-byte gzip: failed to create reader: %w", gzErr) } return &compositeReadCloser{ Reader: gzipReader, closers: []func() error{ gzipReader.Close, pb.Close, }, }, nil case len(magic) >= 4 && magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd: decoder, zdErr := zstd.NewReader(pb) if zdErr != nil { _ = pb.Close() return nil, fmt.Errorf("magic-byte zstd: failed to create reader: %w", zdErr) } return &compositeReadCloser{ Reader: decoder, closers: []func() error{ func() error { decoder.Close(); return nil }, pb.Close, }, }, nil } } return pb, nil } encodings := strings.Split(contentEncoding, ",") reader := io.Reader(body) decoderClosers := make([]func() error, 0, len(encodings)) cleanup := func() { for i := len(decoderClosers) - 1; i >= 0; i-- { _ = decoderClosers[i]() } _ = body.Close() } for index := len(encodings) - 1; index >= 0; index-- { encoding := strings.TrimSpace(strings.ToLower(encodings[index])) switch encoding { case "", "identity": continue case "gzip": gzipReader, errGzip := gzip.NewReader(reader) if errGzip != nil { cleanup() return nil, fmt.Errorf("failed to create gzip reader: %w", errGzip) } reader = gzipReader decoderClosers = append(decoderClosers, gzipReader.Close) case "deflate": deflateReader, errDeflate := newClaudeDeflateReader(reader) if errDeflate != nil { cleanup() return nil, errDeflate } reader = deflateReader decoderClosers = append(decoderClosers, deflateReader.Close) case "br": reader = brotli.NewReader(reader) case "zstd": decoder, errZstd := zstd.NewReader(reader) if errZstd != nil { cleanup() return nil, fmt.Errorf("failed to create zstd reader: %w", errZstd) } reader = decoder decoderClosers = append(decoderClosers, func() error { decoder.Close() return nil }) default: cleanup() return nil, fmt.Errorf("unsupported content encoding %q", encoding) } } if len(decoderClosers) == 0 && reader == body { return body, nil } closers := make([]func() error, 0, len(decoderClosers)+1) for index := len(decoderClosers) - 1; index >= 0; index-- { closers = append(closers, decoderClosers[index]) } closers = append(closers, body.Close) return &compositeReadCloser{Reader: reader, closers: closers}, nil } func newClaudeDeflateReader(reader io.Reader) (io.ReadCloser, error) { buffered := bufio.NewReader(reader) header, errPeek := buffered.Peek(2) if errPeek == nil && isZlibHeader(header) { zlibReader, errZlib := zlib.NewReader(buffered) if errZlib != nil { return nil, fmt.Errorf("failed to create zlib deflate reader: %w", errZlib) } return zlibReader, nil } return flate.NewReader(buffered), nil } func isZlibHeader(header []byte) bool { if len(header) < 2 { return false } cmf, flg := header[0], header[1] return cmf&0x0f == 8 && cmf>>4 <= 7 && (uint16(cmf)<<8|uint16(flg))%31 == 0 } // claudeCredentialUsesOAuth classifies the selected upstream credential. It is the // single authority for every decision that has to agree with the OAuth beta // profile, including the extended-cache-ttl beta and the matching body cache ttl. func claudeCredentialUsesOAuth(auth *cliproxyauth.Auth, apiKey string) bool { if isClaudeOAuthToken(apiKey) { return true } if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey { return false } hasAPIKeyAttr := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "" return !hasAPIKeyAttr } func copyClaudeCallerFingerprintHeaders(dst, src http.Header) { if dst == nil || src == nil { return } for name, values := range src { lowerName := strings.ToLower(strings.TrimSpace(name)) if lowerName != "accept" && lowerName != "accept-encoding" && lowerName != "user-agent" && lowerName != "x-app" && lowerName != "x-client-request-id" && !strings.HasPrefix(lowerName, "anthropic-") && !strings.HasPrefix(lowerName, "x-stainless-") && !strings.HasPrefix(lowerName, "x-claude-code-") && !strings.HasPrefix(lowerName, "x-claude-remote-") && lowerName != "x-client-app" && lowerName != "x-anthropic-additional-protection" { continue } dst.Del(name) for _, value := range values { dst.Add(name, value) } } } func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, body []byte, cfg *config.Config, incomingHeaders http.Header, confirmedClaudeCode bool, sessionIDs ...string) error { return applyClaudeHeadersWithNativeProfile( r, auth, apiKey, stream, extraBetas, body, cfg, incomingHeaders, confirmedClaudeCode, false, sessionIDs..., ) } func applyClaudeHeadersWithNativeProfile( r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, body []byte, cfg *config.Config, incomingHeaders http.Header, confirmedClaudeCode bool, helperProfile bool, sessionIDs ...string, ) error { if r == nil { return nil } hdrDefault := func(cfgVal, fallback string) string { if cfgVal != "" { return cfgVal } return fallback } var hd config.ClaudeHeaderDefaults if cfg != nil { hd = cfg.ClaudeHeaderDefaults } // Authentication and wire fingerprint are separate authorities. File-backed // delegated providers still use Bearer auth, but only real Claude OAuth and // explicit fingerprint-profile opt-ins receive the CLI wire profile. credentialUsesBearer := claudeCredentialUsesOAuth(auth, apiKey) useAPIKey := !credentialUsesBearer fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey) wirePolicy, _ := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) applyCLIFingerprint := fp.ProfileClaudeCodeCLI || wirePolicy.Cloak preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode useOAuthBetas := fp.UseOAuthBetas isAnthropicBase := isAnthropicUpstreamURL(r.URL) if strings.TrimSpace(apiKey) != "" { if isAnthropicBase && useAPIKey { r.Header.Del("Authorization") r.Header.Set("x-api-key", apiKey) } else { r.Header.Del("x-api-key") r.Header.Set("Authorization", "Bearer "+apiKey) } } else { r.Header.Del("Authorization") r.Header.Del("x-api-key") } r.Header.Set("Content-Type", "application/json") if incomingHeaders == nil { if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { incomingHeaders = ginCtx.Request.Header } } stabilizeDeviceProfile := helps.ClaudeDeviceProfileStabilizationEnabled(cfg) var deviceProfile helps.ClaudeDeviceProfile if stabilizeDeviceProfile && confirmedClaudeCode { var errDeviceProfile error deviceProfile, errDeviceProfile = helps.ResolveClaudeDeviceProfileRequired(r.Context(), auth, apiKey, incomingHeaders, cfg) if errDeviceProfile != nil { return errDeviceProfile } } incomingBetas := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ",")) countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens") baseBetas := incomingBetas if !preserveCallerFingerprint { baseBetas = claudeCodeCLIBetas(body, claudeRequestedBetas(incomingBetas, extraBetas), useOAuthBetas) if countTokens { baseBetas = claudeCountTokensBetasForCredential(useOAuthBetas) } } if confirmedClaudeCode && incomingBetas != "" { baseBetas = incomingBetas // Measured Haiku helper requests already carry the exact credential // beta profile and intentionally omit extended-cache-ttl. if useOAuthBetas && !helperProfile { if countTokens { baseBetas = withClaudeCountTokensOAuthBeta(baseBetas) } else { baseBetas = withClaudeOAuthCredentialBetas(baseBetas) } } } existingSet := make(map[string]bool) for _, beta := range strings.Split(baseBetas, ",") { if beta = strings.TrimSpace(beta); beta != "" { existingSet[beta] = true } } appendBeta := func(beta string) { beta = strings.TrimSpace(beta) if beta == "" || existingSet[beta] { return } if strings.TrimSpace(baseBetas) == "" { baseBetas = beta } else { baseBetas += "," + beta } existingSet[beta] = true } if preserveCallerFingerprint { // Caller-owned mode preserves both header and body-lifted betas verbatim. // The explicit speed=fast request still needs its protocol beta. if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "speed").String()), "fast") { appendBeta(claudeFastModeBeta) } for _, beta := range extraBetas { appendBeta(beta) } } else { // On direct Anthropic an unconfirmed CLI-profile caller's own betas are // dropped: appending them to the measured baseline produces a shape real // Claude Code never sends. Custom gateways keep caller extensions. if !confirmedClaudeCode && incomingBetas != "" && !isAnthropicBase { for _, beta := range strings.Split(incomingBetas, ",") { appendBeta(beta) } } if !isAnthropicBase { for _, beta := range extraBetas { appendBeta(beta) } } } applyBetaHeader := func() { if strings.TrimSpace(baseBetas) == "" { r.Header.Del("Anthropic-Beta") return } r.Header.Set("Anthropic-Beta", baseBetas) } applyBetaHeader() if preserveCallerFingerprint { defaultAccept := "application/json" defaultAcceptEncoding := "gzip, deflate, br, zstd" if stream && !isAnthropicBase { defaultAccept = "text/event-stream" defaultAcceptEncoding = "identity" } copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders) misc.EnsureHeader(r.Header, incomingHeaders, "Anthropic-Version", "2023-06-01") misc.EnsureHeader(r.Header, incomingHeaders, "Accept", defaultAccept) misc.EnsureHeader(r.Header, incomingHeaders, "Accept-Encoding", defaultAcceptEncoding) // Caller-owned mode forwards the caller's own User-Agent, but a caller that // sent none must not fall through to Go's transport default // ("Go-http-client/1.1"), which upstreams read as a bot signature. Identify // as CPA instead: honest about the hop, and not a fabricated client. misc.EnsureHeader(r.Header, incomingHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version) applyBetaHeader() var attrs map[string]string if auth != nil { attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs, incomingHeaders) // Scope the custom-header escape hatch exactly like the CLI path below, which // claws overrides back on api.anthropic.com (an operator Anthropic-Beta reaches // a first-party API that rejects unknown values) and on any streaming request // (an Accept override silently disables event negotiation), while letting a // non-streaming third-party gateway keep them. Restoring here means restoring // the caller's own choice, not CPA's default: this mode is caller-owned. restoreCallerTransport := func() { resetHeader := func(name, fallback string) { if value := strings.TrimSpace(incomingHeaders.Get(name)); value != "" { r.Header.Set(name, value) return } r.Header.Set(name, fallback) } resetHeader("Accept", defaultAccept) resetHeader("Accept-Encoding", defaultAcceptEncoding) } if isAnthropicBase { applyBetaHeader() restoreCallerTransport() } else if stream { restoreCallerTransport() } return nil } identityHeader := func(name, fallback string) { if confirmedClaudeCode { misc.EnsureHeader(r.Header, incomingHeaders, name, fallback) return } r.Header.Set(name, fallback) } identityHeader("Anthropic-Version", "2023-06-01") identityHeader("Anthropic-Dangerous-Direct-Browser-Access", "true") identityHeader("X-App", "cli") // Values below match Claude Code 2.1.220 / @anthropic-ai/sdk 0.94.0. identityHeader("X-Stainless-Retry-Count", "0") identityHeader("X-Stainless-Runtime", "node") identityHeader("X-Stainless-Lang", "js") // Native async SDK helpers add this header independently of body.stream. // Preserve it only after the complete native-client detector succeeds. if confirmedClaudeCode && incomingHeaders.Get("X-Stainless-Async") == "async" { r.Header.Set("X-Stainless-Async", "async") } // Claude Code omits X-Stainless-Timeout on count_tokens; only a confirmed // native client that sent one of its own keeps it there. if !countTokens { identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600")) } else if confirmedClaudeCode { if incomingTimeout := incomingHeaders.Get("X-Stainless-Timeout"); incomingTimeout != "" { r.Header.Set("X-Stainless-Timeout", incomingTimeout) } } // Selected-credential OAuth identity is an explicit native passthrough // exception. Callers pass the same agent-conversation UUID written to // metadata.user_id; legacy paths retain their previous cached fallback. sessionID := "" for _, candidate := range sessionIDs { if candidate = strings.TrimSpace(candidate); candidate != "" { sessionID = candidate break } } if sessionID != "" { r.Header.Set("X-Claude-Code-Session-Id", sessionID) } else { var errSessionID error sessionID, errSessionID = helps.CachedSessionIDRequired(r.Context(), apiKey) if errSessionID != nil { return errSessionID } identityHeader("X-Claude-Code-Session-Id", sessionID) } // Preserve native Claude Code subagent and environment headers when present in the incoming request. for _, hdr := range []string{ "X-Claude-Code-Agent-Id", "X-Claude-Code-Parent-Agent-Id", "X-Claude-Remote-Container-Id", "X-Claude-Remote-Session-Id", "X-Client-App", "X-Anthropic-Additional-Protection", } { if val := helps.HeaderValueCaseInsensitive(incomingHeaders, hdr); val != "" { r.Header.Set(hdr, val) } } // Per-request UUID, matches Claude Code's x-client-request-id for first-party API. // identityHeader prefers the incoming value for a confirmed client, so a confirmed // helper keeps its own native request ID and this fresh UUID only covers a caller // that sent none. Helpers opt in on custom gateways too. if isAnthropicBase || helperProfile { identityHeader("x-client-request-id", uuid.New().String()) } r.Header.Set("Connection", "keep-alive") // Regular Claude Code requests negotiate transport identically for streaming // and non-streaming requests. Measured Haiku helpers are the exception: their // minimal non-stream request offers gzip only, while the structured streaming // helper offers the full compression set. Confirmed helpers preserve the // incoming native values. applyTransportNegotiation := func() { if helperProfile { identityHeader("Accept", "application/json") identityHeader("Accept-Encoding", "gzip") return } if stream && !isAnthropicBase { // Other Anthropic-compatible upstreams (Kimi, custom gateways) may select // SSE from Accept and need not compress predictably, so they keep the // conservative contract. r.Header.Set("Accept", "text/event-stream") r.Header.Set("Accept-Encoding", "identity") return } r.Header.Set("Accept", "application/json") r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") } applyTransportNegotiation() // Confirmed Claude Code requests may contribute their real software profile. // Unconfirmed clients always receive the CLI baseline instead of being // allowed to populate or reuse another client's software profile. if stabilizeDeviceProfile { if confirmedClaudeCode { helps.ApplyClaudeDeviceProfileHeaders(r, deviceProfile) } else { helps.ApplyClaudeDefaultDeviceProfileHeaders(r, cfg) } } else { helps.ApplyClaudeLegacyDeviceHeaders(r, incomingHeaders, cfg, confirmedClaudeCode) } var attrs map[string]string if auth != nil { attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(r, attrs, incomingHeaders) // Custom credential headers are a configuration escape hatch for third-party // gateways, so they keep the last word there. On api.anthropic.com they must // not rewrite the reconstructed identity: an overridden Anthropic-Beta yields a // combination real Claude Code never sends and the API rejects, and an // overridden Accept-Encoding contradicts the negotiated transport. Both were // reachable because this ran after the whole header set was assembled. if isAnthropicBase { r.Header.Set("Anthropic-Beta", baseBetas) applyTransportNegotiation() } else if stream { // Elsewhere only streaming is protected, so an Accept override cannot // silently disable event negotiation. applyTransportNegotiation() } return nil } // doClaudeUpstreamRequest is the single send boundary for every Claude upstream // call. Folding the wire-casing pass in here makes it structurally impossible // for one of the three request paths to drift away from the others, which is // exactly how the streaming and non-streaming beta sets diverged before. func doClaudeUpstreamRequest(client *http.Client, req *http.Request) (*http.Response, error) { applyClaudeWireHeaderCasing(req) return client.Do(req) } // claudeWireHeaderCasing maps Go's canonical header name to the exact casing // Claude Code 2.1.220 puts on the wire. Only the names that differ are listed; // the other twelve already survive canonicalisation unchanged. var claudeWireHeaderCasing = map[string]string{ "X-Stainless-Os": "X-Stainless-OS", "Anthropic-Beta": "anthropic-beta", "Anthropic-Version": "anthropic-version", "X-App": "x-app", "X-Client-Request-Id": "x-client-request-id", "Anthropic-Dangerous-Direct-Browser-Access": "anthropic-dangerous-direct-browser-access", } // applyClaudeWireHeaderCasing restores the header name casing of the real client. // // CPA negotiates ALPN http/1.1 with Anthropic, so header names reach the server // verbatim rather than lowercased by HPACK, which makes casing observable. Go // canonicalises every name passed through Header.Set, turning the client's // anthropic-beta and x-app into Anthropic-Beta and X-App. Writing the map keys // directly is the only way to keep the original casing. // // This also fixes ordering for free: Go sorts header names bytewise when it // serialises them, and the real client's order is exactly that same bytewise // sort, so correct casing reproduces the correct order. Host, User-Agent and // Content-Length remain misplaced because Go writes them ahead of the sorted // block; that needs transport-level surgery and is out of scope here. // // Call this immediately before handing the request to the client and nowhere // else. The rewritten keys are unreachable through Header.Get, which // canonicalises its argument, so running it any earlier would silently hide // these headers from the rest of the pipeline. func applyClaudeWireHeaderCasing(r *http.Request) { if r == nil || r.Header == nil || !isAnthropicUpstreamURL(r.URL) { return } for canonical, wire := range claudeWireHeaderCasing { values, ok := r.Header[canonical] if !ok { continue } delete(r.Header, canonical) r.Header[wire] = values } } func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { if a == nil { return "", "" } if a.Attributes != nil { apiKey = a.Attributes["api_key"] baseURL = a.Attributes["base_url"] } if apiKey == "" { apiKey = claudeauth.ReadMetadataString(&a.Metadata, "access_token") } return } // claudePayloadHasMidSystemMessage reports whether the caller placed a // {"role":"system"} turn inside messages. func claudePayloadHasMidSystemMessage(payload []byte) bool { messages := gjson.GetBytes(payload, "messages") if !messages.IsArray() { return false } found := false messages.ForEach(func(_, message gjson.Result) bool { if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") { found = true return false } return true }) return found } func rebuildMidSystemMessagesToTopLevel(payload []byte) []byte { messages := gjson.GetBytes(payload, "messages") if !messages.IsArray() { return payload } var movedSystemParts []string keptMessages := make([]string, 0, int(messages.Get("#").Int())) messages.ForEach(func(_, message gjson.Result) bool { if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") { movedSystemParts = append(movedSystemParts, claudeSystemTextParts(message.Get("content"))...) return true } keptMessages = append(keptMessages, message.Raw) return true }) if len(movedSystemParts) == 0 { return payload } systemParts := claudeSystemTextParts(gjson.GetBytes(payload, "system")) systemParts = append(systemParts, movedSystemParts...) if len(systemParts) > 0 { if updated, errSetSystem := sjson.SetRawBytes(payload, "system", rawJSONArray(systemParts)); errSetSystem == nil { payload = updated } } if updated, errSetMessages := sjson.SetRawBytes(payload, "messages", rawJSONArray(keptMessages)); errSetMessages == nil { payload = updated } return payload } func claudeSystemTextParts(content gjson.Result) []string { if !content.Exists() { return nil } if content.Type == gjson.String { text := content.String() if strings.TrimSpace(text) == "" { return nil } block := []byte(`{"type":"text","text":""}`) block, _ = sjson.SetBytes(block, "text", text) return []string{string(block)} } if !content.IsArray() { return nil } var parts []string content.ForEach(func(_, item gjson.Result) bool { if item.Type == gjson.String { text := item.String() if strings.TrimSpace(text) != "" { block := []byte(`{"type":"text","text":""}`) block, _ = sjson.SetBytes(block, "text", text) parts = append(parts, string(block)) } return true } if item.IsObject() && item.Get("type").String() == "text" && strings.TrimSpace(item.Get("text").String()) != "" { parts = append(parts, item.Raw) } return true }) return parts } func rawJSONArray(items []string) []byte { if len(items) == 0 { return []byte("[]") } var builder strings.Builder builder.WriteByte('[') for i, item := range items { if i > 0 { builder.WriteByte(',') } builder.WriteString(item) } builder.WriteByte(']') return []byte(builder.String()) } func isClaudeOAuthToken(apiKey string) bool { return strings.Contains(apiKey, "sk-ant-oat") } type claudeMCPAliasOptions struct { secret string } func resolveClaudeMCPAliasOptions(ctx context.Context) claudeMCPAliasOptions { // Alias identity belongs to the downstream caller, not to the selected // upstream credential. This keeps names stable across OAuth refresh and auth // failover while giving one caller a shared virtual MCP server component. secret := strings.TrimSpace(helps.APIKeyFromContext(ctx)) if secret == "" { secret = "cpa-claude-mcp-default-caller" } return claudeMCPAliasOptions{secret: secret} } // prepareClaudeOAuthToolNamesForUpstream applies one request-local MCP symbol // table across every Claude OAuth request path. func prepareClaudeOAuthToolNamesForUpstream(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) { return remapOAuthToolNamesWithOptions(body, mcpAliases) } func restoreClaudeOAuthToolNamesFromResponse(body []byte, reverseMap map[string]string) ([]byte, error) { return reverseRemapOAuthToolNames(body, reverseMap) } func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) ([]byte, error) { return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) } // remapOAuthToolNames represents every declared third-party client tool as a // semantic Claude Code MCP extension. Existing valid MCP names and explicit // typed Anthropic tools remain unchanged. // // It operates on tools[].name, tool_choice.name, and all declared // tool_use/tool_reference references in messages. // // The returned map is keyed on the upstream name and maps to the client-supplied // original name. Callers MUST pass this map to the reverse // functions so only aliases allocated for this request are restored on the // response. A global reverse map would mix symbols from unrelated callers. func remapOAuthToolNames(body []byte) ([]byte, map[string]string) { return remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "cpa-claude-mcp-default-caller"}) } type claudeRawJSONEdit struct { start int end int replacement string } func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) { remapped, reverseMap, ok := remapOAuthToolNamesWithBatchedEdits(body, mcpAliases) if ok { return remapped, reverseMap } return remapOAuthToolNamesWithOptionsLegacy(body, mcpAliases) } // remapOAuthToolNamesWithBatchedEdits records offsets from the original JSON // and applies every rename in one copy. Repeated sjson.SetBytes calls copy most // of the request for every historical tool reference, turning this path into // O(body size * reference count) allocation growth. func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string, bool) { if !gjson.ValidBytes(body) { return nil, nil, false } reverseMap := make(map[string]string) recordRename := func(original, renamed string) { // Preserve the first-seen original name if the same upstream name is // produced from multiple call sites; they all map back identically. if _, exists := reverseMap[renamed]; !exists { reverseMap[renamed] = original } } // Build one request-specific forward map from declarations. Every client // tool, including typed custom declarations and names resembling Claude // built-ins, gets an MCP alias. Historical references use this same map. tools := gjson.GetBytes(body, "tools") forwardMap := make(map[string]string) protectedNames := make(map[string]bool) reservedNames := helps.AugmentClaudeBuiltinToolRegistry(body, nil) if tools.Exists() && tools.IsArray() { tools.ForEach(func(_, tool gjson.Result) bool { name := tool.Get("name").String() if name != "" { reservedNames[name] = true } if helps.IsClaudeServerToolType(tool.Get("type").String()) { protectedNames[name] = true } return true }) passthroughMCPTools := make([]string, 0, 4) tools.ForEach(func(_, tool gjson.Result) bool { if helps.IsClaudeServerToolType(tool.Get("type").String()) { return true } name := tool.Get("name").String() if name == "" { return true } if helps.IsClaudeMCPToolName(name) { passthroughMCPTools = append(passthroughMCPTools, name) return true } if _, exists := forwardMap[name]; exists { return true } alias, allocated := helps.AllocateClaudeMCPToolAlias(mcpAliases.secret, name, reservedNames) if !allocated { log.Warnf("claude oauth mcp alias: no free alias left for tool %q, forwarding the original name", name) return true } forwardMap[name] = alias reservedNames[alias] = true return true }) recordPassthroughMCPTools(recordRename, forwardMap, passthroughMCPTools) } rewriteName := func(name string) (string, bool) { if name == "" || protectedNames[name] || helps.IsClaudeMCPToolName(name) { return name, false } if newName, ok := forwardMap[name]; ok && newName != name { return newName, true } return name, false } edits := make([]claudeRawJSONEdit, 0, len(forwardMap)+1) appendRawEdit := func(result gjson.Result, replacement string) bool { start := result.Index end := start + len(result.Raw) if result.Raw == "" || start < 0 || end < start || end > len(body) || !bytes.Equal(body[start:end], []byte(result.Raw)) { return false } edits = append(edits, claudeRawJSONEdit{start: start, end: end, replacement: replacement}) return true } appendStringEdit := func(result gjson.Result, replacement string) bool { // Generated aliases only emit [A-Za-z0-9_-], so adding quotes is // byte-identical to sjson's encoding without another allocation. return appendRawEdit(result, `"`+replacement+`"`) } // 1. Rebuild typed custom tools exactly as before, but replace the original // tools array only after all offsets have been collected. toolsNeedRewrite := false if tools.Exists() && tools.IsArray() { tools.ForEach(func(_, tool gjson.Result) bool { toolType := tool.Get("type").String() if helps.IsClaudeServerToolType(toolType) { return true } if strings.TrimSpace(toolType) != "" { toolsNeedRewrite = true return false } name := tool.Get("name").String() _, toolsNeedRewrite = rewriteName(name) return !toolsNeedRewrite }) } if toolsNeedRewrite { var toolsJSON strings.Builder toolsJSON.WriteByte('[') toolCount := 0 tools.ForEach(func(_, tool gjson.Result) bool { if helps.IsClaudeServerToolType(tool.Get("type").String()) { if toolCount > 0 { toolsJSON.WriteByte(',') } toolsJSON.WriteString(tool.Raw) toolCount++ return true } name := tool.Get("name").String() toolJSON := tool.Raw if strings.TrimSpace(tool.Get("type").String()) != "" { if updatedTool, errDelete := sjson.Delete(toolJSON, "type"); errDelete == nil { toolJSON = updatedTool } } if newName, renamed := rewriteName(name); renamed { updatedTool, err := sjson.Set(toolJSON, "name", newName) if err == nil { toolJSON = updatedTool recordRename(name, newName) } } if toolCount > 0 { toolsJSON.WriteByte(',') } toolsJSON.WriteString(toolJSON) toolCount++ return true }) toolsJSON.WriteByte(']') if !appendRawEdit(tools, toolsJSON.String()) { return nil, nil, false } } // 2. Rename tool_choice if it references a declared client tool. toolChoice := gjson.GetBytes(body, "tool_choice") if toolChoice.Get("type").String() == "tool" { nameResult := toolChoice.Get("name") tcName := nameResult.String() if newName, renamed := rewriteName(tcName); renamed { if !appendStringEdit(nameResult, newName) { return nil, nil, false } recordRename(tcName, newName) } } // 3. Rename tool references in messages while every Result.Index still // points into the original request bytes. messages := gjson.GetBytes(body, "messages") validOffsets := true if messages.Exists() && messages.IsArray() { messages.ForEach(func(_, msg gjson.Result) bool { content := msg.Get("content") if !content.Exists() || !content.IsArray() { return true } content.ForEach(func(_, part gjson.Result) bool { switch part.Get("type").String() { case "tool_use": nameResult := part.Get("name") name := nameResult.String() if newName, renamed := rewriteName(name); renamed { if !appendStringEdit(nameResult, newName) { validOffsets = false return false } recordRename(name, newName) } case "tool_reference": nameResult := part.Get("tool_name") toolName := nameResult.String() if newName, renamed := rewriteName(toolName); renamed { if !appendStringEdit(nameResult, newName) { validOffsets = false return false } recordRename(toolName, newName) } case "tool_result": nestedContent := part.Get("content") if nestedContent.Exists() && nestedContent.IsArray() { nestedContent.ForEach(func(_, nestedPart gjson.Result) bool { if nestedPart.Get("type").String() != "tool_reference" { return true } nameResult := nestedPart.Get("tool_name") nestedToolName := nameResult.String() if newName, renamed := rewriteName(nestedToolName); renamed { if !appendStringEdit(nameResult, newName) { validOffsets = false return false } recordRename(nestedToolName, newName) } return true }) } case "tool_search_tool_result": toolRefs := part.Get("content.tool_references") if toolRefs.Exists() && toolRefs.IsArray() { toolRefs.ForEach(func(_, refPart gjson.Result) bool { if refPart.Get("type").String() != "tool_reference" { return true } nameResult := refPart.Get("tool_name") refToolName := nameResult.String() if newName, renamed := rewriteName(refToolName); renamed { if !appendStringEdit(nameResult, newName) { validOffsets = false return false } recordRename(refToolName, newName) } return true }) } } return validOffsets }) return validOffsets }) } if !validOffsets { return nil, nil, false } remapped, ok := applyClaudeRawJSONEdits(body, edits) if !ok { return nil, nil, false } return remapped, reverseMap, true } func applyClaudeRawJSONEdits(body []byte, edits []claudeRawJSONEdit) ([]byte, bool) { if len(edits) == 0 { return body, true } sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start }) finalSize := len(body) cursor := 0 for _, edit := range edits { if edit.start < cursor || edit.start < 0 || edit.end < edit.start || edit.end > len(body) { return nil, false } finalSize += len(edit.replacement) - (edit.end - edit.start) if finalSize < 0 { return nil, false } cursor = edit.end } out := make([]byte, 0, finalSize) cursor = 0 for _, edit := range edits { out = append(out, body[cursor:edit.start]...) out = append(out, edit.replacement...) cursor = edit.end } out = append(out, body[cursor:]...) return out, true } // remapOAuthToolNamesWithOptionsLegacy is the byte-for-byte compatibility // fallback for malformed JSON or an unexpected GJSON offset. Keep it available // as a differential-test oracle for the batched implementation. func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) { reverseMap := make(map[string]string) recordRename := func(original, renamed string) { // Preserve the first-seen original name if the same upstream name is // produced from multiple call sites; they all map back identically. if _, exists := reverseMap[renamed]; !exists { reverseMap[renamed] = original } } // Build one request-specific forward map from declarations. Every client // tool, including typed custom declarations and names resembling Claude // built-ins, gets an MCP alias. Historical references use this same map. tools := gjson.GetBytes(body, "tools") forwardMap := make(map[string]string) protectedNames := make(map[string]bool) reservedNames := helps.AugmentClaudeBuiltinToolRegistry(body, nil) if tools.Exists() && tools.IsArray() { tools.ForEach(func(_, tool gjson.Result) bool { name := tool.Get("name").String() if name != "" { reservedNames[name] = true } if helps.IsClaudeServerToolType(tool.Get("type").String()) { protectedNames[name] = true } return true }) passthroughMCPTools := make([]string, 0, 4) tools.ForEach(func(_, tool gjson.Result) bool { if helps.IsClaudeServerToolType(tool.Get("type").String()) { return true } name := tool.Get("name").String() if name == "" { return true } if helps.IsClaudeMCPToolName(name) { passthroughMCPTools = append(passthroughMCPTools, name) return true } if _, exists := forwardMap[name]; exists { return true } alias, allocated := helps.AllocateClaudeMCPToolAlias(mcpAliases.secret, name, reservedNames) if !allocated { log.Warnf("claude oauth mcp alias: no free alias left for tool %q, forwarding the original name", name) return true } forwardMap[name] = alias reservedNames[alias] = true return true }) recordPassthroughMCPTools(recordRename, forwardMap, passthroughMCPTools) } rewriteName := func(name string) (string, bool) { if name == "" || protectedNames[name] || helps.IsClaudeMCPToolName(name) { return name, false } if newName, ok := forwardMap[name]; ok && newName != name { return newName, true } return name, false } // 1. Rewrite the tools array without rebuilding from a stale gjson snapshot. toolsNeedRewrite := false if tools.Exists() && tools.IsArray() { tools.ForEach(func(_, tool gjson.Result) bool { toolType := tool.Get("type").String() if helps.IsClaudeServerToolType(toolType) { return true } if strings.TrimSpace(toolType) != "" { toolsNeedRewrite = true return false } name := tool.Get("name").String() _, toolsNeedRewrite = rewriteName(name) return !toolsNeedRewrite }) } if toolsNeedRewrite { var toolsJSON strings.Builder toolsJSON.WriteByte('[') toolCount := 0 tools.ForEach(func(_, tool gjson.Result) bool { if helps.IsClaudeServerToolType(tool.Get("type").String()) { if toolCount > 0 { toolsJSON.WriteByte(',') } toolsJSON.WriteString(tool.Raw) toolCount++ return true } name := tool.Get("name").String() toolJSON := tool.Raw if strings.TrimSpace(tool.Get("type").String()) != "" { if updatedTool, errDelete := sjson.Delete(toolJSON, "type"); errDelete == nil { toolJSON = updatedTool } } if newName, renamed := rewriteName(name); renamed { updatedTool, err := sjson.Set(toolJSON, "name", newName) if err == nil { toolJSON = updatedTool recordRename(name, newName) } } if toolCount > 0 { toolsJSON.WriteByte(',') } toolsJSON.WriteString(toolJSON) toolCount++ return true }) toolsJSON.WriteByte(']') body, _ = sjson.SetRawBytes(body, "tools", []byte(toolsJSON.String())) } // 2. Rename tool_choice if it references a declared client tool. toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String() if toolChoiceType == "tool" { tcName := gjson.GetBytes(body, "tool_choice.name").String() if newName, renamed := rewriteName(tcName); renamed { body, _ = sjson.SetBytes(body, "tool_choice.name", newName) recordRename(tcName, newName) } } // 3. Rename tool references in messages messages := gjson.GetBytes(body, "messages") if messages.Exists() && messages.IsArray() { messages.ForEach(func(msgIndex, msg gjson.Result) bool { content := msg.Get("content") if !content.Exists() || !content.IsArray() { return true } content.ForEach(func(contentIndex, part gjson.Result) bool { partType := part.Get("type").String() switch partType { case "tool_use": name := part.Get("name").String() if newName, renamed := rewriteName(name); renamed { path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, newName) recordRename(name, newName) } case "tool_reference": toolName := part.Get("tool_name").String() if newName, renamed := rewriteName(toolName); renamed { path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, newName) recordRename(toolName, newName) } case "tool_result": // Handle nested tool_reference blocks inside tool_result.content[] toolID := part.Get("tool_use_id").String() _ = toolID // tool_use_id stays as-is nestedContent := part.Get("content") if nestedContent.Exists() && nestedContent.IsArray() { nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool { if nestedPart.Get("type").String() == "tool_reference" { nestedToolName := nestedPart.Get("tool_name").String() if newName, renamed := rewriteName(nestedToolName); renamed { nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int()) body, _ = sjson.SetBytes(body, nestedPath, newName) recordRename(nestedToolName, newName) } } return true }) } case "tool_search_tool_result": toolRefs := part.Get("content.tool_references") if toolRefs.Exists() && toolRefs.IsArray() { toolRefs.ForEach(func(refIndex, refPart gjson.Result) bool { if refPart.Get("type").String() == "tool_reference" { refToolName := refPart.Get("tool_name").String() if newName, renamed := rewriteName(refToolName); renamed { refPath := fmt.Sprintf("messages.%d.content.%d.content.tool_references.%d.tool_name", msgIndex.Int(), contentIndex.Int(), refIndex.Int()) body, _ = sjson.SetBytes(body, refPath, newName) recordRename(refToolName, newName) } } return true }) } } return true }) return true }) } return body, reverseMap } type claudeMCPAliasParts struct { server string toolID string semantic string } type claudeMCPAliasEntry struct { alias string original string parts claudeMCPAliasParts } type claudeMCPAliasResolver struct { exact map[string]string aliases []claudeMCPAliasEntry servers map[string]struct{} } type claudeMCPAliasRestoreError struct { error } func (e claudeMCPAliasRestoreError) Unwrap() error { return e.error } func (claudeMCPAliasRestoreError) IsRequestScoped() bool { return true } func newClaudeMCPAliasResolver(reverseMap map[string]string) claudeMCPAliasResolver { resolver := claudeMCPAliasResolver{ exact: reverseMap, aliases: make([]claudeMCPAliasEntry, 0, len(reverseMap)), servers: make(map[string]struct{}), } for alias, original := range reverseMap { if alias == original { // Caller-owned MCP tool recorded for exact passthrough only. It must not // register a virtual server or take part in fuzzy alias recovery. continue } parts, ok := parseClaudeMCPAlias(alias) if !ok { continue } resolver.aliases = append(resolver.aliases, claudeMCPAliasEntry{ alias: alias, original: original, parts: parts, }) resolver.servers[parts.server] = struct{}{} } return resolver } func parseClaudeMCPAlias(name string) (claudeMCPAliasParts, bool) { if !helps.IsClaudeMCPToolName(name) { return claudeMCPAliasParts{}, false } rest, ok := strings.CutPrefix(name, "mcp__") if !ok { return claudeMCPAliasParts{}, false } server, tool, ok := strings.Cut(rest, "__") if !ok || server == "" { return claudeMCPAliasParts{}, false } toolID, semantic, ok := strings.Cut(tool, "_") if !ok || toolID == "" || semantic == "" { return claudeMCPAliasParts{}, false } return claudeMCPAliasParts{server: server, toolID: toolID, semantic: semantic}, true } func claudeMCPAliasServer(name string) string { rest, ok := strings.CutPrefix(name, "mcp__") if !ok { return "" } server, _, ok := strings.Cut(rest, "__") if !ok { return "" } return server } // recordPassthroughMCPTools remembers caller-owned MCP tool names that were left // untouched. Without this the response resolver would treat such a name as a // drifted alias whenever the derived two-word virtual server happens to equal a // real MCP server name, and would either restore the wrong tool or fail the // request. Recording is skipped when nothing was aliased so an untouched request // keeps an empty reverse map and the restore path stays a no-op. func recordPassthroughMCPTools(recordRename func(original, renamed string), forwardMap map[string]string, passthrough []string) { if len(forwardMap) == 0 { return } for _, name := range passthrough { recordRename(name, name) } } func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error) { if original, ok := resolver.exact[name]; ok { if original == name { // Caller-owned MCP tool: forward it exactly as the client declared it. return "", false, nil } return original, true, nil } server := claudeMCPAliasServer(name) if _, known := resolver.servers[server]; !known { return "", false, nil } canonicalServerPrefix := "mcp__" + server + "__" normalizedName := name suffix := strings.TrimPrefix(name, canonicalServerPrefix) for { strippedSuffix, repeatedServer := strings.CutPrefix(suffix, server+"__") if !repeatedServer { break } suffix = strippedSuffix normalizedName = canonicalServerPrefix + suffix if original, exact := resolver.exact[normalizedName]; exact { return original, true, nil } } matchedOriginal := "" matchCount := 0 for _, entry := range resolver.aliases { if entry.parts.server == server && strings.HasSuffix(name, entry.alias) { matchedOriginal = entry.original matchCount++ } } if matchCount == 1 { return matchedOriginal, true, nil } if matchCount > 1 { return "", false, claudeMCPAliasRestoreError{fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: matched multiple declared aliases", name)} } parts, validAlias := parseClaudeMCPAlias(normalizedName) if validAlias { for _, entry := range resolver.aliases { if entry.parts.server == parts.server && entry.parts.semantic == parts.semantic { matchedOriginal = entry.original matchCount++ } } } // Extra words in the tool component still parse, but the semantic field // is then wrong. Fall through to an unambiguous suffix match so word-level // repeats do not become restore 500s. if matchCount == 0 { var suffixMatches []claudeMCPAliasEntry for _, entry := range resolver.aliases { if entry.parts.server == server && strings.HasSuffix(normalizedName, "_"+entry.parts.semantic) { suffixMatches = append(suffixMatches, entry) } } if len(suffixMatches) == 1 { matchedOriginal = suffixMatches[0].original matchCount = 1 } else if len(suffixMatches) > 1 { // If multiple candidates match (e.g. "_file" and "_read_file"), // choose the strictly longest semantic match when unambiguous. longest := suffixMatches[0] tie := false for _, candidate := range suffixMatches[1:] { if len(candidate.parts.semantic) > len(longest.parts.semantic) { longest = candidate tie = false } else if len(candidate.parts.semantic) == len(longest.parts.semantic) { tie = true } } if !tie { matchedOriginal = longest.original matchCount = 1 } else { matchCount = len(suffixMatches) } } if matchCount == 1 { // This path guesses instead of failing, so leave a trace: it is the only // way to tell a silent wrong-tool restore from a healthy request. log.Debugf("claude oauth mcp alias: recovered drifted tool name %q as %q via semantic suffix", name, matchedOriginal) } } if matchCount == 1 { return matchedOriginal, true, nil } if matchCount > 1 { return "", false, claudeMCPAliasRestoreError{fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: semantic suffix matches multiple declared tools", name)} } return "", false, claudeMCPAliasRestoreError{fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: no unique request-local match", name)} } // reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses // using the per-request map produced by remapOAuthToolNames. Names outside the // request-local generated MCP server are passed through unchanged. func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]byte, error) { if len(reverseMap) == 0 { return body, nil } content := gjson.GetBytes(body, "content") if !content.Exists() || !content.IsArray() { return body, nil } resolver := newClaudeMCPAliasResolver(reverseMap) var resolveErr error content.ForEach(func(index, part gjson.Result) bool { partType := part.Get("type").String() switch partType { case "tool_use": name := part.Get("name").String() origName, matched, errResolve := resolver.resolve(name) if errResolve != nil { resolveErr = errResolve return false } if matched { path := fmt.Sprintf("content.%d.name", index.Int()) body, _ = sjson.SetBytes(body, path, origName) } case "tool_reference": toolName := part.Get("tool_name").String() origName, matched, errResolve := resolver.resolve(toolName) if errResolve != nil { resolveErr = errResolve return false } if matched { path := fmt.Sprintf("content.%d.tool_name", index.Int()) body, _ = sjson.SetBytes(body, path, origName) } case "tool_result": nestedContent := part.Get("content") if nestedContent.Exists() && nestedContent.IsArray() { nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool { if nestedPart.Get("type").String() != "tool_reference" { return true } toolName := nestedPart.Get("tool_name").String() origName, matched, errResolve := resolver.resolve(toolName) if errResolve != nil { resolveErr = errResolve return false } if matched { path := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int()) body, _ = sjson.SetBytes(body, path, origName) } return true }) } case "tool_search_tool_result": toolRefs := part.Get("content.tool_references") if toolRefs.Exists() && toolRefs.IsArray() { toolRefs.ForEach(func(refIndex, refPart gjson.Result) bool { if refPart.Get("type").String() != "tool_reference" { return true } toolName := refPart.Get("tool_name").String() origName, matched, errResolve := resolver.resolve(toolName) if errResolve != nil { resolveErr = errResolve return false } if matched { path := fmt.Sprintf("content.%d.content.tool_references.%d.tool_name", index.Int(), refIndex.Int()) body, _ = sjson.SetBytes(body, path, origName) } return true }) } } return resolveErr == nil }) return body, resolveErr } // reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE // stream lines, using the per-request reverseMap produced by remapOAuthToolNames. func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) ([]byte, error) { if len(reverseMap) == 0 { return line, nil } payload := helps.JSONPayload(line) if len(payload) == 0 || !gjson.ValidBytes(payload) { return line, nil } contentBlock := gjson.GetBytes(payload, "content_block") if !contentBlock.Exists() { return line, nil } resolver := newClaudeMCPAliasResolver(reverseMap) blockType := contentBlock.Get("type").String() var updated []byte var err error switch blockType { case "tool_use": name := contentBlock.Get("name").String() origName, matched, errResolve := resolver.resolve(name) if errResolve != nil { return line, errResolve } if !matched { return line, nil } updated, err = sjson.SetBytes(payload, "content_block.name", origName) case "tool_reference": toolName := contentBlock.Get("tool_name").String() origName, matched, errResolve := resolver.resolve(toolName) if errResolve != nil { return line, errResolve } if !matched { return line, nil } updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName) case "tool_search_tool_result": toolRefs := contentBlock.Get("content.tool_references") if !toolRefs.Exists() || !toolRefs.IsArray() { return line, nil } updatedPayload := payload var resolveErr error hasChange := false toolRefs.ForEach(func(refIndex, refPart gjson.Result) bool { if refPart.Get("type").String() != "tool_reference" { return true } toolName := refPart.Get("tool_name").String() origName, matched, errResolve := resolver.resolve(toolName) if errResolve != nil { resolveErr = errResolve return false } if matched { path := fmt.Sprintf("content_block.content.tool_references.%d.tool_name", refIndex.Int()) updatedPayload, err = sjson.SetBytes(updatedPayload, path, origName) if err != nil { return false } hasChange = true } return true }) if resolveErr != nil { return line, resolveErr } if err != nil { return line, fmt.Errorf("rewrite Claude OAuth MCP tool alias: %w", err) } if !hasChange { return line, nil } updated = updatedPayload default: return line, nil } if err != nil { return line, fmt.Errorf("rewrite Claude OAuth MCP tool alias: %w", err) } trimmed := bytes.TrimSpace(line) if bytes.HasPrefix(trimmed, []byte("data:")) { return append([]byte("data: "), updated...), nil } return updated, nil } func applyClaudeToolPrefix(body []byte, prefix string) []byte { if prefix == "" { return body } // Collect built-in tool names from the authoritative fallback seed list and // augment it with any typed built-ins present in the current request body. builtinTools := helps.AugmentClaudeBuiltinToolRegistry(body, nil) if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() { tools.ForEach(func(index, tool gjson.Result) bool { // Skip built-in tools (web_search, code_execution, etc.) which have // a "type" field and require their name to remain unchanged. if tool.Get("type").Exists() && tool.Get("type").String() != "" { if n := tool.Get("name").String(); n != "" { builtinTools[n] = true } return true } name := tool.Get("name").String() if name == "" || strings.HasPrefix(name, prefix) || helps.IsClaudeMCPToolName(name) { return true } path := fmt.Sprintf("tools.%d.name", index.Int()) body, _ = sjson.SetBytes(body, path, prefix+name) return true }) } if gjson.GetBytes(body, "tool_choice.type").String() == "tool" { name := gjson.GetBytes(body, "tool_choice.name").String() if name != "" && !strings.HasPrefix(name, prefix) && !builtinTools[name] && !helps.IsClaudeMCPToolName(name) { body, _ = sjson.SetBytes(body, "tool_choice.name", prefix+name) } } if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() { messages.ForEach(func(msgIndex, msg gjson.Result) bool { content := msg.Get("content") if !content.Exists() || !content.IsArray() { return true } content.ForEach(func(contentIndex, part gjson.Result) bool { partType := part.Get("type").String() switch partType { case "tool_use": name := part.Get("name").String() if name == "" || strings.HasPrefix(name, prefix) || builtinTools[name] || helps.IsClaudeMCPToolName(name) { return true } path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, prefix+name) case "tool_reference": toolName := part.Get("tool_name").String() if toolName == "" || strings.HasPrefix(toolName, prefix) || builtinTools[toolName] || helps.IsClaudeMCPToolName(toolName) { return true } path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int()) body, _ = sjson.SetBytes(body, path, prefix+toolName) case "tool_result": // Handle nested tool_reference blocks inside tool_result.content[] nestedContent := part.Get("content") if nestedContent.Exists() && nestedContent.IsArray() { nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool { if nestedPart.Get("type").String() == "tool_reference" { nestedToolName := nestedPart.Get("tool_name").String() if nestedToolName != "" && !strings.HasPrefix(nestedToolName, prefix) && !builtinTools[nestedToolName] && !helps.IsClaudeMCPToolName(nestedToolName) { nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int()) body, _ = sjson.SetBytes(body, nestedPath, prefix+nestedToolName) } } return true }) } } return true }) return true }) } return body } func stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte { if prefix == "" { return body } content := gjson.GetBytes(body, "content") if !content.Exists() || !content.IsArray() { return body } content.ForEach(func(index, part gjson.Result) bool { partType := part.Get("type").String() switch partType { case "tool_use": name := part.Get("name").String() if !strings.HasPrefix(name, prefix) { return true } path := fmt.Sprintf("content.%d.name", index.Int()) body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(name, prefix)) case "tool_reference": toolName := part.Get("tool_name").String() if !strings.HasPrefix(toolName, prefix) { return true } path := fmt.Sprintf("content.%d.tool_name", index.Int()) body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(toolName, prefix)) case "tool_result": // Handle nested tool_reference blocks inside tool_result.content[] nestedContent := part.Get("content") if nestedContent.Exists() && nestedContent.IsArray() { nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool { if nestedPart.Get("type").String() == "tool_reference" { nestedToolName := nestedPart.Get("tool_name").String() if strings.HasPrefix(nestedToolName, prefix) { nestedPath := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int()) body, _ = sjson.SetBytes(body, nestedPath, strings.TrimPrefix(nestedToolName, prefix)) } } return true }) } } return true }) return body } func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte { if prefix == "" { return line } payload := helps.JSONPayload(line) if len(payload) == 0 || !gjson.ValidBytes(payload) { return line } contentBlock := gjson.GetBytes(payload, "content_block") if !contentBlock.Exists() { return line } blockType := contentBlock.Get("type").String() var updated []byte var err error switch blockType { case "tool_use": name := contentBlock.Get("name").String() if !strings.HasPrefix(name, prefix) { return line } updated, err = sjson.SetBytes(payload, "content_block.name", strings.TrimPrefix(name, prefix)) if err != nil { return line } case "tool_reference": toolName := contentBlock.Get("tool_name").String() if !strings.HasPrefix(toolName, prefix) { return line } updated, err = sjson.SetBytes(payload, "content_block.tool_name", strings.TrimPrefix(toolName, prefix)) if err != nil { return line } default: return line } trimmed := bytes.TrimSpace(line) if bytes.HasPrefix(trimmed, []byte("data:")) { return append([]byte("data: "), updated...) } return updated }