mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
* feat(codex): add opt-in stream bootstrap buffering
The upstream smuggles capacity rejections into an HTTP 200 stream. The
handshake events arrive normally and only a later event carries
{"error":{"type":"service_unavailable_error","code":
"server_is_overloaded"}}. By then the executor has already handed the
first chunk downstream, the response is committed, and the conductor can
no longer retry on another credential, so the request fails even though
other credentials were available.
When codex.stream-bootstrap-buffering is enabled the executor holds back
the handshake events until it can tell whether the stream carries real
output or a rejection. An overload rejection then fails the attempt
before any chunk is delivered, letting the conductor retry on another
credential; every other terminal failure is flushed in order and
delivered in-stream exactly as before.
Detection uses an event-type allow-list rather than a fixed count. On the
websocket transport codex.rate_limits and codex.response.metadata arrive
before response.created, making the first generated event the fifth
frame, so a small counter would release the stream before the rejection
is visible. Buffering is bounded and hitting the bound degrades to the
original unbuffered behaviour.
Two details are load-bearing. The error must be returned synchronously:
delivering it as the first stream chunk makes ExecuteStream downgrade it
into a committed 200 and the status is lost. And the websocket path must
not signal an upstream disconnect for a rejection it intends to retry,
because the downstream handler closes the client connection on that
signal and the retry would have nowhere to deliver.
The 503 status is produced only on this path rather than in the shared
codexTerminalFailureStatus mapping, so disabling the feature restores the
previous behaviour exactly, including cooldown classification and
retry-after parsing.
Defaults to false: response headers are withheld until generation
starts, which can trip client or reverse-proxy read timeouts.
* test(codex): pin bootstrap overload failover through the conductor
Executor-level tests cannot show what the client finally receives. These
exercise ExecuteStream end to end to pin three properties that are easy
to regress:
- consecutive overloaded credentials are skipped until one serves the
request, and retries are capped by max-retry-credentials rather than
multiplying with request-retry
- exhausting the pool surfaces the upstream status instead of a
committed 200 stream
- with buffering disabled the rejection stays an in-stream error on a
committed stream, which is the behaviour the feature must preserve
The third case also documents why the executor returns its error
synchronously: an error arriving as the first stream chunk is wrapped and
downgraded into a committed 200, silently losing the status.
589 lines
29 KiB
Go
589 lines
29 KiB
Go
package diff
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/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.Pprof.Enable != newCfg.Pprof.Enable {
|
|
changes = append(changes, fmt.Sprintf("pprof.enable: %t -> %t", oldCfg.Pprof.Enable, newCfg.Pprof.Enable))
|
|
}
|
|
if strings.TrimSpace(oldCfg.Pprof.Addr) != strings.TrimSpace(newCfg.Pprof.Addr) {
|
|
changes = append(changes, fmt.Sprintf("pprof.addr: %s -> %s", strings.TrimSpace(oldCfg.Pprof.Addr), strings.TrimSpace(newCfg.Pprof.Addr)))
|
|
}
|
|
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.RedisUsageQueueRetentionSeconds != newCfg.RedisUsageQueueRetentionSeconds {
|
|
changes = append(changes, fmt.Sprintf("redis-usage-queue-retention-seconds: %d -> %d", oldCfg.RedisUsageQueueRetentionSeconds, newCfg.RedisUsageQueueRetentionSeconds))
|
|
}
|
|
if oldCfg.DisableCooling != newCfg.DisableCooling {
|
|
changes = append(changes, fmt.Sprintf("disable-cooling: %t -> %t", oldCfg.DisableCooling, newCfg.DisableCooling))
|
|
}
|
|
if oldCfg.SaveCooldownStatus != newCfg.SaveCooldownStatus {
|
|
changes = append(changes, fmt.Sprintf("save-cooldown-status: %t -> %t", oldCfg.SaveCooldownStatus, newCfg.SaveCooldownStatus))
|
|
}
|
|
if oldCfg.TransientErrorCooldownSeconds != newCfg.TransientErrorCooldownSeconds {
|
|
changes = append(changes, fmt.Sprintf("transient-error-cooldown-seconds: %d -> %d", oldCfg.TransientErrorCooldownSeconds, newCfg.TransientErrorCooldownSeconds))
|
|
}
|
|
if oldCfg.DisableClaudeCloakMode != newCfg.DisableClaudeCloakMode {
|
|
changes = append(changes, fmt.Sprintf("disable-claude-cloak-mode: %t -> %t", oldCfg.DisableClaudeCloakMode, newCfg.DisableClaudeCloakMode))
|
|
}
|
|
if oldCfg.ClaudeCode.DisableCloakingModelList != newCfg.ClaudeCode.DisableCloakingModelList {
|
|
changes = append(changes, fmt.Sprintf("claude-code.disable-cloaking-model-list: %t -> %t", oldCfg.ClaudeCode.DisableCloakingModelList, newCfg.ClaudeCode.DisableCloakingModelList))
|
|
}
|
|
if oldCfg.DisableImageGeneration != newCfg.DisableImageGeneration {
|
|
changes = append(changes, fmt.Sprintf("disable-image-generation: %v -> %v", oldCfg.DisableImageGeneration, newCfg.DisableImageGeneration))
|
|
}
|
|
if strings.TrimSpace(oldCfg.GPTImage2BaseModel) != strings.TrimSpace(newCfg.GPTImage2BaseModel) {
|
|
changes = append(changes, fmt.Sprintf("gpt-image-2-base-model: %s -> %s", strings.TrimSpace(oldCfg.GPTImage2BaseModel), strings.TrimSpace(newCfg.GPTImage2BaseModel)))
|
|
}
|
|
if oldCfg.RequestLog != newCfg.RequestLog {
|
|
changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog))
|
|
}
|
|
if oldCfg.LogsMaxTotalSizeMB != newCfg.LogsMaxTotalSizeMB {
|
|
changes = append(changes, fmt.Sprintf("logs-max-total-size-mb: %d -> %d", oldCfg.LogsMaxTotalSizeMB, newCfg.LogsMaxTotalSizeMB))
|
|
}
|
|
if oldCfg.ErrorLogsMaxFiles != newCfg.ErrorLogsMaxFiles {
|
|
changes = append(changes, fmt.Sprintf("error-logs-max-files: %d -> %d", oldCfg.ErrorLogsMaxFiles, newCfg.ErrorLogsMaxFiles))
|
|
}
|
|
if oldCfg.RequestRetry != newCfg.RequestRetry {
|
|
changes = append(changes, fmt.Sprintf("request-retry: %d -> %d", oldCfg.RequestRetry, newCfg.RequestRetry))
|
|
}
|
|
if oldCfg.MaxRetryCredentials != newCfg.MaxRetryCredentials {
|
|
changes = append(changes, fmt.Sprintf("max-retry-credentials: %d -> %d", oldCfg.MaxRetryCredentials, newCfg.MaxRetryCredentials))
|
|
}
|
|
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))
|
|
}
|
|
if oldCfg.NonStreamKeepAliveInterval != newCfg.NonStreamKeepAliveInterval {
|
|
changes = append(changes, fmt.Sprintf("nonstream-keepalive-interval: %d -> %d", oldCfg.NonStreamKeepAliveInterval, newCfg.NonStreamKeepAliveInterval))
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
if oldCfg.QuotaExceeded.AntigravityCredits != newCfg.QuotaExceeded.AntigravityCredits {
|
|
changes = append(changes, fmt.Sprintf("quota-exceeded.antigravity-credits: %t -> %t", oldCfg.QuotaExceeded.AntigravityCredits, newCfg.QuotaExceeded.AntigravityCredits))
|
|
}
|
|
if !reflect.DeepEqual(oldCfg.Antigravity.SensitiveWords, newCfg.Antigravity.SensitiveWords) {
|
|
changes = append(changes, fmt.Sprintf("antigravity.sensitive-words: %d -> %d", len(oldCfg.Antigravity.SensitiveWords), len(newCfg.Antigravity.SensitiveWords)))
|
|
}
|
|
|
|
if oldCfg.Codex.IdentityConfuse != newCfg.Codex.IdentityConfuse {
|
|
changes = append(changes, fmt.Sprintf("codex.identity-confuse: %t -> %t", oldCfg.Codex.IdentityConfuse, newCfg.Codex.IdentityConfuse))
|
|
}
|
|
if oldCfg.Codex.DisableCodexCloaking != newCfg.Codex.DisableCodexCloaking {
|
|
changes = append(changes, fmt.Sprintf("codex.disable-codex-cloaking: %t -> %t", oldCfg.Codex.DisableCodexCloaking, newCfg.Codex.DisableCodexCloaking))
|
|
}
|
|
if oldCfg.Codex.StreamBootstrapBuffering != newCfg.Codex.StreamBootstrapBuffering {
|
|
changes = append(changes, fmt.Sprintf("codex.stream-bootstrap-buffering: %t -> %t", oldCfg.Codex.StreamBootstrapBuffering, newCfg.Codex.StreamBootstrapBuffering))
|
|
}
|
|
if oldCfg.Codex.OptimizeMultiAgentV2 != newCfg.Codex.OptimizeMultiAgentV2 {
|
|
changes = append(changes, fmt.Sprintf("codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Codex.OptimizeMultiAgentV2, newCfg.Codex.OptimizeMultiAgentV2))
|
|
}
|
|
if oldCfg.XAI.InjectXSearch != newCfg.XAI.InjectXSearch {
|
|
changes = append(changes, fmt.Sprintf("xai.inject-x-search: %t -> %t", oldCfg.XAI.InjectXSearch, newCfg.XAI.InjectXSearch))
|
|
}
|
|
oldLiveRelay := oldCfg.Codex.LiveMediaRelay
|
|
newLiveRelay := newCfg.Codex.LiveMediaRelay
|
|
if oldLiveRelay.Enabled != newLiveRelay.Enabled {
|
|
changes = append(changes, fmt.Sprintf("codex.live-media-relay.enabled: %t -> %t", oldLiveRelay.Enabled, newLiveRelay.Enabled))
|
|
}
|
|
if oldLiveRelay.MaxSessions != newLiveRelay.MaxSessions {
|
|
changes = append(changes, fmt.Sprintf("codex.live-media-relay.max-sessions: %d -> %d", oldLiveRelay.MaxSessions, newLiveRelay.MaxSessions))
|
|
}
|
|
if oldLiveRelay.DisablePrivateRemoteIPs != newLiveRelay.DisablePrivateRemoteIPs {
|
|
changes = append(changes, fmt.Sprintf("codex.live-media-relay.disable-private-remote-ips: %t -> %t", oldLiveRelay.DisablePrivateRemoteIPs, newLiveRelay.DisablePrivateRemoteIPs))
|
|
}
|
|
if strings.TrimSpace(oldLiveRelay.PublicIP) != strings.TrimSpace(newLiveRelay.PublicIP) {
|
|
changes = append(changes, fmt.Sprintf("codex.live-media-relay.public-ip: %s -> %s", displayOptionalValue(oldLiveRelay.PublicIP), displayOptionalValue(newLiveRelay.PublicIP)))
|
|
}
|
|
if oldLiveRelay.UDPPortMin != newLiveRelay.UDPPortMin {
|
|
changes = append(changes, fmt.Sprintf("codex.live-media-relay.udp-port-min: %d -> %d", oldLiveRelay.UDPPortMin, newLiveRelay.UDPPortMin))
|
|
}
|
|
if oldLiveRelay.UDPPortMax != newLiveRelay.UDPPortMax {
|
|
changes = append(changes, fmt.Sprintf("codex.live-media-relay.udp-port-max: %d -> %d", oldLiveRelay.UDPPortMax, newLiveRelay.UDPPortMax))
|
|
}
|
|
if !reflect.DeepEqual(oldLiveRelay.ICEServers, newLiveRelay.ICEServers) {
|
|
changes = append(changes, fmt.Sprintf("codex.live-media-relay.ice-servers: updated (%d -> %d entries, credentials redacted)", len(oldLiveRelay.ICEServers), len(newLiveRelay.ICEServers)))
|
|
}
|
|
|
|
if oldCfg.Routing.Strategy != newCfg.Routing.Strategy {
|
|
changes = append(changes, fmt.Sprintf("routing.strategy: %s -> %s", oldCfg.Routing.Strategy, newCfg.Routing.Strategy))
|
|
}
|
|
if !reflect.DeepEqual(oldCfg.Payload, newCfg.Payload) {
|
|
changes = appendPayloadConfigChanges(changes, oldCfg.Payload, newCfg.Payload)
|
|
}
|
|
|
|
// 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, formatURL(o.BaseURL), formatURL(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)))
|
|
}
|
|
changes = appendOptionalBoolChange(changes, fmt.Sprintf("gemini[%d].disable-cooling", i), o.DisableCooling, n.DisableCooling)
|
|
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))
|
|
}
|
|
changes = appendOptionalIntChange(changes, fmt.Sprintf("gemini[%d].request-retry", i), o.RequestRetry, n.RequestRetry)
|
|
}
|
|
}
|
|
if len(oldCfg.InteractionsKey) != len(newCfg.InteractionsKey) {
|
|
changes = append(changes, fmt.Sprintf("interactions-api-key count: %d -> %d", len(oldCfg.InteractionsKey), len(newCfg.InteractionsKey)))
|
|
} else {
|
|
for i := range oldCfg.InteractionsKey {
|
|
o := oldCfg.InteractionsKey[i]
|
|
n := newCfg.InteractionsKey[i]
|
|
if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {
|
|
changes = append(changes, fmt.Sprintf("interactions[%d].base-url: %s -> %s", i, formatURL(o.BaseURL), formatURL(n.BaseURL)))
|
|
}
|
|
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
|
|
changes = append(changes, fmt.Sprintf("interactions[%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("interactions[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
|
|
}
|
|
changes = appendOptionalBoolChange(changes, fmt.Sprintf("interactions[%d].disable-cooling", i), o.DisableCooling, n.DisableCooling)
|
|
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
|
|
changes = append(changes, fmt.Sprintf("interactions[%d].api-key: updated", i))
|
|
}
|
|
if !equalStringMap(o.Headers, n.Headers) {
|
|
changes = append(changes, fmt.Sprintf("interactions[%d].headers: updated", i))
|
|
}
|
|
oldModels := SummarizeGeminiModels(o.Models)
|
|
newModels := SummarizeGeminiModels(n.Models)
|
|
if oldModels.hash != newModels.hash {
|
|
changes = append(changes, fmt.Sprintf("interactions[%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("interactions[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count))
|
|
}
|
|
changes = appendOptionalIntChange(changes, fmt.Sprintf("interactions[%d].request-retry", i), o.RequestRetry, n.RequestRetry)
|
|
}
|
|
}
|
|
|
|
// 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, formatURL(o.BaseURL), formatURL(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)))
|
|
}
|
|
changes = appendOptionalBoolChange(changes, fmt.Sprintf("claude[%d].disable-cooling", i), o.DisableCooling, n.DisableCooling)
|
|
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))
|
|
}
|
|
if o.RebuildMidSystemMessage != n.RebuildMidSystemMessage {
|
|
changes = append(changes, fmt.Sprintf("claude[%d].rebuild-mid-system-message: %t -> %t", i, o.RebuildMidSystemMessage, n.RebuildMidSystemMessage))
|
|
}
|
|
if strings.TrimSpace(o.FingerprintProfile) != strings.TrimSpace(n.FingerprintProfile) {
|
|
changes = append(changes, fmt.Sprintf("claude[%d].fingerprint-profile: %s -> %s", i, strings.TrimSpace(o.FingerprintProfile), strings.TrimSpace(n.FingerprintProfile)))
|
|
}
|
|
changes = appendOptionalIntChange(changes, fmt.Sprintf("claude[%d].request-retry", i), o.RequestRetry, n.RequestRetry)
|
|
if o.Cloak != nil && n.Cloak != nil {
|
|
if strings.TrimSpace(o.Cloak.Mode) != strings.TrimSpace(n.Cloak.Mode) {
|
|
changes = append(changes, fmt.Sprintf("claude[%d].cloak.mode: %s -> %s", i, o.Cloak.Mode, n.Cloak.Mode))
|
|
}
|
|
if o.Cloak.StrictMode != n.Cloak.StrictMode {
|
|
changes = append(changes, fmt.Sprintf("claude[%d].cloak.strict-mode: %t -> %t", i, o.Cloak.StrictMode, n.Cloak.StrictMode))
|
|
}
|
|
if len(o.Cloak.SensitiveWords) != len(n.Cloak.SensitiveWords) {
|
|
changes = append(changes, fmt.Sprintf("claude[%d].cloak.sensitive-words: %d -> %d", i, len(o.Cloak.SensitiveWords), len(n.Cloak.SensitiveWords)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, formatURL(o.BaseURL), formatURL(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 o.Websockets != n.Websockets {
|
|
changes = append(changes, fmt.Sprintf("codex[%d].websockets: %t -> %t", i, o.Websockets, n.Websockets))
|
|
}
|
|
if o.AlphaSearch != n.AlphaSearch {
|
|
changes = append(changes, fmt.Sprintf("codex[%d].alpha-search: %t -> %t", i, o.AlphaSearch, n.AlphaSearch))
|
|
}
|
|
changes = appendOptionalBoolChange(changes, fmt.Sprintf("codex[%d].disable-cooling", i), o.DisableCooling, n.DisableCooling)
|
|
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))
|
|
}
|
|
changes = appendOptionalIntChange(changes, fmt.Sprintf("codex[%d].request-retry", i), o.RequestRetry, n.RequestRetry)
|
|
}
|
|
}
|
|
|
|
// xAI keys (do not print key material)
|
|
if len(oldCfg.XAIKey) != len(newCfg.XAIKey) {
|
|
changes = append(changes, fmt.Sprintf("xai-api-key count: %d -> %d", len(oldCfg.XAIKey), len(newCfg.XAIKey)))
|
|
} else {
|
|
for i := range oldCfg.XAIKey {
|
|
o := oldCfg.XAIKey[i]
|
|
n := newCfg.XAIKey[i]
|
|
if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {
|
|
changes = append(changes, fmt.Sprintf("xai[%d].base-url: %s -> %s", i, formatURL(o.BaseURL), formatURL(n.BaseURL)))
|
|
}
|
|
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
|
|
changes = append(changes, fmt.Sprintf("xai[%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("xai[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
|
|
}
|
|
if o.Priority != n.Priority {
|
|
changes = append(changes, fmt.Sprintf("xai[%d].priority: %d -> %d", i, o.Priority, n.Priority))
|
|
}
|
|
if o.Websockets != n.Websockets {
|
|
changes = append(changes, fmt.Sprintf("xai[%d].websockets: %t -> %t", i, o.Websockets, n.Websockets))
|
|
}
|
|
changes = appendOptionalBoolChange(changes, fmt.Sprintf("xai[%d].disable-cooling", i), o.DisableCooling, n.DisableCooling)
|
|
changes = appendOptionalIntChange(changes, fmt.Sprintf("xai[%d].request-retry", i), o.RequestRetry, n.RequestRetry)
|
|
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
|
|
changes = append(changes, fmt.Sprintf("xai[%d].api-key: updated", i))
|
|
}
|
|
if !equalStringMap(o.Headers, n.Headers) {
|
|
changes = append(changes, fmt.Sprintf("xai[%d].headers: updated", i))
|
|
}
|
|
oldModels := SummarizeCodexModels(o.Models)
|
|
newModels := SummarizeCodexModels(n.Models)
|
|
if oldModels.hash != newModels.hash {
|
|
changes = append(changes, fmt.Sprintf("xai[%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("xai[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count))
|
|
}
|
|
}
|
|
}
|
|
|
|
if entries, _ := DiffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 {
|
|
changes = append(changes, entries...)
|
|
}
|
|
if entries, _ := DiffOAuthModelAliasChanges(oldCfg.OAuthModelAlias, newCfg.OAuthModelAlias); len(entries) > 0 {
|
|
changes = append(changes, entries...)
|
|
}
|
|
if entries, _ := DiffOAuthRequestScopedErrorsChanges(oldCfg.OAuthRequestScopedErrors, newCfg.OAuthRequestScopedErrors); 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))
|
|
}
|
|
if oldCfg.RemoteManagement.DisableAutoUpdatePanel != newCfg.RemoteManagement.DisableAutoUpdatePanel {
|
|
changes = append(changes, fmt.Sprintf("remote-management.disable-auto-update-panel: %t -> %t", oldCfg.RemoteManagement.DisableAutoUpdatePanel, newCfg.RemoteManagement.DisableAutoUpdatePanel))
|
|
}
|
|
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", formatURL(oldPanelRepo), formatURL(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, formatURL(o.BaseURL), formatURL(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)))
|
|
}
|
|
changes = appendOptionalBoolChange(changes, fmt.Sprintf("vertex[%d].disable-cooling", i), o.DisableCooling, n.DisableCooling)
|
|
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))
|
|
}
|
|
oldExcluded := SummarizeExcludedModels(o.ExcludedModels)
|
|
newExcluded := SummarizeExcludedModels(n.ExcludedModels)
|
|
if oldExcluded.hash != newExcluded.hash {
|
|
changes = append(changes, fmt.Sprintf("vertex[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count))
|
|
}
|
|
if !equalStringMap(o.Headers, n.Headers) {
|
|
changes = append(changes, fmt.Sprintf("vertex[%d].headers: updated", i))
|
|
}
|
|
changes = appendOptionalIntChange(changes, fmt.Sprintf("vertex[%d].request-retry", i), o.RequestRetry, n.RequestRetry)
|
|
}
|
|
}
|
|
|
|
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 appendPayloadConfigChanges(changes []string, oldPayload, newPayload config.PayloadConfig) []string {
|
|
changes = appendPayloadRuleChanges(changes, "default", oldPayload.Default, newPayload.Default)
|
|
changes = appendPayloadRuleChanges(changes, "default-raw", oldPayload.DefaultRaw, newPayload.DefaultRaw)
|
|
changes = appendPayloadRuleChanges(changes, "override", oldPayload.Override, newPayload.Override)
|
|
changes = appendPayloadRuleChanges(changes, "override-raw", oldPayload.OverrideRaw, newPayload.OverrideRaw)
|
|
changes = appendPayloadFilterRuleChanges(changes, "filter", oldPayload.Filter, newPayload.Filter)
|
|
return changes
|
|
}
|
|
|
|
func appendPayloadRuleChanges(changes []string, section string, oldRules, newRules []config.PayloadRule) []string {
|
|
if reflect.DeepEqual(oldRules, newRules) {
|
|
return changes
|
|
}
|
|
return append(changes, fmt.Sprintf("payload.%s: updated (%d -> %d rules)", section, len(oldRules), len(newRules)))
|
|
}
|
|
|
|
func appendPayloadFilterRuleChanges(changes []string, section string, oldRules, newRules []config.PayloadFilterRule) []string {
|
|
if reflect.DeepEqual(oldRules, newRules) {
|
|
return changes
|
|
}
|
|
return append(changes, fmt.Sprintf("payload.%s: updated (%d -> %d rules)", section, len(oldRules), len(newRules)))
|
|
}
|
|
|
|
func appendOptionalIntChange(changes []string, field string, oldVal, newVal *int) []string {
|
|
if optionalIntEqual(oldVal, newVal) {
|
|
return changes
|
|
}
|
|
return append(changes, fmt.Sprintf("%s: %s -> %s", field, formatOptionalInt(oldVal), formatOptionalInt(newVal)))
|
|
}
|
|
|
|
func appendOptionalBoolChange(changes []string, field string, oldVal, newVal *bool) []string {
|
|
if optionalBoolEqual(oldVal, newVal) {
|
|
return changes
|
|
}
|
|
return append(changes, fmt.Sprintf("%s: %s -> %s", field, formatOptionalBool(oldVal), formatOptionalBool(newVal)))
|
|
}
|
|
|
|
func optionalBoolEqual(a, b *bool) bool {
|
|
if a == nil && b == nil {
|
|
return true
|
|
}
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
func formatOptionalBool(value *bool) string {
|
|
if value == nil {
|
|
return "inherit"
|
|
}
|
|
return fmt.Sprintf("%t", *value)
|
|
}
|
|
|
|
func optionalIntEqual(a, b *int) bool {
|
|
if a == nil && b == nil {
|
|
return true
|
|
}
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
func formatOptionalInt(v *int) string {
|
|
if v == nil {
|
|
return "<unset>"
|
|
}
|
|
return strconv.Itoa(*v)
|
|
}
|
|
|
|
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 displayOptionalValue(raw string) string {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return "<none>"
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func formatProxyURL(raw string) string {
|
|
return formatURL(raw)
|
|
}
|
|
|
|
func formatURL(raw string) string {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return "<none>"
|
|
}
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return "<redacted>"
|
|
}
|
|
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 "<redacted>"
|
|
}
|
|
if scheme == "" {
|
|
return host
|
|
}
|
|
return scheme + "://" + host
|
|
}
|