From 9db0729f5fefb37139672ee0e577c50f8fcd4382 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 15 Oct 2026 00:57:02 +0800 Subject: [PATCH] feat(config): add operator overrides for Claude header defaults and client request ID modes - Add `anthropic-beta-strip`, `anthropic-beta-append`, `inject-diagnostics`, and `client-request-id` options to `ClaudeHeaderDefaults`. - Support `on`, `off`, and `passthrough` modes for `client-request-id` header handling in Claude requests. - Sanitize and normalize configured beta override lists and client request ID configuration values. Closes: #5093 --- config.example.yaml | 14 ++++++ .../config/claude_header_defaults_test.go | 36 ++++++++++++++ internal/config/config_normalization.go | 39 ++++++++++++++- internal/config/config_types.go | 31 ++++++++---- .../executor/claude_executor_request.go | 47 +++++++++++++++++-- .../executor/claude_fingerprint_policy.go | 8 +++- 6 files changed, 160 insertions(+), 15 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index f8e8534731..d415306e4e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -460,6 +460,20 @@ upstream: # timeout: "600" # timezone: "Asia/Singapore" # fallback IANA timezone for cloaked currentDate; a credential JSON "timezone" takes priority # stabilize-device-profile: false # optional, default false; set true to enable per-auth fingerprint pinning + # # Advanced operator overrides for exact-parity or gateway lanes. They apply to every Claude + # # request, after the per-request derivation above, and can produce a beta set real Claude Code + # # never sends, which api.anthropic.com may reject. Names are matched exactly; strip runs first. + # # A credential's own "headers" entries are more specific and keep precedence where they already + # # did: Anthropic-Beta on custom gateways and x-client-request-id everywhere. + # anthropic-beta-strip: [] # remove these names from the derived or caller-supplied set, keeping order + # anthropic-beta-append: [] # append these names at the end unless already present + # # Cloaked requests to api.anthropic.com get a "diagnostics" body field like Claude Code, which in + # # turn adds cache-diagnosis-2026-04-07. Set false to stop injecting it (default true). + # inject-diagnostics: true + # # x-client-request-id on first-party CLI-profile requests: "on" (default) synthesizes a fresh + # # UUID per request (a confirmed Claude Code client keeps its own), "passthrough" forwards only + # # the caller's value and never synthesizes one, "off" never sends the header. + # client-request-id: "on" xai: # When true, inject the native x_search tool when the request does not declare it. diff --git a/internal/config/claude_header_defaults_test.go b/internal/config/claude_header_defaults_test.go index a161a650e6..3ec0245e17 100644 --- a/internal/config/claude_header_defaults_test.go +++ b/internal/config/claude_header_defaults_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "testing" ) @@ -57,3 +58,38 @@ claude-header-defaults: t.Fatalf("StabilizeDeviceProfile = %v, want false", got) } } + +func TestParseConfigBytes_ClaudeHeaderDefaultsOperatorOverrides(t *testing.T) { + cfg, err := ParseConfigBytes([]byte(`upstream: + claude: + header-defaults: + anthropic-beta-strip: [" cache-diagnosis-2026-04-07 ", "", "cache-diagnosis-2026-04-07"] + anthropic-beta-append: ["context-1m-2025-08-07", " context-1m-2025-08-07 ", "advisor-tool-2026-03-01"] + inject-diagnostics: false + client-request-id: " PassThrough " +`)) + if err != nil { + t.Fatalf("ParseConfigBytes() error = %v", err) + } + hd := cfg.ClaudeHeaderDefaults + if want := []string{"cache-diagnosis-2026-04-07"}; !reflect.DeepEqual(hd.AnthropicBetaStrip, want) { + t.Fatalf("AnthropicBetaStrip = %q, want %q", hd.AnthropicBetaStrip, want) + } + if want := []string{"context-1m-2025-08-07", "advisor-tool-2026-03-01"}; !reflect.DeepEqual(hd.AnthropicBetaAppend, want) { + t.Fatalf("AnthropicBetaAppend = %q, want %q", hd.AnthropicBetaAppend, want) + } + if hd.InjectDiagnostics == nil || *hd.InjectDiagnostics { + t.Fatalf("InjectDiagnostics = %v, want explicit false", hd.InjectDiagnostics) + } + if hd.ClientRequestID != ClaudeClientRequestIDPassthrough { + t.Fatalf("ClientRequestID = %q, want %q", hd.ClientRequestID, ClaudeClientRequestIDPassthrough) + } + + cfg, err = ParseConfigBytes([]byte("claude-header-defaults: {client-request-id: sometimes}\n")) + if err != nil { + t.Fatalf("ParseConfigBytes() error = %v", err) + } + if got := cfg.ClaudeHeaderDefaults.ClientRequestID; got != "" { + t.Fatalf("ClientRequestID = %q, want unknown mode reset to default", got) + } +} diff --git a/internal/config/config_normalization.go b/internal/config/config_normalization.go index 6dc033ba21..59bb5950f2 100644 --- a/internal/config/config_normalization.go +++ b/internal/config/config_normalization.go @@ -5,6 +5,7 @@ import ( "strings" sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginstore" + log "github.com/sirupsen/logrus" ) // NormalizePluginsConfig applies default plugin configuration values. @@ -45,11 +46,22 @@ func (cfg *Config) SanitizeCodexHeaderDefaults() { } // SanitizeClaudeHeaderDefaults trims surrounding whitespace from the -// configured Claude fingerprint baseline values. +// configured Claude fingerprint baseline values, normalizes the beta override +// lists, and resets an unknown client-request-id mode to the default. func (cfg *Config) SanitizeClaudeHeaderDefaults() { if cfg == nil { return } + cfg.ClaudeHeaderDefaults.AnthropicBetaStrip = normalizeClaudeBetaNames(cfg.ClaudeHeaderDefaults.AnthropicBetaStrip) + cfg.ClaudeHeaderDefaults.AnthropicBetaAppend = normalizeClaudeBetaNames(cfg.ClaudeHeaderDefaults.AnthropicBetaAppend) + mode := strings.ToLower(strings.TrimSpace(cfg.ClaudeHeaderDefaults.ClientRequestID)) + switch mode { + case "", ClaudeClientRequestIDOn, ClaudeClientRequestIDOff, ClaudeClientRequestIDPassthrough: + default: + log.Warnf("unrecognized claude header-defaults client-request-id %q (supported: on, off, passthrough); falling back to on", cfg.ClaudeHeaderDefaults.ClientRequestID) + mode = "" + } + cfg.ClaudeHeaderDefaults.ClientRequestID = mode cfg.ClaudeHeaderDefaults.UserAgent = strings.TrimSpace(cfg.ClaudeHeaderDefaults.UserAgent) cfg.ClaudeHeaderDefaults.PackageVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.PackageVersion) cfg.ClaudeHeaderDefaults.RuntimeVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.RuntimeVersion) @@ -59,6 +71,31 @@ func (cfg *Config) SanitizeClaudeHeaderDefaults() { cfg.ClaudeHeaderDefaults.Timezone = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timezone) } +// normalizeClaudeBetaNames trims beta names and drops empty and duplicate +// entries while keeping the configured order. +func normalizeClaudeBetaNames(names []string) []string { + if len(names) == 0 { + return nil + } + out := make([]string, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + if len(out) == 0 { + return nil + } + return out +} + // SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases. // It trims whitespace, normalizes channel keys to lower-case, drops empty entries, // allows multiple aliases per upstream name, and ensures aliases are unique within each channel. diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 32347c5945..63d253640c 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -128,17 +128,32 @@ type ClaudeConfig struct { // Claude Code, package, and runtime versions exactly match this baseline; unmeasured // versions use the configured values. Timeout remains a fallback. Stabilized profiles // also pin OS and Arch and never learn newer software versions automatically. +// AnthropicBetaStrip and AnthropicBetaAppend are exact-name operator overrides applied +// to the final Anthropic-Beta set; InjectDiagnostics and ClientRequestID tune the +// first-party Claude Code CLI request shape. type ClaudeHeaderDefaults struct { - UserAgent string `yaml:"user-agent" json:"user-agent"` - PackageVersion string `yaml:"package-version" json:"package-version"` - RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"` - OS string `yaml:"os" json:"os"` - Arch string `yaml:"arch" json:"arch"` - Timeout string `yaml:"timeout" json:"timeout"` - Timezone string `yaml:"timezone" json:"timezone"` - StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"` + UserAgent string `yaml:"user-agent" json:"user-agent"` + PackageVersion string `yaml:"package-version" json:"package-version"` + RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"` + OS string `yaml:"os" json:"os"` + Arch string `yaml:"arch" json:"arch"` + Timeout string `yaml:"timeout" json:"timeout"` + Timezone string `yaml:"timezone" json:"timezone"` + StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"` + AnthropicBetaStrip []string `yaml:"anthropic-beta-strip,omitempty" json:"anthropic-beta-strip,omitempty"` + AnthropicBetaAppend []string `yaml:"anthropic-beta-append,omitempty" json:"anthropic-beta-append,omitempty"` + InjectDiagnostics *bool `yaml:"inject-diagnostics,omitempty" json:"inject-diagnostics,omitempty"` + ClientRequestID string `yaml:"client-request-id,omitempty" json:"client-request-id,omitempty"` } +// Supported ClaudeHeaderDefaults.ClientRequestID values. An empty value means +// ClaudeClientRequestIDOn. +const ( + ClaudeClientRequestIDOn = "on" + ClaudeClientRequestIDOff = "off" + ClaudeClientRequestIDPassthrough = "passthrough" +) + // CodexHeaderDefaults configures fallback header values injected into Codex // model requests for OAuth/file-backed auth when the client omits them. // UserAgent applies to HTTP and websocket requests; BetaFeatures only applies to websockets. diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 2e7b02190a..75a37252b6 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -600,6 +600,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) @@ -1260,12 +1277,22 @@ func applyClaudeHeadersWithNativeProfile( if isClaudeHaikuModel(reqModel) && !gjson.GetBytes(body, "fallbacks").Exists() && !helperProfile { baseBetas = withoutClaudeBeta(baseBetas, claudeServerSideFallbackBeta) } + // 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() @@ -1277,6 +1304,9 @@ func applyClaudeHeadersWithNativeProfile( defaultAcceptEncoding = "identity" } copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode) + 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) @@ -1410,8 +1440,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 @@ -1460,7 +1497,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 diff --git a/internal/runtime/executor/claude_fingerprint_policy.go b/internal/runtime/executor/claude_fingerprint_policy.go index 6f41319d52..05e8e11001 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, } }