2240 lines · 75.7 KB · Go
RawDownload
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 2240.