mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-05 23:50:29 +08:00
Claude Code builds Anthropic-Beta per request instead of sending a fixed list.
Captured from an isolated 2.1.220 profile pointed at api.anthropic.com through
a local proxy, over two rounds covering 11 model IDs and the [1m] variants:
constant claude-code, interleaved-thinking, redact-thinking,
thinking-token-count, context-management, prompt-caching-scope
tools advanced-tool-use-2025-11-20 only when tools are declared
model mid-conversation-system-2026-04-07 only on models that accept a
role=system turn
[1m] context-1m-2025-08-07, directly after claude-code-20250219 rather
than at the end
trailing effort-2025-11-24, then server-side-fallback-2026-06-01
claude-sonnet-5 emits mid-conversation-system-2026-04-07, so it accepts a
role=system turn and must not sit in the legacy reminder whitelist.
count_tokens does not reuse the inference fingerprint. Running /context in an
interactive session issues 37 identical calls, which made the endpoint
observable for the first time: four betas only, and 21 headers rather than 22
because X-Stainless-Timeout is absent. The profile is selected from the request
path so no call site has to thread another flag.
212 lines
6.9 KiB
Go
212 lines
6.9 KiB
Go
package 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
|
|
oauthProfileFetcher claudeOAuthProfileFetcher
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// 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 := isAnthropicUpstreamURL(req.URL)
|
|
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)
|
|
}
|