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
This commit is contained in:
Luis Pater
2026-10-15 00:57:02 +08:00
parent 5fc580b1d7
commit 9db0729f5f
6 changed files with 160 additions and 15 deletions

View File

@@ -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.

View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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,
}
}