Files
CLIProxyAPI/internal/runtime/executor/claude_executor.go
sususu f3e25ab2ba feat(claude): align OAuth wire identity and TLS with Claude Code 2.1.220
Detect confirmed CLI, sdk-cli and VSCode callers before mutation so native
software, system, tool, cache and beta shapes pass through, while unconfirmed
OAuth clients receive a coherent minimum CLI identity.

Persist each Claude OAuth credential's upstream account metadata and one stable
device ID, derive one stable session per agent conversation, and keep body and
header identity synchronized across Messages, streaming and count_tokens.

Alias every cloaked third-party custom tool through caller-stable opaque MCP
names and restore declarations, choices, history, references, non-stream
responses and SSE events without changing tool ownership.

Implement the Claude Code 2.1.220 CCH algorithm over the final serialized
request bytes, align currentDate and first-user cache layout, update the
official beta/header baseline, and use upstream count_tokens for OAuth and
first-party Anthropic credentials.

Match the 2.1.220 TLS ClientHello so the transport fingerprint agrees with the
identity the request now claims, and document the CLI defaults and automatic
OAuth signing / tool alias behaviour in config.example.yaml.
2026-08-03 14:47:26 +08:00

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 := 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)
}