package diff import ( "fmt" "net/url" "reflect" "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" ) // BuildConfigChangeDetails computes a redacted, human-readable list of config changes. // Secrets are never printed; only structural or non-sensitive fields are surfaced. 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.DisableCooling != newCfg.DisableCooling { changes = append(changes, fmt.Sprintf("disable-cooling: %t -> %t", oldCfg.DisableCooling, newCfg.DisableCooling)) } 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.MaxRetryInterval != newCfg.MaxRetryInterval { changes = append(changes, fmt.Sprintf("max-retry-interval: %d -> %d", oldCfg.MaxRetryInterval, newCfg.MaxRetryInterval)) } if oldCfg.ProxyURL != newCfg.ProxyURL { changes = append(changes, fmt.Sprintf("proxy-url: %s -> %s", formatProxyURL(oldCfg.ProxyURL), formatProxyURL(newCfg.ProxyURL))) } if oldCfg.WebsocketAuth != newCfg.WebsocketAuth { changes = append(changes, fmt.Sprintf("ws-auth: %t -> %t", oldCfg.WebsocketAuth, newCfg.WebsocketAuth)) } if oldCfg.ForceModelPrefix != newCfg.ForceModelPrefix { changes = append(changes, fmt.Sprintf("force-model-prefix: %t -> %t", oldCfg.ForceModelPrefix, newCfg.ForceModelPrefix)) } // 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.GeminiKey) != len(newCfg.GeminiKey) { changes = append(changes, fmt.Sprintf("gemini-api-key count: %d -> %d", len(oldCfg.GeminiKey), len(newCfg.GeminiKey))) } else { for i := range oldCfg.GeminiKey { o := oldCfg.GeminiKey[i] n := newCfg.GeminiKey[i] if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) { changes = append(changes, fmt.Sprintf("gemini[%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("gemini[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL))) } if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) { changes = append(changes, fmt.Sprintf("gemini[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix))) } if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { changes = append(changes, fmt.Sprintf("gemini[%d].api-key: updated", i)) } if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("gemini[%d].headers: updated", i)) } oldModels := SummarizeGeminiModels(o.Models) newModels := SummarizeGeminiModels(n.Models) if oldModels.hash != newModels.hash { changes = append(changes, fmt.Sprintf("gemini[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) } oldExcluded := SummarizeExcludedModels(o.ExcludedModels) newExcluded := SummarizeExcludedModels(n.ExcludedModels) if oldExcluded.hash != newExcluded.hash { changes = append(changes, fmt.Sprintf("gemini[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) } } } // 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 { 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, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL))) } if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) { changes = append(changes, fmt.Sprintf("claude[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix))) } if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i)) } if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i)) } oldModels := SummarizeClaudeModels(o.Models) newModels := SummarizeClaudeModels(n.Models) if oldModels.hash != newModels.hash { changes = append(changes, fmt.Sprintf("claude[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) } oldExcluded := SummarizeExcludedModels(o.ExcludedModels) newExcluded := SummarizeExcludedModels(n.ExcludedModels) if oldExcluded.hash != newExcluded.hash { changes = append(changes, fmt.Sprintf("claude[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) } } } // 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 { 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, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL))) } if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) { changes = append(changes, fmt.Sprintf("codex[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix))) } if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i)) } if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i)) } oldModels := SummarizeCodexModels(o.Models) newModels := SummarizeCodexModels(n.Models) if oldModels.hash != newModels.hash { changes = append(changes, fmt.Sprintf("codex[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) } oldExcluded := SummarizeExcludedModels(o.ExcludedModels) newExcluded := SummarizeExcludedModels(n.ExcludedModels) if oldExcluded.hash != newExcluded.hash { changes = append(changes, fmt.Sprintf("codex[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) } } } // AmpCode settings (redacted where needed) oldAmpURL := strings.TrimSpace(oldCfg.AmpCode.UpstreamURL) newAmpURL := strings.TrimSpace(newCfg.AmpCode.UpstreamURL) if oldAmpURL != newAmpURL { changes = append(changes, fmt.Sprintf("ampcode.upstream-url: %s -> %s", oldAmpURL, newAmpURL)) } oldAmpKey := strings.TrimSpace(oldCfg.AmpCode.UpstreamAPIKey) newAmpKey := strings.TrimSpace(newCfg.AmpCode.UpstreamAPIKey) switch { case oldAmpKey == "" && newAmpKey != "": changes = append(changes, "ampcode.upstream-api-key: added") case oldAmpKey != "" && newAmpKey == "": changes = append(changes, "ampcode.upstream-api-key: removed") case oldAmpKey != newAmpKey: changes = append(changes, "ampcode.upstream-api-key: updated") } if oldCfg.AmpCode.RestrictManagementToLocalhost != newCfg.AmpCode.RestrictManagementToLocalhost { changes = append(changes, fmt.Sprintf("ampcode.restrict-management-to-localhost: %t -> %t", oldCfg.AmpCode.RestrictManagementToLocalhost, newCfg.AmpCode.RestrictManagementToLocalhost)) } oldMappings := SummarizeAmpModelMappings(oldCfg.AmpCode.ModelMappings) newMappings := SummarizeAmpModelMappings(newCfg.AmpCode.ModelMappings) if oldMappings.hash != newMappings.hash { changes = append(changes, fmt.Sprintf("ampcode.model-mappings: updated (%d -> %d entries)", oldMappings.count, newMappings.count)) } if oldCfg.AmpCode.ForceModelMappings != newCfg.AmpCode.ForceModelMappings { changes = append(changes, fmt.Sprintf("ampcode.force-model-mappings: %t -> %t", oldCfg.AmpCode.ForceModelMappings, newCfg.AmpCode.ForceModelMappings)) } oldUpstreamAPIKeysCount := len(oldCfg.AmpCode.UpstreamAPIKeys) newUpstreamAPIKeysCount := len(newCfg.AmpCode.UpstreamAPIKeys) if !equalUpstreamAPIKeys(oldCfg.AmpCode.UpstreamAPIKeys, newCfg.AmpCode.UpstreamAPIKeys) { changes = append(changes, fmt.Sprintf("ampcode.upstream-api-keys: updated (%d -> %d entries)", oldUpstreamAPIKeysCount, newUpstreamAPIKeysCount)) } if entries, _ := DiffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 { changes = append(changes, entries...) } if entries, _ := DiffOAuthModelMappingChanges(oldCfg.OAuthModelMappings, newCfg.OAuthModelMappings); len(entries) > 0 { changes = append(changes, entries...) } // 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)) } oldPanelRepo := strings.TrimSpace(oldCfg.RemoteManagement.PanelGitHubRepository) newPanelRepo := strings.TrimSpace(newCfg.RemoteManagement.PanelGitHubRepository) if oldPanelRepo != newPanelRepo { changes = append(changes, fmt.Sprintf("remote-management.panel-github-repository: %s -> %s", oldPanelRepo, newPanelRepo)) } 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) } } // Vertex-compatible API keys if len(oldCfg.VertexCompatAPIKey) != len(newCfg.VertexCompatAPIKey) { changes = append(changes, fmt.Sprintf("vertex-api-key count: %d -> %d", len(oldCfg.VertexCompatAPIKey), len(newCfg.VertexCompatAPIKey))) } else { for i := range oldCfg.VertexCompatAPIKey { o := oldCfg.VertexCompatAPIKey[i] n := newCfg.VertexCompatAPIKey[i] if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) { changes = append(changes, fmt.Sprintf("vertex[%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("vertex[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL))) } if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) { changes = append(changes, fmt.Sprintf("vertex[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix))) } if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { changes = append(changes, fmt.Sprintf("vertex[%d].api-key: updated", i)) } oldModels := SummarizeVertexModels(o.Models) newModels := SummarizeVertexModels(n.Models) if oldModels.hash != newModels.hash { changes = append(changes, fmt.Sprintf("vertex[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) } if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("vertex[%d].headers: updated", i)) } } } return changes } func trimStrings(in []string) []string { out := make([]string, len(in)) for i := range in { out[i] = strings.TrimSpace(in[i]) } return out } func equalStringMap(a, b map[string]string) bool { if len(a) != len(b) { return false } for k, v := range a { if b[k] != v { return false } } return true } func formatProxyURL(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } parsed, err := url.Parse(trimmed) if err != nil { return "" } host := strings.TrimSpace(parsed.Host) scheme := strings.TrimSpace(parsed.Scheme) if host == "" { // Allow host:port style without scheme. parsed2, err2 := url.Parse("http://" + trimmed) if err2 == nil { host = strings.TrimSpace(parsed2.Host) } scheme = "" } if host == "" { return "" } if scheme == "" { return host } return scheme + "://" + host } func equalStringSet(a, b []string) bool { if len(a) == 0 && len(b) == 0 { return true } aSet := make(map[string]struct{}, len(a)) for _, k := range a { aSet[strings.TrimSpace(k)] = struct{}{} } bSet := make(map[string]struct{}, len(b)) for _, k := range b { bSet[strings.TrimSpace(k)] = struct{}{} } if len(aSet) != len(bSet) { return false } for k := range aSet { if _, ok := bSet[k]; !ok { return false } } return true } // equalUpstreamAPIKeys compares two slices of AmpUpstreamAPIKeyEntry for equality. // Comparison is done by count and content (upstream key and client keys). func equalUpstreamAPIKeys(a, b []config.AmpUpstreamAPIKeyEntry) bool { if len(a) != len(b) { return false } for i := range a { if strings.TrimSpace(a[i].UpstreamAPIKey) != strings.TrimSpace(b[i].UpstreamAPIKey) { return false } if !equalStringSet(a[i].APIKeys, b[i].APIKeys) { return false } } return true }