Something went wrong. Try again.
sloproxy
Something went wrong. Try again.
8.7 kB · 246 lines
Go
at commit e8e39526
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247package executor
import ( "bytes" "context" "fmt" "net/http" "strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson")
// ClaudeExecutor is a stateless executor for Anthropic Claude over the messages API.// 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}
// claudeToolPrefix is empty to match real Claude Code behavior (no tool name prefix).// Previously "proxy_" was used but this is a detectable fingerprint difference.const claudeToolPrefix = ""
func shouldSanitizeClaudeMessagesForUpstream(baseModel string) bool { return sigcompat.SignatureProviderFromModelName(baseModel) == sigcompat.SignatureProviderClaude}
func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string) []byte { sanitized := body if shouldSanitizeClaudeMessagesForUpstream(baseModel) { var report sigcompat.SignatureSanitizeReport sanitized, report = sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel) logClaudeSignatureSanitizeReport(ctx, baseModel, report) } return 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")}
// oauthToolRenameMap maps OpenCode-style (lowercase) tool names to Claude Code-style// (TitleCase) names. Anthropic uses tool name fingerprinting to detect third-party// clients on OAuth traffic. Renaming to official names avoids extra-usage billing.// All tools are mapped to TitleCase equivalents to match Claude Code naming patterns.var oauthToolRenameMap = map[string]string{ "bash": "Bash", "read": "Read", "write": "Write", "edit": "Edit", "glob": "Glob", "grep": "Grep", "task": "Task", "webfetch": "WebFetch", "todowrite": "TodoWrite", "question": "Question", "skill": "Skill", "ls": "LS", "todoread": "TodoRead", "notebookedit": "NotebookEdit",}
// The reverse map is now computed per-request in remapOAuthToolNames so that// only names the client actually caused us to rewrite are restored on the// response. A global reverse map — as used previously — corrupted responses// for clients that sent mixed casing (e.g. `Bash` TitleCase alongside `glob`// lowercase; the request flagged renames via `glob` -> `Glob`, then the global// reverse map incorrectly rewrote every `Bash` in the response to `bash`).
// oauthToolsToRemove lists tool names that must be stripped from OAuth requests// even after remapping. Currently empty — all tools are mapped instead of removed.var oauthToolsToRemove = map[string]bool{}
// 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
func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} }
func (e *ClaudeExecutor) Identifier() string { return "claude" }
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) if strings.TrimSpace(apiKey) == "" { return nil } useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != "" isAnthropicBase := req.URL != nil && strings.EqualFold(req.URL.Scheme, "https") && strings.EqualFold(req.URL.Host, "api.anthropic.com") 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) } 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)}