mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-27 01:53:28 +08:00
- Implement LRU cache and helpers for Claude native web search tool results. - Record search results from stream events and responses, and restore them in subsequent requests. - Add configuration options and test coverage for Claude web search result caching. Closes: #6753
383 lines
14 KiB
Go
383 lines
14 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"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 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 {
|
|
cause error
|
|
}
|
|
|
|
func (e *claudeOAuthCancellationError) Error() string {
|
|
if e == nil || e.cause == nil {
|
|
return ""
|
|
}
|
|
return e.cause.Error()
|
|
}
|
|
|
|
func (e *claudeOAuthCancellationError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.cause
|
|
}
|
|
|
|
func (e *claudeOAuthCancellationError) IsRequestScoped() bool {
|
|
return e != nil
|
|
}
|
|
|
|
func newClaudeOAuthCancellationError(ctx context.Context, oauth bool, err error) error {
|
|
if !oauth {
|
|
return nil
|
|
}
|
|
cause := err
|
|
if ctx != nil && ctx.Err() != nil {
|
|
cause = ctx.Err()
|
|
}
|
|
if !errors.Is(cause, context.Canceled) {
|
|
return nil
|
|
}
|
|
return &claudeOAuthCancellationError{cause: cause}
|
|
}
|
|
|
|
func shouldSanitizeClaudeMessagesForUpstream(baseModel string) bool {
|
|
return sigcompat.SignatureProviderFromModelName(baseModel) == sigcompat.SignatureProviderClaude
|
|
}
|
|
|
|
func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string, preserveEmptyThinkingBlocks ...bool) []byte {
|
|
sanitized := 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 helps.NormalizeClaudeToolCallIDs(sanitizeClaudeWebSearchDomains(sanitized))
|
|
}
|
|
|
|
// sanitizeClaudeWebSearchDomains removes empty allowed_domains/blocked_domains
|
|
// arrays from built-in web_search tools. Some clients (e.g. litellm) emit an
|
|
// empty array instead of omitting the field, and Anthropic rejects it with
|
|
// "Empty list of domains is ambiguous. Provide at least one domain or null.".
|
|
// Deleting the key is equivalent to leaving it unset.
|
|
func sanitizeClaudeWebSearchDomains(body []byte) []byte {
|
|
tools := gjson.GetBytes(body, "tools")
|
|
if !tools.Exists() || !tools.IsArray() {
|
|
return body
|
|
}
|
|
tools.ForEach(func(index, tool gjson.Result) bool {
|
|
if !strings.HasPrefix(tool.Get("type").String(), "web_search_") {
|
|
return true
|
|
}
|
|
for _, field := range []string{"allowed_domains", "blocked_domains"} {
|
|
value := tool.Get(field)
|
|
if value.Exists() && value.IsArray() && len(value.Array()) == 0 {
|
|
path := fmt.Sprintf("tools.%d.%s", index.Int(), field)
|
|
if updated, errDelete := sjson.DeleteBytes(body, path); errDelete == nil {
|
|
body = updated
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return body
|
|
}
|
|
|
|
func logClaudeSignatureSanitizeReport(ctx context.Context, baseModel string, report sigcompat.SignatureSanitizeReport) {
|
|
if report.DroppedBlocks == 0 && report.DroppedSignatures == 0 && report.ReplacedSignatures == 0 {
|
|
return
|
|
}
|
|
|
|
fields := log.Fields{
|
|
"component": "signature_sanitizer",
|
|
"executor": "claude",
|
|
"action": "sanitize_claude_messages",
|
|
"target_provider": string(report.TargetProvider),
|
|
"target_model": baseModel,
|
|
"preserved": report.Preserved,
|
|
"dropped_blocks": report.DroppedBlocks,
|
|
"dropped_signatures": report.DroppedSignatures,
|
|
"replaced_signatures": report.ReplacedSignatures,
|
|
}
|
|
if len(report.Decisions) > 0 {
|
|
decision := report.Decisions[0]
|
|
fields["first_block_kind"] = string(decision.BlockKind)
|
|
fields["first_detected_provider"] = string(decision.DetectedProvider)
|
|
fields["first_reason"] = decision.Reason
|
|
}
|
|
|
|
helps.LogWithRequestID(ctx).WithFields(fields).Debug("claude executor: sanitized signature history before upstream")
|
|
}
|
|
|
|
// Anthropic-compatible upstreams may reject or even crash when Claude models
|
|
// omit max_tokens. Prefer registered model metadata before using a fallback.
|
|
const defaultModelMaxTokens = 1024
|
|
|
|
// 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, 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
|
|
}
|
|
cacheCfg := e.cfg.Client.Codex.WebSearchResultCache
|
|
return sessionKey, cacheCfg.EffectiveTTL(), cacheCfg.EffectiveLimit()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (e *ClaudeExecutor) upstreamRequestLogProvider() string {
|
|
if provider := strings.TrimSpace(e.requestLogProvider); provider != "" {
|
|
return provider
|
|
}
|
|
return e.Identifier()
|
|
}
|
|
|
|
func (e *ClaudeExecutor) upstreamModel(baseModel string) string {
|
|
if e.upstreamModelNormalizer != nil {
|
|
return e.upstreamModelNormalizer(baseModel)
|
|
}
|
|
return baseModel
|
|
}
|
|
|
|
func (e *ClaudeExecutor) restoreResponseModel(payload []byte, model string) []byte {
|
|
if e.upstreamModelNormalizer == nil || strings.TrimSpace(model) == "" {
|
|
return payload
|
|
}
|
|
return restoreClaudeResponseModel(payload, model)
|
|
}
|
|
|
|
func restoreClaudeResponseModel(payload []byte, model string) []byte {
|
|
if updated, changed := setClaudeResponseModel(payload, model); changed {
|
|
return updated
|
|
}
|
|
|
|
trimmed := bytes.TrimSpace(payload)
|
|
if !bytes.HasPrefix(trimmed, []byte("data:")) {
|
|
return payload
|
|
}
|
|
dataIndex := bytes.Index(payload, []byte("data:"))
|
|
if dataIndex < 0 {
|
|
return payload
|
|
}
|
|
rawJSON := bytes.TrimSpace(payload[dataIndex+len("data:"):])
|
|
updated, changed := setClaudeResponseModel(rawJSON, model)
|
|
if !changed {
|
|
return payload
|
|
}
|
|
rebuilt := make([]byte, 0, dataIndex+len("data: ")+len(updated))
|
|
rebuilt = append(rebuilt, payload[:dataIndex]...)
|
|
rebuilt = append(rebuilt, []byte("data: ")...)
|
|
rebuilt = append(rebuilt, updated...)
|
|
return rebuilt
|
|
}
|
|
|
|
func setClaudeResponseModel(payload []byte, model string) ([]byte, bool) {
|
|
if !gjson.ValidBytes(payload) {
|
|
return payload, false
|
|
}
|
|
updated := payload
|
|
changed := false
|
|
for _, path := range []string{"model", "message.model"} {
|
|
if !gjson.GetBytes(updated, path).Exists() {
|
|
continue
|
|
}
|
|
next, errSet := sjson.SetBytes(updated, path, model)
|
|
if errSet != nil {
|
|
continue
|
|
}
|
|
updated = next
|
|
changed = true
|
|
}
|
|
return updated, changed
|
|
}
|
|
|
|
// PrepareRequest injects Claude credentials into the outgoing HTTP request.
|
|
func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
apiKey, _ := claudeCreds(auth)
|
|
useAPIKey := auth != nil && (auth.AuthKind() == cliproxyauth.AuthKindAPIKey || (auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != ""))
|
|
isAnthropicBase := isAnthropicUpstreamURL(req.URL)
|
|
if strings.TrimSpace(apiKey) != "" {
|
|
if isAnthropicBase && useAPIKey {
|
|
req.Header.Del("Authorization")
|
|
req.Header.Set("x-api-key", apiKey)
|
|
} else {
|
|
req.Header.Del("x-api-key")
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
} else {
|
|
req.Header.Del("Authorization")
|
|
req.Header.Del("x-api-key")
|
|
}
|
|
var attrs map[string]string
|
|
if auth != nil {
|
|
attrs = auth.Attributes
|
|
}
|
|
util.ApplyCustomHeadersFromAttrs(req, attrs)
|
|
return nil
|
|
}
|
|
|
|
// HttpRequest injects Claude credentials into the request and executes it.
|
|
func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("claude executor: request is nil")
|
|
}
|
|
if ctx == nil {
|
|
ctx = req.Context()
|
|
}
|
|
httpReq := req.WithContext(ctx)
|
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
|
|
return httpClient.Do(httpReq)
|
|
}
|
|
|
|
// SupportsApplyPatch reports the actual executor contract, independent of its provider name.
|
|
func (e *ClaudeExecutor) SupportsApplyPatch() bool { return e != nil }
|