diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 0fc1f0acf..c8029732a 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -3,28 +3,38 @@ package executor import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/http" "strings" + "time" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" + responsesclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/claude/openai/responses" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) -// ClaudeExecutor is a stateless executor for Anthropic Claude over the messages API. +// ClaudeExecutor handles Anthropic Claude over the messages API and keeps bounded +// in-memory thread alias state for OAuth continuations. // If api_key is unavailable on auth, it falls back to legacy via ClientAdapter. type ClaudeExecutor struct { cfg *config.Config requestLogProvider string upstreamModelNormalizer func(string) string oauthProfileFetcher claudeOAuthProfileFetcher + // oauthToolAliases is shared by every ForAPIKey copy of this executor; + // constructors allocate it so copies made per request reuse one store. + oauthToolAliases *claudeOAuthToolAliasStore } type claudeOAuthCancellationError struct { @@ -68,14 +78,17 @@ func shouldSanitizeClaudeMessagesForUpstream(baseModel string) bool { } func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string, preserveEmptyThinkingBlocks ...bool) []byte { - sanitized := body + // The internal commentary marker must never reach any upstream, so its + // restore runs unconditionally, independent of the provider signature + // sanitization gate below (which non-Claude model names skip entirely). + sanitized, _ := sigcompat.RestoreCodexCommentaryMarkers(body) preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0] if shouldSanitizeClaudeMessagesForUpstream(baseModel) || preserveEmpty { var report sigcompat.SignatureSanitizeReport sanitized, report = sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel, preserveEmptyThinkingBlocks...) logClaudeSignatureSanitizeReport(ctx, baseModel, report) } - return sanitizeClaudeWebSearchDomains(sanitized) + return helps.NormalizeClaudeToolCallIDs(sanitizeClaudeWebSearchDomains(sanitized)) } // sanitizeClaudeWebSearchDomains removes empty allowed_domains/blocked_domains @@ -136,10 +149,151 @@ func logClaudeSignatureSanitizeReport(ctx context.Context, baseModel string, rep // omit max_tokens. Prefer registered model metadata before using a fallback. const defaultModelMaxTokens = 1024 -func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} } +// stripTranslatedDefaultMaxTokens removes the translator-synthesized default +// max_tokens from originalTranslated when the caller's original request did not +// explicitly supply an output token limit in the source protocol, allowing +// payload.default rules targeting max_tokens to take effect. +func stripTranslatedDefaultMaxTokens(from sdktranslator.Format, originalPayload, originalTranslated []byte) []byte { + if len(originalTranslated) == 0 || from == sdktranslator.FormatClaude { + return originalTranslated + } + hasExplicitMaxTokens := func(paths ...string) bool { + for _, path := range paths { + if v := gjson.GetBytes(originalPayload, path); v.Exists() && v.Type != gjson.Null { + return true + } + } + return false + } + switch from { + case sdktranslator.FormatOpenAI: + if hasExplicitMaxTokens("max_tokens", "max_completion_tokens") { + return originalTranslated + } + case sdktranslator.FormatOpenAIResponse: + if hasExplicitMaxTokens("max_output_tokens") { + return originalTranslated + } + case sdktranslator.FormatGemini: + if hasExplicitMaxTokens("generationConfig.maxOutputTokens") { + return originalTranslated + } + default: + return originalTranslated + } + stripped, errDelete := sjson.DeleteBytes(originalTranslated, "max_tokens") + if errDelete != nil { + return originalTranslated + } + return stripped +} + +func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { + return &ClaudeExecutor{cfg: cfg, oauthToolAliases: &claudeOAuthToolAliasStore{}} +} func (e *ClaudeExecutor) Identifier() string { return "claude" } +func (e *ClaudeExecutor) claudeWebSearchResultCacheScope(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, originalPayload []byte) (string, time.Duration, int) { + if e == nil || e.cfg == nil || !e.cfg.Client.Codex.WebSearchResultCache.Enabled { + return "", 0, 0 + } + if !sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse) { + return "", 0, 0 + } + sessionKey := xaiReasoningReplayIsolateSessionKey(ctx, codexReasoningReplaySessionKey(ctx, from, req, opts, originalPayload)) + if sessionKey == "" { + return "", 0, 0 + } + if credScope := claudeWebSearchResultCacheCredentialScope(auth); credScope != "" { + sessionKey = credScope + ":" + sessionKey + } + cacheCfg := e.cfg.Client.Codex.WebSearchResultCache + return sessionKey, cacheCfg.EffectiveTTL(), cacheCfg.EffectiveLimit() +} + +func claudeWebSearchResultCacheCredentialScope(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + identity := strings.TrimSpace(auth.ID) + if identity == "" { + identity = helps.ClaudeCLIAuthIdentitySeed(auth) + } + if identity == "" { + apiKey, baseURL := claudeCreds(auth) + identity = strings.TrimSpace(apiKey) + if identity == "" { + identity = strings.TrimSpace(baseURL) + } + } + if identity == "" { + return "" + } + sum := sha256.Sum256([]byte(identity)) + return "claude:" + hex.EncodeToString(sum[:8]) +} + +var _ cliproxyauth.NativeToolSearchSupport = (*ClaudeExecutor)(nil) + +// SupportsNativeToolSearch reports whether a credential can receive Codex +// tool_search as Anthropic native tool search (defer_loading tools and +// tool_reference results) for the given upstream model. Anthropic's own origin +// qualifies; a custom base URL qualifies only when native-tool-search is +// enabled for it. Models that predate tool search never qualify, and nothing +// qualifies while upstream.claude.tool-search is off. That switch is read from +// the translator so the executor and the translator always agree on it. +func (e *ClaudeExecutor) SupportsNativeToolSearch(auth *cliproxyauth.Auth, upstreamModel string) bool { + return e.supportsNativeToolSearch(responsesclaude.ClientToolSearchEnabled(), auth, upstreamModel) +} + +func (e *ClaudeExecutor) supportsNativeToolSearch(toolSearch bool, auth *cliproxyauth.Auth, upstreamModel string) bool { + if !toolSearch || helps.ClaudeModelLacksToolSearch(upstreamModel) { + return false + } + _, baseURL := claudeCreds(auth) + if baseURL = strings.TrimSpace(baseURL); baseURL == "" || isAnthropicUpstreamBase(baseURL) { + return true + } + if auth != nil && auth.Attributes != nil && strings.EqualFold(strings.TrimSpace(auth.Attributes["native_tool_search"]), "true") { + return true + } + entry := resolveClaudeKeyConfig(e.cfg, auth) + return entry != nil && entry.NativeToolSearch +} + +// applyNativeToolSearchFallback removes Codex tool_search from a Responses +// request before translation when the credential cannot use native tool +// search, so the translated body matches the pre-tool-search translation. +// While upstream.claude.tool-search is off the translator already produces +// that translation from the unmodified request, so nothing is removed. +// +// The returned context pins upstream.claude.tool-search for the rest of the +// request, so this decision and the request and response translations agree +// even if the configuration is reloaded mid-request. +func (e *ClaudeExecutor) applyNativeToolSearchFallback(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, upstreamModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (context.Context, cliproxyexecutor.Request, cliproxyexecutor.Options) { + if from != sdktranslator.FormatOpenAIResponse { + return ctx, req, opts + } + toolSearch := responsesclaude.ClientToolSearchEnabledFor(ctx) + ctx = responsesclaude.WithClientToolSearch(ctx, toolSearch) + if !toolSearch { + return ctx, req, opts + } + if !helps.ResponsesHasToolSearch(req.Payload) && !helps.ResponsesHasToolSearch(opts.OriginalRequest) { + return ctx, req, opts + } + if e.supportsNativeToolSearch(toolSearch, auth, upstreamModel) { + return ctx, req, opts + } + req.Payload = helps.StripResponsesToolSearch(req.Payload) + if len(opts.OriginalRequest) > 0 { + opts.OriginalRequest = helps.StripResponsesToolSearch(opts.OriginalRequest) + } + log.Debugf("claude executor: native tool search unavailable, removed tool_search from request (model=%s)", upstreamModel) + return ctx, req, opts +} + func (e *ClaudeExecutor) modelLevelCooling() bool { return e != nil && e.cfg != nil && e.cfg.Claude.ModelLevelCooling } diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index c2e34aa9f..57804fcff 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -217,7 +217,7 @@ func resolveClaudeContinuityTags( // The currentDate reminder is pinned to the session on its first request so // a local-midnight flip between requests cannot rewrite it and invalidate // the prompt-cache prefix in front of the whole conversation. - pinnedDate := helps.PinClaudeSessionDate(continuityKey, claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth))) + pinnedDate := resolveClaudeSessionPinnedDate(cfg, auth, payload, continuityKey, sessionID) if existingPromptID != "" { promptID = existingPromptID @@ -310,8 +310,9 @@ const claudeCodeFableReportingOutcomes = `# Reporting outcomes Report what actually happened, not what you intended. When you say something is done, sent, saved, fixed, or verified, that claim must rest on a result you observed in this session — tool output, the file as it now reads, the page as it now loads — not on what the step should have produced. If you did not check, say you did not check. If any step failed, was skipped, or came back different from what you expected, say so in the first sentence of your report, before anything else, even when the rest of the work succeeded. Never quietly work around a failure in a way that makes it look resolved; a problem the user can see is recoverable, one your summary hides is not. When you stop before the task is complete, your first line says so plainly and names what is left. Do not describe partial work as done, and do not let a summary read as more certain than the evidence behind it.` -func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { - return checkSystemInstructionsWithSigningMode(payload, strictMode, false, "2.1.280", "cli", "") +func checkSystemInstructionsWithMode(payload []byte, strictMode bool, keepCallerSystemTopLevel ...bool) []byte { + keepAtTopLevel := len(keepCallerSystemTopLevel) > 0 && keepCallerSystemTopLevel[0] + return checkSystemInstructionsWithSigningMode(payload, strictMode, false, "2.1.280", "cli", "", keepAtTopLevel) } // checkSystemInstructionsWithSigningMode keeps the top-level system in Claude @@ -319,8 +320,9 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { // mid-conversation system message after the first user turn, where supported // Claude models give it operator-level authority without changing the cached // top-level prefix. -func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cchSigning bool, version, entrypoint, workload string) []byte { - return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, claudeCodeLocalDate(time.Now()), false, "", "") +func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cchSigning bool, version, entrypoint, workload string, keepCallerSystemTopLevel ...bool) []byte { + keepAtTopLevel := len(keepCallerSystemTopLevel) > 0 && keepCallerSystemTopLevel[0] + return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, claudeCodeLocalDate(time.Now()), false, "", "", keepAtTopLevel, "") } // isClaudeFable51Model reports whether the model is specifically Fable 5.1 / Mythos 5.1, @@ -339,6 +341,65 @@ func isClaudeFable51Model(model string) bool { return false } +type forwardedClaudeSystemPromptBlock struct { + Text string + CacheControl *claudeCacheControl + RawCC string +} + +func buildForwardedSystemBlock(text string, block forwardedClaudeSystemPromptBlock, isExplicit bool) string { + if isExplicit { + if block.RawCC != "" { + return `{"type":"text","text":` + marshalJSONStringWithoutHTMLEscape(text) + `,"cache_control":` + block.RawCC + `}` + } + return buildTextBlock(text, nil) + } + return buildTextBlock(text, &claudeCodeCacheControl) +} + +func isValidClaudeCacheControl(cc gjson.Result) bool { + if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() { + return false + } + typ := cc.Get("type") + return typ.Type == gjson.String && typ.String() == "ephemeral" +} + +func collectForwardedClaudeSystemPromptBlocksWithCache(system gjson.Result) []forwardedClaudeSystemPromptBlock { + var blocks []forwardedClaudeSystemPromptBlock + appendBlock := func(text string, cc *claudeCacheControl, rawCC string) { + if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) || isClaudeCodeIdentitySystemText(text) { + return + } + blocks = append(blocks, forwardedClaudeSystemPromptBlock{Text: text, CacheControl: cc, RawCC: rawCC}) + } + + if system.Type == gjson.String { + appendBlock(system.String(), nil, "") + return blocks + } + if !system.IsArray() { + return blocks + } + system.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "text" { + var cc *claudeCacheControl + rawCC := "" + if itemCC := item.Get("cache_control"); isValidClaudeCacheControl(itemCC) { + cc = &claudeCacheControl{ + Type: itemCC.Get("type").String(), + TTL: itemCC.Get("ttl").String(), + Scope: itemCC.Get("scope").String(), + } + rawCC = itemCC.Raw + } + appendBlock(item.Get("text").String(), cc, rawCC) + } + return true + }) + return blocks +} + func checkSystemInstructionsWithSigningModeAt( payload []byte, strictMode bool, @@ -347,6 +408,7 @@ func checkSystemInstructionsWithSigningModeAt( currentDate string, isSubagent bool, prevReq, promptID string, + keepCallerSystemTopLevel bool, turnOrigin ...string, ) []byte { system := gjson.GetBytes(payload, "system") @@ -354,7 +416,13 @@ func checkSystemInstructionsWithSigningModeAt( billingText := generateBillingHeader(cchSigning, version, messageText, entrypoint, workload, isSubagent, prevReq, promptID, turnOrigin...) billingBlock := buildTextBlock(billingText, nil) - agentBlock := buildTextBlock(claudeCodeCLIIdentity, &claudeCodeCacheControl) + + isExplicit := (len(turnOrigin) > 1 && turnOrigin[1] == "explicit_cache_mode") || isExplicitPromptCacheMode(payload) + var agentCC *claudeCacheControl = &claudeCodeCacheControl + if isExplicit { + agentCC = nil + } + agentBlock := buildTextBlock(claudeCodeCLIIdentity, agentCC) systemBlocks := []string{billingBlock, agentBlock} model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) @@ -363,29 +431,31 @@ func checkSystemInstructionsWithSigningModeAt( } payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]")) if strictMode { - return injectClaudeCodeCurrentDate(payload, currentDate) + return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit) } - forwardedSystemBlocks := collectForwardedClaudeSystemPromptBlocks(system) + forwardedSystemBlocks := collectForwardedClaudeSystemPromptBlocksWithCache(system) if len(forwardedSystemBlocks) == 0 { - return injectClaudeCodeCurrentDate(payload, currentDate) + return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit) } if claudeHistoryHasAdvisorCallOrResult(payload) { for _, block := range forwardedSystemBlocks { - systemBlocks = append(systemBlocks, buildTextBlock(block, nil)) + systemBlocks = append(systemBlocks, buildForwardedSystemBlock(block.Text, block, isExplicit)) } payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]")) - return injectClaudeCodeCurrentDate(payload, currentDate) + return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit) } if claudeUsesLegacySystemReminder(payload) { - payload = prependClaudeSystemRemindersToFirstUserMessage(payload, forwardedSystemBlocks) + payload = prependClaudeSystemReminderBlocksToFirstUserMessage(payload, forwardedSystemBlocks, isExplicit) + } else if keepCallerSystemTopLevel && claudeMidConversationSystemMessagesAtEnd(payload) { + for _, block := range forwardedSystemBlocks { + systemBlocks = append(systemBlocks, buildForwardedSystemBlock(block.Text, block, isExplicit)) + } + payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]")) } else { - // Unknown and future model IDs optimistically use the authoritative - // mid-conversation system role. Only empirically unsupported legacy IDs - // stay on the user-reminder compatibility path. - payload = insertClaudeMidConversationSystemMessages(payload, forwardedSystemBlocks) + payload = insertClaudeMidConversationSystemBlocks(payload, forwardedSystemBlocks, isExplicit) } - return injectClaudeCodeCurrentDate(payload, currentDate) + return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit) } // relocateClaudeSystemPromptForCountTokens keeps a cloaked count_tokens request @@ -395,16 +465,22 @@ func checkSystemInstructionsWithSigningModeAt( // using the same positional mapping as the Messages path. That keeps the counted // tokens aligned with the request the caller is about to send while preventing a // third-party system prompt from reaching Anthropic in the system slot. -func relocateClaudeSystemPromptForCountTokens(payload []byte, strictMode bool) []byte { +func relocateClaudeSystemPromptForCountTokens(payload []byte, strictMode bool, explicitCacheMode ...bool) []byte { + isExplicit := (len(explicitCacheMode) > 0 && explicitCacheMode[0]) || isExplicitPromptCacheMode(payload) + return relocateClaudeSystemPromptForCountTokensWithPolicy(payload, strictMode, isExplicit, false) +} + +func relocateClaudeSystemPromptForCountTokensWithPolicy(payload []byte, strictMode, explicitCacheMode, keepCallerSystemTopLevel bool) []byte { system := gjson.GetBytes(payload, "system") if !system.Exists() { return payload } + isExplicit := explicitCacheMode // Strict mode drops caller prompts on the Messages path, so it must not // reintroduce them here either. - var forwardedSystemBlocks []string + var forwardedSystemBlocks []forwardedClaudeSystemPromptBlock if !strictMode { - forwardedSystemBlocks = collectForwardedClaudeSystemPromptBlocks(system) + forwardedSystemBlocks = collectForwardedClaudeSystemPromptBlocksWithCache(system) } if len(forwardedSystemBlocks) == 0 { updated, _ := sjson.DeleteBytes(payload, "system") @@ -417,7 +493,7 @@ func relocateClaudeSystemPromptForCountTokens(payload []byte, strictMode bool) [ // remains aligned with the Messages path without splicing messages[]. blocks := make([]string, 0, len(forwardedSystemBlocks)) for _, block := range forwardedSystemBlocks { - blocks = append(blocks, buildTextBlock(block, nil)) + blocks = append(blocks, buildForwardedSystemBlock(block.Text, block, isExplicit)) } updated, _ := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(blocks, ",")+"]")) return updated @@ -428,9 +504,17 @@ func relocateClaudeSystemPromptForCountTokens(payload []byte, strictMode bool) [ } payload = updated if claudeUsesLegacySystemReminder(payload) { - return prependClaudeSystemRemindersToFirstUserMessage(payload, forwardedSystemBlocks) + return prependClaudeSystemReminderBlocksToFirstUserMessage(payload, forwardedSystemBlocks, isExplicit) } - return insertClaudeMidConversationSystemMessages(payload, forwardedSystemBlocks) + if keepCallerSystemTopLevel && claudeMidConversationSystemMessagesAtEnd(payload) { + blocks := make([]string, 0, len(forwardedSystemBlocks)) + for _, block := range forwardedSystemBlocks { + blocks = append(blocks, buildForwardedSystemBlock(block.Text, block, isExplicit)) + } + updated, _ := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(blocks, ",")+"]")) + return updated + } + return insertClaudeMidConversationSystemBlocks(payload, forwardedSystemBlocks, isExplicit) } // claudeLegacySystemReminderModels lists the official Anthropic model IDs and @@ -622,6 +706,9 @@ func buildTextBlock(text string, cacheControl *claudeCacheControl) string { if cacheControl.TTL != "" { block += `,"ttl":` + marshalJSONStringWithoutHTMLEscape(cacheControl.TTL) } + if cacheControl.Scope != "" { + block += `,"scope":` + marshalJSONStringWithoutHTMLEscape(cacheControl.Scope) + } block += "}" } return block + "}" @@ -636,58 +723,71 @@ func marshalJSONStringWithoutHTMLEscape(value string) string { } func prependClaudeSystemRemindersToFirstUserMessage(payload []byte, texts []string) []byte { - firstUserIdx := firstClaudeUserMessageIndex(payload) - if firstUserIdx < 0 || len(texts) == 0 { - return payload + blocks := make([]forwardedClaudeSystemPromptBlock, 0, len(texts)) + for _, text := range texts { + blocks = append(blocks, forwardedClaudeSystemPromptBlock{Text: text}) } + return prependClaudeSystemReminderBlocksToFirstUserMessage(payload, blocks, false) +} - reminderTexts := make([]string, 0, len(texts)) - for _, text := range texts { - reminderTexts = append(reminderTexts, claudeCallerSystemReminder(text)) +func prependClaudeSystemReminderBlocksToFirstUserMessage(payload []byte, blocks []forwardedClaudeSystemPromptBlock, isExplicit bool) []byte { + firstUserIdx := firstClaudeUserMessageIndex(payload) + if firstUserIdx < 0 || len(blocks) == 0 { + return payload } contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx) content := gjson.GetBytes(payload, contentPath) if content.IsArray() { - blocks := content.Array() - existing := make(map[string]int, len(blocks)) - for _, block := range blocks { + contentBlocks := content.Array() + existing := make(map[string]int, len(contentBlocks)) + for _, block := range contentBlocks { if block.Get("type").String() == "text" { existing[block.Get("text").String()]++ } } - reminderBlocks := make([]string, 0, len(reminderTexts)) - for _, reminderText := range reminderTexts { + reminderBlocks := make([]string, 0, len(blocks)) + for _, b := range blocks { + reminderText := claudeCallerSystemReminder(b.Text) if existing[reminderText] > 0 { existing[reminderText]-- continue } - reminderBlocks = append(reminderBlocks, buildTextBlock(reminderText, nil)) + if isExplicit && b.RawCC != "" { + reminderBlocks = append(reminderBlocks, `{"type":"text","text":`+marshalJSONStringWithoutHTMLEscape(reminderText)+`,"cache_control":`+b.RawCC+`}`) + } else { + reminderBlocks = append(reminderBlocks, buildTextBlock(reminderText, nil)) + } } if len(reminderBlocks) == 0 { return payload } insertAt := 0 - for insertAt < len(blocks) && blocks[insertAt].Get("type").String() == "tool_result" { + for insertAt < len(contentBlocks) && contentBlocks[insertAt].Get("type").String() == "tool_result" { insertAt++ } - rawBlocks := make([]string, 0, len(blocks)+len(reminderBlocks)) - for idx, block := range blocks { + rawBlocks := make([]string, 0, len(contentBlocks)+len(reminderBlocks)) + for idx, block := range contentBlocks { if idx == insertAt { rawBlocks = append(rawBlocks, reminderBlocks...) } rawBlocks = append(rawBlocks, block.Raw) } - if insertAt == len(blocks) { + if insertAt == len(contentBlocks) { rawBlocks = append(rawBlocks, reminderBlocks...) } payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) } else if content.Type == gjson.String { - rawBlocks := make([]string, 0, len(reminderTexts)+1) - for _, reminderText := range reminderTexts { - rawBlocks = append(rawBlocks, buildTextBlock(reminderText, nil)) + rawBlocks := make([]string, 0, len(blocks)+1) + for _, b := range blocks { + reminderText := claudeCallerSystemReminder(b.Text) + if isExplicit && b.RawCC != "" { + rawBlocks = append(rawBlocks, `{"type":"text","text":`+marshalJSONStringWithoutHTMLEscape(reminderText)+`,"cache_control":`+b.RawCC+`}`) + } else { + rawBlocks = append(rawBlocks, buildTextBlock(reminderText, nil)) + } } rawBlocks = append(rawBlocks, buildTextBlock(content.String(), nil)) payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) @@ -753,9 +853,54 @@ func claudeHistoryHasAdvisorCallOrResult(payload []byte) bool { return false } +// claudeLeadingUserRunEnd skips directive-only system turns because Anthropic +// accepts them at any position. They must not cause caller system blocks to be +// inserted before a subsequent user turn. +func claudeLeadingUserRunEnd(messageBlocks []gjson.Result, firstUserIdx int) int { + insertAt := firstUserIdx + 1 + for insertAt < len(messageBlocks) { + message := messageBlocks[insertAt] + if message.Get("role").String() != "user" && !isClaudeSystemDirectiveMessage(message) { + break + } + insertAt++ + } + return insertAt +} + +func isClaudeSystemDirectiveMessage(message gjson.Result) bool { + content := message.Get("content") + return message.Get("role").String() == "system" && + message.Get("output_config").Exists() && + content.IsArray() && len(content.Array()) == 0 +} + +func claudeMidConversationSystemMessagesAtEnd(payload []byte) bool { + firstUserIdx := firstClaudeUserMessageIndex(payload) + if firstUserIdx < 0 { + return false + } + + messages := gjson.GetBytes(payload, "messages") + if !messages.IsArray() { + return false + } + messageBlocks := messages.Array() + insertAt := claudeLeadingUserRunEnd(messageBlocks, firstUserIdx) + return insertAt == len(messageBlocks) || insertAt > firstUserIdx+1 +} + func insertClaudeMidConversationSystemMessages(payload []byte, texts []string) []byte { + blocks := make([]forwardedClaudeSystemPromptBlock, 0, len(texts)) + for _, text := range texts { + blocks = append(blocks, forwardedClaudeSystemPromptBlock{Text: text, CacheControl: &claudeCodeCacheControl}) + } + return insertClaudeMidConversationSystemBlocks(payload, blocks, false) +} + +func insertClaudeMidConversationSystemBlocks(payload []byte, blocks []forwardedClaudeSystemPromptBlock, isExplicit bool) []byte { firstUserIdx := firstClaudeUserMessageIndex(payload) - if firstUserIdx < 0 || len(texts) == 0 { + if firstUserIdx < 0 || len(blocks) == 0 { return payload } @@ -764,15 +909,12 @@ func insertClaudeMidConversationSystemMessages(payload []byte, texts []string) [ return payload } messageBlocks := messages.Array() - insertAt := firstUserIdx + 1 - for insertAt < len(messageBlocks) && messageBlocks[insertAt].Get("role").String() == "user" { - insertAt++ - } - if len(messageBlocks)-insertAt >= len(texts) { + insertAt := claudeLeadingUserRunEnd(messageBlocks, firstUserIdx) + if len(messageBlocks)-insertAt >= len(blocks) { matches := true - for idx, text := range texts { + for idx, block := range blocks { message := messageBlocks[insertAt+idx] - if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != text { + if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != block.Text { matches = false break } @@ -782,9 +924,9 @@ func insertClaudeMidConversationSystemMessages(payload []byte, texts []string) [ } } - systemMessages := make([]string, 0, len(texts)) - for _, text := range texts { - content := "[" + buildTextBlock(text, &claudeCodeCacheControl) + "]" + systemMessages := make([]string, 0, len(blocks)) + for _, block := range blocks { + content := "[" + buildForwardedSystemBlock(block.Text, block, isExplicit) + "]" systemMessages = append(systemMessages, `{"role":"system","content":`+content+"}") } rawMessages := make([]string, 0, len(messageBlocks)+len(systemMessages)) @@ -850,10 +992,7 @@ func captureClaudeCodeSystemPlacement(before, after []byte, cloaked bool) claude if firstUserIdx < 0 { return claudeCodeSystemPlacementState{} } - insertAt := firstUserIdx + 1 - for insertAt < len(beforeMessages) && beforeMessages[insertAt].Get("role").String() == "user" { - insertAt++ - } + insertAt := claudeLeadingUserRunEnd(beforeMessages, firstUserIdx) if insertAt+len(texts) > len(afterMessages) { return claudeCodeSystemPlacementState{} } @@ -1162,8 +1301,121 @@ func isClaudeCodeContextReminder(text string) bool { return strings.HasPrefix(text, "") && strings.Contains(text, "") } +const claudeCodeCurrentDateReminderPrefix = "\nAs you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is " + func isClaudeCodeCurrentDateReminder(text string) bool { - return strings.HasPrefix(text, "\nAs you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is ") + return strings.HasPrefix(text, claudeCodeCurrentDateReminderPrefix) +} + +// claudeCodeCurrentDateReminderDate extracts the YYYY-MM-DD date from a +// currentDate reminder, or "" when the reminder date is not a valid date. +func claudeCodeCurrentDateReminderDate(text string) string { + rest, found := strings.CutPrefix(text, claudeCodeCurrentDateReminderPrefix) + if !found || len(rest) < len("2006-01-02.") || rest[len("2006-01-02")] != '.' { + return "" + } + date := rest[:len("2006-01-02")] + if _, errParse := time.Parse("2006-01-02", date); errParse != nil { + return "" + } + return date +} + +func claudeSessionDatePinRetention(cfg *config.Config) time.Duration { + if cfg == nil { + return 0 + } + return cfg.Claude.SessionDatePinRetention() +} + +// claudeCallerCurrentDateReminderDate returns the date of the first valid +// caller-supplied currentDate reminder in the first user message. +func claudeCallerCurrentDateReminderDate(payload []byte) string { + firstUserIdx := firstClaudeUserMessageIndex(payload) + if firstUserIdx < 0 { + return "" + } + content := gjson.GetBytes(payload, fmt.Sprintf("messages.%d.content", firstUserIdx)) + if content.Type == gjson.String { + return claudeCodeCurrentDateReminderDate(content.String()) + } + if !content.IsArray() { + return "" + } + for _, block := range content.Array() { + if block.Get("type").String() != "text" { + continue + } + if date := claudeCodeCurrentDateReminderDate(block.Get("text").String()); date != "" { + return date + } + } + return "" +} + +// stripClaudeCodeCurrentDateReminder removes a leading caller currentDate +// reminder from string content so the injection path replaces it with the +// canonical reminder instead of duplicating it. +func stripClaudeCodeCurrentDateReminder(text string) string { + if claudeCodeCurrentDateReminderDate(text) == "" { + return text + } + end := strings.Index(text, "") + if end < 0 { + return text + } + return strings.TrimLeft(text[end+len(""):], "\n") +} + +// stripFirstUserClaudeCodeCurrentDateReminder strips a leading caller +// currentDate reminder from a string-form first user message before the +// cloaking pipeline converts the content into blocks, preserving user text. +func stripFirstUserClaudeCodeCurrentDateReminder(payload []byte) []byte { + firstUserIdx := firstClaudeUserMessageIndex(payload) + if firstUserIdx < 0 { + return payload + } + contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx) + content := gjson.GetBytes(payload, contentPath) + if content.Type != gjson.String { + return payload + } + stripped := stripClaudeCodeCurrentDateReminder(content.String()) + if stripped == content.String() { + return payload + } + updated, errSet := sjson.SetBytes(payload, contentPath, stripped) + if errSet != nil { + return payload + } + return updated +} + +// resolveClaudeSessionPinnedDate resolves the session date pin. With a +// configured retention, an existing pin always wins; a caller-supplied +// currentDate reminder only seeds the first anchor, so a later caller date +// cannot rewrite the established conversation prefix. +func resolveClaudeSessionPinnedDate( + cfg *config.Config, + auth *cliproxyauth.Auth, + payload []byte, + continuityKey string, + sessionID string, +) string { + retention := claudeSessionDatePinRetention(cfg) + candidate := claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth)) + if retention <= 0 { + return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention) + } + if existing := helps.LookupClaudeSessionDatePin(sessionID, retention); existing != "" { + // Re-pin the anchored date so the sliding retention refreshes + // without ever changing the established prefix. + return helps.PinClaudeSessionDate(continuityKey, sessionID, existing, retention) + } + if callerDate := claudeCallerCurrentDateReminderDate(payload); callerDate != "" { + candidate = callerDate + } + return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention) } // injectClaudeCodeCurrentDate stamps the currentDate reminder into the first @@ -1171,6 +1423,10 @@ func isClaudeCodeCurrentDateReminder(text string) bool { // one whenever continuity is available) so the reminder text cannot change // between requests of one session. func injectClaudeCodeCurrentDate(payload []byte, date string) []byte { + return injectClaudeCodeCurrentDateInternal(payload, date, false) +} + +func injectClaudeCodeCurrentDateInternal(payload []byte, date string, isExplicit bool) []byte { firstUserIdx := firstClaudeUserMessageIndex(payload) if firstUserIdx < 0 { return payload @@ -1182,7 +1438,11 @@ func injectClaudeCodeCurrentDate(payload []byte, date string) []byte { dateBlock := buildTextBlock(dateText, nil) if content.Type == gjson.String { - userBlock := buildTextBlock(content.String(), &claudeCodeCacheControl) + var userCC *claudeCacheControl = &claudeCodeCacheControl + if isExplicit || strings.TrimSpace(content.String()) == "" { + userCC = nil + } + userBlock := buildTextBlock(content.String(), userCC) newArray := "[" + dateBlock + "," + userBlock + "]" payload, _ = sjson.SetRawBytes(payload, contentPath, []byte(newArray)) return payload @@ -1200,10 +1460,12 @@ func injectClaudeCodeCurrentDate(payload []byte, date string) []byte { if isClaudeCodeCurrentDateReminder(text) { continue } - if !actualTextCached && !isClaudeCodeContextReminder(text) { - rawBlocks = append(rawBlocks, withEphemeralCacheControl(block.Raw)) - actualTextCached = true - continue + if !actualTextCached && !isClaudeCodeContextReminder(text) && strings.TrimSpace(text) != "" { + if !isExplicit { + rawBlocks = append(rawBlocks, withEphemeralCacheControl(block.Raw)) + actualTextCached = true + continue + } } } rawBlocks = append(rawBlocks, block.Raw) @@ -1270,11 +1532,10 @@ type claudeCodeContextManagementState struct { eligible bool callerOwned bool automaticallyInjected bool - payloadRuleTouched bool } -// reconcileClaudeCodeContextManagement resolves automatic ownership after all -// payload rules and forced tool-choice processing have completed. +// reconcileClaudeCodeContextManagement resolves built-in ownership after forced +// tool-choice processing. User payload rules run later and never need reconciliation. func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContextManagementState) []byte { contextManagement := gjson.GetBytes(payload, "context_management") @@ -1283,7 +1544,7 @@ func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContex // thinking field after injection, so this also covers a request that was // still eligible when injectClaudeCodeContextManagement ran. if !claudeThinkingAcceptsClearThinking(payload) { - if state.callerOwned || !state.automaticallyInjected || state.payloadRuleTouched { + if state.callerOwned || !state.automaticallyInjected { return payload } if contextManagement.Raw != claudeCodeContextManagement { @@ -1296,7 +1557,7 @@ func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContex return updated } - if !state.eligible || state.callerOwned || state.payloadRuleTouched || contextManagement.Exists() { + if !state.eligible || state.callerOwned || contextManagement.Exists() { return payload } updated, err := sjson.SetRawBytes(payload, "context_management", []byte(claudeCodeContextManagement)) @@ -1323,6 +1584,8 @@ type claudeWirePolicy struct { ProfileClaudeCodeCLI bool // request fingerprint looks like Claude Code CLI ConfirmedClaudeCode bool Cloak bool + Uncloak bool + ExplicitNever bool } type claudeCloakSettings struct { @@ -1331,6 +1594,90 @@ type claudeCloakSettings struct { cacheUserID bool } +func isClaudeCodeIdentitySystemText(text string) bool { + trimmed := strings.TrimSpace(text) + return trimmed == claudeCodeCLIIdentity || + trimmed == "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." || + trimmed == "You are a Claude agent, built on Anthropic's Claude Agent SDK." +} + +func isEffectiveConfirmedClaudeCode(detection helps.ClaudeCodeRequestDetection, policy claudeWirePolicy) bool { + if policy.Uncloak { + return false + } + if detection.Confirmed { + return true + } + return !policy.Cloak && policy.ExplicitNever && detection.ConfirmedShape +} + +func stripClaudeCodeSystemBlocks(payload []byte) []byte { + payload = util.StripClaudeCodeAttributionSystem(payload) + system := gjson.GetBytes(payload, "system") + if !system.Exists() { + return payload + } + if system.Type == gjson.String { + if !isClaudeCodeIdentitySystemText(system.String()) { + return payload + } + if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil { + return updated + } + return payload + } + if !system.IsArray() { + return payload + } + kept := make([]string, 0, len(system.Array())) + removed := false + system.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() == "text" && isClaudeCodeIdentitySystemText(block.Get("text").String()) { + removed = true + return true + } + if block.Raw != "" { + kept = append(kept, block.Raw) + } + return true + }) + if !removed { + return payload + } + if len(kept) == 0 { + if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil { + return updated + } + return payload + } + if updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]")); errSet == nil { + return updated + } + return payload +} + +func uncloakClaudeCodePayload(payload []byte, confirmedClaudeCode bool) []byte { + payload = stripClaudeCodeSystemBlocks(payload) + if metadata := gjson.GetBytes(payload, "metadata"); metadata.Exists() && metadata.IsObject() { + if userID := metadata.Get("user_id"); userID.Exists() && (confirmedClaudeCode || helps.IsValidUserID(userID.String())) { + if updated, errDelete := sjson.DeleteBytes(payload, "metadata.user_id"); errDelete == nil { + payload = updated + if remaining := gjson.GetBytes(payload, "metadata"); remaining.Exists() && remaining.IsObject() && len(remaining.Map()) == 0 { + if updatedMeta, errDeleteMeta := sjson.DeleteBytes(payload, "metadata"); errDeleteMeta == nil { + payload = updatedMeta + } + } + } + } + } + if diag := gjson.GetBytes(payload, "diagnostics"); diag.Exists() && (confirmedClaudeCode || diag.Get("previous_message_id").Exists()) { + if updated, errDelete := sjson.DeleteBytes(payload, "diagnostics"); errDelete == nil { + payload = updated + } + } + return payload +} + func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string, confirmedClaudeCode bool) (claudeWirePolicy, claudeCloakSettings) { if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey { cfg = cfg.ForAPIKey() @@ -1347,12 +1694,14 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey sensitiveWords: attrWords, cacheUserID: attrCache, } + explicitMode := strings.ToLower(strings.TrimSpace(attrMode)) if attrMode != "" { cloakMode = attrMode } if cloakCfg != nil { if mode := strings.TrimSpace(cloakCfg.Mode); mode != "" { cloakMode = mode + explicitMode = strings.ToLower(mode) } if cloakCfg.StrictMode { settings.strictMode = true @@ -1367,23 +1716,27 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey) cloakConfigured := cloakCfg != nil || attrMode != "" || attrStrict || len(attrWords) > 0 || attrCache + trimmedMode := strings.ToLower(strings.TrimSpace(cloakMode)) + uncloak := !fp.ProfileClaudeCodeCLI && (explicitMode == "never" || explicitMode == "uncloak") + explicitNever := trimmedMode == "never" && !uncloak policy := claudeWirePolicy{ OAuth: fp.AuthIsOAuthToken, ProfileClaudeCodeCLI: fp.ProfileClaudeCodeCLI, ConfirmedClaudeCode: confirmedClaudeCode, - Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode, + Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode && !uncloak, + Uncloak: uncloak, + ExplicitNever: explicitNever, } if confirmedClaudeCode { - // Native Claude Code is always a passthrough client. An operator-level - // "always" mode may cloak unknown callers, but must not overwrite a - // strongly confirmed CLI, sdk-cli, or claude-vscode fingerprint. + // Native Claude Code is always a passthrough client unless the + // credential explicitly opts out of Claude Code identity via never/uncloak. policy.Cloak = false return policy, settings } - switch strings.ToLower(strings.TrimSpace(cloakMode)) { + switch trimmedMode { case "always": policy.Cloak = true - case "never": + case "never", "uncloak": policy.Cloak = false default: // Auto applies the CLI cloak only to real Claude OAuth credentials, @@ -1416,11 +1769,16 @@ func applyCloakingInternal( confirmedClaudeCode bool, cchSigning bool, obfuscateSensitiveWords bool, + explicitCacheMode ...bool, ) ([]byte, bool, error) { policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) if !policy.Cloak { + if policy.Uncloak { + payload = uncloakClaudeCodePayload(payload, confirmedClaudeCode) + } return payload, false, nil } + isExplicit := (len(explicitCacheMode) > 0 && explicitCacheMode[0]) || isExplicitPromptCacheModeContext(ctx) // Strict mode drops caller system prompts entirely, so nothing needs a // destination and an unusable block cannot lose information. if !settings.strictMode { @@ -1458,11 +1816,20 @@ func applyCloakingInternal( if pinnedDate == "" { pinnedDate = claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth)) } + if !isProbeOrHelper && claudeSessionDatePinRetention(cfg) > 0 { + // Strip a string-form caller reminder before the pipeline turns the + // content into blocks, so user text is never dropped with it. + payload = stripFirstUserClaudeCodeCurrentDateReminder(payload) + } turnOrigin := "" if !isProbeOrHelper && !isSubagent { turnOrigin = "human" } + turnOriginArgs := []string{turnOrigin} + if isExplicit { + turnOriginArgs = append(turnOriginArgs, "explicit_cache_mode") + } payload = checkSystemInstructionsWithSigningModeAt( payload, settings.strictMode, @@ -1474,7 +1841,8 @@ func applyCloakingInternal( isSubagent, prevReq, promptID, - turnOrigin, + !policy.OAuth, // OAuth top-level caller prompts trigger Anthropic's third-party classifier (#6432). + turnOriginArgs..., ) // In native Claude Code 2.1.258, claude-fable-5-1 requests carry: @@ -1495,7 +1863,7 @@ func applyCloakingInternal( // Probes never use 1h cache in native Claude Code; ensure any caller-supplied // 1h ttl is stripped to match extended-cache-ttl beta suppression. Subagents // preserve caller-requested 1h cache TTL (e.g. subagentPromptCacheTtl: 1h). - if isProbeOrHelper || (isSubagent && !helps.ClaudeSubagentRequests1h(incomingHeaders, payload)) { + if !isExplicit && (isProbeOrHelper || (isSubagent && !helps.ClaudeSubagentRequests1h(incomingHeaders, payload))) { payload = stripClaudeCacheControlTTL(payload) } @@ -1520,8 +1888,9 @@ func applyCloakingInternal( } type claudeCacheControl struct { - Type string `json:"type"` - TTL string `json:"ttl,omitempty"` + Type string `json:"type"` + TTL string `json:"ttl,omitempty"` + Scope string `json:"scope,omitempty"` } // claudeCodeCacheControl is the default Claude Code breakpoint shape. @@ -1572,14 +1941,15 @@ func ensureCacheControl(payload []byte) []byte { // claudePayloadHasCacheableSystem reports whether the payload has a system prompt // that injectSystemCacheControl can actually host a breakpoint on. An absent key, an -// empty array and an empty string all leave the tools prefix uncovered. +// empty array, an array of only empty/whitespace text blocks, and an empty string +// all leave the tools prefix uncovered. func claudePayloadHasCacheableSystem(payload []byte) bool { system := gjson.GetBytes(payload, "system") switch { case !system.Exists(): return false case system.IsArray(): - return system.Get("#").Int() > 0 + return lastCacheableClaudeSystemIndex(system) >= 0 case system.Type == gjson.String: return strings.TrimSpace(system.String()) != "" default: @@ -1587,6 +1957,30 @@ func claudePayloadHasCacheableSystem(payload []byte) bool { } } +func claudeSystemBlockCanHostCacheControl(item gjson.Result) bool { + if !item.IsObject() { + return false + } + if typ := item.Get("type").String(); typ != "" && typ != "text" { + return false + } + return strings.TrimSpace(item.Get("text").String()) != "" +} + +func lastCacheableClaudeSystemIndex(system gjson.Result) int { + if !system.IsArray() { + return -1 + } + lastEligible := -1 + system.ForEach(func(index, item gjson.Result) bool { + if claudeSystemBlockCanHostCacheControl(item) { + lastEligible = int(index.Int()) + } + return true + }) + return lastEligible +} + // upgradeClaudeCacheControlTTL mirrors the native ttl upgrade helper, which only // touches blocks that already carry a cache_control without a ttl: // @@ -1693,7 +2087,50 @@ func forEachClaudeCacheControlBlock(payload []byte, visit func(path string, bloc } } -func shouldEnsureCacheControl(payload []byte, cloaked, confirmedClaudeCode bool) bool { +// isExplicitPromptCacheMode reports whether any of the candidate payloads +// specifies prompt_cache_options.mode as "explicit" (case-insensitive). +func isExplicitPromptCacheMode(payloads ...[]byte) bool { + for _, payload := range payloads { + if len(payload) == 0 { + continue + } + mode := gjson.GetBytes(payload, "prompt_cache_options.mode").String() + if strings.EqualFold(strings.TrimSpace(mode), "explicit") { + return true + } + } + return false +} + +type explicitPromptCacheModeContextKey struct{} + +func withExplicitPromptCacheMode(ctx context.Context, explicit bool) context.Context { + return context.WithValue(ctx, explicitPromptCacheModeContextKey{}, explicit) +} + +func isExplicitPromptCacheModeContext(ctx context.Context) bool { + if ctx == nil { + return false + } + v, _ := ctx.Value(explicitPromptCacheModeContextKey{}).(bool) + return v +} + +func stripPromptCacheOptions(payload []byte) []byte { + if !gjson.GetBytes(payload, "prompt_cache_options").Exists() { + return payload + } + updated, err := sjson.DeleteBytes(payload, "prompt_cache_options") + if err != nil { + return payload + } + return updated +} + +func shouldEnsureCacheControl(payload []byte, cloaked, confirmedClaudeCode bool, candidates ...[]byte) bool { + if isExplicitPromptCacheMode(append([][]byte{payload}, candidates...)...) { + return false + } return !confirmedClaudeCode && (cloaked || countCacheControls(payload) == 0) } @@ -1704,7 +2141,7 @@ func countCacheControls(payload []byte) int { system := gjson.GetBytes(payload, "system") if system.IsArray() { system.ForEach(func(_, item gjson.Result) bool { - if item.Get("cache_control").Exists() { + if isValidClaudeCacheControl(item.Get("cache_control")) { count++ } return true @@ -1715,7 +2152,7 @@ func countCacheControls(payload []byte) int { tools := gjson.GetBytes(payload, "tools") if tools.IsArray() { tools.ForEach(func(_, item gjson.Result) bool { - if item.Get("cache_control").Exists() { + if isValidClaudeCacheControl(item.Get("cache_control")) { count++ } return true @@ -1729,7 +2166,7 @@ func countCacheControls(payload []byte) int { content := msg.Get("content") if content.IsArray() { content.ForEach(func(_, item gjson.Result) bool { - if item.Get("cache_control").Exists() { + if isValidClaudeCacheControl(item.Get("cache_control")) { count++ } return true @@ -1764,11 +2201,7 @@ func normalizeCacheControlTTL(payload []byte) []byte { processBlock := func(path string, obj gjson.Result) { cc := obj.Get("cache_control") - if !cc.Exists() { - return - } - if !cc.IsObject() { - seen5m = true + if !isValidClaudeCacheControl(cc) { return } ttl := cc.Get("ttl") @@ -1846,6 +2279,12 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { return payload } + thread := gjson.GetBytes(payload, "thread") + if thread.Exists() && thread.Type != gjson.Null && maxBlocks > 0 { + // Anthropic reserves one cache breakpoint for thread continuation. + maxBlocks-- + } + total := countCacheControls(payload) if total <= maxBlocks { return payload @@ -1857,7 +2296,7 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { if system.IsArray() { lastIdx := -1 system.ForEach(func(idx, item gjson.Result) bool { - if item.Get("cache_control").Exists() { + if isValidClaudeCacheControl(item.Get("cache_control")) { lastIdx = int(idx.Int()) } return true @@ -1871,7 +2310,7 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { if i == lastIdx { return true } - if !item.Get("cache_control").Exists() { + if !isValidClaudeCacheControl(item.Get("cache_control")) { return true } path := fmt.Sprintf("system.%d.cache_control", i) @@ -1893,7 +2332,7 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { if tools.IsArray() { lastIdx := -1 tools.ForEach(func(idx, item gjson.Result) bool { - if item.Get("cache_control").Exists() { + if isValidClaudeCacheControl(item.Get("cache_control")) { lastIdx = int(idx.Int()) } return true @@ -1907,7 +2346,7 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { if i == lastIdx { return true } - if !item.Get("cache_control").Exists() { + if !isValidClaudeCacheControl(item.Get("cache_control")) { return true } path := fmt.Sprintf("tools.%d.cache_control", i) @@ -1939,7 +2378,7 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { if excess <= 0 { return false } - if !item.Get("cache_control").Exists() { + if !isValidClaudeCacheControl(item.Get("cache_control")) { return true } path := fmt.Sprintf("messages.%d.content.%d.cache_control", int(msgIdx.Int()), int(itemIdx.Int())) @@ -1964,7 +2403,7 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { if excess <= 0 { return false } - if !item.Get("cache_control").Exists() { + if !isValidClaudeCacheControl(item.Get("cache_control")) { return true } path := fmt.Sprintf("system.%d.cache_control", int(idx.Int())) @@ -1987,7 +2426,7 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte { if excess <= 0 { return false } - if !item.Get("cache_control").Exists() { + if !isValidClaudeCacheControl(item.Get("cache_control")) { return true } path := fmt.Sprintf("tools.%d.cache_control", int(idx.Int())) @@ -2058,9 +2497,9 @@ func injectMessagesCacheControl(payload []byte) []byte { } if content.IsArray() { - contentCount := int(content.Get("#").Int()) - if contentCount > 0 { - cacheControlPath := fmt.Sprintf("messages.%d.content.%d.cache_control", lastEligibleIndex, contentCount-1) + targetBlockIndex := lastCacheableClaudeMessageContentBlockIndex(messages.Get(fmt.Sprintf("%d", lastEligibleIndex))) + if targetBlockIndex >= 0 { + cacheControlPath := fmt.Sprintf("messages.%d.content.%d.cache_control", lastEligibleIndex, targetBlockIndex) result, err := sjson.SetBytes(payload, cacheControlPath, claudeCodeCacheControl) if err != nil { log.Warnf("failed to inject cache_control into messages: %v", err) @@ -2068,7 +2507,7 @@ func injectMessagesCacheControl(payload []byte) []byte { } payload = result } - } else if content.Type == gjson.String { + } else if content.Type == gjson.String && strings.TrimSpace(content.String()) != "" { newContent := "[" + buildTextBlock(content.String(), &claudeCodeCacheControl) + "]" result, err := sjson.SetRawBytes(payload, contentPath, []byte(newContent)) if err != nil { @@ -2081,28 +2520,64 @@ func injectMessagesCacheControl(payload []byte) []byte { return payload } +func isEmptyClaudeTextBlock(block gjson.Result) bool { + return block.IsObject() && + block.Get("type").String() == "text" && + strings.TrimSpace(block.Get("text").String()) == "" +} + +func claudeContentBlockCanHostCacheControl(block gjson.Result) bool { + if !block.IsObject() { + return false + } + switch block.Get("type").String() { + case "", "thinking", "redacted_thinking": + return false + case "text": + return strings.TrimSpace(block.Get("text").String()) != "" + default: + return true + } +} + +func lastCacheableClaudeMessageContentBlockIndex(message gjson.Result) int { + content := message.Get("content") + if !content.IsArray() { + return -1 + } + blocks := content.Array() + if len(blocks) == 0 { + return -1 + } + isAssistant := message.Get("role").String() == "assistant" + for i := len(blocks) - 1; i >= 0; i-- { + block := blocks[i] + if isEmptyClaudeTextBlock(block) { + continue + } + if isAssistant { + switch block.Get("type").String() { + case "thinking", "redacted_thinking": + return -1 + } + } + if claudeContentBlockCanHostCacheControl(block) { + return i + } + } + return -1 +} + // claudeMessageEligibleForRollingCache reports whether the native selector would // consider this user/assistant turn as a rolling breakpoint host. Native rejects // an assistant turn whose last content block is thinking-like, because a thinking -// block cannot host the marker. +// block cannot host the marker, and Anthropic rejects cache_control on empty text blocks. func claudeMessageEligibleForRollingCache(message gjson.Result) bool { content := message.Get("content") if content.Type == gjson.String { - return true - } - if !content.IsArray() || content.Get("#").Int() == 0 { - return false - } - if message.Get("role").String() != "assistant" { - return true - } - lastBlock := content.Get(fmt.Sprintf("%d", content.Get("#").Int()-1)) - switch lastBlock.Get("type").String() { - case "thinking", "redacted_thinking": - return false - default: - return true + return strings.TrimSpace(content.String()) != "" } + return lastCacheableClaudeMessageContentBlockIndex(message) >= 0 } // injectClaudeFinalSystemCacheControl reproduces the native final-system special @@ -2122,7 +2597,7 @@ func messageContentHasCacheControl(content gjson.Result) bool { if content.IsArray() { found := false content.ForEach(func(_, item gjson.Result) bool { - if item.Get("cache_control").Exists() { + if isValidClaudeCacheControl(item.Get("cache_control")) { found = true return false } @@ -2146,7 +2621,7 @@ func injectToolsCacheControl(payload []byte) []byte { hasCacheControlInTools := false lastEligibleToolIndex := -1 tools.ForEach(func(index, tool gjson.Result) bool { - if tool.Get("cache_control").Exists() { + if isValidClaudeCacheControl(tool.Get("cache_control")) { hasCacheControlInTools = true return false } @@ -2184,21 +2659,25 @@ func injectSystemCacheControl(payload []byte) []byte { return payload } - // Check if ANY system element already has cache_control + // Check if ANY system element already has cache_control and find the last cacheable element. hasCacheControlInSystem := false - system.ForEach(func(_, item gjson.Result) bool { - if item.Get("cache_control").Exists() { + lastEligibleSystemIndex := -1 + system.ForEach(func(index, item gjson.Result) bool { + if isValidClaudeCacheControl(item.Get("cache_control")) { hasCacheControlInSystem = true return false } + if claudeSystemBlockCanHostCacheControl(item) { + lastEligibleSystemIndex = int(index.Int()) + } return true }) - if hasCacheControlInSystem { + if hasCacheControlInSystem || lastEligibleSystemIndex < 0 { return payload } - // Add cache_control to the last system element - lastSystemPath := fmt.Sprintf("system.%d.cache_control", count-1) + // Add cache_control to the last cacheable system element + lastSystemPath := fmt.Sprintf("system.%d.cache_control", lastEligibleSystemIndex) result, err := sjson.SetBytes(payload, lastSystemPath, claudeCodeCacheControl) if err != nil { log.Warnf("failed to inject cache_control into system array: %v", err) diff --git a/internal/runtime/executor/claude_executor_compaction.go b/internal/runtime/executor/claude_executor_compaction.go new file mode 100644 index 000000000..7afb97138 --- /dev/null +++ b/internal/runtime/executor/claude_executor_compaction.go @@ -0,0 +1,252 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type claudeCompactionSummaryContextKey struct{} + +// expandClaudeResponsesCompaction rewrites sealed compaction items into context +// before translation. Unrecognized capsules fail the request instead of being dropped. +func expandClaudeResponsesCompaction(req *cliproxyexecutor.Request, opts *cliproxyexecutor.Options) error { + return helps.ExpandResponsesCompactionCapsules(req, opts, "claude") +} + +func dropForeignClaudeCompactionItems(payload []byte) []byte { + return helps.DropForeignResponsesCompactionItems(payload, "claude") +} + +func claudeResponsesCompactionRequested(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) bool { + return opts.Alt == "responses/compact" || helps.HasResponsesCompactionTriggerRequest(req, opts) +} + +func claudeCompactionSourcePayload(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) []byte { + return helps.ResponsesCompactionSourcePayload(req, opts) +} + +func (e *ClaudeExecutor) executeClaudeCompaction(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + var resp cliproxyexecutor.Response + baseModel := thinking.ParseSuffix(req.Model).ModelName + summaryPayload := prepareClaudeCompactionSummaryPayload(claudeCompactionSourcePayload(req, opts), baseModel) + summaryReq := cliproxyexecutor.Request{ + Model: req.Model, + Payload: summaryPayload, + Metadata: req.Metadata, + } + summaryOpts := opts + summaryOpts.Alt = "" + summaryOpts.Stream = false + summaryOpts.OriginalRequest = nil + summaryOpts.SourceFormat = sdktranslator.FormatOpenAIResponse + summaryOpts.ResponseFormat = sdktranslator.FormatClaude + + summaryResp, errSummary := e.Execute(withClaudeCompactionSummary(ctx), auth, summaryReq, summaryOpts) + if errSummary != nil { + return resp, errSummary + } + summaryText, errExtract := helps.ExtractAntigravitySummaryText(summaryResp.Payload) + if errExtract != nil { + return resp, fmt.Errorf("extract summary: %w", errExtract) + } + capsule, errSeal := helps.SealAntigravityCompaction(summaryText, baseModel) + if errSeal != nil { + return resp, fmt.Errorf("seal compaction capsule: %w", errSeal) + } + + inputTokens, outputTokens, totalTokens, cachedTokens := claudeCompactionResponsesUsage(summaryResp.Payload) + respPayload := helps.BuildAntigravityCompactionResponse(baseModel, capsule, inputTokens, outputTokens, totalTokens) + respPayload, _ = sjson.SetBytes(respPayload, "usage.input_tokens_details.cached_tokens", cachedTokens) + return cliproxyexecutor.Response{ + Payload: respPayload, + Headers: summaryResp.Headers, + }, nil +} + +func (e *ClaudeExecutor) executeClaudeCompactionStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + baseModel := thinking.ParseSuffix(req.Model).ModelName + summaryResp, errSummary := e.executeClaudeCompaction(ctx, auth, req, opts) + if errSummary != nil { + return nil, errSummary + } + // Reuse the capsule and usage already sealed for the non-stream response. + item := gjson.GetBytes(summaryResp.Payload, "output.0") + capsule := item.Get("encrypted_content").String() + if item.Get("type").String() != "compaction" || capsule == "" { + return nil, fmt.Errorf("extract summary: compaction item missing") + } + inputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens").Int()) + outputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.output_tokens").Int()) + totalTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.total_tokens").Int()) + cachedTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens_details.cached_tokens").Int()) + chunks := helps.BuildAntigravityCompactionStreamChunks(baseModel, capsule, inputTokens, outputTokens, totalTokens) + for i := range chunks { + chunks[i] = patchClaudeCompactionStreamUsage(chunks[i], inputTokens, outputTokens, totalTokens, cachedTokens) + } + out := make(chan cliproxyexecutor.StreamChunk, len(chunks)) + for _, chunk := range chunks { + out <- cliproxyexecutor.StreamChunk{Payload: chunk} + } + close(out) + + headers := summaryResp.Headers.Clone() + if headers == nil { + headers = make(http.Header) + } + headers.Set("Content-Type", "text/event-stream") + return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}, nil +} + +func withClaudeCompactionSummary(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, claudeCompactionSummaryContextKey{}, true) +} + +func claudeCompactionSummaryFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + flag, ok := ctx.Value(claudeCompactionSummaryContextKey{}).(bool) + return ok && flag +} + +// prepareClaudeCompactionSummaryPayload keeps tool definitions that Claude needs +// in order to accept tool_use history, while still asking for a text summary. +func prepareClaudeCompactionSummaryPayload(payload []byte, modelName string) []byte { + tools := gjson.GetBytes(payload, "tools") + additionalTools := gjson.GetBytes(payload, "additional_tools") + out := helps.PrepareAntigravityCompactionSummaryPayload(payload, modelName) + if tools.Exists() { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + } + if additionalTools.Exists() { + out, _ = sjson.SetRawBytes(out, "additional_tools", []byte(additionalTools.Raw)) + } + return out +} + +// finalizeClaudeCompactionSummaryBody runs after built-in translation and before +// user payload rules. Tool history stays structured when definitions exist, and +// tool_choice none stops the summary turn from calling a tool. History without +// definitions is flattened so Anthropic does not reject orphan tool blocks. +func finalizeClaudeCompactionSummaryBody(body []byte) []byte { + if claudeBodyHasToolDefinitions(body) { + body, _ = sjson.SetRawBytes(body, "tool_choice", []byte(`{"type":"none"}`)) + return body + } + return flattenClaudeToolBlocksForCompaction(body) +} + +func claudeBodyHasToolDefinitions(body []byte) bool { + tools := gjson.GetBytes(body, "tools") + return tools.IsArray() && len(tools.Array()) > 0 +} + +func flattenClaudeToolBlocksForCompaction(body []byte) []byte { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + changed := false + rewritten := make([]string, 0, len(messages.Array())) + for _, msg := range messages.Array() { + content := msg.Get("content") + if !content.IsArray() { + rewritten = append(rewritten, msg.Raw) + continue + } + parts := make([]string, 0, len(content.Array())) + msgChanged := false + for _, part := range content.Array() { + switch part.Get("type").String() { + case "tool_use": + text := fmt.Sprintf("Tool call %s (%s): %s", part.Get("name").String(), part.Get("id").String(), part.Get("input").Raw) + block, _ := sjson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text) + parts = append(parts, string(block)) + msgChanged = true + case "tool_result": + result := part.Get("content") + resultText := result.Raw + if result.Type == gjson.String { + resultText = result.String() + } + text := fmt.Sprintf("Tool result %s: %s", part.Get("tool_use_id").String(), resultText) + block, _ := sjson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text) + parts = append(parts, string(block)) + msgChanged = true + default: + parts = append(parts, part.Raw) + } + } + if !msgChanged { + rewritten = append(rewritten, msg.Raw) + continue + } + changed = true + updated, _ := sjson.SetRawBytes([]byte(msg.Raw), "content", []byte("["+strings.Join(parts, ",")+"]")) + rewritten = append(rewritten, string(updated)) + } + if !changed { + return body + } + body, _ = sjson.SetRawBytes(body, "messages", []byte("["+strings.Join(rewritten, ",")+"]")) + return body +} + +// claudeCompactionResponsesUsage matches the Responses translator: input includes +// cache creation and cache read, cached_tokens is cache read, and total is their sum. +func claudeCompactionResponsesUsage(payload []byte) (inputTokens, outputTokens, totalTokens, cachedTokens int) { + usage := gjson.GetBytes(payload, "usage") + if !usage.Exists() { + parsed := helps.ParseOpenAIUsage(payload) + return int(parsed.InputTokens), int(parsed.OutputTokens), int(parsed.TotalTokens), 0 + } + rawInput := int(usage.Get("input_tokens").Int()) + outputTokens = int(usage.Get("output_tokens").Int()) + cacheCreation := int(usage.Get("cache_creation_input_tokens").Int()) + cachedTokens = int(usage.Get("cache_read_input_tokens").Int()) + inputTokens = rawInput + cacheCreation + cachedTokens + totalTokens = inputTokens + outputTokens + return inputTokens, outputTokens, totalTokens, cachedTokens +} + +func patchClaudeCompactionStreamUsage(chunk []byte, inputTokens, outputTokens, totalTokens, cachedTokens int) []byte { + const dataPrefix = "data: " + idx := bytes.Index(chunk, []byte(dataPrefix)) + if idx < 0 { + return chunk + } + data := bytes.TrimSpace(chunk[idx+len(dataPrefix):]) + path := "" + switch { + case gjson.GetBytes(data, "response.usage").Exists(): + path = "response.usage" + case gjson.GetBytes(data, "usage").Exists(): + path = "usage" + default: + return chunk + } + data, _ = sjson.SetBytes(data, path+".input_tokens", inputTokens) + data, _ = sjson.SetBytes(data, path+".output_tokens", outputTokens) + data, _ = sjson.SetBytes(data, path+".total_tokens", totalTokens) + data, _ = sjson.SetBytes(data, path+".input_tokens_details.cached_tokens", cachedTokens) + var buf bytes.Buffer + buf.Write(chunk[:idx]) + buf.WriteString(dataPrefix) + buf.Write(data) + buf.WriteString("\n\n") + return buf.Bytes() +} diff --git a/internal/runtime/executor/claude_executor_diagnostics.go b/internal/runtime/executor/claude_executor_diagnostics.go index e448be19b..87e8943f4 100644 --- a/internal/runtime/executor/claude_executor_diagnostics.go +++ b/internal/runtime/executor/claude_executor_diagnostics.go @@ -92,15 +92,7 @@ func claudeMessageIDFromResponse(data []byte) string { return strings.TrimSpace(gjson.GetBytes(data, "id").String()) } -func observeClaudeStreamLine(line []byte, messageID *string, completed *bool) { - line = bytes.TrimSpace(line) - if !bytes.HasPrefix(line, []byte("data:")) { - return - } - payload := bytes.TrimSpace(line[len("data:"):]) - if !gjson.ValidBytes(payload) { - return - } +func observeClaudeStreamJSONPayload(payload []byte, hasDataPrefix bool, messageID *string, completed *bool) { root := gjson.ParseBytes(payload) switch root.Get("type").String() { case "message_start": @@ -109,14 +101,59 @@ func observeClaudeStreamLine(line []byte, messageID *string, completed *bool) { } case "message_stop": *completed = true + case "message": + if !hasDataPrefix && strings.TrimSpace(root.Get("stop_reason").String()) != "" { + if id := strings.TrimSpace(root.Get("id").String()); id != "" { + *messageID = id + } + *completed = true + } + } +} + +func observeClaudeSSEEvent(rawEvent []byte, messageID *string, completed *bool, streamErr *error) { + var dataLines [][]byte + for _, line := range bytes.Split(rawEvent, []byte("\n")) { + trimmed := bytes.TrimSpace(line) + if !bytes.HasPrefix(trimmed, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(trimmed[len("data:"):]) + if len(payload) > 0 { + dataLines = append(dataLines, payload) + } + } + if len(dataLines) == 0 { + trimmed := bytes.TrimSpace(rawEvent) + if bytes.HasPrefix(trimmed, []byte("{")) && gjson.ValidBytes(trimmed) { + observeClaudeStreamJSONPayload(trimmed, false, messageID, completed) + } + return + } + joined := bytes.TrimSpace(bytes.Join(dataLines, []byte("\n"))) + if !gjson.ValidBytes(joined) { + return + } + observeClaudeStreamJSONPayload(joined, true, messageID, completed) + if streamErr != nil && *streamErr == nil && gjson.ParseBytes(joined).Get("type").String() == "error" { + *streamErr = statusErr{code: 502, msg: string(joined)} } } func claudeMessageIDFromSSE(data []byte) string { var messageID string completed := false + var eventBuf bytes.Buffer for _, line := range bytes.Split(data, []byte("\n")) { - observeClaudeStreamLine(line, &messageID, &completed) + eventBuf.Write(line) + eventBuf.WriteByte('\n') + if len(bytes.TrimSpace(line)) == 0 { + observeClaudeSSEEvent(eventBuf.Bytes(), &messageID, &completed, nil) + eventBuf.Reset() + } + } + if eventBuf.Len() > 0 { + observeClaudeSSEEvent(eventBuf.Bytes(), &messageID, &completed, nil) } if !completed { return "" diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index cfda1b352..8a9c808a7 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -14,13 +14,15 @@ import ( sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { ctx = helps.EnsureSessionContext(ctx, opts, req.Payload) - if opts.Alt == "responses/compact" { - return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} + if errExpand := expandClaudeResponsesCompaction(&req, &opts); errExpand != nil { + return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()} + } + if claudeResponsesCompactionRequested(req, opts) { + return e.executeClaudeCompaction(ctx, auth, req, opts) } baseModel := thinking.ParseSuffix(req.Model).ModelName upstreamModel := e.upstreamModel(baseModel) @@ -42,6 +44,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r reporter.SetUpstreamModel(upstreamModel) } from := opts.SourceFormat + ctx, req, opts = e.applyNativeToolSearchFallback(ctx, auth, from, upstreamModel, req, opts) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("claude") var replayScope claudeThinkingReplayScope @@ -62,7 +65,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } originalPayload := originalPayloadSource incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false, e.cfg) - confirmedClaudeCode := claudeCodeDetection.Confirmed + wirePolicy, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, claudeCodeDetection.Confirmed) + confirmedClaudeCode := isEffectiveConfirmedClaudeCode(claudeCodeDetection, wirePolicy) claudeSessionID := "" if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) @@ -77,22 +81,30 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, body := helps.TranslateRequestPairWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, upstreamStream, isCompat) + originalTranslated, body, err := helps.TranslateRequestPairReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, upstreamStream, isCompat) + if err != nil { + return resp, err + } body = helps.SetStringIfDifferent(body, "model", upstreamModel) + body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } + webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload) + body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL) if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) } // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. - _, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) - bodyBeforeCloaking := body - isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body) + explicitCacheMode := isExplicitPromptCacheMode(originalPayload, req.Payload, body) + if explicitCacheMode { + ctx = withExplicitPromptCacheMode(ctx, true) + } var cloaked bool body, cloaked, err = applyCloakingInternal( ctx, @@ -103,12 +115,11 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r confirmedClaudeCode, cchSigning, false, + explicitCacheMode, ) if err != nil { return resp, err } - systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) - fableState := captureClaudeCodeFableState(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. diagnosticsState := claudeDiagnosticsRequestState{} @@ -126,11 +137,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r eligible: cloaked && isAnthropicUpstreamBase(baseURL), callerOwned: gjson.GetBytes(body, "context_management").Exists(), } - diagnosticsInjectedByCPA := false if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) if fp.InjectDiagnostics && !isProbeOrHelper { - diagnosticsInjectedByCPA = true if continuityCtx.Initialized { body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID) } else { @@ -139,68 +148,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - var touchedPayloadPaths map[string]bool - body, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( - e.cfg, - baseModel, - to.String(), - from.String(), - "", - body, - originalTranslated, - requestedModel, - requestPath, - opts.Headers, - "context_management", - "fallbacks", - "thinking.display", - "diagnostics", - ) - contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] - body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) - wasProbeOrHelper := isProbeOrHelper - isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) - if isProbeOrHelper { - diagnosticsState = claudeDiagnosticsRequestState{} - if diagnosticsInjectedByCPA && !touchedPayloadPaths["diagnostics"] { - body, _ = sjson.DeleteBytes(body, "diagnostics") - } - if cloaked { - body = helps.StripClaudeBillingTags(body) - } - if continuityCtx != nil { - *continuityCtx = helps.ClaudeContinuityContext{} - } - } else if wasProbeOrHelper { - // Declassified as probe (e.g. payload override changed max_tokens: 1 to normal request): - // Initialize continuity and diagnostics if cloaked and eligible. - if cloaked { - existingPrevReq, existingPromptID := helps.ExtractClaudeBillingTags(body) - prevReq, promptID, cCtx, ok := resolveClaudeContinuityTags(ctx, e.cfg, auth, incomingHeaders, body, confirmedClaudeCode, existingPrevReq, existingPromptID) - if ok { - if continuityCtx != nil { - *continuityCtx = cCtx - } - if cCtx.PinnedDate != "" { - body = injectClaudeCodeCurrentDate(body, cCtx.PinnedDate) - } - body = helps.InjectClaudeBillingTags(body, prevReq, promptID) - if fp.InjectDiagnostics && isAnthropicUpstreamBase(baseURL) { - body, diagnosticsState = injectClaudeDiagnosticsWithState(body, cCtx.Key, cCtx.Sequence, cCtx.PreviousMessageID, promptID) - } - } - } - } - body = reconcileClaudeCodeFableModelAfterPayload( - body, - fableState, - touchedPayloadPaths["fallbacks"], - touchedPayloadPaths["thinking.display"], - cloaked, - isProbeOrHelper, - ) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -214,7 +161,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // first-user marker cannot suppress system/latest-user breakpoints. // cloaked and confirmedClaudeCode are mutually exclusive: resolveClaudeWirePolicy // forces Cloak off for a confirmed native client. - cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode) + cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode, originalPayload, req.Payload) if cpaOwnsCacheControl { body = ensureCacheControl(body) } @@ -223,6 +170,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Cloaking and ensureCacheControl may push the total over 4 when the client // already sends multiple cache_control blocks. body = enforceCacheControlLimit(body, 4) + body = stripPromptCacheOptions(body) // Native selects the 1h cache pool only for OAuth credentials and pairs it with // extended-cache-ttl-2025-04-11, which claudeCodeCLIBetas emits on exactly the @@ -241,15 +189,16 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body) if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) - } else if isProbeOrHelper || (isSubagent && !subagent1h) { + } else if (isProbeOrHelper || (isSubagent && !subagent1h)) && !explicitCacheMode { body = stripClaudeCacheControlTTL(body) } // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. // A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages). - body = normalizeCacheControlTTL(body) - // Payload rules and other request processing may rewrite stream. Keep the - // upstream body, transport headers, and response parser on one authority. + if !explicitCacheMode { + body = normalizeCacheControlTTL(body) + } + // Set the built-in transport default before the explicit payload barrier. // Native non-stream Haiku helper requests omit stream rather than sending // false, so preserve that measured wire shape when the transport agrees. streamField := gjson.GetBytes(body, "stream") @@ -259,13 +208,15 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Extract betas from body and convert to header var extraBetas []string - extraBetas, body = extractAndRemoveBetas(body) bodyForTranslation := body bodyForUpstream := body var oauthToolNamesReverseMap map[string]string if fp.MCPAlias && cloaked { - mcpAliases := resolveClaudeMCPAliasOptions(ctx) - bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, mcpAliases) + mcpAliases := resolveClaudeMCPAliasOptions(ctx, opts.Metadata, req.Metadata) + bodyForUpstream, oauthToolNamesReverseMap, err = e.prepareClaudeOAuthToolNamesForRequest(bodyForUpstream, mcpAliases) + if err != nil { + return resp, err + } } bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) if fp.ApplyCLIIdentity { @@ -283,15 +234,35 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) } - bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) + bodyForUpstream, err = ensureClaudeBillingHeaderCCHPlaceholder(bodyForUpstream, cchBilling) if err != nil { return resp, fmt.Errorf("finalize Claude CCH: %w", err) } } bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream) - // Runs on the finished body: payload rules can rewrite model and messages - // long after translation, so an earlier check would not describe the request - // that is about to be sent. + if claudeCompactionSummaryFromContext(ctx) { + bodyForUpstream = finalizeClaudeCompactionSummaryBody(bodyForUpstream) + } + bodyForUpstream = applyClaudePreservedThinkingBinding(bodyForUpstream) + // User rules match the fully prepared business body and are applied only once. + var touchedPayloadPaths map[string]bool + bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( + e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated), + helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers, + "diagnostics", + ) + if touchedPayloadPaths["diagnostics"] { + diagnosticsState = claudeDiagnosticsRequestState{} + } + extraBetas, bodyForUpstream = extractAndRemoveBetas(bodyForUpstream) + bodyForUpstream = stripPromptCacheOptions(bodyForUpstream) + if cchSigning { + bodyForUpstream, err = signAnthropicMessagesBody(bodyForUpstream) + if err != nil { + return resp, fmt.Errorf("sign Claude CCH: %w", err) + } + } + // Read-only validation must observe the final configured model and messages. if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil { return resp, errMidSystem } @@ -396,6 +367,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } if msgID := claudeMessageIDFromSSE(data); msgID != "" { commitClaudeContinuity(diagnosticsState, msgID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) + e.rememberClaudeOAuthToolAliases(body, oauthToolNamesReverseMap, msgID) } lines := bytes.Split(data, []byte("\n")) for i, line := range lines { @@ -412,7 +384,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } data = bytes.Join(lines, []byte("\n")) } else { - commitClaudeContinuity(diagnosticsState, claudeMessageIDFromResponse(data), helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) + messageID := claudeMessageIDFromResponse(data) + commitClaudeContinuity(diagnosticsState, messageID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) + e.rememberClaudeOAuthToolAliases(body, oauthToolNamesReverseMap, messageID) reporter.ObserveResponseModel(data) var errRestore error data, errRestore = restoreClaudeOAuthToolNamesFromResponse(data, oauthToolNamesReverseMap) @@ -423,6 +397,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } } data = e.restoreResponseModel(data, req.Model) + helps.RecordClaudeWebSearchResultsContext(ctx, webSearchSessionKey, data, webSearchTTL, webSearchLimit) cacheClaudeThinkingReplayResponse(ctx, replayScope, data) var param any out := sdktranslator.TranslateNonStream( diff --git a/internal/runtime/executor/claude_executor_fast_error.go b/internal/runtime/executor/claude_executor_fast_error.go index 27d66fc9c..53a685569 100644 --- a/internal/runtime/executor/claude_executor_fast_error.go +++ b/internal/runtime/executor/claude_executor_fast_error.go @@ -146,7 +146,7 @@ func newClaudeFastDirectResponseError(resp *http.Response, body []byte) error { var retryAfter *time.Duration credentialScoped := false if resp.StatusCode == http.StatusTooManyRequests { - retryAfter = helps.ParseClaudeRateLimitReset(resp.Header, time.Now()) + retryAfter = helps.ParseClaudeRateLimitResetForError(resp.Header, body, time.Now()) if helps.ClaudeHeadersIndicateUnifiedRateLimitRejection(resp.Header) { credentialScoped = true } diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 054f13770..b0541229c 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -1,11 +1,7 @@ package executor import ( - "bufio" "bytes" - "compress/flate" - "compress/gzip" - "compress/zlib" "context" "fmt" "io" @@ -15,9 +11,7 @@ import ( "strings" "time" - "github.com/andybalholm/brotli" "github.com/google/uuid" - "github.com/klauspost/compress/zstd" claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v8/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" @@ -26,6 +20,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -315,6 +310,105 @@ func isClaudeSonnet5Model(model string) bool { return model == "claude-sonnet-5" || strings.HasPrefix(model, "claude-sonnet-5-") || strings.HasPrefix(model, "claude-sonnet-5[") } +func isClaudeHaiku55Model(model string) bool { + model = claudeCanonicalModel(model) + return model == "claude-haiku-5-5" || strings.HasPrefix(model, "claude-haiku-5-5-") || strings.HasPrefix(model, "claude-haiku-5-5[") || model == "claude-haiku-5.5" || strings.HasPrefix(model, "claude-haiku-5.5-") || strings.HasPrefix(model, "claude-haiku-5.5[") +} + +// isClaudeFable51OnlyModel reports Fable 5.1 specifically, excluding Mythos +// 5.1, which shares the fable family checks elsewhere but does not run the +// preserved-thinking prefix check. +func isClaudeFable51OnlyModel(model string) bool { + m := claudeCanonicalModel(model) + for _, target := range []string{"fable-5-1", "fable-5.1"} { + idx := strings.Index(m, target) + if idx != -1 { + nextIdx := idx + len(target) + if nextIdx >= len(m) || m[nextIdx] < '0' || m[nextIdx] > '9' { + return true + } + } + } + return false +} + +// isClaudePreservedThinkingModel reports the models whose signed thinking +// blocks Anthropic binds to the creation-time conversation prefix (system, +// tools, and every earlier message). Replaying those blocks under a reshaped +// prefix fails binding validation unless the request opts into dropping them. +// See https://platform.claude.com/docs/en/build-with-claude/preserved-thinking +func isClaudePreservedThinkingModel(model string) bool { + return isClaudeOpus55Model(model) || isClaudeSonnet55Model(model) || isClaudeHaiku55Model(model) || isClaudeFable51OnlyModel(model) +} + +// claudeThinkingTypeAcceptsBlockBinding reports whether the body's thinking +// mode may carry block_binding on this model. Haiku 5.5 and Sonnet 5.5 accept +// it only with adaptive thinking (disabled/between_tools return 400); the +// other preserved-thinking models also accept manual enabled thinking. +func claudeThinkingTypeAcceptsBlockBinding(model, thinkingType string) bool { + switch strings.ToLower(strings.TrimSpace(thinkingType)) { + case "adaptive": + return true + case "enabled": + return !isClaudeHaiku55Model(model) && !isClaudeSonnet55Model(model) + default: + return false + } +} + +// claudeBodyHasSignedThinkingBlock reports whether any assistant message +// carries a replayed thinking block with a signature or a redacted_thinking +// block, i.e. history Anthropic subjects to prefix binding validation. +func claudeBodyHasSignedThinkingBlock(body []byte) bool { + found := false + gjson.GetBytes(body, "messages").ForEach(func(_, msg gjson.Result) bool { + msg.Get("content").ForEach(func(_, block gjson.Result) bool { + switch strings.TrimSpace(block.Get("type").String()) { + case "thinking": + found = strings.TrimSpace(block.Get("signature").String()) != "" + case "redacted_thinking": + found = strings.TrimSpace(block.Get("data").String()) != "" + } + return !found + }) + return !found + }) + return found +} + +// applyClaudePreservedThinkingBinding makes relayed or reshaped histories +// survivable on preserved-thinking models: when the request replays signed +// thinking blocks, default thinking.block_binding.prefix_mismatch_behavior to +// drop_block so a prefix mismatch (client reconnect resending history, a +// changed tools list, reconstructed server-tool blocks) drops the bound +// blocks instead of failing the whole request with a 400. On preserved-thinking +// models, an omitted thinking field runs adaptive thinking and is equivalent to +// {"type":"adaptive"}, so synthesize that adaptive thinking object unless +// tool_choice forces tool use. A thinking object without a type and any +// caller-supplied prefix_mismatch_behavior stay untouched. This must run before +// user payload rules, which stay the final semantic barrier and may override or +// filter the injected value. +func applyClaudePreservedThinkingBinding(body []byte) []byte { + model := gjson.GetBytes(body, "model").String() + if len(body) == 0 || !isClaudePreservedThinkingModel(model) || claudeToolChoiceForcesToolUse(body) { + return body + } + thinking := gjson.GetBytes(body, "thinking") + if thinking.Exists() { + if !claudeThinkingTypeAcceptsBlockBinding(model, thinking.Get("type").String()) || thinking.Get("block_binding.prefix_mismatch_behavior").Exists() { + return body + } + } + if !claudeBodyHasSignedThinkingBlock(body) { + return body + } + if !thinking.Exists() { + body, _ = sjson.SetBytes(body, "thinking.type", "adaptive") + } + body, _ = sjson.SetBytes(body, "thinking.block_binding.prefix_mismatch_behavior", "drop_block") + return body +} + // claudeModelUsesProgressDisplay reports the 2.1.280 interactive CLI models that // send thinking.display=updates unless the caller already chose a display mode. func claudeModelUsesProgressDisplay(model string) bool { @@ -600,6 +694,23 @@ func withoutClaudeBeta(betas, removeBeta string) string { return strings.Join(res, ",") } +// withClaudeBetaAppended appends beta at the end unless it is already present. +func withClaudeBetaAppended(betas, beta string) string { + beta = strings.TrimSpace(beta) + if beta == "" { + return betas + } + for _, existing := range strings.Split(betas, ",") { + if strings.TrimSpace(existing) == beta { + return betas + } + } + if strings.TrimSpace(betas) == "" { + return beta + } + return betas + "," + beta +} + func withClaudeExtendedCacheTTLBeta(betas string) string { parts := make([]string, 0, 16) seen := make(map[string]bool) @@ -698,7 +809,7 @@ func classifyClaudeUpstreamError(statusCode int, headers http.Header, body []byt func classifyClaudeUpstreamErrorWithCooling(statusCode int, headers http.Header, body []byte, modelLevelCooling bool) error { var retryAfter *time.Duration if statusCode == http.StatusTooManyRequests || (statusCode >= 400 && statusCode < 600) { - retryAfter = helps.ParseClaudeRateLimitReset(headers, time.Now()) + retryAfter = helps.ParseClaudeRateLimitResetForError(headers, body, time.Now()) } err := statusErr{code: statusCode, msg: string(body), retryAfter: retryAfter} if statusCode == http.StatusTooManyRequests { @@ -785,13 +896,17 @@ func extractAndRemoveBetas(body []byte) ([]string, []byte) { return betas, body } +func claudeToolChoiceForcesToolUse(body []byte) bool { + toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String() + return toolChoiceType == "any" || toolChoiceType == "tool" +} + // 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" { + if claudeToolChoiceForcesToolUse(body) { // 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 @@ -852,162 +967,12 @@ func normalizeClaudeSamplingForUpstream(body []byte, nativeOwned bool) []byte { 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 + return helps.DecodeResponseBody(body, http.Header{"Content-Encoding": {contentEncoding}}) } // claudeCredentialUsesOAuth classifies the selected upstream credential. It is the @@ -1024,7 +989,7 @@ func claudeCredentialUsesOAuth(auth *cliproxyauth.Auth, apiKey string) bool { return !hasAPIKeyAttr } -func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode bool) { +func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode, uncloak bool) { if dst == nil || src == nil { return } @@ -1043,6 +1008,21 @@ func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCod if !confirmedClaudeCode && (strings.HasPrefix(lowerName, "x-claude-code-") || strings.HasPrefix(lowerName, "x-claude-remote-")) { continue } + if uncloak { + if lowerName == "x-app" || + lowerName == "x-client-request-id" || + lowerName == "x-client-app" || + lowerName == "x-anthropic-additional-protection" || + lowerName == "anthropic-dangerous-direct-browser-access" || + strings.HasPrefix(lowerName, "x-stainless-") || + strings.HasPrefix(lowerName, "x-claude-code-") || + strings.HasPrefix(lowerName, "x-claude-remote-") { + continue + } + if lowerName == "user-agent" && len(values) > 0 && helps.IsClaudeCodeUserAgent(values[0]) { + continue + } + } dst.Del(name) for _, value := range values { dst.Add(name, value) @@ -1101,6 +1081,11 @@ func applyClaudeHeadersWithNativeProfile( useAPIKey := !credentialUsesBearer fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey) wirePolicy, _ := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) + if wirePolicy.Uncloak { + confirmedClaudeCode = false + helperProfile = false + sessionIDs = nil + } applyCLIFingerprint := fp.ProfileClaudeCodeCLI || wirePolicy.Cloak preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode useOAuthBetas := fp.UseOAuthBetas @@ -1126,16 +1111,16 @@ func applyClaudeHeadersWithNativeProfile( } 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(helps.HeaderValuesCaseInsensitive(incomingHeaders, "Anthropic-Beta"), ",")) countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens") + if !confirmedClaudeCode && !wirePolicy.Cloak && wirePolicy.ExplicitNever && !wirePolicy.Uncloak { + det := helps.DetectClaudeCodeRequest(incomingHeaders, body, countTokens, cfg) + if det.ConfirmedShape { + confirmedClaudeCode = true + helperProfile = helperProfile || (!det.BetasPresent && !countTokens) + } + } requestedMap := claudeRequestedBetas(incomingBetas, extraBetas) advisorNeeded := requestedMap[claudeAdvisorToolBeta] || claudeBodyHasAdvisorTool(body) @@ -1154,17 +1139,19 @@ func applyClaudeHeadersWithNativeProfile( if advisorNeeded { baseBetas = withClaudeAdvisorToolBeta(baseBetas) } - // Measured Haiku helper requests already carry the exact credential - // beta profile and intentionally omit extended-cache-ttl. + // Measured Haiku helper requests already carry their own cache-ttl beta + // choice (omitting extended-cache-ttl on 2.1.220/2.1.280 probes/title + // helpers), while helpers arriving via ANTHROPIC_BASE_URL still need + // oauth-2025-04-20 when CPA authenticates upstream with OAuth. // Native Claude Code subagents and probes also omit extended-cache-ttl. - if useOAuthBetas && !helperProfile { + if useOAuthBetas { if countTokens { baseBetas = withClaudeCountTokensOAuthBeta(baseBetas) } else { isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) isProbe := helps.IsClaudeProbeOrHelperRequest(body) subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body) - includeExtendedCacheTTL := (!isSubagent || subagent1h) && !isProbe + includeExtendedCacheTTL := !helperProfile && (!isSubagent || subagent1h) && !isProbe baseBetas = withClaudeOAuthCredentialBetas(baseBetas, includeExtendedCacheTTL) } } @@ -1236,6 +1223,21 @@ func applyClaudeHeadersWithNativeProfile( } } applyBetaHeader := func() { + if wirePolicy.Uncloak { + baseBetas = withoutClaudeBeta(baseBetas, claudeCodeBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeOAuthBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeRedactThinkingBeta) + baseBetas = withoutClaudeBeta(baseBetas, claudeAFKModeBeta) + if !gjson.GetBytes(body, "diagnostics").Exists() { + baseBetas = withoutClaudeBeta(baseBetas, claudeCacheDiagnosisBeta) + } + if !gjson.GetBytes(body, "fallback_credit_token").Exists() { + baseBetas = withoutClaudeBeta(baseBetas, claudeFallbackCreditBeta) + } + if !helps.ClaudePayloadHas1hTTL(body) { + baseBetas = withoutClaudeBeta(baseBetas, claudeExtendedCacheTTLBeta) + } + } // Enforce strict native Claude Code 2.1.280 model & turn beta gating: if !claudeRequestSupportsEffort(body, nil) { baseBetas = withoutClaudeBeta(baseBetas, claudeEffortBeta) @@ -1260,12 +1262,28 @@ func applyClaudeHeadersWithNativeProfile( if isClaudeHaikuModel(reqModel) && !gjson.GetBytes(body, "fallbacks").Exists() && !helperProfile { baseBetas = withoutClaudeBeta(baseBetas, claudeServerSideFallbackBeta) } + // A body carrying thinking.block_binding is rejected upstream unless + // the binding-controls beta travels with it, in every fingerprint + // mode (cloaked, caller-owned, and confirmed passthrough alike). + if gjson.GetBytes(body, "thinking.block_binding").Exists() { + baseBetas = withClaudeBetaAppended(baseBetas, claudeThinkingBindingBeta) + } + // Operator overrides get the last word over the derived and caller betas: + // strip filters without re-sorting, append adds missing names at the end. + // They only shape the emitted value, so re-applying the header stays stable. + emittedBetas := baseBetas + for _, beta := range hd.AnthropicBetaStrip { + emittedBetas = withoutClaudeBeta(emittedBetas, beta) + } + for _, beta := range hd.AnthropicBetaAppend { + emittedBetas = withClaudeBetaAppended(emittedBetas, beta) + } - if strings.TrimSpace(baseBetas) == "" { + if strings.TrimSpace(emittedBetas) == "" { r.Header.Del("Anthropic-Beta") return } - r.Header.Set("Anthropic-Beta", baseBetas) + r.Header.Set("Anthropic-Beta", emittedBetas) } applyBetaHeader() @@ -1276,7 +1294,10 @@ func applyClaudeHeadersWithNativeProfile( defaultAccept = "text/event-stream" defaultAcceptEncoding = "identity" } - copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode) + copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode, wirePolicy.Uncloak) + if hd.ClientRequestID == config.ClaudeClientRequestIDOff { + r.Header.Del("X-Client-Request-Id") + } 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) @@ -1284,7 +1305,11 @@ func applyClaudeHeadersWithNativeProfile( // 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) + uaSourceHeaders := incomingHeaders + if wirePolicy.Uncloak && helps.IsClaudeCodeUserAgent(helps.HeaderValueCaseInsensitive(incomingHeaders, "User-Agent")) { + uaSourceHeaders = nil + } + misc.EnsureHeader(r.Header, uaSourceHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version) applyBetaHeader() sessionID := "" for _, candidate := range sessionIDs { @@ -1410,8 +1435,15 @@ func applyClaudeHeadersWithNativeProfile( // request ID they already sent: 2.1.258 attaches the header just for a first-party // base URL, so a helper that arrived without one keeps it absent upstream rather // than gaining a synthesized ID the real client would not have sent. - if isAnthropicBase || (helperProfile && helps.HeaderValueCaseInsensitive(incomingHeaders, "x-client-request-id") != "") { - identityHeader("x-client-request-id", uuid.New().String()) + // header-defaults.client-request-id=passthrough forwards only the caller's own + // value and never synthesizes one; off never sends the header. + incomingClientRequestID := helps.HeaderValueCaseInsensitive(incomingHeaders, "x-client-request-id") + if hd.ClientRequestID != config.ClaudeClientRequestIDOff && (isAnthropicBase || (helperProfile && incomingClientRequestID != "")) { + if hd.ClientRequestID != config.ClaudeClientRequestIDPassthrough { + identityHeader("x-client-request-id", uuid.New().String()) + } else if incomingClientRequestID != "" { + r.Header.Set("x-client-request-id", incomingClientRequestID) + } } r.Header.Set("Connection", "keep-alive") // Regular Claude Code requests negotiate transport identically for streaming @@ -1439,8 +1471,15 @@ func applyClaudeHeadersWithNativeProfile( // 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 wirePolicy.ExplicitNever && confirmedClaudeCode { + helps.ApplyClaudeLegacyDeviceHeaders(r, incomingHeaders, cfg, true, true) + } else if stabilizeDeviceProfile { if confirmedClaudeCode { + var errDeviceProfile error + deviceProfile, errDeviceProfile = helps.ResolveClaudeDeviceProfileRequired(r.Context(), auth, apiKey, incomingHeaders, cfg) + if errDeviceProfile != nil { + return errDeviceProfile + } helps.ApplyClaudeDeviceProfileHeaders(r, deviceProfile) } else { helps.ApplyClaudeDefaultDeviceProfileHeaders(r, cfg) @@ -1460,7 +1499,7 @@ func applyClaudeHeadersWithNativeProfile( // 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) + applyBetaHeader() applyTransportNegotiation() } else if stream { // Elsewhere only streaming is protected, so an Accept override cannot @@ -1650,17 +1689,29 @@ type claudeMCPAliasOptions struct { secret string } -func resolveClaudeMCPAliasOptions(ctx context.Context) claudeMCPAliasOptions { +func resolveClaudeMCPAliasOptions(ctx context.Context, metadataSets ...map[string]any) 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)) + secret := callerScopeFromMetadataSets(metadataSets...) + if secret == "" { + secret = session.CallerScope(helps.APIKeyFromContext(ctx)) + } if secret == "" { secret = "cpa-claude-mcp-default-caller" } return claudeMCPAliasOptions{secret: secret} } +func callerScopeFromMetadataSets(metadataSets ...map[string]any) string { + for _, metadata := range metadataSets { + if scope := metadataString(metadata, cliproxyexecutor.CallerScopeMetadataKey); scope != "" { + return scope + } + } + return "" +} + // prepareClaudeOAuthToolNamesForUpstream applies one request-local MCP symbol // table across every Claude OAuth request path. func prepareClaudeOAuthToolNamesForUpstream(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) { @@ -2293,6 +2344,20 @@ func newClaudeMCPAliasResolver(reverseMap map[string]string) claudeMCPAliasResol return resolver } +// restorePassthroughWrapped maps a tail that is exactly a declared caller-owned MCP +// tool name (mcp__server__tool) back to that tool. +func (resolver claudeMCPAliasResolver) restorePassthroughWrapped(tail string) (string, bool) { + if tail == "" { + return "", false + } + for _, original := range resolver.passthroughs { + if original == tail { + return original, true + } + } + return "", false +} + func parseClaudeMCPAlias(name string) (claudeMCPAliasParts, bool) { if !helps.IsClaudeMCPToolName(name) { return claudeMCPAliasParts{}, false @@ -2439,6 +2504,14 @@ func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error } if len(resolver.passthroughs) > 0 { + // Recovery 0: The model kept the caller's full tool name, mcp__ prefix included, + // under the virtual server (e.g. "mcp____mcp____"). + // Recoveries 1 and 2 below only cover the __ and tails. + if original, ok := resolver.restorePassthroughWrapped(suffix); ok { + log.Debugf("claude oauth mcp alias: recovered %q as caller-owned tool %q", name, original) + return original, true, nil + } + // Recovery 1: The model prepended the virtual server to the full caller MCP tool name // (e.g. "mcp______"). After stripping the virtual server prefix, // re-prefixing suffix with "mcp__" produces the original caller tool name. diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 1356d45a9..c0ec95ca9 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -16,13 +16,15 @@ import ( sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { ctx = helps.EnsureSessionContext(ctx, opts, req.Payload) - if opts.Alt == "responses/compact" { - return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} + if errExpand := expandClaudeResponsesCompaction(&req, &opts); errExpand != nil { + return nil, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()} + } + if claudeResponsesCompactionRequested(req, opts) { + return e.executeClaudeCompactionStream(ctx, auth, req, opts) } baseModel := thinking.ParseSuffix(req.Model).ModelName upstreamModel := e.upstreamModel(baseModel) @@ -48,6 +50,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A reporter.SetUpstreamModel(upstreamModel) } from := opts.SourceFormat + ctx, req, opts = e.applyNativeToolSearchFallback(ctx, auth, from, upstreamModel, req, opts) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("claude") var replayScope claudeThinkingReplayScope @@ -65,7 +68,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } originalPayload := originalPayloadSource incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false, e.cfg) - confirmedClaudeCode := claudeCodeDetection.Confirmed + wirePolicy, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, claudeCodeDetection.Confirmed) + confirmedClaudeCode := isEffectiveConfirmedClaudeCode(claudeCodeDetection, wirePolicy) claudeSessionID := "" if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) @@ -80,22 +84,30 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } isCompat := helps.APIKeyModelIsCompat(req) - originalTranslated, body := helps.TranslateRequestPairWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat) + originalTranslated, body, err := helps.TranslateRequestPairReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat) + if err != nil { + return nil, err + } body = helps.SetStringIfDifferent(body, "model", upstreamModel) + body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } + webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload) + body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL) if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) } // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. - _, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) - bodyBeforeCloaking := body - isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body) + explicitCacheMode := isExplicitPromptCacheMode(originalPayload, req.Payload, body) + if explicitCacheMode { + ctx = withExplicitPromptCacheMode(ctx, true) + } var cloaked bool body, cloaked, err = applyCloakingInternal( ctx, @@ -106,12 +118,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A confirmedClaudeCode, cchSigning, false, + explicitCacheMode, ) if err != nil { return nil, err } - systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) - fableState := captureClaudeCodeFableState(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. diagnosticsState := claudeDiagnosticsRequestState{} @@ -129,11 +140,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A eligible: cloaked && isAnthropicUpstreamBase(baseURL), callerOwned: gjson.GetBytes(body, "context_management").Exists(), } - diagnosticsInjectedByCPA := false if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) if fp.InjectDiagnostics && !isProbeOrHelper { - diagnosticsInjectedByCPA = true if continuityCtx.Initialized { body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID) } else { @@ -142,68 +151,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - var touchedPayloadPaths map[string]bool - body, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( - e.cfg, - baseModel, - to.String(), - from.String(), - "", - body, - originalTranslated, - requestedModel, - requestPath, - opts.Headers, - "context_management", - "fallbacks", - "thinking.display", - "diagnostics", - ) - contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] - body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) - wasProbeOrHelper := isProbeOrHelper - isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) - if isProbeOrHelper { - diagnosticsState = claudeDiagnosticsRequestState{} - if diagnosticsInjectedByCPA && !touchedPayloadPaths["diagnostics"] { - body, _ = sjson.DeleteBytes(body, "diagnostics") - } - if cloaked { - body = helps.StripClaudeBillingTags(body) - } - if continuityCtx != nil { - *continuityCtx = helps.ClaudeContinuityContext{} - } - } else if wasProbeOrHelper { - // Declassified as probe (e.g. payload override changed max_tokens: 1 to normal request): - // Initialize continuity and diagnostics if cloaked and eligible. - if cloaked { - existingPrevReq, existingPromptID := helps.ExtractClaudeBillingTags(body) - prevReq, promptID, cCtx, ok := resolveClaudeContinuityTags(ctx, e.cfg, auth, incomingHeaders, body, confirmedClaudeCode, existingPrevReq, existingPromptID) - if ok { - if continuityCtx != nil { - *continuityCtx = cCtx - } - if cCtx.PinnedDate != "" { - body = injectClaudeCodeCurrentDate(body, cCtx.PinnedDate) - } - body = helps.InjectClaudeBillingTags(body, prevReq, promptID) - if fp.InjectDiagnostics && isAnthropicUpstreamBase(baseURL) { - body, diagnosticsState = injectClaudeDiagnosticsWithState(body, cCtx.Key, cCtx.Sequence, cCtx.PreviousMessageID, promptID) - } - } - } - } - body = reconcileClaudeCodeFableModelAfterPayload( - body, - fableState, - touchedPayloadPaths["fallbacks"], - touchedPayloadPaths["thinking.display"], - cloaked, - isProbeOrHelper, - ) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -217,7 +164,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // first-user marker cannot suppress system/latest-user breakpoints. // cloaked and confirmedClaudeCode are mutually exclusive: resolveClaudeWirePolicy // forces Cloak off for a confirmed native client. - cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode) + cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode, originalPayload, req.Payload) if cpaOwnsCacheControl { body = ensureCacheControl(body) } @@ -226,6 +173,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Cloaking and ensureCacheControl may push the total over 4 when the client // already sends multiple cache_control blocks. body = enforceCacheControlLimit(body, 4) + body = stripPromptCacheOptions(body) // Native selects the 1h cache pool only for OAuth credentials and pairs it with // extended-cache-ttl-2025-04-11, which claudeCodeCLIBetas emits on exactly the @@ -244,22 +192,26 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body) if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) - } else if isProbeOrHelper || (isSubagent && !subagent1h) { + } else if (isProbeOrHelper || (isSubagent && !subagent1h)) && !explicitCacheMode { body = stripClaudeCacheControlTTL(body) } // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. - body = normalizeCacheControlTTL(body) + if !explicitCacheMode { + body = normalizeCacheControlTTL(body) + } // Extract betas from body and convert to header var extraBetas []string - extraBetas, body = extractAndRemoveBetas(body) bodyForTranslation := body bodyForUpstream := body var oauthToolNamesReverseMap map[string]string if fp.MCPAlias && cloaked { - mcpAliases := resolveClaudeMCPAliasOptions(ctx) - bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, mcpAliases) + mcpAliases := resolveClaudeMCPAliasOptions(ctx, opts.Metadata, req.Metadata) + bodyForUpstream, oauthToolNamesReverseMap, err = e.prepareClaudeOAuthToolNamesForRequest(bodyForUpstream, mcpAliases) + if err != nil { + return nil, err + } } bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) if fp.ApplyCLIIdentity { @@ -277,15 +229,32 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) } - bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) + bodyForUpstream, err = ensureClaudeBillingHeaderCCHPlaceholder(bodyForUpstream, cchBilling) if err != nil { return nil, fmt.Errorf("finalize Claude CCH: %w", err) } } bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream) - // Runs on the finished body: payload rules can rewrite model and messages - // long after translation, so an earlier check would not describe the request - // that is about to be sent. + bodyForUpstream = applyClaudePreservedThinkingBinding(bodyForUpstream) + // User rules match the fully prepared business body and are applied only once. + var touchedPayloadPaths map[string]bool + bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( + e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated), + helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers, + "diagnostics", + ) + if touchedPayloadPaths["diagnostics"] { + diagnosticsState = claudeDiagnosticsRequestState{} + } + extraBetas, bodyForUpstream = extractAndRemoveBetas(bodyForUpstream) + bodyForUpstream = stripPromptCacheOptions(bodyForUpstream) + if cchSigning { + bodyForUpstream, err = signAnthropicMessagesBody(bodyForUpstream) + if err != nil { + return nil, fmt.Errorf("sign Claude CCH: %w", err) + } + } + // Read-only validation must observe the final configured model and messages. if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil { return nil, errMidSystem } @@ -410,11 +379,13 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A scanner.Buffer(nil, 52_428_800) // 50MB var event bytes.Buffer var upstreamMessageID string + var upstreamStreamErr error upstreamCompleted := false flushEvent := func() bool { if event.Len() == 0 { return true } + observeClaudeSSEEvent(event.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr) cloned := bytes.Clone(event.Bytes()) event.Reset() select { @@ -426,7 +397,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } for scanner.Scan() { line := scanner.Bytes() - observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted) helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) streamUsage.ObserveClaudeStream(line) @@ -443,7 +413,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A emitCancellation(ctx.Err()) return } - if upstreamCompleted { + if upstreamCompleted || upstreamStreamErr != nil { break } } @@ -456,7 +426,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if emitCancellation(scanner.Err()) { return } - if errScan := scanner.Err(); errScan != nil { + errScan := scanner.Err() + if errScan == nil { + errScan = upstreamStreamErr + } + if errScan == nil && ctx.Err() == nil { + errScan = statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before message_stop"} + } + if errScan != nil { errScan = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errScan) helps.RecordAPIResponseError(ctx, e.cfg, errScan) streamUsage.PublishFailure(ctx, reporter, errScan) @@ -469,6 +446,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } if upstreamCompleted { commitClaudeContinuity(diagnosticsState, upstreamMessageID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) + e.rememberClaudeOAuthToolAliases(body, oauthToolNamesReverseMap, upstreamMessageID) } return } @@ -479,10 +457,17 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A var param any helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), bodyForTranslation, ¶m) var upstreamMessageID string + var upstreamStreamErr error + var eventBuf bytes.Buffer upstreamCompleted := false for scanner.Scan() { line := scanner.Bytes() - observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted) + eventBuf.Write(line) + eventBuf.WriteByte('\n') + if len(bytes.TrimSpace(line)) == 0 { + observeClaudeSSEEvent(eventBuf.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr) + eventBuf.Reset() + } helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) streamUsage.ObserveClaudeStream(line) @@ -492,6 +477,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return } line = e.restoreResponseModel(restoredLine, req.Model) + helps.RecordClaudeWebSearchResultsContext(ctx, webSearchSessionKey, line, webSearchTTL, webSearchLimit) chunks := sdktranslator.TranslateStream( ctx, to, @@ -519,10 +505,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } - if upstreamCompleted { + if upstreamCompleted || upstreamStreamErr != nil { break } } + if eventBuf.Len() > 0 { + observeClaudeSSEEvent(eventBuf.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr) + eventBuf.Reset() + } if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { return } @@ -530,7 +520,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if emitCancellation(scanner.Err()) { return } - if errScan := scanner.Err(); errScan != nil { + errScan := scanner.Err() + if errScan == nil { + errScan = upstreamStreamErr + } + if errScan == nil && ctx.Err() == nil { + errScan = statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before message_stop"} + } + if errScan != nil { errScan = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errScan) helps.RecordAPIResponseError(ctx, e.cfg, errScan) streamUsage.PublishFailure(ctx, reporter, errScan) @@ -543,6 +540,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } if upstreamCompleted { commitClaudeContinuity(diagnosticsState, upstreamMessageID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) + e.rememberClaudeOAuthToolAliases(body, oauthToolNamesReverseMap, upstreamMessageID) } }() result := &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out} diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index 8e95d91c5..a4855194d 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -32,22 +32,42 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut } baseModel := thinking.ParseSuffix(req.Model).ModelName + upstreamModel := e.upstreamModel(baseModel) from := opts.SourceFormat + ctx, req, opts = e.applyNativeToolSearchFallback(ctx, auth, from, upstreamModel, req, opts) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("claude") // Use streaming translation to preserve function calling, except for claude. stream := from != to - body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) + body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) + if err != nil { + return cliproxyexecutor.Response{}, err + } + originalTranslatedForPayload := append([]byte(nil), body...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, stream, helps.APIKeyModelIsCompat(req)) + } + body = helps.SetStringIfDifferent(body, "model", upstreamModel) + body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body) var errThinking error body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if errThinking != nil { return cliproxyexecutor.Response{}, errThinking } + originalPayload := req.Payload + if len(opts.OriginalRequest) > 0 { + originalPayload = opts.OriginalRequest + } + webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload) + body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL) if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) } body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req)) + body = applyClaudePreservedThinkingBinding(body) + body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body) + body = stripPromptCacheOptions(body) if errValidate := validateClaudeTokenCountRequest(body); errValidate != nil { return cliproxyexecutor.Response{}, errValidate } @@ -129,6 +149,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy fp := resolveClaudeFingerprintPolicy(e.cfg, auth, apiKey) from := opts.SourceFormat + ctx, req, opts = e.applyNativeToolSearchFallback(ctx, auth, from, upstreamModel, req, opts) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("claude") originalPayload := req.Payload @@ -136,20 +157,31 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy originalPayload = opts.OriginalRequest } incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, true, e.cfg) - confirmedClaudeCode := claudeCodeDetection.Confirmed + initialPolicy, _ := resolveClaudeWirePolicy(e.cfg, auth, apiKey, claudeCodeDetection.Confirmed) + confirmedClaudeCode := isEffectiveConfirmedClaudeCode(claudeCodeDetection, initialPolicy) claudeSessionID := "" if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } // Use streaming translation to preserve function calling, except for claude. stream := from != to - body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) + body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) + if err != nil { + return cliproxyexecutor.Response{}, err + } + originalTranslatedForPayload := append([]byte(nil), body...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, stream, helps.APIKeyModelIsCompat(req)) + } body = helps.SetStringIfDifferent(body, "model", upstreamModel) + body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body) var errThinking error body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if errThinking != nil { return cliproxyexecutor.Response{}, errThinking } + webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload) + body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL) if rebuildMidSystemMessageEnabled(e.cfg, auth) { body = rebuildMidSystemMessagesToTopLevel(body) } @@ -162,21 +194,26 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy // Messages path. Kimi opt-in uses the same contract. policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) cloaked := policy.Cloak + explicitCacheMode := isExplicitPromptCacheMode(originalPayload, req.Payload, body) if cloaked { if !settings.strictMode { if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(body, "system")); errSystem != nil { return cliproxyexecutor.Response{}, errSystem } } - body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode) + body = relocateClaudeSystemPromptForCountTokensWithPolicy(body, settings.strictMode, explicitCacheMode, !policy.OAuth) if len(settings.sensitiveWords) > 0 { body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords)) } + } else if policy.Uncloak { + body = uncloakClaudeCodePayload(body, confirmedClaudeCode) } // Keep count_tokens requests compatible with Anthropic cache-control constraints too. body = enforceCacheControlLimit(body, 4) - body = normalizeCacheControlTTL(body) + if !explicitCacheMode { + body = normalizeCacheControlTTL(body) + } // Extract betas from body and convert to header (for count_tokens too) var extraBetas []string @@ -184,10 +221,12 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy // Claude Code 2.1.220's beta.messages.countTokens() always appends this beta. extraBetas = append(extraBetas, claudeTokenCountingBeta) if fp.MCPAlias && cloaked { - mcpAliases := resolveClaudeMCPAliasOptions(ctx) + mcpAliases := resolveClaudeMCPAliasOptions(ctx, opts.Metadata, req.Metadata) body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases) } body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req)) + body = applyClaudePreservedThinkingBinding(body) + body = stripPromptCacheOptions(body) // Two different reasons converge on the same deletions, and they must stay // separable. // @@ -217,6 +256,8 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy if errMidSystem := validateClaudeMidSystemMessageModel(body, confirmedClaudeCode, directAnthropic); errMidSystem != nil { return cliproxyexecutor.Response{}, errMidSystem } + body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body) + body = stripPromptCacheOptions(body) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return cliproxyexecutor.Response{}, err diff --git a/internal/runtime/executor/claude_executor_tool_state.go b/internal/runtime/executor/claude_executor_tool_state.go new file mode 100644 index 000000000..ccb457fb1 --- /dev/null +++ b/internal/runtime/executor/claude_executor_tool_state.go @@ -0,0 +1,150 @@ +package executor + +import ( + "net/http" + "sync" + + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/tidwall/gjson" +) + +const claudeOAuthToolAliasStateLimit = 1024 + +// claudeOAuthToolAliasStoreInitMu guards lazy allocation for executors built +// without a constructor. It lives outside ClaudeExecutor because ForAPIKey +// copies the executor by value, and a mutex field would be copied with it. +var claudeOAuthToolAliasStoreInitMu sync.Mutex + +type claudeOAuthToolAliasStore struct { + mu sync.Mutex + entries map[string]map[string]string + order []string +} + +func (s *claudeOAuthToolAliasStore) load(keys []string) (map[string]string, bool) { + if s == nil { + return nil, false + } + s.mu.Lock() + defer s.mu.Unlock() + for _, key := range keys { + aliases, ok := s.entries[key] + if !ok { + continue + } + return cloneClaudeOAuthToolAliasMap(aliases), true + } + return nil, false +} + +func (s *claudeOAuthToolAliasStore) save(keys []string, aliases map[string]string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.entries == nil { + s.entries = make(map[string]map[string]string) + } + for _, key := range keys { + if key == "" { + continue + } + if _, exists := s.entries[key]; !exists { + s.order = append(s.order, key) + } + s.entries[key] = cloneClaudeOAuthToolAliasMap(aliases) + } + for len(s.order) > claudeOAuthToolAliasStateLimit { + oldest := s.order[0] + s.order = s.order[1:] + delete(s.entries, oldest) + } +} + +func cloneClaudeOAuthToolAliasMap(source map[string]string) map[string]string { + if len(source) == 0 { + return map[string]string{} + } + clone := make(map[string]string, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func (e *ClaudeExecutor) claudeOAuthToolAliasStore() *claudeOAuthToolAliasStore { + claudeOAuthToolAliasStoreInitMu.Lock() + defer claudeOAuthToolAliasStoreInitMu.Unlock() + if e.oauthToolAliases == nil { + e.oauthToolAliases = &claudeOAuthToolAliasStore{} + } + return e.oauthToolAliases +} + +func claudeOAuthToolAliasKeys(payload []byte, messageID string) []string { + keys := make([]string, 0, 2) + if previousMessageID := gjson.GetBytes(payload, "thread.previous_message_id").String(); previousMessageID != "" { + keys = append(keys, "message:"+previousMessageID) + } + if messageID != "" { + keys = append(keys, "message:"+messageID) + } + return keys +} + +func claudeThreadContinuationNeedsAliasState(payload []byte) bool { + if gjson.GetBytes(payload, "thread.type").String() != "continue" { + return false + } + previousMessageID := gjson.GetBytes(payload, "thread.previous_message_id") + if !previousMessageID.Exists() || previousMessageID.String() == "" { + return false + } + tools := gjson.GetBytes(payload, "tools") + return !tools.Exists() || !tools.IsArray() || len(tools.Array()) == 0 +} + +func (e *ClaudeExecutor) prepareClaudeOAuthToolNamesForRequest(payload []byte, options claudeMCPAliasOptions) ([]byte, map[string]string, error) { + if claudeThreadContinuationNeedsAliasState(payload) { + aliases, ok := e.claudeOAuthToolAliasStore().load(claudeOAuthToolAliasKeys(payload, "")) + if !ok { + return nil, nil, newClaudeThreadNotFoundError() + } + return payload, aliases, nil + } + remapped, aliases := prepareClaudeOAuthToolNamesForUpstream(payload, options) + return remapped, aliases, nil +} + +func (e *ClaudeExecutor) rememberClaudeOAuthToolAliases(payload []byte, aliases map[string]string, messageID string) { + if gjson.GetBytes(payload, "thread.type").String() == "" { + return + } + keys := claudeOAuthToolAliasKeys(payload, messageID) + if len(keys) == 0 { + return + } + e.claudeOAuthToolAliasStore().save(keys, aliases) +} + +const claudeThreadNotFoundErrorMessage = "No thread state was found for the requested previous_message_id. Replay the full conversation with thread create to start a new Thread." + +type claudeThreadNotFoundError struct { + statusErr +} + +func newClaudeThreadNotFoundError() claudeThreadNotFoundError { + return claudeThreadNotFoundError{statusErr: statusErr{ + code: http.StatusNotFound, + msg: claudeThreadNotFoundErrorMessage, + }} +} + +func (claudeThreadNotFoundError) IsRequestScoped() bool { return true } + +func (claudeThreadNotFoundError) ResponseBody() []byte { + return []byte(`{"type":"error","error":{"type":"not_found_error","message":"` + claudeThreadNotFoundErrorMessage + `"}}`) +} + +var _ cliproxyexecutor.RequestScopedError = claudeThreadNotFoundError{} diff --git a/internal/runtime/executor/claude_fingerprint_policy.go b/internal/runtime/executor/claude_fingerprint_policy.go index 6f41319d5..05e8e1100 100644 --- a/internal/runtime/executor/claude_fingerprint_policy.go +++ b/internal/runtime/executor/claude_fingerprint_policy.go @@ -99,6 +99,12 @@ func resolveClaudeFingerprintPolicy(cfg *config.Config, auth *cliproxyauth.Auth, authIsOAuth := isClaudeOAuthToken(apiKey) profile := claudeFingerprintProfileFromConfig(cfg, auth) profileClaudeCodeCLI := authIsOAuth || profile == claudeFingerprintProfileClaudeCodeCLI + // header-defaults.inject-diagnostics=false opts out of the injected + // diagnostics field, and with it the body-derived cache-diagnosis beta. + injectDiagnostics := profileClaudeCodeCLI + if cfg != nil && cfg.ClaudeHeaderDefaults.InjectDiagnostics != nil && !*cfg.ClaudeHeaderDefaults.InjectDiagnostics { + injectDiagnostics = false + } return claudeFingerprintPolicy{ AuthIsOAuthToken: authIsOAuth, @@ -107,7 +113,7 @@ func resolveClaudeFingerprintPolicy(cfg *config.Config, auth *cliproxyauth.Auth, ApplyCLIIdentity: profileClaudeCodeCLI, SynthesizeIdentity: profileClaudeCodeCLI && !authIsOAuth, MCPAlias: profileClaudeCodeCLI, - InjectDiagnostics: profileClaudeCodeCLI, + InjectDiagnostics: injectDiagnostics, OAuthCancellation: authIsOAuth, } } diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go index 6d5a4c0df..7acdd393d 100644 --- a/internal/runtime/executor/helps/claude_client_detection.go +++ b/internal/runtime/executor/helps/claude_client_detection.go @@ -60,12 +60,13 @@ var claudeCodeSubclientByEntrypoint = map[string]string{ "claude-coworker-terminal": "claude-coworker-terminal", } -// Only product surfaces with verified 2.1.220 wire behavior are eligible for +// Only product surfaces with verified 2.1.220+ wire behavior are eligible for // pass-through. Other first-party-looking entrypoints are cloaked until their // CPA-reachable request shape has been captured and reviewed. var nativeClaudeEntrypoints = map[string]bool{ "cli": true, "sdk-cli": true, + "sdk-ts": true, "claude-vscode": true, } @@ -117,6 +118,7 @@ var measuredClaudeCodeHelperBetaProfiles = map[string]claudeCodeHelperShape{ // a client that only copied its User-Agent. type ClaudeCodeRequestDetection struct { Confirmed bool + ConfirmedShape bool StrongSignals bool NativeClient bool XAppCLI bool @@ -133,8 +135,8 @@ type ClaudeCodeRequestDetection struct { // applies CPA's native-client policy. Standard Messages requests require all // four strong signals; count_tokens omits metadata.user_id. A separate narrow // profile recognizes measured native Haiku helper requests that intentionally -// omit claude-code-20250219. Generic sdk-ts/sdk-py Agent SDK entrypoints remain -// unconfirmed and receive CLI cloaking. +// omit claude-code-20250219. Unverified entrypoints (such as generic sdk-py) +// remain unconfirmed by default and receive CLI cloaking unless cloak_mode is never. func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bool, configs ...*config.Config) ClaudeCodeRequestDetection { var cfg *config.Config if len(configs) > 0 { @@ -158,6 +160,11 @@ func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bo detection.HelperProfile = detection.NativeClient && matchesMeasuredClaudeCodeHelperProfile(headers, payload, countTokens, detection, cfg) detection.StrongSignals = standardSignals || detection.HelperProfile detection.Confirmed = detection.StrongSignals && detection.NativeClient + detection.ConfirmedShape = detection.StrongSignals || + (!countTokens && detection.XAppCLI && detection.UserAgent && detection.MetadataUserID && + claudeCodeSubclientByEntrypoint[entrypoint] != "" && + measuredClaudeCodeHaiku55HelperSessionMatches(headers, payload) && + measuredClaudeCodeBillingAndIdentityMatch(payload, entrypoint)) return detection } @@ -183,7 +190,6 @@ func matchesMeasuredClaudeCodeHelperProfile( cfg *config.Config, ) bool { if countTokens || - detection.Entrypoint != "cli" || detection.BetasPresent || !detection.XAppCLI || !detection.UserAgent || @@ -191,14 +197,163 @@ func matchesMeasuredClaudeCodeHelperProfile( return false } - shape := measuredClaudeCodeHelperBetaProfiles[normalizedClaudeBetaHeader(headers)] - if shape == claudeCodeHelperShapeNone || measuredClaudeCodeHelperBodyShape(payload) != shape { + if detection.Entrypoint == "cli" { + shape := measuredClaudeCodeHelperBetaProfiles[normalizedClaudeBetaHeader(headers)] + if shape != claudeCodeHelperShapeNone && + measuredClaudeCodeHelperBodyShape(payload) == shape && + measuredClaudeCodeHelperHeadersMatch(headers, cfg, shape) && + measuredClaudeCodeHelperSessionMatches(headers, payload) { + return true + } + } + return matchesMeasuredClaudeCodeHaiku55HelperProfile(headers, payload, detection, cfg) +} + +func matchesMeasuredClaudeCodeHaiku55HelperProfile( + headers http.Header, + payload []byte, + detection ClaudeCodeRequestDetection, + cfg *config.Config, +) bool { + if !detection.NativeClient || gjson.GetBytes(payload, "model").String() != "claude-haiku-5-5" { + return false + } + if !measuredClaudeCodeHaiku55HelperHeadersMatch(headers, cfg) { + return false + } + if !measuredClaudeCodeHaiku55HelperSessionMatches(headers, payload) { + return false + } + return measuredClaudeCodeHaiku55HelperBodyMatches(payload, detection.Entrypoint) +} + +func measuredClaudeCodeHaiku55HelperHeadersMatch(headers http.Header, cfg *config.Config) bool { + profile := defaultClaudeDeviceProfile(cfg) + expected := map[string]string{ + "Accept": "application/json", + "Content-Type": "application/json", + "X-Stainless-Lang": "js", + "X-Stainless-Runtime": "node", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Timeout": claudeDefaultStainlessTimeout, + "Anthropic-Version": claudeAnthropicVersion, + "Anthropic-Dangerous-Direct-Browser-Access": "true", + } + for name, want := range expected { + if headerValue(headers, name) != want { + return false + } + } + for _, name := range []string{ + "X-Stainless-Package-Version", + "X-Stainless-Runtime-Version", + "X-Stainless-OS", + "X-Stainless-Arch", + } { + if headerValue(headers, name) == "" { + return false + } + } + if !plausibleClaudeCodeUserAgent(headerValue(headers, "User-Agent"), cfg) || + !plausibleClaudePackageVersion(headerValue(headers, "X-Stainless-Package-Version"), profile.PackageVersion) || + !plausibleClaudeRuntimeVersion(headerValue(headers, "X-Stainless-Runtime-Version"), profile.RuntimeVersion) { + return false + } + if headerValue(headers, "X-Stainless-Async") != "" { + return false + } + if headerValue(headers, "Accept-Encoding") != "gzip, deflate, br, zstd" { + return false + } + if requestID := headerValue(headers, "X-Client-Request-Id"); requestID != "" { + if _, errRequestID := uuid.Parse(requestID); errRequestID != nil { + return false + } + } + betaHeader := normalizedClaudeBetaHeader(headers) + return strings.Contains(betaHeader, "interleaved-thinking-2025-05-14") && + strings.Contains(betaHeader, "thinking-token-count-2026-05-13") && + strings.Contains(betaHeader, "context-management-2025-06-27") && + strings.Contains(betaHeader, "prompt-caching-scope-2026-01-05") +} + +func measuredClaudeCodeHaiku55HelperSessionMatches(headers http.Header, payload []byte) bool { + metadata := gjson.GetBytes(payload, "metadata") + if !metadata.IsObject() || !claudeJSONObjectHasKeys([]byte(metadata.Raw), []string{"user_id"}) { + return false + } + userID := metadata.Get("user_id") + if userID.Type != gjson.String || !isValidUserID(userID.String()) { + return false + } + var identityMap map[string]json.RawMessage + if errUnmarshal := json.Unmarshal([]byte(userID.String()), &identityMap); errUnmarshal != nil { + return false + } + for key := range identityMap { + switch key { + case "device_id", "account_uuid", "session_id", "parent_session_id": + default: + return false + } + } + if len(identityMap) < 3 { + return false + } + return headerValue(headers, ClaudeCodeSessionHeader) == gjson.GetBytes([]byte(userID.String()), "session_id").String() +} + +func measuredClaudeCodeHaiku55HelperBodyMatches(payload []byte, entrypoint string) bool { + if gjson.GetBytes(payload, "max_tokens").Type != gjson.Number { + return false + } + messages := gjson.GetBytes(payload, "messages") + if !messages.IsArray() || len(messages.Array()) != 1 || messages.Get("0.role").String() != "user" { + return false + } + if !measuredClaudeCodeBillingAndIdentityMatch(payload, entrypoint) { + return false + } + tools := gjson.GetBytes(payload, "tools") + if !tools.IsArray() { + return false + } + toolItems := tools.Array() + if len(toolItems) == 0 { + format := gjson.GetBytes(payload, "output_config.format") + required := format.Get("schema.required") + return format.Get("type").String() == "json_schema" && + format.Get("schema.type").String() == "object" && + format.Get("schema.properties.title.type").String() == "string" && + required.IsArray() && len(required.Array()) == 1 && required.Get("0").String() == "title" && + format.Get("schema.additionalProperties").Type == gjson.False + } + if len(toolItems) == 1 { + return toolItems[0].Get("name").String() == "web_search" && + strings.HasPrefix(toolItems[0].Get("type").String(), "web_search_") + } + return false +} + +func measuredClaudeCodeBillingAndIdentityMatch(payload []byte, entrypoint string) bool { + system := gjson.GetBytes(payload, "system") + if !system.IsArray() || len(system.Array()) < 2 { + return false + } + billing := system.Get("0.text").String() + if !strings.HasPrefix(billing, "x-anthropic-billing-header:") || !strings.Contains(billing, "cc_version=") { + return false + } + if entrypoint != "" && !strings.Contains(billing, "cc_entrypoint="+entrypoint) { return false } - if !measuredClaudeCodeHelperHeadersMatch(headers, cfg, shape) { + if strings.Contains(billing, " cch=") && !measuredClaudeBillingCCH(billing) { return false } - return measuredClaudeCodeHelperSessionMatches(headers, payload) + identity := strings.TrimSpace(system.Get("1.text").String()) + return identity == "You are Claude Code, Anthropic's official CLI for Claude." || + identity == "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." || + identity == "You are a Claude agent, built on Anthropic's Claude Agent SDK." } // normalizedClaudeBetaHeader joins every Anthropic-Beta value in wire order. @@ -496,6 +651,11 @@ func claudeJSONObjectHasKeys(raw []byte, want []string) bool { return errClosing == nil && closing == json.Delim('}') && keyIndex == len(want) } +// IsClaudeCodeUserAgent reports whether userAgent identifies a Claude Code CLI client. +func IsClaudeCodeUserAgent(userAgent string) bool { + return claudeCodeUserAgentPattern.MatchString(strings.TrimSpace(userAgent)) +} + func plausibleClaudeCodeUserAgent(userAgent string, cfg *config.Config) bool { userAgent = strings.TrimSpace(userAgent) if !claudeCodeUserAgentPattern.MatchString(userAgent) || !claudeCodeNativeUserAgentPattern.MatchString(userAgent) { diff --git a/internal/runtime/executor/helps/claude_device_profile.go b/internal/runtime/executor/helps/claude_device_profile.go index 6a9502e7f..8c2a9e6f9 100644 --- a/internal/runtime/executor/helps/claude_device_profile.go +++ b/internal/runtime/executor/helps/claude_device_profile.go @@ -221,6 +221,52 @@ func plausibleClaudeCLIVersion(candidate, baseline claudeCLIVersion) bool { candidate.patch >= baseline.patch } +func parseClaudeSemver(raw string) (claudeCLIVersion, bool) { + parts := strings.Split(strings.TrimSpace(raw), ".") + if len(parts) != 3 { + return claudeCLIVersion{}, false + } + major, errMajor := strconv.Atoi(parts[0]) + if errMajor != nil { + return claudeCLIVersion{}, false + } + minor, errMinor := strconv.Atoi(parts[1]) + if errMinor != nil { + return claudeCLIVersion{}, false + } + patch, errPatch := strconv.Atoi(parts[2]) + if errPatch != nil { + return claudeCLIVersion{}, false + } + return claudeCLIVersion{major: major, minor: minor, patch: patch}, true +} + +func plausibleClaudePackageVersion(candidate, baseline string) bool { + candidate = strings.TrimSpace(candidate) + if !claudePackageVersionPattern.MatchString(candidate) { + return false + } + candVer, okCand := parseClaudeSemver(candidate) + baseVer, okBase := parseClaudeSemver(baseline) + if !okCand || !okBase { + return candidate == strings.TrimSpace(baseline) + } + return candVer.Compare(baseVer) >= 0 +} + +func plausibleClaudeRuntimeVersion(candidate, baseline string) bool { + candidate = strings.TrimSpace(candidate) + if !claudeRuntimeVersionPattern.MatchString(candidate) { + return false + } + candVer, okCand := parseClaudeSemver(strings.TrimPrefix(candidate, "v")) + baseVer, okBase := parseClaudeSemver(strings.TrimPrefix(strings.TrimSpace(baseline), "v")) + if !okCand || !okBase { + return candidate == strings.TrimSpace(baseline) + } + return candVer.Compare(baseVer) >= 0 +} + func meetsClaudeDeviceProfileBaseline(candidate, baseline ClaudeDeviceProfile) bool { if candidate.UserAgent == "" || !candidate.hasVersion { return false @@ -602,10 +648,11 @@ func ApplyClaudeDefaultDeviceProfileHeaders(r *http.Request, cfg *config.Config) ApplyClaudeDeviceProfileHeaders(r, defaultClaudeDeviceProfile(cfg)) } -func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config, confirmedClaudeCode bool) { +func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config, confirmedClaudeCode bool, keepNewerVersions ...bool) { if r == nil { return } + allowNewer := len(keepNewerVersions) > 0 && keepNewerVersions[0] profile := defaultClaudeDeviceProfile(cfg) miscEnsure := func(name, fallback string, valid func(string) bool) { if current := strings.TrimSpace(r.Header.Get(name)); current != "" && (valid == nil || valid(current)) { @@ -619,8 +666,20 @@ func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg } if confirmedClaudeCode { - miscEnsure("X-Stainless-Runtime-Version", profile.RuntimeVersion, func(value string) bool { return value == profile.RuntimeVersion }) - miscEnsure("X-Stainless-Package-Version", profile.PackageVersion, func(value string) bool { return value == profile.PackageVersion }) + validRuntime := func(value string) bool { + if !allowNewer && cfg != nil && strings.TrimSpace(cfg.ClaudeHeaderDefaults.RuntimeVersion) != "" { + return value == profile.RuntimeVersion + } + return plausibleClaudeRuntimeVersion(value, profile.RuntimeVersion) + } + validPackage := func(value string) bool { + if !allowNewer && cfg != nil && strings.TrimSpace(cfg.ClaudeHeaderDefaults.PackageVersion) != "" { + return value == profile.PackageVersion + } + return plausibleClaudePackageVersion(value, profile.PackageVersion) + } + miscEnsure("X-Stainless-Runtime-Version", profile.RuntimeVersion, validRuntime) + miscEnsure("X-Stainless-Package-Version", profile.PackageVersion, validPackage) miscEnsure("X-Stainless-Os", mapStainlessOS(), nil) miscEnsure("X-Stainless-Arch", mapStainlessArch(), nil) if clientUA := strings.TrimSpace(ginHeaders.Get("User-Agent")); plausibleClaudeCodeUserAgent(clientUA, cfg) { diff --git a/internal/runtime/executor/helps/claude_diagnostics.go b/internal/runtime/executor/helps/claude_diagnostics.go index c43fe6cfe..8e93f1fbc 100644 --- a/internal/runtime/executor/helps/claude_diagnostics.go +++ b/internal/runtime/executor/helps/claude_diagnostics.go @@ -119,13 +119,52 @@ type claudeDiagnosticsEntry struct { expiresAt time.Time } +// claudeSessionDatePin retains a session date independently of continuity +// state when the operator configures a retention window. +type claudeSessionDatePin struct { + date string + expiresAt time.Time +} + var claudeDiagnosticsState = struct { sync.Mutex entries map[string]claudeDiagnosticsEntry + datePins map[string]claudeSessionDatePin lastCleanup time.Time nextSequence uint64 nextAccess uint64 -}{entries: make(map[string]claudeDiagnosticsEntry)} +}{entries: make(map[string]claudeDiagnosticsEntry), datePins: make(map[string]claudeSessionDatePin)} + +// ClaudeSessionDatePinKey returns the credential-independent store key +// for a session date pin, or "" for an empty session ID. +func ClaudeSessionDatePinKey(sessionID string) string { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return "" + } + digest := sha256.Sum256([]byte(sessionID)) + return hex.EncodeToString(digest[:]) +} + +// LookupClaudeSessionDatePin returns the retained pinned date for a session +// without creating or refreshing it. Non-positive retention returns "". +func LookupClaudeSessionDatePin(sessionID string, retention time.Duration) string { + if retention <= 0 { + return "" + } + pinKey := ClaudeSessionDatePinKey(sessionID) + if pinKey == "" { + return "" + } + now := time.Now() + claudeDiagnosticsState.Lock() + defer claudeDiagnosticsState.Unlock() + pin, okPin := claudeDiagnosticsState.datePins[pinKey] + if !okPin || now.After(pin.expiresAt) { + return "" + } + return pin.date +} // IsValidClaudePromptID verifies whether an explicit prompt ID adheres to strict RFC 4122 UUIDv4 semantics. func IsValidClaudePromptID(id string) bool { @@ -197,32 +236,47 @@ func BeginClaudeContinuity(credentialIdentity, sessionID string, isNewPromptTurn return key, sequence, entry.previousMessageID, entry.previousRequestID, activePromptID } -// PinClaudeSessionDate returns the calendar date pinned for this continuity -// session, recording date on the first call of a session and returning the -// pinned value on later calls. This keeps the cloaked currentDate reminder -// byte-stable within a session so a local-midnight flip cannot invalidate the -// prompt-cache prefix. TTL expiry resets the entry, which re-anchors the next -// request of the same session to the then-current date. An unknown key (for -// example after a process restart) returns date unchanged, matching the -// per-request behaviour. -func PinClaudeSessionDate(key, date string) string { +// PinClaudeSessionDate returns the calendar date pinned for a session, +// recording date on the first call and returning the pinned value on later +// calls. A non-positive retention keeps the legacy continuity-scoped pin, +// whose lifetime follows claudeDiagnosticsTTL. A positive retention stores +// the pin separately under a session-only key, so continuity expiry or a +// credential switch cannot re-anchor it before the retention expires. An +// unknown continuity key returns date unchanged, matching per-request dates. +func PinClaudeSessionDate(key, sessionID, date string, retention time.Duration) string { key = strings.TrimSpace(key) + sessionID = strings.TrimSpace(sessionID) date = strings.TrimSpace(date) if key == "" || date == "" { return date } + now := time.Now() claudeDiagnosticsState.Lock() defer claudeDiagnosticsState.Unlock() - entry, ok := claudeDiagnosticsState.entries[key] - if !ok { + entry, okEntry := claudeDiagnosticsState.entries[key] + if !okEntry { return date } - if entry.pinnedDate == "" { - entry.pinnedDate = date - claudeDiagnosticsState.entries[key] = entry - return date + if retention <= 0 || sessionID == "" { + if entry.pinnedDate == "" { + entry.pinnedDate = date + claudeDiagnosticsState.entries[key] = entry + return date + } + return entry.pinnedDate } - return entry.pinnedDate + cleanupClaudeDiagnosticsLocked(now) + pinKey := ClaudeSessionDatePinKey(sessionID) + pin, okPin := claudeDiagnosticsState.datePins[pinKey] + if !okPin { + evictClaudeSessionDatePinsLocked() + } + if !okPin || now.After(pin.expiresAt) { + pin.date = date + } + pin.expiresAt = now.Add(retention) + claudeDiagnosticsState.datePins[pinKey] = pin + return pin.date } // BeginClaudeDiagnostics starts one request generation for a stable credential @@ -287,9 +341,37 @@ func cleanupClaudeDiagnosticsLocked(now time.Time) { delete(claudeDiagnosticsState.entries, key) } } + for key, pin := range claudeDiagnosticsState.datePins { + if now.After(pin.expiresAt) { + delete(claudeDiagnosticsState.datePins, key) + } + } claudeDiagnosticsState.lastCleanup = now } +// evictClaudeSessionDatePinsLocked drops the pins closest to expiry once +// the store is full. Sliding expiry makes those the least recently used. +func evictClaudeSessionDatePinsLocked() { + if len(claudeDiagnosticsState.datePins) < claudeDiagnosticsMaxEntries { + return + } + type candidate struct { + key string + expiresAt time.Time + } + candidates := make([]candidate, 0, len(claudeDiagnosticsState.datePins)) + for key, pin := range claudeDiagnosticsState.datePins { + candidates = append(candidates, candidate{key: key, expiresAt: pin.expiresAt}) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].expiresAt.Before(candidates[j].expiresAt) + }) + count := min(claudeDiagnosticsEvictBatchSize, len(candidates)) + for _, candidate := range candidates[:count] { + delete(claudeDiagnosticsState.datePins, candidate.key) + } +} + func evictClaudeDiagnosticsLocked() { if len(claudeDiagnosticsState.entries) < claudeDiagnosticsMaxEntries { return @@ -316,10 +398,49 @@ func ResetClaudeDiagnosticsForTest() { resetClaudeDiagnosticsForTest() } +// ClaudeSessionDatePinExpiryForTest returns the expiry of the retained date +// pin for a session, or the zero time when no pin exists. +func ClaudeSessionDatePinExpiryForTest(sessionID string) time.Time { + pinKey := ClaudeSessionDatePinKey(sessionID) + if pinKey == "" { + return time.Time{} + } + claudeDiagnosticsState.Lock() + defer claudeDiagnosticsState.Unlock() + pin, okPin := claudeDiagnosticsState.datePins[pinKey] + if !okPin { + return time.Time{} + } + return pin.expiresAt +} + +// ExpireClaudeDiagnosticsForTest expires continuity entries and retained date +// pins without waiting for wall-clock TTLs. Pin expiry is relative to now. +func ExpireClaudeDiagnosticsForTest(expireContinuity bool, pinExpiryOffset *time.Duration) { + claudeDiagnosticsState.Lock() + defer claudeDiagnosticsState.Unlock() + if expireContinuity { + past := time.Now().Add(-time.Second) + for key, entry := range claudeDiagnosticsState.entries { + entry.expiresAt = past + claudeDiagnosticsState.entries[key] = entry + } + } + if pinExpiryOffset != nil { + expiresAt := time.Now().Add(*pinExpiryOffset) + for key, pin := range claudeDiagnosticsState.datePins { + pin.expiresAt = expiresAt + claudeDiagnosticsState.datePins[key] = pin + } + } + claudeDiagnosticsState.lastCleanup = time.Time{} +} + func resetClaudeDiagnosticsForTest() { claudeDiagnosticsState.Lock() defer claudeDiagnosticsState.Unlock() claudeDiagnosticsState.entries = make(map[string]claudeDiagnosticsEntry) + claudeDiagnosticsState.datePins = make(map[string]claudeSessionDatePin) claudeDiagnosticsState.lastCleanup = time.Time{} claudeDiagnosticsState.nextSequence = 0 claudeDiagnosticsState.nextAccess = 0 diff --git a/internal/runtime/executor/helps/claude_ratelimit.go b/internal/runtime/executor/helps/claude_ratelimit.go index 12264fcd3..ff1c7f27e 100644 --- a/internal/runtime/executor/helps/claude_ratelimit.go +++ b/internal/runtime/executor/helps/claude_ratelimit.go @@ -2,6 +2,7 @@ package helps import ( cryptorand "crypto/rand" + "encoding/json" "math" "math/big" "net/http" @@ -15,6 +16,10 @@ import ( const ( defaultClaudeRateLimitFuzzMinSeconds = 1 defaultClaudeRateLimitFuzzMaxSeconds = 30 + // maxClaudeRateLimitWindow is the longest Anthropic rate-limit window (7 days) + // plus a small clock-skew allowance. A later header timestamp is a billing + // boundary, not a recoverable rate limit. + maxClaudeRateLimitWindow = 7*24*time.Hour + time.Hour ) // ClaudeHeadersIndicateUnifiedRateLimitRejection reports whether response headers explicitly @@ -38,14 +43,14 @@ func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { return false } status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status"))) - return !isOverageOrFableOnlyRejection(headers, status5h, status7d, status7dOI) + return !isOverageOrFableOnlyRejection(headers, unifiedStatus, status5h, status7d, status7dOI) } func isClaudeWindowAllowed(status string) bool { return status == "allowed" || status == "allowed_warning" } -func isOverageOrFableOnlyRejection(headers http.Header, status5h, status7d, status7dOI string) bool { +func isOverageOrFableOnlyRejection(headers http.Header, unifiedStatus, status5h, status7d, status7dOI string) bool { if status5h == "rejected" || status7d == "rejected" { return false } @@ -85,6 +90,20 @@ func isOverageOrFableOnlyRejection(headers http.Header, status5h, status7d, stat } } + // Anthropic omits both shared-window headers when it did not evaluate them. + // Only a rejected overage claim uses that absence. An allowed or missing + // unified status must still honor a short Retry-After. Other signals + // (overage disabled, overage status rejected) do not widen this exception. + if unifiedStatus == "rejected" && status5h == "" && status7d == "" && strings.Contains(representativeClaim, "overage") { + util5h := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Utilization") + util7d := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Utilization") + util5hAbsentOrHealthy := strings.TrimSpace(util5h) == "" || isClaudeUtilizationHealthy(util5h) + util7dAbsentOrHealthy := strings.TrimSpace(util7d) == "" || isClaudeUtilizationHealthy(util7d) + if util5hAbsentOrHealthy && util7dAbsentOrHealthy { + return true + } + } + return false } @@ -100,6 +119,106 @@ func isClaudeUtilizationHealthy(raw string) bool { return u >= 0 && u < 1.0 } +// claudeQuotaStatusHeaders are the Anthropic headers whose non-empty value, under any +// casing or in any repeated entry, declares a quota window. An overage status declares +// its own window, which keeps the conservative parser in charge. +var claudeQuotaStatusHeaders = []string{ + "Anthropic-Ratelimit-Unified-Status", + "Anthropic-Ratelimit-Unified-5h-Status", + "Anthropic-Ratelimit-Unified-7d-Status", + "Anthropic-Ratelimit-Unified-7d_oi-Status", + "Anthropic-Ratelimit-Unified-Overage-Status", +} + +// ParseClaudeRateLimitResetForError is ParseClaudeRateLimitReset with one narrow, +// body-aware exception. An explicit per-minute rate-limit error carries no quota +// window, so an unclassified unified reset must not stretch its short Retry-After +// into an hours-long cooldown. The exception applies only when the body is exactly +// one complete per-minute message, no status header declares a quota window, and +// Retry-After is a single unambiguous value usable by the downstream deadline +// filter. Every other input keeps the conservative header-only result. +func ParseClaudeRateLimitResetForError(headers http.Header, body []byte, now time.Time) *time.Duration { + if headers == nil || !claudeBodyIsExplicitPerMinuteRateLimit(body) || claudeHeadersHaveQuotaStatus(headers) { + return ParseClaudeRateLimitReset(headers, now) + } + rawRetryAfter, ok := singleHeaderValueCaseInsensitive(headers, "Retry-After") + if !ok { + return ParseClaudeRateLimitReset(headers, now) + } + retryAt, okRetry := parseRetryAfterHeader(rawRetryAfter, now) + if !okRetry || !retryAt.After(now) || retryAt.Sub(now) > maxClaudeRateLimitWindow { + return ParseClaudeRateLimitReset(headers, now) + } + // Only the unclassified unified reset can stretch the cooldown here: every + // window-specific reset is consulted solely when its window is rejected. + rateHeaders := headers.Clone() + for name := range rateHeaders { + if strings.EqualFold(name, "Anthropic-Ratelimit-Unified-Reset") { + delete(rateHeaders, name) + } + } + return ParseClaudeRateLimitReset(rateHeaders, now) +} + +// claudeBodyIsExplicitPerMinuteRateLimit reports whether body is exactly one of the +// complete per-minute rate-limit messages, ignoring case and surrounding whitespace. +// Substring matching is unsafe here: a per-minute mention can also be negated or +// accompany genuine quota exhaustion. +func claudeBodyIsExplicitPerMinuteRateLimit(body []byte) bool { + var envelope struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if errUnmarshal := json.Unmarshal(body, &envelope); errUnmarshal != nil || envelope.Error.Type != "rate_limit_error" { + return false + } + switch strings.ToLower(strings.TrimSpace(envelope.Error.Message)) { + case "this request would exceed the requests per minute rate limit. please try again later.", + "this request would exceed the input tokens per minute rate limit. please try again later.", + "this request would exceed the output tokens per minute rate limit. please try again later.": + return true + default: + return false + } +} + +func claudeHeadersHaveQuotaStatus(headers http.Header) bool { + for _, name := range claudeQuotaStatusHeaders { + for key, values := range headers { + if !strings.EqualFold(key, name) { + continue + } + for _, value := range values { + if strings.TrimSpace(value) != "" { + return true + } + } + } + } + return false +} + +// singleHeaderValueCaseInsensitive returns the sole value of target. A repeated value +// or a differently cased alias key is ambiguous even when the values agree, because a +// canonical lookup could then consume a different value than the one validated. +func singleHeaderValueCaseInsensitive(headers http.Header, target string) (string, bool) { + value := "" + found := false + for key, values := range headers { + if !strings.EqualFold(key, target) { + continue + } + if found || len(values) != 1 { + return "", false + } + found = true + value = values[0] + } + return value, found +} + // ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific // unified rate-limit and standard Retry-After reset information, returning the conservative cooldown // duration including a bounded non-negative random grace period. @@ -117,7 +236,7 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status"))) status7d := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Status"))) status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status"))) - overageOnlyRejection := isOverageOrFableOnlyRejection(headers, status5h, status7d, status7dOI) + overageOnlyRejection := isOverageOrFableOnlyRejection(headers, unifiedStatus, status5h, status7d, status7dOI) var candidateDeadlines []time.Time var rejectedWindows []string @@ -183,12 +302,18 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu if !containsString(rejectedWindows, "unified") { rejectedWindows = append(rejectedWindows, "unified") } - if t, ok := parseUnixOrTimestamp(raw); ok && t.After(now) { - candidateDeadlines = append(candidateDeadlines, t) + // When the representative claim is overage and this timestamp matches + // the overage reset, it is the billing-period boundary, not a rate-limit + // recovery time. + if !claudeUnifiedResetIsOverageBillingBoundary(headers, raw) { + if resetAt, ok := parseUnixOrTimestamp(raw); ok && resetAt.After(now) { + candidateDeadlines = append(candidateDeadlines, resetAt) + } } } } + candidateDeadlines = filterClaudeRateLimitDeadlines(candidateDeadlines, now) if len(candidateDeadlines) == 0 { if len(rejectedWindows) > 0 { log.WithFields(log.Fields{ @@ -232,6 +357,37 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu return &effectiveDuration } +func claudeUnifiedResetIsOverageBillingBoundary(headers http.Header, unifiedReset string) bool { + claim := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Representative-Claim"))) + if !strings.Contains(claim, "overage") { + return false + } + overageReset := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Overage-Reset") + unifiedAt, okUnified := parseUnixOrTimestamp(unifiedReset) + overageAt, okOverage := parseUnixOrTimestamp(overageReset) + if !okUnified || !okOverage { + return false + } + return unifiedAt.Equal(overageAt) +} + +func filterClaudeRateLimitDeadlines(deadlines []time.Time, now time.Time) []time.Time { + if len(deadlines) == 0 { + return deadlines + } + kept := deadlines[:0] + for _, deadline := range deadlines { + if !deadline.After(now) { + continue + } + if deadline.Sub(now) > maxClaudeRateLimitWindow { + continue + } + kept = append(kept, deadline) + } + return kept +} + func containsString(list []string, target string) bool { for _, item := range list { if item == target { diff --git a/internal/runtime/executor/helps/claude_tool_choice.go b/internal/runtime/executor/helps/claude_tool_choice.go new file mode 100644 index 000000000..dbd5dfc35 --- /dev/null +++ b/internal/runtime/executor/helps/claude_tool_choice.go @@ -0,0 +1,30 @@ +package helps + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// NormalizeClaudeToolChoice downgrades forced tool use on Claude 5.5 to auto. +// Use the routed upstream model, not the payload model, and run before thinking +// preparation so forced-mode cleanup cannot discard thinking or signed history. +// Delegated providers keep their own tool-choice contract. User payload rules +// must run afterwards and remain authoritative. +func NormalizeClaudeToolChoice(provider, model string, payload []byte) []byte { + if provider != "claude" { + return payload + } + model = strings.ToLower(thinking.ParseSuffix(strings.TrimSpace(model)).ModelName) + if !claude55Model.MatchString(model) { + return payload + } + switch gjson.GetBytes(payload, "tool_choice.type").String() { + case "any", "tool": + payload, _ = sjson.SetBytes(payload, "tool_choice.type", "auto") + payload, _ = sjson.DeleteBytes(payload, "tool_choice.name") + } + return payload +} diff --git a/internal/runtime/executor/helps/claude_tool_search_model.go b/internal/runtime/executor/helps/claude_tool_search_model.go new file mode 100644 index 000000000..eb237931b --- /dev/null +++ b/internal/runtime/executor/helps/claude_tool_search_model.go @@ -0,0 +1,49 @@ +package helps + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" +) + +// claudeToolSearchLegacyPrefixes and claudeToolSearchLegacyModels list the +// Anthropic models that predate tool search: Claude Opus 4.1 and earlier do not +// accept defer_loading tools or tool_reference blocks. CLIProxyAPIHome keeps the +// same list in internal/home/claude_tool_search.go. +var claudeToolSearchLegacyPrefixes = []string{"claude-3", "claude-2", "claude-instant"} + +var claudeToolSearchLegacyModels = map[string]struct{}{ + "claude-opus-4": {}, + "claude-opus-4-0": {}, + "claude-opus-4-20250514": {}, + "claude-opus-4-1": {}, + "claude-opus-4-1-20250805": {}, + "claude-sonnet-4": {}, + "claude-sonnet-4-0": {}, + "claude-sonnet-4-20250514": {}, +} + +// ClaudeModelLacksToolSearch reports Anthropic models that predate tool search. +// Unknown names, including third-party models, are treated as supported. +func ClaudeModelLacksToolSearch(model string) bool { + model = strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(model).ModelName)) + if slash := strings.LastIndexByte(model, '/'); slash >= 0 { + model = model[slash+1:] + } + if bracket := strings.IndexByte(model, '['); bracket >= 0 { + model = model[:bracket] + } + model = strings.TrimSpace(model) + if model == "" { + return false + } + if _, legacy := claudeToolSearchLegacyModels[model]; legacy { + return true + } + for _, prefix := range claudeToolSearchLegacyPrefixes { + if strings.HasPrefix(model, prefix) { + return true + } + } + return false +} diff --git a/internal/runtime/executor/helps/claude_web_search_results.go b/internal/runtime/executor/helps/claude_web_search_results.go new file mode 100644 index 000000000..97757dcdd --- /dev/null +++ b/internal/runtime/executor/helps/claude_web_search_results.go @@ -0,0 +1,114 @@ +package helps + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/cache" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var claudeWebSearchToolResultMarker = []byte(`"web_search_tool_result"`) + +// RecordClaudeWebSearchResults stores the web_search_tool_result blocks of a +// Claude response for sessionKey. data is either a complete Messages response +// or SSE text (one or more lines); the result block arrives whole in its +// content_block_start event. +func RecordClaudeWebSearchResults(sessionKey string, data []byte, ttl time.Duration, limit int) { + RecordClaudeWebSearchResultsContext(context.Background(), sessionKey, data, ttl, limit) +} + +// RecordClaudeWebSearchResultsContext stores the web_search_tool_result blocks +// of a Claude response for sessionKey using ctx for Home KV operations. +func RecordClaudeWebSearchResultsContext(ctx context.Context, sessionKey string, data []byte, ttl time.Duration, limit int) { + if strings.TrimSpace(sessionKey) == "" || !bytes.Contains(data, claudeWebSearchToolResultMarker) { + return + } + if content := gjson.GetBytes(data, "content"); content.IsArray() { + content.ForEach(func(_, block gjson.Result) bool { + recordClaudeWebSearchResultBlock(ctx, sessionKey, block, ttl, limit) + return true + }) + return + } + for _, line := range bytes.Split(data, []byte("\n")) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + event := gjson.ParseBytes(bytes.TrimSpace(line[len("data:"):])) + if event.Get("type").String() == "content_block_start" { + recordClaudeWebSearchResultBlock(ctx, sessionKey, event.Get("content_block"), ttl, limit) + } + } +} + +func recordClaudeWebSearchResultBlock(ctx context.Context, sessionKey string, block gjson.Result, ttl time.Duration, limit int) { + if block.Get("type").String() != "web_search_tool_result" { + return + } + content := block.Get("content") + if !content.Exists() || claudeWebSearchResultContentEmpty(content) { + return + } + cache.CacheClaudeWebSearchResultBestEffort(ctx, sessionKey, block.Get("tool_use_id").String(), []byte(content.Raw), ttl, limit) +} + +// RestoreClaudeWebSearchResults fills assistant web_search_tool_result blocks +// whose content is missing or empty with the content stored for sessionKey. +// Blocks that already carry content, and blocks without a stored entry, are +// left unchanged. +func RestoreClaudeWebSearchResults(body []byte, sessionKey string, ttl ...time.Duration) []byte { + return RestoreClaudeWebSearchResultsContext(context.Background(), body, sessionKey, ttl...) +} + +// RestoreClaudeWebSearchResultsContext fills assistant web_search_tool_result +// blocks whose content is missing or empty with the content stored for +// sessionKey using ctx for Home KV operations. +func RestoreClaudeWebSearchResultsContext(ctx context.Context, body []byte, sessionKey string, ttl ...time.Duration) []byte { + if strings.TrimSpace(sessionKey) == "" || !bytes.Contains(body, claudeWebSearchToolResultMarker) { + return body + } + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + restored := 0 + for i, message := range messages.Array() { + if message.Get("role").String() != "assistant" { + continue + } + for j, block := range message.Get("content").Array() { + if block.Get("type").String() != "web_search_tool_result" || !claudeWebSearchResultContentEmpty(block.Get("content")) { + continue + } + cached, ok, errGet := cache.GetClaudeWebSearchResultRequired(ctx, sessionKey, block.Get("tool_use_id").String(), ttl...) + if errGet != nil || !ok { + continue + } + updated, errSet := sjson.SetRawBytes(body, fmt.Sprintf("messages.%d.content.%d.content", i, j), cached) + if errSet != nil { + log.Debugf("claude web search results: restore messages.%d.content.%d failed: %v", i, j, errSet) + continue + } + body = updated + restored++ + } + } + if restored > 0 { + log.Debugf("claude web search results: restored %d result block(s) from cache", restored) + } + return body +} + +func claudeWebSearchResultContentEmpty(content gjson.Result) bool { + if !content.Exists() || content.Type == gjson.Null { + return true + } + return content.IsArray() && len(content.Array()) == 0 +} diff --git a/internal/signature/claude_antigravity_validation.go b/internal/signature/claude_antigravity_validation.go new file mode 100644 index 000000000..18c57e997 --- /dev/null +++ b/internal/signature/claude_antigravity_validation.go @@ -0,0 +1,45 @@ +package signature + +import ( + "encoding/base64" + "fmt" + "strings" +) + +// InspectAntigravityClaudeCAQSSignature validates the double-base64 CAQS +// envelope observed on Antigravity Claude 5.5. This is structural validation, +// not proof of cryptographic validity or cross-account replay compatibility. +// Only two encoding layers are supported; arbitrary recursive wrapping is not. +func InspectAntigravityClaudeCAQSSignature(rawSignature string) (*ClaudeCAISSignatureInfo, error) { + sig := stripClaudeSignaturePrefix(rawSignature) + if len(sig) == 0 || len(sig) > MaxClaudeThinkingSignatureLen { + return nil, fmt.Errorf("invalid Antigravity CAQS signature length") + } + if sig[0] != 'Q' || strings.ContainsAny(sig, "\r\n") { + return nil, fmt.Errorf("invalid Antigravity CAQS wrapper") + } + inner, errDecode := base64.StdEncoding.Strict().DecodeString(sig) + if errDecode != nil { + return nil, fmt.Errorf("invalid Antigravity CAQS encoding: %w", errDecode) + } + innerText := string(inner) + if len(innerText) == 0 || innerText[0] != 'C' || strings.ContainsAny(innerText, " \t\r\n#") { + return nil, fmt.Errorf("invalid Antigravity CAQS inner encoding") + } + decoded, errInner := base64.StdEncoding.Strict().DecodeString(innerText) + if errInner != nil { + return nil, fmt.Errorf("invalid Antigravity CAQS inner encoding: %w", errInner) + } + info, errInspect := inspectClaudeCAISPayload(decoded, true) + if errInspect != nil { + return nil, fmt.Errorf("invalid Antigravity CAQS payload: %w", errInspect) + } + // Scope this new replay shape to the observed Google thinking channel. + // Other versions/channels require their own capture and replay verification. + if info.EnvelopeVersion != 4 || info.ChannelID != 18 || + info.Infrastructure == nil || *info.Infrastructure != 2 || + info.BlockKind != "thinking" || !info.SignatureInContainer { + return nil, fmt.Errorf("unsupported Antigravity CAQS envelope or channel schema") + } + return info, nil +} diff --git a/internal/signature/claude_messages_sanitize.go b/internal/signature/claude_messages_sanitize.go index 3baea48ef..0f2c0d5e2 100644 --- a/internal/signature/claude_messages_sanitize.go +++ b/internal/signature/claude_messages_sanitize.go @@ -19,6 +19,77 @@ type ClaudeMessagesSignatureSanitizeOptions struct { PreserveEmptyThinkingBlocks bool } +// CodexCommentarySignatureMarker is the CLIProxyAPI-internal marker carried in +// the signature field of the Claude thinking block that relays a Codex +// commentary message. It is not a provider signature and must never be +// forwarded to any upstream: consumers either restore the block as assistant +// commentary text (Claude-to-Codex request translation) or rewrite it as a +// plain text block (this sanitizer). +const CodexCommentarySignatureMarker = "cpa:commentary:v1" + +// IsCodexCommentarySignatureMarker reports whether rawSignature is exactly the +// internal commentary marker, ignoring surrounding whitespace. The marker is a +// plain, forgeable literal rather than a credential; it only distinguishes +// CPA-relayed commentary from real reasoning history. +func IsCodexCommentarySignatureMarker(rawSignature string) bool { + return strings.TrimSpace(rawSignature) == CodexCommentarySignatureMarker +} + +// RestoreCodexCommentaryMarkers rewrites every assistant thinking block that +// carries the internal commentary marker as a plain assistant text block and +// reports how many blocks were restored. It is deliberately independent of +// provider signature compatibility policy: the marker must never reach any +// upstream, including Claude-compatible endpoints whose model name maps to a +// non-Claude family and therefore skips full signature sanitization. Blocks +// with empty thinking text are dropped because they carry no history. When no +// marker is present the payload is returned unchanged. +func RestoreCodexCommentaryMarkers(payload []byte) ([]byte, int) { + messages := gjson.GetBytes(payload, "messages") + if !messages.IsArray() { + return payload, 0 + } + messageResults := messages.Array() + restored := 0 + keptMessages := make([]string, 0, len(messageResults)) + for _, message := range messageResults { + content := message.Get("content") + if !content.IsArray() { + keptMessages = append(keptMessages, message.Raw) + continue + } + contentResults := content.Array() + keptParts := make([]string, 0, len(contentResults)) + messageModified := false + for _, part := range contentResults { + if part.Get("type").String() != "thinking" || !IsCodexCommentarySignatureMarker(part.Get("signature").String()) { + keptParts = append(keptParts, part.Raw) + continue + } + restored++ + messageModified = true + if text := part.Get("thinking").String(); text != "" { + textBlock := []byte(`{"type":"text","text":""}`) + textBlock, _ = sjson.SetBytes(textBlock, "text", text) + keptParts = append(keptParts, string(textBlock)) + } + } + if !messageModified { + keptMessages = append(keptMessages, message.Raw) + continue + } + if len(keptParts) == 0 { + continue + } + updated, _ := sjson.SetRaw(message.Raw, "content", "["+strings.Join(keptParts, ",")+"]") + keptMessages = append(keptMessages, updated) + } + if restored == 0 { + return payload, 0 + } + output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]")) + return output, restored +} + type SignatureSanitizeReport struct { TargetProvider SignatureProvider Preserved int @@ -125,6 +196,22 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag } rawSignature := part.Get("signature").String() + if IsCodexCommentarySignatureMarker(rawSignature) { + // The internal commentary marker is not a provider signature: + // restore its text as a plain assistant text block so neither + // the marker nor a fake signature reaches the upstream. + if text := part.Get("thinking").String(); text != "" { + textBlock := []byte(`{"type":"text","text":""}`) + textBlock, _ = sjson.SetBytes(textBlock, "text", text) + keptParts = append(keptParts, string(textBlock)) + report.DroppedSignatures++ + messageModified = true + continue + } + report.DroppedBlocks++ + messageModified = true + continue + } if opts.PreserveEmptyThinkingBlocks { report.Preserved++ keptParts = append(keptParts, part.Raw) diff --git a/internal/signature/claude_validation.go b/internal/signature/claude_validation.go index 9a3b174fc..29191559d 100644 --- a/internal/signature/claude_validation.go +++ b/internal/signature/claude_validation.go @@ -12,9 +12,11 @@ // base64 index 4 = E. // - R prefix: double-layer, inner[0] == E (0x45), first 6 bits = 010001, // base64 index 17 = R. +// - Q prefix: double-layer CAQS, inner[0] == C (0x43); inspected separately +// with the observed Google version-4 thinking channel schema. // -// Valid signatures can be normalized to R-form (double-layer base64) before -// sending to the Antigravity backend. +// Classic signatures normalize to R-form before Antigravity replay. Valid +// Q-form signatures keep their original wrapper unchanged. // // # Protobuf structure (Spec sections 4.1 and 4.2) in strict mode only // @@ -100,11 +102,11 @@ // channel_id 16 channel schema, so only the envelope differs. // - Claude Messages API: the full Claude model range, same envelopes as the // Claude Code OAuth subscription. -// - Antigravity: only opus-4-6-think and sonnet-4-6, and always the -// double-layer R form on Google infrastructure (infra_google). Antigravity -// never issues a CAIS envelope or a single-layer E signature, and its replay -// path requires R form, so CompatibleAntigravityClaudeThinkingSignature -// rejects CAIS signatures. +// - Antigravity: legacy Claude models use double-layer R form on Google +// infrastructure. Claude 5.5 also emits double-layer CAQS (Q-form): one +// base64 decode yields CAQS, and the second yields an envelope version 4 +// protobuf with Google infrastructure in the channel block. Preserve that +// wrapper on replay; bare native CAIS/CAQS is not an Antigravity wrapper. // // A single conversation therefore mixes envelopes whenever a user switches model // generations or providers, and every form must stay replayable toward the @@ -126,14 +128,16 @@ const MaxClaudeThinkingSignatureLen = 32 * 1024 * 1024 // ClaudeSignatureValidationOptions controls how far Claude thinking signatures // are inspected. The base validation always checks the cache prefix, base64 // layers, and decoded 0x12 Claude payload marker. Strict mode additionally -// verifies the known protobuf tree used by Claude thinking signatures. +// verifies the classic protobuf tree. Q-form always receives full structural +// validation, regardless of these legacy validation options. type ClaudeSignatureValidationOptions struct { - // PrefixOnly only checks for an optional cache prefix followed by an E/R - // Claude signature prefix. Use it to preserve legacy shallow cleanup. + // PrefixOnly checks the legacy E/R prefix or a fully validated Q envelope. + // Use it to preserve legacy shallow cleanup without blindly allowing Q. PrefixOnly bool // Base64Only checks the optional cache prefix, E/R Claude signature prefix, // and base64 layers without validating the decoded Claude marker or protobuf - // tree. Use it for conservative request cleanup. + // tree for legacy E/R; Q envelopes always receive structural validation. + // Use it for conservative request cleanup. Base64Only bool // AllowEmptySignatureWithEmptyText preserves empty thinking placeholders with // no signature and no thinking/text payload during strip operations. @@ -176,8 +180,8 @@ func IsValidClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignature return err == nil } -// HasDecodableClaudeThinkingSignature reports whether rawSignature has the -// Claude E/R shape and its expected base64 layer(s) can be decoded. +// HasDecodableClaudeThinkingSignature checks legacy E/R encoding or a strictly +// validated Antigravity double-layer CAQS envelope. func HasDecodableClaudeThinkingSignature(rawSignature string) bool { sig := stripClaudeSignaturePrefix(rawSignature) if sig == "" || len(sig) > MaxClaudeThinkingSignatureLen { @@ -185,6 +189,9 @@ func HasDecodableClaudeThinkingSignature(rawSignature string) bool { } switch sig[0] { + case 'Q': + _, err := InspectAntigravityClaudeCAQSSignature(sig) + return err == nil case 'E': decoded, err := base64.StdEncoding.DecodeString(sig) return err == nil && len(decoded) > 0 @@ -201,12 +208,16 @@ func HasDecodableClaudeThinkingSignature(rawSignature string) bool { } // HasClaudeThinkingSignaturePrefix reports whether rawSignature has the Claude -// E/R signature prefix after stripping an optional cache prefix. +// E/R prefix or a structurally validated Q envelope after cache-prefix removal. func HasClaudeThinkingSignaturePrefix(rawSignature string) bool { sig := stripClaudeSignaturePrefix(rawSignature) if sig == "" { return false } + if sig[0] == 'Q' { + _, err := InspectAntigravityClaudeCAQSSignature(sig) + return err == nil + } return sig[0] == 'E' || sig[0] == 'R' } @@ -258,8 +269,8 @@ func ValidateClaudeThinkingSignatures(inputRawJSON []byte, opts ...ClaudeSignatu } // NormalizeClaudeThinkingSignature strips any cache prefix, validates the -// signature, and returns the double-layer R-form expected by Antigravity bypass -// mode. +// signature, and returns the double-layer R or Q form expected by Antigravity. +// Q envelopes always require structural validation, even in shallow modes. func NormalizeClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) (string, error) { opt := claudeSignatureValidationOptions(opts) sig := stripClaudeSignaturePrefix(rawSignature) @@ -272,6 +283,11 @@ func NormalizeClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatu } switch sig[0] { + case 'Q': + if _, err := InspectAntigravityClaudeCAQSSignature(sig); err != nil { + return "", err + } + return sig, nil case 'R': if err := validateClaudeDoubleLayerSignature(sig, opt); err != nil { return "", err @@ -283,7 +299,7 @@ func NormalizeClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatu } return base64.StdEncoding.EncodeToString([]byte(sig)), nil default: - return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0])) + return "", fmt.Errorf("invalid signature: expected 'E', 'R' or 'Q' prefix, got %q", string(sig[0])) } } @@ -591,12 +607,14 @@ const claudeCAISModelTextPrefix = "claude-" type ClaudeCAISSignatureInfo struct { FirstByte byte EnvelopeVersion uint64 + Infrastructure *uint64 ChannelID uint64 ModelText string BlockKind string ContextID string - SignatureLen int + SignatureLen int + SignatureInContainer bool } // IsValidClaudeCAISSignature returns whether rawSignature is a valid Claude CAIS @@ -629,6 +647,12 @@ func InspectClaudeCAISSignature(rawSignature string) (*ClaudeCAISSignatureInfo, if err != nil { return nil, fmt.Errorf("invalid Claude CAIS signature: base64 decode failed: %w", err) } + return inspectClaudeCAISPayload(decoded, false) +} + +// inspectClaudeCAISPayload shares native envelope parsing with the Q replay gate. +// Only Q rejects duplicate submessages; native CAIS/CAQS behavior is unchanged. +func inspectClaudeCAISPayload(decoded []byte, rejectDuplicateMessages bool) (*ClaudeCAISSignatureInfo, error) { if len(decoded) == 0 { return nil, fmt.Errorf("invalid Claude CAIS signature: empty after decode") } @@ -640,7 +664,8 @@ func InspectClaudeCAISSignature(rawSignature string) (*ClaudeCAISSignatureInfo, var container []byte var containerSignatureBytes []byte - err = walkClaudeProtobufFields(decoded, func(num protowire.Number, typ protowire.Type, raw []byte) error { + var haveContainer bool + err := walkClaudeProtobufFields(decoded, func(num protowire.Number, typ protowire.Type, raw []byte) error { switch num { case 1: value, errField := decodeClaudeCAISVarint(raw, typ, "CAIS top-level field 1 envelope version") @@ -649,6 +674,10 @@ func InspectClaudeCAISSignature(rawSignature string) (*ClaudeCAISSignatureInfo, } info.EnvelopeVersion = value case 2: + if rejectDuplicateMessages && haveContainer { + return fmt.Errorf("invalid Antigravity CAQS signature: duplicate container") + } + haveContainer = true value, errField := decodeClaudeCAISBytes(raw, typ, "CAIS top-level field 2 container") if errField != nil { return errField @@ -669,9 +698,14 @@ func InspectClaudeCAISSignature(rawSignature string) (*ClaudeCAISSignatureInfo, } var channelBlock []byte + var haveChannelBlock bool err = walkClaudeProtobufFields(container, func(num protowire.Number, typ protowire.Type, raw []byte) error { switch num { case 1: + if rejectDuplicateMessages && haveChannelBlock { + return fmt.Errorf("invalid Antigravity CAQS signature: duplicate channel block") + } + haveChannelBlock = true value, errField := decodeClaudeCAISBytes(raw, typ, "CAIS container field 1 channel block") if errField != nil { return errField @@ -703,6 +737,12 @@ func InspectClaudeCAISSignature(rawSignature string) (*ClaudeCAISSignatureInfo, } info.ChannelID = value haveChannelID = true + case 2: + value, errField := decodeClaudeCAISVarint(raw, typ, "CAIS channel field 2 infrastructure") + if errField != nil { + return errField + } + info.Infrastructure = &value case 3: if _, errField := decodeClaudeCAISVarint(raw, typ, "CAIS channel field 3 version"); errField != nil { return errField @@ -754,6 +794,7 @@ func InspectClaudeCAISSignature(rawSignature string) (*ClaudeCAISSignatureInfo, } if !haveSignatureBytes && info.EnvelopeVersion >= 4 && len(containerSignatureBytes) > 0 { info.SignatureLen = len(containerSignatureBytes) + info.SignatureInContainer = true haveSignatureBytes = true } switch { diff --git a/internal/signature/gemini_sanitize.go b/internal/signature/gemini_sanitize.go index 91f4dfa86..392de60b6 100644 --- a/internal/signature/gemini_sanitize.go +++ b/internal/signature/gemini_sanitize.go @@ -27,9 +27,10 @@ func GeminiReplaySignatureOrBypass(rawSignature string, blockKind SignatureBlock // Gemini-shaped request. Existing provider signatures stay on their original // model parts. Server-side tool blocks (toolCall and toolResponse) are echoed back // untouched per the Gemini API contract. Only a missing or incompatible first -// functionCall gets the bypass sentinel; unsigned sibling calls remain unsigned, -// matching native Gemini parallel-call history. functionResponse parts never carry -// signatures. +// functionCall in each parallel-call group (at the start of a model turn or after +// a non-thought part) gets the bypass sentinel; unsigned sibling calls remain +// unsigned, matching native Gemini parallel-call history. functionResponse parts +// never carry signatures. func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) []byte { contentsPath = strings.TrimSpace(contentsPath) if contentsPath == "" { @@ -104,8 +105,10 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) hasFunctionCall := part.Get("functionCall").Exists() isFirstFunctionCall := hasFunctionCall && !firstFunctionCallSeen - if hasFunctionCall { - firstFunctionCallSeen = true + // A text part, even an empty signature carrier, ends the parallel group: + // the Gemini API rejects an unsigned functionCall after it. Thought parts do not. + if hasFunctionCall || !part.Get("thought").Bool() { + firstFunctionCallSeen = hasFunctionCall } if !hasFunctionCall && !hasSignature { partItems = append(partItems, partJSON) @@ -247,8 +250,8 @@ func geminiContentsThoughtSignaturesNeedSanitize(contents gjson.Result) bool { } hasFunctionCall := part.Get("functionCall").Exists() isFirstFunctionCall := hasFunctionCall && !firstFunctionCallSeen - if hasFunctionCall { - firstFunctionCallSeen = true + if hasFunctionCall || !part.Get("thought").Bool() { + firstFunctionCallSeen = hasFunctionCall } if isFirstFunctionCall { replaySignature := GeminiReplaySignatureOrBypass(rawSignature, SignatureBlockKindGeminiFunctionCall) diff --git a/internal/signature/gemini_validation.go b/internal/signature/gemini_validation.go index 08cf5d47e..3d939eaad 100644 --- a/internal/signature/gemini_validation.go +++ b/internal/signature/gemini_validation.go @@ -218,9 +218,10 @@ func InspectGeminiThoughtSignature(rawSignature string, opts ...GeminiThoughtSig } // ValidateGeminiThoughtSignatures validates thoughtSignature fields in a Gemini -// native payload. The first functionCall in each model Content must have a valid -// provider signature or allowed synthetic sentinel. Later parallel sibling calls -// may be unsigned, but any signature they do carry must still be valid. +// native payload. The first functionCall in each parallel-call group of a model +// Content must have a valid provider signature or allowed synthetic sentinel. +// Later parallel sibling calls may be unsigned, but any signature they do carry +// must still be valid. func ValidateGeminiThoughtSignatures(inputRawJSON []byte, opts ...GeminiThoughtSignatureValidationOptions) error { contents, contentsPath := geminiContents(inputRawJSON) if !contents.IsArray() { @@ -242,8 +243,8 @@ func ValidateGeminiThoughtSignatures(inputRawJSON []byte, opts ...GeminiThoughtS part := partResults[j] hasFunctionCall := part.Get("functionCall").Exists() isFirstFunctionCall := isModelTurn && hasFunctionCall && !firstFunctionCallSeen - if isModelTurn && hasFunctionCall { - firstFunctionCallSeen = true + if isModelTurn && (hasFunctionCall || !part.Get("thought").Bool()) { + firstFunctionCallSeen = hasFunctionCall } rawSignature, hasSignature := geminiPartThoughtSignature(part) if !hasFunctionCall && !hasSignature { diff --git a/internal/signature/gpt_validation.go b/internal/signature/gpt_validation.go index a0964275a..e69da4b84 100644 --- a/internal/signature/gpt_validation.go +++ b/internal/signature/gpt_validation.go @@ -12,6 +12,9 @@ const MaxGPTReasoningSignatureLen = 32 * 1024 * 1024 type GPTReasoningSignatureInfo struct { DecodedLen int CiphertextLen int + // RouterSuffix is the dot-delimited suffix a router such as OpenRouter + // appends after the native Fernet envelope; empty for native values. + RouterSuffix string } func IsValidGPTReasoningSignature(rawSignature string) bool { @@ -38,11 +41,20 @@ func InspectGPTReasoningSignature(rawSignature string) (*GPTReasoningSignatureIn if !strings.HasPrefix(sig, "gAAAA") { return nil, fmt.Errorf("invalid GPT reasoning signature: expected gAAAA prefix") } - if index, r, ok := firstInvalidGPTReasoningSignatureChar(sig); ok { + native := sig + routerSuffix := "" + if head, tail, ok := strings.Cut(sig, "."); ok { + if !isValidMetaRouterSuffix(tail) { + return nil, fmt.Errorf("invalid GPT reasoning signature: router suffix must be 1-%d alphanumeric characters", maxMetaRouterSuffixLen) + } + native = head + routerSuffix = tail + } + if index, r, ok := firstInvalidGPTReasoningSignatureChar(native); ok { return nil, fmt.Errorf("invalid GPT reasoning signature: contains non-base64url character U+%04X at byte %d", r, index) } - decoded, err := decodeGPTReasoningSignature(sig) + decoded, err := decodeGPTReasoningSignature(native) if err != nil { return nil, err } @@ -61,6 +73,7 @@ func InspectGPTReasoningSignature(rawSignature string) (*GPTReasoningSignatureIn return &GPTReasoningSignatureInfo{ DecodedLen: len(decoded), CiphertextLen: ciphertextLen, + RouterSuffix: routerSuffix, }, nil } diff --git a/internal/signature/grok_validation.go b/internal/signature/grok_validation.go index 8b424ac3c..60fdb67de 100644 --- a/internal/signature/grok_validation.go +++ b/internal/signature/grok_validation.go @@ -29,6 +29,9 @@ const ( type GrokEncryptedContentInfo struct { RawLen int DecodedLen int + // RouterSuffix is the dot-delimited suffix a router such as OpenRouter + // appends after the native Grok ciphertext; empty for native values. + RouterSuffix string } // InspectGrokEncryptedContent validates the transport shape of xAI/Grok @@ -56,12 +59,21 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error) if strings.Contains(sig, "=") { return nil, fmt.Errorf("invalid Grok encrypted_content: expected unpadded standard base64") } - if index, r, ok := firstInvalidGrokEncryptedContentChar(sig); ok { - return nil, fmt.Errorf("invalid Grok encrypted_content: contains non-base64 character U+%04X at byte %d", r, index) - } if _, _, ok := SplitSignatureProviderPrefix(sig); ok { return nil, fmt.Errorf("invalid Grok encrypted_content: carries another provider's cache prefix") } + native := sig + routerSuffix := "" + if head, tail, ok := strings.Cut(sig, "."); ok { + if !isValidMetaRouterSuffix(tail) { + return nil, fmt.Errorf("invalid Grok encrypted_content: router suffix must be 1-%d alphanumeric characters", maxMetaRouterSuffixLen) + } + native = head + routerSuffix = tail + } + if index, r, ok := firstInvalidGrokEncryptedContentChar(native); ok { + return nil, fmt.Errorf("invalid Grok encrypted_content: contains non-base64 character U+%04X at byte %d", r, index) + } // Foreign-envelope rejection only has to run for the narrow set of base64 // first characters a self-describing envelope can produce. Native xAI // ciphertext is uniformly distributed, so this skips the whole chain for @@ -70,17 +82,20 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error) // particular is high-entropy standard base64 that drops its padding whenever // the decoded length is a multiple of 3, so the padding gate above does not // exclude it on its own. - if maybeSelfDescribingSignatureEnvelope(sig) { - if strings.HasPrefix(sig, "gAAAA") { + if maybeSelfDescribingSignatureEnvelope(native) { + if strings.HasPrefix(native, "gAAAA") { return nil, fmt.Errorf("Grok encrypted_content looks like GPT/Codex reasoning signature") } - if IsValidClaudeThinkingSignature(sig, ClaudeSignatureValidationOptions{Strict: true}) { + if IsValidMetaReasoningSignature(native) { + return nil, fmt.Errorf("Grok encrypted_content looks like Meta reasoning signature") + } + if IsValidClaudeThinkingSignature(native, ClaudeSignatureValidationOptions{Strict: true}) { return nil, fmt.Errorf("Grok encrypted_content looks like Claude thinking signature") } - if IsValidClaudeCAISSignature(sig) { + if IsValidClaudeCAISSignature(native) { return nil, fmt.Errorf("Grok encrypted_content looks like Claude CAIS thinking signature") } - if _, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}); err == nil { + if _, err := InspectGeminiThoughtSignature(native, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}); err == nil { return nil, fmt.Errorf("Grok encrypted_content looks like Gemini thoughtSignature") } } @@ -90,14 +105,17 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error) // length tracks reasoning volume continuously at 1-byte granularity. Neither // observed Kimi length appears anywhere in 1027 catalogued signatures or 215 // native Grok samples, so rejecting them here costs no real Grok traffic. - if IsValidKimiThinkingSignature(sig) { + if IsValidKimiThinkingSignature(native) { return nil, fmt.Errorf("Grok encrypted_content has a Kimi thinking signature length") } - decoded, err := decodeGrokEncryptedContent(sig) + decoded, err := decodeGrokEncryptedContent(native) if err != nil { return nil, err } + if strings.HasPrefix(string(decoded), "cpa-codex-gemini-carrier-v1:") { + return nil, fmt.Errorf("invalid Grok encrypted_content: carries Gemini Codex thoughtSignature carrier") + } if len(decoded) < MinGrokEncryptedContentDecodedLen { return nil, fmt.Errorf("invalid Grok encrypted_content: decoded payload too short (%d bytes)", len(decoded)) } @@ -105,8 +123,9 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error) return nil, fmt.Errorf("invalid Grok encrypted_content: decoded payload entropy ratio %.3f below %.3f", entropyRatio, MinGrokEncryptedContentEntropyRatio) } return &GrokEncryptedContentInfo{ - RawLen: len(sig), - DecodedLen: len(decoded), + RawLen: len(sig), + DecodedLen: len(decoded), + RouterSuffix: routerSuffix, }, nil } diff --git a/internal/signature/meta_validation.go b/internal/signature/meta_validation.go new file mode 100644 index 000000000..fe75192e0 --- /dev/null +++ b/internal/signature/meta_validation.go @@ -0,0 +1,174 @@ +package signature + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" +) + +// MaxMetaReasoningSignatureLen bounds the raw base64url text accepted by +// InspectMetaReasoningSignature. It mirrors the GPT bound; the bucket-length +// rule below is far more restrictive in practice. +const MaxMetaReasoningSignatureLen = 32 * 1024 * 1024 + +const ( + metaReasoningSignatureOverheadLen = 45 + metaReasoningSignatureMinPayload = 1024 + // maxMetaRouterSuffixLen bounds the dot-delimited suffix a router such as + // OpenRouter appends after the native envelope. Observed values are well + // under this bound. + maxMetaRouterSuffixLen = 128 +) + +// metaReasoningSignatureHeader is the fixed five-byte prefix observed on every +// captured Meta Muse reasoning encrypted_content value (api.meta.ai, +// 2026-10-15 corpus: 82 values across nine muse-* models). Its semantics are +// unresolved - it may be a format magic, a key/context fingerprint, or a +// versioned identifier ending in 0x01 - and every capture came from one +// account on one day, so a future rotation would fall back to Unknown rather +// than being claimed here on weaker evidence. Cache-prefixed forms +// ("meta#...") are intentionally not recognized: no producer emits them, so +// there is no alias in SignatureProviderFromCachePrefix. +var metaReasoningSignatureHeader = []byte{0x43, 0xe3, 0xda, 0x0e, 0x01} + +// MetaReasoningSignatureInfo describes the transport shape of a Meta Muse +// reasoning encrypted_content envelope. +type MetaReasoningSignatureInfo struct { + DecodedLen int + // PayloadLen is DecodedLen minus the fixed 45-byte envelope overhead. It + // is the power-of-two padded region, not a proven plaintext length. + PayloadLen int + BucketExponent int + // RouterSuffix is the dot-delimited suffix a router such as OpenRouter + // appends after the native envelope; empty for native values. Replay must + // forward the full value verbatim; never strip the suffix. + RouterSuffix string +} + +func IsValidMetaReasoningSignature(rawSignature string) bool { + _, err := InspectMetaReasoningSignature(rawSignature) + return err == nil +} + +// InspectMetaReasoningSignature validates the outer envelope shape observed +// for Meta Muse reasoning encrypted_content: canonical unpadded base64url, +// the fixed five-byte header above, and a decoded length of exactly +// 45 + 1024*2^k bytes. A router such as OpenRouter may serve the same model +// family and append one "." plus a short alphanumeric routing suffix to the +// native value; that suffix is validated here and reported via +// MetaReasoningSignatureInfo.RouterSuffix, and the full value must be +// replayed verbatim. This is only a transport-shape check; it does not +// prove decryptability or upstream replay acceptance. +func InspectMetaReasoningSignature(rawSignature string) (*MetaReasoningSignatureInfo, error) { + sig := strings.TrimSpace(rawSignature) + if sig == "" { + return nil, fmt.Errorf("empty Meta reasoning signature") + } + if len(sig) > MaxMetaReasoningSignatureLen { + return nil, fmt.Errorf("Meta reasoning signature exceeds maximum length (%d bytes)", MaxMetaReasoningSignatureLen) + } + // Reject other providers' signatures before scanning for a router suffix. + if !strings.HasPrefix(sig, "Q-PaDg") { + return nil, fmt.Errorf("invalid Meta reasoning signature: expected Q-PaDg prefix") + } + native := sig + routerSuffix := "" + if head, tail, ok := strings.Cut(sig, "."); ok { + // The native alphabet never contains '.', so the first dot is an + // unambiguous boundary. The suffix is restricted to the observed + // router form: a single run of alphanumeric characters. + if !isValidMetaRouterSuffix(tail) { + return nil, fmt.Errorf("invalid Meta reasoning signature: router suffix must be 1-%d alphanumeric characters", maxMetaRouterSuffixLen) + } + native = head + routerSuffix = tail + } + info, err := inspectNativeMetaReasoningSignature(native) + if err != nil { + return nil, err + } + info.RouterSuffix = routerSuffix + return info, nil +} + +// inspectNativeMetaReasoningSignature validates the native (unsuffixed) +// envelope shape described on InspectMetaReasoningSignature. +func inspectNativeMetaReasoningSignature(sig string) (*MetaReasoningSignatureInfo, error) { + // The literal prefix is the cheapest discriminator: every observed value + // starts with "Q-PaDg", and probing this validator is on the hot path for + // signatures of every provider. + if !strings.HasPrefix(sig, "Q-PaDg") { + return nil, fmt.Errorf("invalid Meta reasoning signature: expected Q-PaDg prefix") + } + // Reject by encoded length before scanning or allocating a decoded copy: + // only bucket-sized candidates ever reach the decoder. + if len(sig)%4 == 1 { + return nil, fmt.Errorf("invalid Meta reasoning signature: base64url length %d cannot decode", len(sig)) + } + decodedLen := len(sig) / 4 * 3 + switch len(sig) % 4 { + case 2: + decodedLen++ + case 3: + decodedLen += 2 + } + payloadLen := decodedLen - metaReasoningSignatureOverheadLen + if payloadLen < metaReasoningSignatureMinPayload || payloadLen&(payloadLen-1) != 0 { + return nil, fmt.Errorf("invalid Meta reasoning signature: payload length %d is not a power-of-two bucket", payloadLen) + } + if index, r, ok := firstInvalidMetaReasoningSignatureChar(sig); ok { + return nil, fmt.Errorf("invalid Meta reasoning signature: contains non-base64url character U+%04X at byte %d", r, index) + } + // Strict decoding rejects non-canonical trailing bits, so no re-encode + // comparison is needed to prove canonical form. + decoded, err := base64.RawURLEncoding.Strict().DecodeString(sig) + if err != nil { + return nil, fmt.Errorf("invalid Meta reasoning signature: base64url decode failed") + } + if !bytes.Equal(decoded[:len(metaReasoningSignatureHeader)], metaReasoningSignatureHeader) { + return nil, fmt.Errorf("invalid Meta reasoning signature: unexpected envelope header %x", decoded[:len(metaReasoningSignatureHeader)]) + } + exponent := 0 + for p := payloadLen; p > metaReasoningSignatureMinPayload; p >>= 1 { + exponent++ + } + return &MetaReasoningSignatureInfo{ + DecodedLen: len(decoded), + PayloadLen: payloadLen, + BucketExponent: exponent, + }, nil +} + +// metaReasoningSignatureCharSet is the unpadded base64url alphabet. Observed +// Meta values never carry '=' padding, so padding is rejected rather than +// normalized away. +var metaReasoningSignatureCharSet = base64AlphabetSet("-_") + +func firstInvalidMetaReasoningSignatureChar(sig string) (int, rune, bool) { + for index := 0; index < len(sig); index++ { + if !metaReasoningSignatureCharSet[sig[index]] { + r, _ := utf8.DecodeRuneInString(sig[index:]) + return index, r, true + } + } + return 0, 0, false +} + +// metaRouterSuffixCharSet is the alphanumeric-only alphabet observed on +// router-appended suffixes; base64url's '-'/'_' and any second '.' are all +// rejected so the suffix can never be mistaken for more envelope payload. +var metaRouterSuffixCharSet = base64AlphabetSet("") + +func isValidMetaRouterSuffix(suffix string) bool { + if len(suffix) == 0 || len(suffix) > maxMetaRouterSuffixLen { + return false + } + for index := 0; index < len(suffix); index++ { + if !metaRouterSuffixCharSet[suffix[index]] { + return false + } + } + return true +} diff --git a/internal/signature/provider_compatibility.go b/internal/signature/provider_compatibility.go index 6df2c3975..95d719a12 100644 --- a/internal/signature/provider_compatibility.go +++ b/internal/signature/provider_compatibility.go @@ -22,6 +22,12 @@ const ( SignatureProviderGrok SignatureProvider = "grok" // SignatureProviderSWE represents Cognition's SWE model family emitting sealed.v1 envelopes. SignatureProviderSWE SignatureProvider = "swe" + // SignatureProviderMeta is identified by the observed Meta Muse envelope: + // a fixed five-byte header plus a power-of-two padded payload length, + // optionally followed by a router-appended (e.g. OpenRouter) dot suffix + // that replays verbatim. See meta_validation.go for the capture basis and + // its limits. + SignatureProviderMeta SignatureProvider = "meta" ) type SignatureBlockKind string @@ -59,6 +65,28 @@ type SignatureCompatibilityDecision struct { // whose signed history can be safely replayed for that model. func SignatureProviderFromModelName(modelName string) SignatureProvider { lower := strings.ToLower(strings.TrimSpace(modelName)) + if lower == "" { + return SignatureProviderUnknown + } + // A namespaced ID's canonical model is its last path segment, so a + // vendor/router prefix such as "openai-compatible/" must not outrank the + // actual model family ("openai-compatible/muse-spark-1.3" is Meta, not + // GPT). Only when the last segment names no family does the full string + // get the legacy substring treatment. + candidate := lower + if idx := strings.LastIndexAny(lower, "/:"); idx >= 0 && idx < len(lower)-1 { + candidate = lower[idx+1:] + } + if provider := signatureProviderFromModelNameValue(candidate); provider != SignatureProviderUnknown { + return provider + } + if candidate != lower { + return signatureProviderFromModelNameValue(lower) + } + return SignatureProviderUnknown +} + +func signatureProviderFromModelNameValue(lower string) SignatureProvider { switch { case strings.Contains(lower, "claude"): return SignatureProviderClaude @@ -78,6 +106,8 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider { return SignatureProviderKimi case strings.Contains(lower, "grok"): return SignatureProviderGrok + case isMetaMuseModelName(lower): + return SignatureProviderMeta case strings.Contains(lower, "swe-"): return SignatureProviderSWE default: @@ -85,6 +115,29 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider { } } +// isMetaMuseModelName reports whether lower names a Meta Muse model on a +// native or third-party provider ("muse", "muse-*", "muse_*", "meta-muse-*") +// without matching unrelated substrings such as "museum" or "amuse". +func isMetaMuseModelName(lower string) bool { + if lower == "muse" || + strings.HasPrefix(lower, "muse-") || + strings.HasPrefix(lower, "muse_") || + strings.HasSuffix(lower, "/muse") || + strings.Contains(lower, "/muse-") || + strings.Contains(lower, "/muse_") { + return true + } + for _, prefix := range []string{"meta-", "meta_"} { + if idx := strings.Index(lower, prefix+"muse"); idx >= 0 { + rest := lower[idx+len(prefix+"muse"):] + if rest == "" || rest[0] == '-' || rest[0] == '_' || rest[0] == '.' || rest[0] == ':' { + return true + } + } + } + return false +} + // selfDescribingSignatureFirstChars are the base64 first characters that a // self-describing provider envelope can produce. A base64 first character is // exactly the first payload byte shifted right by two, so a single character @@ -92,6 +145,7 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider { // // 'C' -> 0x08..0x0b : Claude CAIS (0x08) // 'E' -> 0x10..0x13 : Claude single-layer (0x12), Gemini protobuf_field_2 (0x12) +// 'Q' -> 0x40..0x43 : Antigravity double-layer CAQS (0x43, inner 'C'), Meta Muse (0x43) // 'R' -> 0x44..0x47 : Claude double-layer R (0x45, inner 'E') // 'g' -> 0x80..0x83 : GPT Fernet reasoning (0x80) // @@ -106,7 +160,7 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider { // DetectSignatureProviderForBlock, otherwise its signatures would fall through // to the residual class. TestSelfDescribingSignatureFirstChars_CoversEveryKnownEnvelope // fails when a replay-safe envelope is missing from this set. -const selfDescribingSignatureFirstChars = "CERg" +const selfDescribingSignatureFirstChars = "CEQRg" // base64AlphabetSet builds a byte lookup table for the alphanumeric base64 core // plus the alphabet-specific characters in extra. Signature charset validation @@ -136,7 +190,7 @@ func base64AlphabetSet(extra string) [256]bool { // classifier: a false result is conclusive, a true result only narrows the // candidate set. Opaque ciphertext that carries no envelope (xAI/Grok // encrypted_content) is uniformly distributed over the byte space, so this -// rejects roughly 92% of it with one comparison and no allocation. +// rejects most of it with one comparison and no allocation. func maybeSelfDescribingSignatureEnvelope(rawSignature string) bool { if rawSignature == "" { return false @@ -163,6 +217,11 @@ func DetectSignatureProviderForBlock(rawSignature string, blockKind SignatureBlo } if prefixedProvider, unprefixed, ok := SplitSignatureProviderPrefix(sig); ok { + // Validators may strip cache prefixes themselves; never let a second + // prefix make detection disagree with the payload used for replay. + if strings.Contains(unprefixed, "#") { + return SignatureProviderUnknown + } switch prefixedProvider { case SignatureProviderGemini: if IsGeminiThoughtSignatureBypass(unprefixed) { @@ -201,9 +260,11 @@ func DetectSignatureProviderForBlock(rawSignature string, blockKind SignatureBlo // Probes run from the strongest marker to the weakest: // 1. GPT carries the literal "gAAAA" prefix, which pins both the version // byte and the high timestamp bytes. - // 2. Claude CAIS/CAQS carries marker 0x08 with nested container/channel structure. - // 3. Claude single/double-layer carries marker 0x12 plus the same literal. - // 4. Gemini validates wire shape only and has no literal to anchor on, so + // 2. Meta Muse carries a fixed five-byte decoded header plus an exact + // power-of-two padded length, which no other family can collide with. + // 3. Claude CAIS/CAQS carries marker 0x08 with nested container/channel structure. + // 4. Claude single/double-layer carries marker 0x12 plus the same literal. + // 5. Gemini validates wire shape only and has no literal to anchor on, so // it is the weakest judge and goes last. // // This ordering is defense in depth rather than a correctness requirement: @@ -216,12 +277,15 @@ func DetectSignatureProviderForBlock(rawSignature string, blockKind SignatureBlo // The envelope pre-filter gates only the envelope probes. A blob that cannot // be an envelope skips straight to the size probe below rather than returning // early, because Kimi's uniformly distributed base64 starts with one of - // "CERg" about 6% of the time and would otherwise be dropped by whichever + // "CEQRg" about 8% of the time and would otherwise be dropped by whichever // side of the gate it happened to land on. if maybeSelfDescribingSignatureEnvelope(sig) { if IsValidGPTReasoningSignature(sig) { return SignatureProviderGPT } + if IsValidMetaReasoningSignature(sig) { + return SignatureProviderMeta + } if IsValidClaudeCAISSignature(sig) { return SignatureProviderClaude } @@ -269,14 +333,27 @@ func DecideSignatureCompatibilityForModel(targetProvider SignatureProvider, targ BlockKind: blockKind, } - if signatureProviderMatchesTarget(targetProvider, detected) { - decision.Compatible = true - decision.Action = SignatureActionPreserve - decision.NormalizedSignature = normalizeCompatibleSignatureForProvider(targetProvider, rawSignature, blockKind) - decision.Reason = claudeCompatibleSignatureReason(targetProvider, rawSignature, targetModel) + // Recognizing a Claude envelope does not authorize a Google transport + // wrapper on native Claude endpoints. Antigravity has its own replay gate. + if targetProvider == SignatureProviderClaude && detected == SignatureProviderClaude && + strings.HasPrefix(SignaturePayloadWithoutProviderPrefix(rawSignature), "Q") { + decision.Action = SignatureActionDropBlock + decision.Reason = "Antigravity CAQS wrapper requires Antigravity replay" return decision } + if signatureProviderMatchesTarget(targetProvider, detected) { + // A matching family is not sufficient: replay also requires successful + // normalization. Otherwise sanitizers could preserve the original input. + if normalized := normalizeCompatibleSignatureForProvider(targetProvider, rawSignature, blockKind); normalized != "" { + decision.Compatible = true + decision.Action = SignatureActionPreserve + decision.NormalizedSignature = normalized + decision.Reason = claudeCompatibleSignatureReason(targetProvider, rawSignature, targetModel) + return decision + } + } + decision.Compatible = false switch targetProvider { case SignatureProviderGemini: @@ -297,6 +374,9 @@ func DecideSignatureCompatibilityForModel(targetProvider SignatureProvider, targ case SignatureProviderSWE: decision.Action = SignatureActionDropBlock decision.Reason = "SWE requires sealed.v1 signature from its own backend" + case SignatureProviderMeta: + decision.Action = SignatureActionDropBlock + decision.Reason = "Meta Muse requires its native encrypted_content envelope and has no cross-provider bypass sentinel" case SignatureProviderKimi: // Kimi is the only target that can keep the reasoning text when the // signature does not match. Its Messages endpoint never reads the field @@ -377,7 +457,7 @@ func CompatibleSignatureForProviderBlock(targetProvider SignatureProvider, rawSi return decision.NormalizedSignature, true } -// CompatibleAntigravityClaudeThinkingSignature returns the double-layer R-form +// CompatibleAntigravityClaudeThinkingSignature returns the double-layer R or Q form // required by Antigravity Claude replay. It only accepts signatures that are // strictly identifiable as Claude, so Gemini E-prefixed envelopes cannot slip // through the looser Antigravity bypass normalization path. @@ -441,6 +521,8 @@ func signatureProviderMatchesTarget(target, detected SignatureProvider) bool { return detected == SignatureProviderGPT case SignatureProviderSWE: return detected == SignatureProviderSWE + case SignatureProviderMeta: + return detected == SignatureProviderMeta case SignatureProviderKimi: return detected == SignatureProviderKimi default: @@ -478,6 +560,10 @@ func normalizeCompatibleSignatureForProvider(targetProvider SignatureProvider, r if strings.HasPrefix(payload, "sealed.v1.") { return payload } + case SignatureProviderMeta: + if IsValidMetaReasoningSignature(payload) { + return payload + } case SignatureProviderKimi: if IsValidKimiThinkingSignature(payload) { return payload @@ -498,7 +584,7 @@ func isRecognizedGeminiProviderSignature(rawSignature string, blockKind Signatur // IsRecognizedReasoningSignature reports whether rawSignature is a structurally valid // reasoning signature or encrypted_content payload from any known provider -// (GPT, Claude, Gemini, Kimi, Grok, Devin). +// (GPT, Claude, Gemini, Kimi, Grok, Devin, Meta). func IsRecognizedReasoningSignature(rawSignature string) bool { sig := strings.TrimSpace(rawSignature) if sig == "" { diff --git a/internal/signature/signaturetest/claude.go b/internal/signature/signaturetest/claude.go new file mode 100644 index 000000000..197fcea14 --- /dev/null +++ b/internal/signature/signaturetest/claude.go @@ -0,0 +1,35 @@ +// Package signaturetest provides synthetic envelopes for signature regression tests. +package signaturetest + +import ( + "encoding/base64" + + "google.golang.org/protobuf/encoding/protowire" +) + +// AntigravityCAQS builds a double-base64 Google Claude thinking envelope. +// All opaque fields are generated zeros, not captured or replayable state. +func AntigravityCAQS() string { + appendVarint := func(dst []byte, field protowire.Number, value uint64) []byte { + return protowire.AppendVarint(protowire.AppendTag(dst, field, protowire.VarintType), value) + } + appendBytes := func(dst []byte, field protowire.Number, value []byte) []byte { + return protowire.AppendBytes(protowire.AppendTag(dst, field, protowire.BytesType), value) + } + var channel []byte + channel = appendVarint(channel, 1, 18) + channel = appendVarint(channel, 2, 2) + channel = appendVarint(channel, 3, 2) + channel = appendVarint(channel, 7, 1) + channel = appendBytes(channel, 8, []byte("thinking")) + container := appendBytes(nil, 1, channel) + container = appendBytes(container, 2, make([]byte, 12)) + container = appendBytes(container, 3, make([]byte, 12)) + container = appendBytes(container, 4, make([]byte, 48)) + container = appendBytes(container, 5, make([]byte, 1020)) + payload := appendVarint(nil, 1, 4) + payload = appendBytes(payload, 2, container) + payload = appendVarint(payload, 3, 1) + inner := base64.StdEncoding.EncodeToString(payload) + return base64.StdEncoding.EncodeToString([]byte(inner)) +} diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 7b65c77e8..3f11d9d5b 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -8,6 +8,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) type pluginProviderApplier struct { @@ -50,6 +51,26 @@ func GetProviderApplier(provider string) ProviderApplier { return pluginProviderAppliers[provider].applier } +func getPluginProviderApplier(provider string) ProviderApplier { + provider = normalizedProviderName(provider) + if provider == "" { + return nil + } + providerAppliersMu.RLock() + defer providerAppliersMu.RUnlock() + return pluginProviderAppliers[provider].applier +} + +func resolveProviderApplier(providerFormat, providerKey string) (ProviderApplier, bool) { + if applier := getPluginProviderApplier(providerKey); applier != nil { + return applier, true + } + if applier := getPluginProviderApplier(providerFormat); applier != nil { + return applier, true + } + return GetProviderApplier(providerFormat), false +} + // RegisterProvider registers a provider applier by name. func RegisterProvider(name string, applier ProviderApplier) { name = normalizedProviderName(name) @@ -185,6 +206,13 @@ func ApplyThinkingWithSourceAndSummary(body, sourceBody []byte, model, fromForma return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig, normalizedUpdatesChanged...) } +// ApplyThinkingWithFallbackModelInfoAndSummary applies thinking using the global +// registry first and falls back to fallbackModelInfo when the registry has no +// entry for the model. +func ApplyThinkingWithFallbackModelInfoAndSummary(body, sourceBody []byte, model, fromFormat, toFormat, providerKey string, fallbackModelInfo *registry.ModelInfo, summaryConfig SummaryConfig, normalizedUpdatesChanged ...bool) ([]byte, error) { + return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, fallbackModelInfo, false, summaryConfig, normalizedUpdatesChanged...) +} + // ApplyThinkingWithModelInfo applies thinking with the exact configured model // definition selected for an API-key execution attempt while preserving summary // visibility from the original source body. @@ -225,7 +253,9 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF // Use provider-specific lookup to handle capability differences across providers. modelInfo := resolvedModelInfo if !modelInfoResolved { - modelInfo = registry.LookupModelInfo(baseModel, providerKey) + if lookedUp := registry.LookupModelInfo(baseModel, providerKey); lookedUp != nil { + modelInfo = lookedUp + } } // Resolve source intent before stripping unsupported target input items. @@ -248,7 +278,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF nativeResponses := responseTarget && isResponsesFormat(fromFormat) && supportsUpdates // 2. Route check: Get provider applier after target cleanup. - applier := GetProviderApplier(providerFormat) + applier, isPluginApplier := resolveProviderApplier(providerFormat, providerKey) if applier == nil { log.WithFields(log.Fields{ "provider": providerFormat, @@ -286,7 +316,12 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF } return body, nil } - if IsUserDefinedModel(modelInfo) { + if !isPluginApplier && fromFormat == "claude" && providerFormat == "claude" && !suffixResult.HasSuffix && !gjson.GetBytes(body, "thinking").Exists() { + // Native Claude effort is independent of thinking activation. Preserve the + // model's thinking default unless a suffix or explicit summary changes it. + return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil + } + if IsUserDefinedModel(modelInfo) || (isPluginApplier && modelInfo.Thinking == nil) { return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, providerKey, suffixResult, sourceConfig, nativeResponses, summaryConfig) } if modelInfo.Thinking == nil { @@ -312,6 +347,9 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF var config ThinkingConfig if suffixResult.HasSuffix { config = parseSuffixToConfig(suffixResult.RawSuffix, providerFormat, model) + if !hasThinkingConfig(config) && !nativeResponses && strings.TrimSpace(suffixResult.RawSuffix) != "" && !strings.EqualFold(strings.TrimSpace(modelInfo.ID), strings.TrimSpace(model)) { + config = ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(strings.TrimSpace(suffixResult.RawSuffix))} + } log.WithFields(log.Fields{ "provider": providerFormat, "model": model, @@ -327,6 +365,9 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF if !hasThinkingConfig(config) { config = extractThinkingConfig(body, providerFormat) } + if !hasThinkingConfig(config) && fromFormat != providerFormat { + config = extractThinkingConfig(body, fromFormat) + } if hasThinkingConfig(config) { log.WithFields(log.Fields{ "provider": providerFormat, @@ -405,6 +446,14 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF if thinkingIsFullyDisabled(*validated) || nativeResponses { return applied, nil } + if providerFormat == "claude" && thinkingRequestedOff(config) { + // The model cannot turn thinking off, so ValidateConfig fell back to its + // lowest level. Drop any stale block_binding from the off request and keep + // the caller's intent of seeing no thinking: without an explicit omitted + // display, cloaking would add progress updates. + applied, _ = sjson.DeleteBytes(applied, "thinking.block_binding") + summaryConfig = SummaryConfig{Mode: SummaryDisabled} + } return applySummaryConfigForProvider(applied, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil } @@ -412,6 +461,10 @@ func thinkingIsFullyDisabled(config ThinkingConfig) bool { return config.Mode == ModeNone && config.Budget == 0 && config.Level == "" } +func thinkingRequestedOff(config ThinkingConfig) bool { + return config.Mode == ModeNone || (config.Mode == ModeBudget && config.Budget == 0) +} + func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool { fromFormat = strings.ToLower(strings.TrimSpace(fromFormat)) toFormat = strings.ToLower(strings.TrimSpace(toFormat)) @@ -545,7 +598,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma return applySummaryConfigForProvider(body, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil } - applier := GetProviderApplier(toFormat) + applier, isPluginApplier := resolveProviderApplier(toFormat, providerKey) if applier == nil { log.WithFields(log.Fields{ "model": modelID, @@ -554,7 +607,9 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma return body, nil } - config = normalizeUserDefinedConfig(config, fromFormat, toFormat) + if !isPluginApplier { + config = normalizeUserDefinedConfig(config, fromFormat, toFormat) + } log.WithFields(log.Fields{ "provider": toFormat, "model": modelID, @@ -668,6 +723,12 @@ func extractThinkingConfigForUsage(body []byte, provider string) ThinkingConfig switch strings.ToLower(strings.TrimSpace(provider)) { case "codex", "xai", "openai-response": return extractCodexUsageConfig(body) + case "claude": + // between_tools is how claude-sonnet-5-5 turns thinking off. + if gjson.GetBytes(body, "thinking.type").String() == "between_tools" { + return ThinkingConfig{Mode: ModeNone, Budget: 0} + } + return extractThinkingConfig(body, provider) default: return extractThinkingConfig(body, provider) } @@ -718,8 +779,8 @@ func extractClaudeConfig(body []byte) ThinkingConfig { if thinkingType == "disabled" { return ThinkingConfig{Mode: ModeNone, Budget: 0} } - if thinkingType == "adaptive" || thinkingType == "auto" { - // Claude adaptive thinking uses output_config.effort (low/medium/high/max). + if !gjson.GetBytes(body, "thinking").Exists() || thinkingType == "adaptive" || thinkingType == "auto" { + // Claude output_config.effort also applies when thinking is omitted. // We only treat it as a thinking config when effort is explicitly present; // otherwise we passthrough and let upstream defaults apply. if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() && effort.Type == gjson.String { diff --git a/internal/thinking/configuration_update.go b/internal/thinking/configuration_update.go index b826641a9..a2e5f50e9 100644 --- a/internal/thinking/configuration_update.go +++ b/internal/thinking/configuration_update.go @@ -45,6 +45,37 @@ func extractConfigurationUpdateConfig(body []byte) ThinkingConfig { } } +// CollapseConfigurationUpdates removes every configuration_update input item. +// Unless keepTopLevelEffort is true, the last nonempty update effort is also +// promoted into the top-level reasoning.effort. Targets that reject +// configuration_update items entirely (for example Sign in with ChatGPT +// subscriptions) use it to degrade to the classic request-level effort, +// accepting the resulting prompt-cache loss. Keep the top-level effort when +// an explicit model thinking suffix already resolved the operator's override +// into it. A body without array input or without any configuration_update +// item is returned unchanged, and the top-level effort is only touched when +// an update carries a usable effort. +func CollapseConfigurationUpdates(body []byte, keepTopLevelEffort ...bool) []byte { + updated := stripConfigurationUpdates(body) + if len(keepTopLevelEffort) > 0 && keepTopLevelEffort[0] { + return updated + } + effort := reasoningEffortFromConfig(extractConfigurationUpdateConfig(body)) + if effort == "" { + return updated + } + collapsed, errSet := sjson.SetBytes(updated, "reasoning.effort", effort) + if errSet != nil { + return updated + } + return collapsed +} + +// StripConfigurationUpdates removes configuration_update input items without changing reasoning. +func StripConfigurationUpdates(body []byte) []byte { + return stripConfigurationUpdates(body) +} + // stripConfigurationUpdates removes unsupported Responses input items without // modifying other input items or introducing an input field. func stripConfigurationUpdates(body []byte) []byte { diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index ea2cefd79..57ea7d47a 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -40,7 +40,7 @@ func init() { // - ModeBudget: manual thinking budget_tokens // - ModeLevel: adaptive thinking effort (Claude 4.6) // - ModeAuto: provider default adaptive/manual behavior -// - ModeNone: disabled +// - ModeNone: disabled, or the model's ThinkingSupport.DisableType (between_tools on claude-sonnet-5-5) // // Expected output format when enabled: // @@ -85,15 +85,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * switch config.Mode { case thinking.ModeNone: - result, _ := sjson.SetBytes(body, "thinking.type", "disabled") - result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") - // Summary display only applies to an active thinking block. - result, _ = sjson.DeleteBytes(result, "thinking.display") - result, _ = sjson.DeleteBytes(result, "output_config.effort") - if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { - result, _ = sjson.DeleteBytes(result, "output_config") - } - return result, nil + return disableThinking(body, modelInfo), nil case thinking.ModeLevel: // Adaptive thinking effort is only valid when the model advertises discrete levels. @@ -119,13 +111,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * // Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced). // Decide enabled/disabled based on budget value. if config.Budget == 0 { - result, _ := sjson.SetBytes(body, "thinking.type", "disabled") - result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") - result, _ = sjson.DeleteBytes(result, "output_config.effort") - if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { - result, _ = sjson.DeleteBytes(result, "output_config") - } - return result, nil + return disableThinking(body, modelInfo), nil } result, _ := sjson.SetBytes(body, "thinking.type", "enabled") @@ -166,6 +152,25 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * } } +// disableThinking turns thinking off with the type the model accepts. Most Claude +// models take "disabled"; claude-sonnet-5-5 rejects it and needs "between_tools". +// Neither type accepts extra thinking fields (budget_tokens, display, block_binding), +// and between_tools only accepts effort high or below, so effort is left to the +// model default in both cases. +func disableThinking(body []byte, modelInfo *registry.ModelInfo) []byte { + disableType := "disabled" + if modelInfo != nil && modelInfo.Thinking != nil && modelInfo.Thinking.DisableType != "" { + disableType = modelInfo.Thinking.DisableType + } + result, _ := sjson.SetRawBytes(body, "thinking", []byte(`{}`)) + result, _ = sjson.SetBytes(result, "thinking.type", disableType) + result, _ = sjson.DeleteBytes(result, "output_config.effort") + if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { + result, _ = sjson.DeleteBytes(result, "output_config") + } + return result +} + // normalizeClaudeBudget applies Claude-specific constraints to ensure max_tokens > budget_tokens. // Anthropic API requires this constraint; violating it returns a 400 error. func (a *Applier) normalizeClaudeBudget(body []byte, budgetTokens int, modelInfo *registry.ModelInfo) []byte { @@ -231,15 +236,7 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, switch config.Mode { case thinking.ModeNone: - result, _ := sjson.SetBytes(body, "thinking.type", "disabled") - result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") - // Summary display only applies to an active thinking block. - result, _ = sjson.DeleteBytes(result, "thinking.display") - result, _ = sjson.DeleteBytes(result, "output_config.effort") - if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 { - result, _ = sjson.DeleteBytes(result, "output_config") - } - return result, nil + return disableThinking(body, nil), nil case thinking.ModeAuto: result, _ := sjson.SetBytes(body, "thinking.type", "enabled") result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens") @@ -259,6 +256,9 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level)) return result, nil default: + if config.Budget == 0 { + return disableThinking(body, nil), nil + } result, _ := sjson.SetBytes(body, "thinking.type", "enabled") result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget) result, _ = sjson.DeleteBytes(result, "output_config.effort") diff --git a/internal/thinking/suffix.go b/internal/thinking/suffix.go index 7f2959da5..5df8dec99 100644 --- a/internal/thinking/suffix.go +++ b/internal/thinking/suffix.go @@ -43,6 +43,12 @@ func ParseSuffix(model string) SuffixResult { } } +// HasThinkingSuffix reports whether the model suffix defines a thinking configuration. +func HasThinkingSuffix(model string) bool { + result := ParseSuffix(model) + return result.HasSuffix && hasThinkingConfig(parseSuffixToConfig(result.RawSuffix, "", result.ModelName)) +} + // ParseNumericSuffix attempts to parse a raw suffix as a numeric budget value. // // This function parses the raw suffix content (from ParseSuffix.RawSuffix) as an integer. diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 1be8db485..e68a1d242 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -67,7 +67,7 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo modelFamilyMismatch := false if modelInfo != nil { modelType := strings.ToLower(strings.TrimSpace(modelInfo.Type)) - if modelType != "" { + if isKnownProviderFamily(modelType) { if (fromFormat != "" && !isSameProviderFamily(fromFormat, modelType)) || (toFormat != "" && !isSameProviderFamily(toFormat, modelType)) { modelFamilyMismatch = true @@ -167,10 +167,24 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo } if config.Mode == ModeNone && toFormat == "claude" { - // Claude supports explicit disable via thinking.type="disabled". - // Keep Budget=0 so applier can omit budget_tokens. - config.Budget = 0 - config.Level = "" + if !support.ZeroAllowed { + if lowest, ok := lowestSupportedLevel(support.Levels); ok { + // Adaptive-only Claude models (for example claude-opus-5-5, claude-fable-5, + // and claude-fable-5-1) reject thinking.type="disabled". Like other models + // that cannot be disabled, fall back to the lowest supported level. + config.Mode = ModeLevel + config.Budget = 0 + config.Level = lowest + } else { + config.Budget = 0 + config.Level = "" + } + } else { + // Claude supports explicit disable via thinking.type="disabled" (or the + // model's DisableType). Keep Budget=0 so applier can omit budget_tokens. + config.Budget = 0 + config.Level = "" + } } else { switch config.Mode { case ModeBudget, ModeAuto, ModeNone: @@ -183,7 +197,9 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo // their Min/Max range is zero. cannotDisableLevelModel := !support.ZeroAllowed && !isLevelSupported(string(LevelNone), support.Levels) if config.Mode == ModeNone && len(support.Levels) > 0 && (config.Budget > 0 || cannotDisableLevelModel) { - config.Level = ThinkingLevel(support.Levels[0]) + if lowest, ok := lowestSupportedLevel(support.Levels); ok { + config.Level = lowest + } } } @@ -239,6 +255,23 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp // standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest. var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh, LevelMax} +// lowestSupportedLevel returns the lowest canonical effort level in supported, +// ignoring mode markers such as "none" and "auto". +func lowestSupportedLevel(supported []string) (ThinkingLevel, bool) { + bestIdx := -1 + for _, s := range supported { + if idx := levelIndex(strings.TrimSpace(s)); idx != -1 { + if bestIdx == -1 || idx < bestIdx { + bestIdx = idx + } + } + } + if bestIdx >= 0 { + return standardLevelOrder[bestIdx], true + } + return "", false +} + // clampLevel clamps the given level to the nearest supported level. // On tie, prefers the lower level. func clampLevel(level ThinkingLevel, modelInfo *registry.ModelInfo, provider string) ThinkingLevel { @@ -390,6 +423,15 @@ func isOpenAIFamily(provider string) bool { } } +func isKnownProviderFamily(provider string) bool { + switch provider { + case "gemini", "antigravity", "claude", "openai", "openai-response", "codex", "kimi", "kimi-ai", "kimi.ai", "kimi.com", "xai", "interactions", "devin": + return true + default: + return false + } +} + func isSameProviderFamily(from, to string) bool { if from == to { return true