vibe-proxy/backend/internal/config/config_normalization.go
2026-08-24 00:10:41 +02:00

392 lines
12 KiB
Go

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
}