mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-23 16:15:46 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user