package config import ( "sort" "strings" sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore" ) // NormalizePluginsConfig applies default plugin configuration values. func (cfg *Config) NormalizePluginsConfig() { if cfg == nil { return } cfg.Plugins.Dir = strings.TrimSpace(cfg.Plugins.Dir) if cfg.Plugins.Dir == "" { cfg.Plugins.Dir = defaultPluginsDir } if len(cfg.Plugins.StoreSources) > 0 { sources := make([]string, 0, len(cfg.Plugins.StoreSources)) for _, source := range cfg.Plugins.StoreSources { source = strings.TrimSpace(source) if source == "" { continue } sources = append(sources, source) } cfg.Plugins.StoreSources = sources } cfg.Plugins.StoreAuth = sdkpluginstore.NormalizeAuthConfigs(cfg.Plugins.StoreAuth) if cfg.Plugins.Configs == nil { cfg.Plugins.Configs = map[string]PluginInstanceConfig{} } } // SanitizeCodexHeaderDefaults trims surrounding whitespace from the // configured Codex header fallback values. func (cfg *Config) SanitizeCodexHeaderDefaults() { if cfg == nil { return } cfg.CodexHeaderDefaults.UserAgent = strings.TrimSpace(cfg.CodexHeaderDefaults.UserAgent) cfg.CodexHeaderDefaults.BetaFeatures = strings.TrimSpace(cfg.CodexHeaderDefaults.BetaFeatures) } // SanitizeClaudeHeaderDefaults trims surrounding whitespace from the // configured Claude fingerprint baseline values. func (cfg *Config) SanitizeClaudeHeaderDefaults() { if cfg == nil { return } cfg.ClaudeHeaderDefaults.UserAgent = strings.TrimSpace(cfg.ClaudeHeaderDefaults.UserAgent) cfg.ClaudeHeaderDefaults.PackageVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.PackageVersion) cfg.ClaudeHeaderDefaults.RuntimeVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.RuntimeVersion) cfg.ClaudeHeaderDefaults.OS = strings.TrimSpace(cfg.ClaudeHeaderDefaults.OS) cfg.ClaudeHeaderDefaults.Arch = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Arch) cfg.ClaudeHeaderDefaults.Timeout = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timeout) cfg.ClaudeHeaderDefaults.Timezone = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timezone) } // 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. func (cfg *Config) SanitizeOAuthModelAlias() { if cfg == nil || len(cfg.OAuthModelAlias) == 0 { return } out := make(map[string][]OAuthModelAlias, len(cfg.OAuthModelAlias)) for rawChannel, aliases := range cfg.OAuthModelAlias { channel := strings.ToLower(strings.TrimSpace(rawChannel)) if channel == "" || len(aliases) == 0 { continue } seenAlias := make(map[string]struct{}, len(aliases)) clean := make([]OAuthModelAlias, 0, len(aliases)) for _, entry := range aliases { name := strings.TrimSpace(entry.Name) alias := strings.TrimSpace(entry.Alias) if name == "" || alias == "" { continue } if strings.EqualFold(name, alias) { continue } aliasKey := strings.ToLower(alias) if _, ok := seenAlias[aliasKey]; ok { continue } seenAlias[aliasKey] = struct{}{} clean = append(clean, OAuthModelAlias{ Name: name, Alias: alias, Fork: entry.Fork, DisplayName: strings.TrimSpace(entry.DisplayName), ForceMapping: entry.ForceMapping, }) } if len(clean) > 0 { out[channel] = clean } } cfg.OAuthModelAlias = out } // SanitizeOAuthRequestScopedErrors normalizes and validates global OAuth request-scoped error rules. // It trims whitespace, normalizes channel keys to lower-case, validates status/action, and drops invalid rules. func (cfg *Config) SanitizeOAuthRequestScopedErrors() { if cfg == nil || len(cfg.OAuthRequestScopedErrors) == 0 { return } out := make(map[string][]RequestScopedErrorRule, len(cfg.OAuthRequestScopedErrors)) for rawChannel, rules := range cfg.OAuthRequestScopedErrors { channel := strings.ToLower(strings.TrimSpace(rawChannel)) if channel == "" || len(rules) == 0 { continue } clean := make([]RequestScopedErrorRule, 0, len(rules)) for _, r := range rules { action := strings.ToLower(strings.TrimSpace(r.Action)) match := make([]string, 0, len(r.Match)) for _, m := range r.Match { if tm := strings.TrimSpace(m); tm != "" { match = append(match, tm) } } matchRegexr := make([]string, 0, len(r.MatchRegexr)) for _, re := range r.MatchRegexr { if tre := strings.TrimSpace(re); tre != "" { matchRegexr = append(matchRegexr, tre) } } if r.Status <= 0 || (len(match) == 0 && len(matchRegexr) == 0) || action == "" { continue } clean = append(clean, RequestScopedErrorRule{ Status: r.Status, Match: match, MatchRegexr: matchRegexr, Action: action, }) } if len(clean) > 0 { out[channel] = clean } } if len(out) == 0 { cfg.OAuthRequestScopedErrors = nil return } cfg.OAuthRequestScopedErrors = out } // SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are // not actionable, specifically those missing a BaseURL. It trims whitespace before // evaluation and preserves the relative order of remaining entries. func (cfg *Config) SanitizeOpenAICompatibility() { if cfg == nil || len(cfg.OpenAICompatibility) == 0 { return } out := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility)) for i := range cfg.OpenAICompatibility { e := cfg.OpenAICompatibility[i] e.Name = strings.TrimSpace(e.Name) e.Prefix = normalizeModelPrefix(e.Prefix) e.BaseURL = strings.TrimSpace(e.BaseURL) e.Headers = NormalizeHeaders(e.Headers) if e.BaseURL == "" { // Skip providers with no base-url; treated as removed continue } out = append(out, e) } cfg.OpenAICompatibility = out } // SanitizeCodexKeys removes Codex API key entries missing a BaseURL. // It trims whitespace and preserves order for remaining entries. func (cfg *Config) SanitizeCodexKeys() { if cfg == nil { return } cfg.CodexKey = sanitizeCodexKeyEntries(cfg.CodexKey) } // SanitizeXAIKeys removes xAI API key entries missing a BaseURL. // It applies the same normalization rules as codex-api-key. func (cfg *Config) SanitizeXAIKeys() { if cfg == nil { return } cfg.XAIKey = sanitizeCodexKeyEntries(cfg.XAIKey) for i := range cfg.XAIKey { cfg.XAIKey[i].AlphaSearch = false } } func sanitizeCodexKeyEntries(entries []CodexKey) []CodexKey { if len(entries) == 0 { return entries } out := make([]CodexKey, 0, len(entries)) for i := range entries { e := entries[i] e.Prefix = normalizeModelPrefix(e.Prefix) e.BaseURL = strings.TrimSpace(e.BaseURL) e.Headers = NormalizeHeaders(e.Headers) e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels) if e.BaseURL == "" { continue } out = append(out, e) } return out } // SanitizeClaudeKeys normalizes headers for Claude credentials. func (cfg *Config) SanitizeClaudeKeys() { if cfg == nil || len(cfg.ClaudeKey) == 0 { return } for i := range cfg.ClaudeKey { entry := &cfg.ClaudeKey[i] entry.Prefix = normalizeModelPrefix(entry.Prefix) entry.Headers = NormalizeHeaders(entry.Headers) entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) // Only a recognized value is rewritten. An unrecognized one is preserved as // written so sanitizing a config file never destroys operator input; the // request path falls back to the default profile and reports it once. if normalized, ok := NormalizeClaudeFingerprintProfile(entry.FingerprintProfile); ok { entry.FingerprintProfile = normalized } else { entry.FingerprintProfile = strings.TrimSpace(entry.FingerprintProfile) } } } func sanitizeGeminiKeyEntries(entries []GeminiKey) []GeminiKey { seen := make(map[string]struct{}, len(entries)) out := entries[:0] for i := range entries { entry := entries[i] entry.APIKey = strings.TrimSpace(entry.APIKey) entry.BaseURL = strings.TrimSpace(entry.BaseURL) if entry.APIKey == "" && entry.BaseURL == "" { continue } entry.Prefix = normalizeModelPrefix(entry.Prefix) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = NormalizeHeaders(entry.Headers) entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) uniqueKey := formatGeminiKeyDedupID(entry) if _, exists := seen[uniqueKey]; exists { continue } seen[uniqueKey] = struct{}{} out = append(out, entry) } return out } func formatGeminiKeyDedupID(entry GeminiKey) string { var b strings.Builder b.WriteString(entry.APIKey) b.WriteByte(0) b.WriteString(entry.BaseURL) b.WriteByte(0) b.WriteString(entry.ProxyURL) b.WriteByte(0) b.WriteString(entry.Prefix) b.WriteByte(0) b.WriteString(FormatSortedHeaders(entry.Headers)) return b.String() } // FormatSortedHeaders serializes headers deterministically with null byte separators. func FormatSortedHeaders(headers map[string]string) string { if len(headers) == 0 { return "" } keys := make([]string, 0, len(headers)) for k := range headers { keys = append(keys, k) } sort.Strings(keys) var b strings.Builder for _, k := range keys { b.WriteString(k) b.WriteByte(0) b.WriteString(headers[k]) b.WriteByte(0) } return b.String() } // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials. // It uses API key, base URL, proxy URL, prefix, and custom headers as the uniqueness key. func (cfg *Config) SanitizeGeminiKeys() { if cfg == nil { return } cfg.GeminiKey = sanitizeGeminiKeyEntries(cfg.GeminiKey) } // SanitizeInteractionsKeys deduplicates and normalizes native Interactions credentials. // It uses API key, base URL, proxy URL, prefix, and custom headers as the uniqueness key. func (cfg *Config) SanitizeInteractionsKeys() { if cfg == nil { return } cfg.InteractionsKey = sanitizeGeminiKeyEntries(cfg.InteractionsKey) } func normalizeModelPrefix(prefix string) string { trimmed := strings.TrimSpace(prefix) trimmed = strings.Trim(trimmed, "/") if trimmed == "" { return "" } if strings.Contains(trimmed, "/") { return "" } return trimmed } // NormalizeHeaders trims header keys and values and removes empty pairs. func NormalizeHeaders(headers map[string]string) map[string]string { if len(headers) == 0 { return nil } clean := make(map[string]string, len(headers)) for k, v := range headers { key := strings.TrimSpace(k) val := strings.TrimSpace(v) if key == "" || val == "" { continue } clean[key] = val } if len(clean) == 0 { return nil } return clean } // NormalizeExcludedModels trims, lowercases, and deduplicates model exclusion patterns. // It preserves the order of first occurrences and drops empty entries. func NormalizeExcludedModels(models []string) []string { if len(models) == 0 { return nil } seen := make(map[string]struct{}, len(models)) out := make([]string, 0, len(models)) for _, raw := range models { trimmed := strings.ToLower(strings.TrimSpace(raw)) if trimmed == "" { continue } if _, exists := seen[trimmed]; exists { continue } seen[trimmed] = struct{}{} out = append(out, trimmed) } if len(out) == 0 { return nil } return out } // NormalizeOAuthExcludedModels cleans provider -> excluded models mappings by normalizing provider keys // and applying model exclusion normalization to each entry. func NormalizeOAuthExcludedModels(entries map[string][]string) map[string][]string { if len(entries) == 0 { return nil } out := make(map[string][]string, len(entries)) for provider, models := range entries { key := strings.ToLower(strings.TrimSpace(provider)) if key == "" { continue } normalized := NormalizeExcludedModels(models) if len(normalized) == 0 { continue } out[key] = normalized } if len(out) == 0 { return nil } return out }