392 lines
12 KiB
Go
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
|
|
}
|