From 06e6f0a5f27b934516bfc9cb1536cdd5cea5ab1c Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 13 Oct 2025 08:38:57 +0800 Subject: [PATCH 1/4] refactor(watcher): Extract config change logging to new function --- internal/watcher/watcher.go | 206 +++++++++++++++++++++++++----------- 1 file changed, 143 insertions(+), 63 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index e8586c2b..e30dfb0e 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -538,71 +538,16 @@ func (w *Watcher) reloadConfig() bool { log.Debugf("log level updated - debug mode changed from %t to %t", oldConfig.Debug, newConfig.Debug) } - // Log configuration changes in debug mode + // Log configuration changes in debug mode, only when there are material diffs if oldConfig != nil { - log.Debugf("config changes detected:") - if oldConfig.Port != newConfig.Port { - log.Debugf(" port: %d -> %d", oldConfig.Port, newConfig.Port) - } - if oldConfig.AuthDir != newConfig.AuthDir { - log.Debugf(" auth-dir: %s -> %s", oldConfig.AuthDir, newConfig.AuthDir) - } - if oldConfig.Debug != newConfig.Debug { - log.Debugf(" debug: %t -> %t", oldConfig.Debug, newConfig.Debug) - } - if oldConfig.ProxyURL != newConfig.ProxyURL { - log.Debugf(" proxy-url: %s -> %s", oldConfig.ProxyURL, newConfig.ProxyURL) - } - if oldConfig.RequestLog != newConfig.RequestLog { - log.Debugf(" request-log: %t -> %t", oldConfig.RequestLog, newConfig.RequestLog) - } - if oldConfig.RequestRetry != newConfig.RequestRetry { - log.Debugf(" request-retry: %d -> %d", oldConfig.RequestRetry, newConfig.RequestRetry) - } - if len(oldConfig.APIKeys) != len(newConfig.APIKeys) { - log.Debugf(" api-keys count: %d -> %d", len(oldConfig.APIKeys), len(newConfig.APIKeys)) - } - if len(oldConfig.GlAPIKey) != len(newConfig.GlAPIKey) { - log.Debugf(" generative-language-api-key count: %d -> %d", len(oldConfig.GlAPIKey), len(newConfig.GlAPIKey)) - } - if len(oldConfig.ClaudeKey) != len(newConfig.ClaudeKey) { - log.Debugf(" claude-api-key count: %d -> %d", len(oldConfig.ClaudeKey), len(newConfig.ClaudeKey)) - } - if len(oldConfig.CodexKey) != len(newConfig.CodexKey) { - log.Debugf(" codex-api-key count: %d -> %d", len(oldConfig.CodexKey), len(newConfig.CodexKey)) - } - if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote { - log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote) - } - if oldConfig.RemoteManagement.SecretKey != newConfig.RemoteManagement.SecretKey { - switch { - case oldConfig.RemoteManagement.SecretKey == "" && newConfig.RemoteManagement.SecretKey != "": - log.Debug(" remote-management.secret-key: created") - case oldConfig.RemoteManagement.SecretKey != "" && newConfig.RemoteManagement.SecretKey == "": - log.Debug(" remote-management.secret-key: deleted") - default: - log.Debug(" remote-management.secret-key: updated") - } - if newConfig.RemoteManagement.SecretKey == "" { - log.Info("management routes will be disabled after secret key removal") - } else { - log.Info("management routes will be enabled after secret key update") - } - } - if oldConfig.RemoteManagement.DisableControlPanel != newConfig.RemoteManagement.DisableControlPanel { - log.Debugf(" remote-management.disable-control-panel: %t -> %t", oldConfig.RemoteManagement.DisableControlPanel, newConfig.RemoteManagement.DisableControlPanel) - } - if oldConfig.LoggingToFile != newConfig.LoggingToFile { - log.Debugf(" logging-to-file: %t -> %t", oldConfig.LoggingToFile, newConfig.LoggingToFile) - } - if oldConfig.UsageStatisticsEnabled != newConfig.UsageStatisticsEnabled { - log.Debugf(" usage-statistics-enabled: %t -> %t", oldConfig.UsageStatisticsEnabled, newConfig.UsageStatisticsEnabled) - } - if changes := diffOpenAICompatibility(oldConfig.OpenAICompatibility, newConfig.OpenAICompatibility); len(changes) > 0 { - log.Debugf(" openai-compatibility:") - for _, change := range changes { - log.Debugf(" %s", change) + details := buildConfigChangeDetails(oldConfig, newConfig) + if len(details) > 0 { + log.Debugf("config changes detected:") + for _, d := range details { + log.Debugf(" %s", d) } + } else { + log.Debugf("no material config field changes detected") } if oldConfig.QuotaExceeded.SwitchProject != newConfig.QuotaExceeded.SwitchProject { log.Debugf(" quota-exceeded.switch-project: %t -> %t", oldConfig.QuotaExceeded.SwitchProject, newConfig.QuotaExceeded.SwitchProject) @@ -1213,3 +1158,138 @@ func openAICompatKey(entry config.OpenAICompatibility, index int) (string, strin } return fmt.Sprintf("index:%d", index), fmt.Sprintf("entry-%d", index+1) } + +// buildConfigChangeDetails computes a redacted, human-readable list of config changes. +// It avoids printing secrets (like API keys) and focuses on structural or non-sensitive fields. +func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { + changes := make([]string, 0, 16) + if oldCfg == nil || newCfg == nil { + return changes + } + + // Simple scalars + if oldCfg.Port != newCfg.Port { + changes = append(changes, fmt.Sprintf("port: %d -> %d", oldCfg.Port, newCfg.Port)) + } + if oldCfg.AuthDir != newCfg.AuthDir { + changes = append(changes, fmt.Sprintf("auth-dir: %s -> %s", oldCfg.AuthDir, newCfg.AuthDir)) + } + if oldCfg.Debug != newCfg.Debug { + changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug)) + } + if oldCfg.LoggingToFile != newCfg.LoggingToFile { + changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile)) + } + if oldCfg.UsageStatisticsEnabled != newCfg.UsageStatisticsEnabled { + changes = append(changes, fmt.Sprintf("usage-statistics-enabled: %t -> %t", oldCfg.UsageStatisticsEnabled, newCfg.UsageStatisticsEnabled)) + } + if oldCfg.RequestLog != newCfg.RequestLog { + changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog)) + } + if oldCfg.RequestRetry != newCfg.RequestRetry { + changes = append(changes, fmt.Sprintf("request-retry: %d -> %d", oldCfg.RequestRetry, newCfg.RequestRetry)) + } + if oldCfg.ProxyURL != newCfg.ProxyURL { + changes = append(changes, fmt.Sprintf("proxy-url: %s -> %s", oldCfg.ProxyURL, newCfg.ProxyURL)) + } + + // Quota-exceeded behavior + if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject { + changes = append(changes, fmt.Sprintf("quota-exceeded.switch-project: %t -> %t", oldCfg.QuotaExceeded.SwitchProject, newCfg.QuotaExceeded.SwitchProject)) + } + if oldCfg.QuotaExceeded.SwitchPreviewModel != newCfg.QuotaExceeded.SwitchPreviewModel { + changes = append(changes, fmt.Sprintf("quota-exceeded.switch-preview-model: %t -> %t", oldCfg.QuotaExceeded.SwitchPreviewModel, newCfg.QuotaExceeded.SwitchPreviewModel)) + } + + // API keys (redacted) and counts + if len(oldCfg.APIKeys) != len(newCfg.APIKeys) { + changes = append(changes, fmt.Sprintf("api-keys count: %d -> %d", len(oldCfg.APIKeys), len(newCfg.APIKeys))) + } else if !reflect.DeepEqual(trimStrings(oldCfg.APIKeys), trimStrings(newCfg.APIKeys)) { + changes = append(changes, "api-keys: values updated (count unchanged, redacted)") + } + if len(oldCfg.GlAPIKey) != len(newCfg.GlAPIKey) { + changes = append(changes, fmt.Sprintf("generative-language-api-key count: %d -> %d", len(oldCfg.GlAPIKey), len(newCfg.GlAPIKey))) + } else if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) { + changes = append(changes, "generative-language-api-key: values updated (count unchanged, redacted)") + } + + // Claude keys (do not print key material) + if len(oldCfg.ClaudeKey) != len(newCfg.ClaudeKey) { + changes = append(changes, fmt.Sprintf("claude-api-key count: %d -> %d", len(oldCfg.ClaudeKey), len(newCfg.ClaudeKey))) + } else { + for i := range oldCfg.ClaudeKey { + if i >= len(newCfg.ClaudeKey) { + break + } + o := oldCfg.ClaudeKey[i] + n := newCfg.ClaudeKey[i] + if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) { + changes = append(changes, fmt.Sprintf("claude[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL))) + } + if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) { + changes = append(changes, fmt.Sprintf("claude[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL))) + } + if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { + changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i)) + } + } + } + + // Codex keys (do not print key material) + if len(oldCfg.CodexKey) != len(newCfg.CodexKey) { + changes = append(changes, fmt.Sprintf("codex-api-key count: %d -> %d", len(oldCfg.CodexKey), len(newCfg.CodexKey))) + } else { + for i := range oldCfg.CodexKey { + if i >= len(newCfg.CodexKey) { + break + } + o := oldCfg.CodexKey[i] + n := newCfg.CodexKey[i] + if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) { + changes = append(changes, fmt.Sprintf("codex[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL))) + } + if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) { + changes = append(changes, fmt.Sprintf("codex[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL))) + } + if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { + changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i)) + } + } + } + + // Remote management (never print the key) + if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote { + changes = append(changes, fmt.Sprintf("remote-management.allow-remote: %t -> %t", oldCfg.RemoteManagement.AllowRemote, newCfg.RemoteManagement.AllowRemote)) + } + if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel { + changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel)) + } + if oldCfg.RemoteManagement.SecretKey != newCfg.RemoteManagement.SecretKey { + switch { + case oldCfg.RemoteManagement.SecretKey == "" && newCfg.RemoteManagement.SecretKey != "": + changes = append(changes, "remote-management.secret-key: created") + case oldCfg.RemoteManagement.SecretKey != "" && newCfg.RemoteManagement.SecretKey == "": + changes = append(changes, "remote-management.secret-key: deleted") + default: + changes = append(changes, "remote-management.secret-key: updated") + } + } + + // OpenAI compatibility providers (summarized) + if compat := diffOpenAICompatibility(oldCfg.OpenAICompatibility, newCfg.OpenAICompatibility); len(compat) > 0 { + changes = append(changes, "openai-compatibility:") + for _, c := range compat { + changes = append(changes, " "+c) + } + } + + return changes +} + +func trimStrings(in []string) []string { + out := make([]string, len(in)) + for i := range in { + out[i] = strings.TrimSpace(in[i]) + } + return out +} From 5fd4a8b97439000d62af32192c983b2949c882b5 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:02:32 +0800 Subject: [PATCH 2/4] feat(config): Remove OpenAI providers with empty BaseURL --- .../api/handlers/management/config_lists.go | 37 ++++++++++++++++++- internal/config/config.go | 25 +++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 22bff1b3..b49940b8 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -227,7 +227,14 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) { for i := range arr { normalizeOpenAICompatibilityEntry(&arr[i]) } - h.cfg.OpenAICompatibility = arr + // Filter out providers with empty base-url -> remove provider entirely + filtered := make([]config.OpenAICompatibility, 0, len(arr)) + for i := range arr { + if strings.TrimSpace(arr[i].BaseURL) != "" { + filtered = append(filtered, arr[i]) + } + } + h.cfg.OpenAICompatibility = filtered h.persist(c) } func (h *Handler) PatchOpenAICompat(c *gin.Context) { @@ -241,6 +248,32 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { return } normalizeOpenAICompatibilityEntry(body.Value) + // If base-url becomes empty, delete the provider instead of updating + if strings.TrimSpace(body.Value.BaseURL) == "" { + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) { + h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:*body.Index], h.cfg.OpenAICompatibility[*body.Index+1:]...) + h.persist(c) + return + } + if body.Name != nil { + out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility)) + removed := false + for i := range h.cfg.OpenAICompatibility { + if !removed && h.cfg.OpenAICompatibility[i].Name == *body.Name { + removed = true + continue + } + out = append(out, h.cfg.OpenAICompatibility[i]) + } + if removed { + h.cfg.OpenAICompatibility = out + h.persist(c) + return + } + } + c.JSON(404, gin.H{"error": "item not found"}) + return + } if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) { h.cfg.OpenAICompatibility[*body.Index] = *body.Value h.persist(c) @@ -359,6 +392,8 @@ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) { if entry == nil { return } + // Trim base-url; empty base-url indicates provider should be removed by sanitization + entry.BaseURL = strings.TrimSpace(entry.BaseURL) existing := make(map[string]struct{}, len(entry.APIKeyEntries)) for i := range entry.APIKeyEntries { trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey) diff --git a/internal/config/config.go b/internal/config/config.go index 3de596a7..e144c263 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "os" + "strings" "syscall" "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" @@ -207,10 +208,34 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sync request authentication providers with inline API keys for backwards compatibility. syncInlineAccessProvider(&cfg) + // Sanitize OpenAI compatibility providers: drop entries without base-url + sanitizeOpenAICompatibility(&cfg) + // Return the populated configuration struct. return &cfg, nil } +// 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 sanitizeOpenAICompatibility(cfg *Config) { + 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.BaseURL = strings.TrimSpace(e.BaseURL) + if e.BaseURL == "" { + // Skip providers with no base-url; treated as removed + continue + } + out = append(out, e) + } + cfg.OpenAICompatibility = out +} + func syncInlineAccessProvider(cfg *Config) { if cfg == nil { return From 0aa8706ef7632fcbc9f65cda5f81bcf8bcb3e43f Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:16:38 +0800 Subject: [PATCH 3/4] feat(config): Treat empty BaseURL for Codex keys as deletion --- .../api/handlers/management/config_lists.go | 55 +++++++++++++++---- internal/config/config.go | 21 +++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index b49940b8..fe8d3141 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -335,7 +335,17 @@ func (h *Handler) PutCodexKeys(c *gin.Context) { } arr = obj.Items } - h.cfg.CodexKey = arr + // Filter out codex entries with empty base-url (treat as removed) + filtered := make([]config.CodexKey, 0, len(arr)) + for i := range arr { + entry := arr[i] + entry.BaseURL = strings.TrimSpace(entry.BaseURL) + if entry.BaseURL == "" { + continue + } + filtered = append(filtered, entry) + } + h.cfg.CodexKey = filtered h.persist(c) } func (h *Handler) PatchCodexKey(c *gin.Context) { @@ -348,19 +358,44 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } - if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { - h.cfg.CodexKey[*body.Index] = *body.Value - h.persist(c) - return - } - if body.Match != nil { - for i := range h.cfg.CodexKey { - if h.cfg.CodexKey[i].APIKey == *body.Match { - h.cfg.CodexKey[i] = *body.Value + // If base-url becomes empty, delete instead of update + if strings.TrimSpace(body.Value.BaseURL) == "" { + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { + h.cfg.CodexKey = append(h.cfg.CodexKey[:*body.Index], h.cfg.CodexKey[*body.Index+1:]...) + h.persist(c) + return + } + if body.Match != nil { + out := make([]config.CodexKey, 0, len(h.cfg.CodexKey)) + removed := false + for i := range h.cfg.CodexKey { + if !removed && h.cfg.CodexKey[i].APIKey == *body.Match { + removed = true + continue + } + out = append(out, h.cfg.CodexKey[i]) + } + if removed { + h.cfg.CodexKey = out h.persist(c) return } } + } else { + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { + h.cfg.CodexKey[*body.Index] = *body.Value + h.persist(c) + return + } + if body.Match != nil { + for i := range h.cfg.CodexKey { + if h.cfg.CodexKey[i].APIKey == *body.Match { + h.cfg.CodexKey[i] = *body.Value + h.persist(c) + return + } + } + } } c.JSON(404, gin.H{"error": "item not found"}) } diff --git a/internal/config/config.go b/internal/config/config.go index e144c263..50dcd5a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -211,6 +211,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sanitize OpenAI compatibility providers: drop entries without base-url sanitizeOpenAICompatibility(&cfg) + // Sanitize Codex keys: drop entries without base-url + sanitizeCodexKeys(&cfg) + // Return the populated configuration struct. return &cfg, nil } @@ -236,6 +239,24 @@ func sanitizeOpenAICompatibility(cfg *Config) { cfg.OpenAICompatibility = out } +// sanitizeCodexKeys removes Codex API key entries missing a BaseURL. +// It trims whitespace and preserves order for remaining entries. +func sanitizeCodexKeys(cfg *Config) { + if cfg == nil || len(cfg.CodexKey) == 0 { + return + } + out := make([]CodexKey, 0, len(cfg.CodexKey)) + for i := range cfg.CodexKey { + e := cfg.CodexKey[i] + e.BaseURL = strings.TrimSpace(e.BaseURL) + if e.BaseURL == "" { + continue + } + out = append(out, e) + } + cfg.CodexKey = out +} + func syncInlineAccessProvider(cfg *Config) { if cfg == nil { return From 39abde2413623483c93654488ef57134939924d3 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:02:55 +0800 Subject: [PATCH 4/4] refactor(watcher): remove redundant quota-exceeded change logs --- internal/watcher/watcher.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index e30dfb0e..5bc03b17 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -549,12 +549,6 @@ func (w *Watcher) reloadConfig() bool { } else { log.Debugf("no material config field changes detected") } - if oldConfig.QuotaExceeded.SwitchProject != newConfig.QuotaExceeded.SwitchProject { - log.Debugf(" quota-exceeded.switch-project: %t -> %t", oldConfig.QuotaExceeded.SwitchProject, newConfig.QuotaExceeded.SwitchProject) - } - if oldConfig.QuotaExceeded.SwitchPreviewModel != newConfig.QuotaExceeded.SwitchPreviewModel { - log.Debugf(" quota-exceeded.switch-preview-model: %t -> %t", oldConfig.QuotaExceeded.SwitchPreviewModel, newConfig.QuotaExceeded.SwitchPreviewModel) - } } authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir