diff --git a/config.example.yaml b/config.example.yaml index 1c58d46dd..d006dd88c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -154,6 +154,7 @@ max-retry-credentials: 0 max-retry-interval: 30 # When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states). +# A credential/provider disable-cooling value, when present, overrides this global value. disable-cooling: false # When true, persist per-auth cooldown status as .cds files next to auth files. @@ -296,7 +297,7 @@ nonstream-keepalive-interval: 0 # - api-key: "AIzaSy...01" # weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000 # prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential -# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling +# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global # request-retry: 3 # optional: per-auth override of the global request-retry; 0 disables retries; omit or set < 0 to use the global value # request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns # - status: 400 # HTTP status code to match @@ -335,7 +336,7 @@ nonstream-keepalive-interval: 0 # - api-key: "AIzaSy...03" # weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000 # prefix: "native" # optional: require calls like "native/gemini-3-pro-preview" to target this credential -# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling +# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global # request-retry: 3 # optional: per-auth override of the global request-retry; 0 disables retries; omit or set < 0 to use the global value # request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns # - status: 400 @@ -362,7 +363,7 @@ nonstream-keepalive-interval: 0 # - api-key: "sk-atSM..." # weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000 # prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential -# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling +# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global # request-retry: 3 # optional: per-auth override of the global request-retry; 0 disables retries; omit or set < 0 to use the global value # request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns # - status: 400 @@ -401,7 +402,7 @@ nonstream-keepalive-interval: 0 # - api-key: "xai-..." # weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000 # prefix: "xai" # optional: require calls like "xai/grok-4.5" to target this credential -# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling +# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global # request-retry: 3 # optional: per-auth override of the global request-retry; 0 disables retries; omit or set < 0 to use the global value # request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns # - status: 400 @@ -433,7 +434,7 @@ nonstream-keepalive-interval: 0 # - api-key: "sk-atSM..." # weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000 # prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential -# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling +# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global # request-retry: 3 # optional: per-auth override of the global request-retry; 0 disables retries; omit or set < 0 to use the global value # request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns # - status: 400 @@ -521,7 +522,7 @@ nonstream-keepalive-interval: 0 # prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials # base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. # support-prompt-cache-key: false # optional: derive prompt_cache_key for requests from all input protocols -# disable-cooling: false # optional: per-provider override for auth/model cooldown scheduling +# disable-cooling: false # optional provider override: true disables cooling, false enables it; omit to inherit global # request-retry: 3 # optional: per-provider override of the global request-retry; 0 disables retries; omit or set < 0 to use the global value # request-scoped-errors: # optional: custom rules to classify upstream errors by status and body patterns # - status: 400 @@ -568,6 +569,7 @@ nonstream-keepalive-interval: 0 # - api-key: "vk-123..." # x-goog-api-key header # weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000 # prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential +# disable-cooling: false # optional override: true disables cooling, false enables it; omit to inherit global # request-retry: 3 # optional: per-auth override of the global request-retry; 0 disables retries; omit or set < 0 to use the global value # base-url: "https://example.com/api" # optional, e.g. https://zenmux.ai/api; falls back to Google Vertex when omitted # proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go index 5f3ece059..d129047c7 100644 --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -48,7 +48,7 @@ type openAICompatibilityWithAuthIndex struct { Models []config.OpenAICompatibilityModel `json:"models,omitempty"` Headers map[string]string `json:"headers,omitempty"` SupportPromptCacheKey bool `json:"support-prompt-cache-key,omitempty"` - DisableCooling bool `json:"disable-cooling,omitempty"` + DisableCooling *bool `json:"disable-cooling,omitempty"` RequestRetry *int `json:"request-retry,omitempty"` RequestScopedErrors []config.RequestScopedErrorRule `json:"request-scoped-errors,omitempty"` AuthIndex string `json:"auth-index,omitempty"` diff --git a/internal/api/handlers/management/config_disable_cooling_test.go b/internal/api/handlers/management/config_disable_cooling_test.go new file mode 100644 index 000000000..f41d75c09 --- /dev/null +++ b/internal/api/handlers/management/config_disable_cooling_test.go @@ -0,0 +1,129 @@ +package management + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestPatchDisableCoolingOverrideForEveryFamily(t *testing.T) { + initial := true + tests := []struct { + name string + setup func(*config.Config) + patch func(*Handler, *gin.Context) + get func(*config.Config) *bool + }{ + { + name: "gemini", + setup: func(cfg *config.Config) { + cfg.GeminiKey = []config.GeminiKey{{APIKey: "key", DisableCooling: &initial}} + }, + patch: (*Handler).PatchGeminiKey, + get: func(cfg *config.Config) *bool { return cfg.GeminiKey[0].DisableCooling }, + }, + { + name: "interactions", + setup: func(cfg *config.Config) { + cfg.InteractionsKey = []config.GeminiKey{{APIKey: "key", DisableCooling: &initial}} + }, + patch: (*Handler).PatchInteractionsKey, + get: func(cfg *config.Config) *bool { return cfg.InteractionsKey[0].DisableCooling }, + }, + { + name: "claude", + setup: func(cfg *config.Config) { + cfg.ClaudeKey = []config.ClaudeKey{{APIKey: "key", DisableCooling: &initial}} + }, + patch: (*Handler).PatchClaudeKey, + get: func(cfg *config.Config) *bool { return cfg.ClaudeKey[0].DisableCooling }, + }, + { + name: "openai compatibility", + setup: func(cfg *config.Config) { + cfg.OpenAICompatibility = []config.OpenAICompatibility{{ + Name: "compat", + BaseURL: "https://compat.example.com", + APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: "key"}}, + DisableCooling: &initial, + }} + }, + patch: (*Handler).PatchOpenAICompat, + get: func(cfg *config.Config) *bool { return cfg.OpenAICompatibility[0].DisableCooling }, + }, + { + name: "vertex", + setup: func(cfg *config.Config) { + cfg.VertexCompatAPIKey = []config.VertexCompatKey{{ + APIKey: "key", + BaseURL: "https://vertex.example.com", + DisableCooling: &initial, + }} + }, + patch: (*Handler).PatchVertexCompatKey, + get: func(cfg *config.Config) *bool { return cfg.VertexCompatAPIKey[0].DisableCooling }, + }, + { + name: "codex", + setup: func(cfg *config.Config) { + cfg.CodexKey = []config.CodexKey{{ + APIKey: "key", + BaseURL: "https://codex.example.com", + DisableCooling: &initial, + }} + }, + patch: (*Handler).PatchCodexKey, + get: func(cfg *config.Config) *bool { return cfg.CodexKey[0].DisableCooling }, + }, + { + name: "xai", + setup: func(cfg *config.Config) { + cfg.XAIKey = []config.XAIKey{{ + APIKey: "key", + BaseURL: "https://api.x.ai/v1", + DisableCooling: &initial, + }} + }, + patch: (*Handler).PatchXAIKey, + get: func(cfg *config.Config) *bool { return cfg.XAIKey[0].DisableCooling }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{} + tc.setup(cfg) + h := &Handler{cfg: cfg, configFilePath: writeTestConfigFile(t)} + + patch := func(value string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + body := fmt.Sprintf(`{"index":0,"value":{"disable-cooling":%s}}`, value) + ctx.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/key", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + tc.patch(h, ctx) + return rec + } + + if rec := patch("false"); rec.Code != http.StatusOK { + t.Fatalf("false patch status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if override := tc.get(cfg); override == nil || *override { + t.Fatalf("disable-cooling = %v, want explicit false", override) + } + + if rec := patch("null"); rec.Code != http.StatusOK { + t.Fatalf("null patch status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if override := tc.get(cfg); override != nil { + t.Fatalf("disable-cooling = %v, want inherited value", override) + } + }) + } +} diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 1b76ceae1..2287d18b2 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -186,6 +186,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { ProxyURL *string `json:"proxy-url"` Headers *map[string]string `json:"headers"` ExcludedModels *[]string `json:"excluded-models"` + DisableCooling json.RawMessage `json:"disable-cooling"` RequestRetry *int `json:"request-retry"` RequestScopedErrors *[]config.RequestScopedErrorRule `json:"request-scoped-errors"` } @@ -255,6 +256,9 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { if body.Value.ExcludedModels != nil { entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) } + if !applyDisableCoolingPatch(c, body.Value.DisableCooling, &entry.DisableCooling) { + return + } if body.Value.RequestRetry != nil { entry.RequestRetry = body.Value.RequestRetry } @@ -367,6 +371,7 @@ func (h *Handler) PatchInteractionsKey(c *gin.Context) { ProxyURL *string `json:"proxy-url"` Headers *map[string]string `json:"headers"` ExcludedModels *[]string `json:"excluded-models"` + DisableCooling json.RawMessage `json:"disable-cooling"` RequestRetry *int `json:"request-retry"` RequestScopedErrors *[]config.RequestScopedErrorRule `json:"request-scoped-errors"` } @@ -437,6 +442,9 @@ func (h *Handler) PatchInteractionsKey(c *gin.Context) { if body.Value.ExcludedModels != nil { entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) } + if !applyDisableCoolingPatch(c, body.Value.DisableCooling, &entry.DisableCooling) { + return + } if body.Value.RequestRetry != nil { entry.RequestRetry = body.Value.RequestRetry } @@ -551,6 +559,7 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { Headers *map[string]string `json:"headers"` ExcludedModels *[]string `json:"excluded-models"` RebuildMidSystemMessage *bool `json:"rebuild-mid-system-message"` + DisableCooling json.RawMessage `json:"disable-cooling"` RequestRetry *int `json:"request-retry"` RequestScopedErrors *[]config.RequestScopedErrorRule `json:"request-scoped-errors"` } @@ -617,6 +626,9 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { if body.Value.RebuildMidSystemMessage != nil { entry.RebuildMidSystemMessage = *body.Value.RebuildMidSystemMessage } + if !applyDisableCoolingPatch(c, body.Value.DisableCooling, &entry.DisableCooling) { + return + } if body.Value.RequestRetry != nil { entry.RequestRetry = body.Value.RequestRetry } @@ -728,7 +740,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { Name *string `json:"name"` Prefix *string `json:"prefix"` Disabled *bool `json:"disabled"` - DisableCooling *bool `json:"disable-cooling"` + DisableCooling json.RawMessage `json:"disable-cooling"` BaseURL *string `json:"base-url"` APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"` Models *[]config.OpenAICompatibilityModel `json:"models"` @@ -777,8 +789,8 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { if body.Value.Disabled != nil { entry.Disabled = *body.Value.Disabled } - if body.Value.DisableCooling != nil { - entry.DisableCooling = *body.Value.DisableCooling + if !applyDisableCoolingPatch(c, body.Value.DisableCooling, &entry.DisableCooling) { + return } if body.Value.RequestRetry != nil { entry.RequestRetry = body.Value.RequestRetry @@ -895,6 +907,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { Headers *map[string]string `json:"headers"` Models *[]config.VertexCompatModel `json:"models"` ExcludedModels *[]string `json:"excluded-models"` + DisableCooling json.RawMessage `json:"disable-cooling"` RequestRetry *int `json:"request-retry"` } var body struct { @@ -973,6 +986,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { if body.Value.ExcludedModels != nil { entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) } + if !applyDisableCoolingPatch(c, body.Value.DisableCooling, &entry.DisableCooling) { + return + } if body.Value.RequestRetry != nil { entry.RequestRetry = body.Value.RequestRetry } @@ -1268,6 +1284,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { Models *[]config.CodexModel `json:"models"` Headers *map[string]string `json:"headers"` ExcludedModels *[]string `json:"excluded-models"` + DisableCooling json.RawMessage `json:"disable-cooling"` RequestRetry *int `json:"request-retry"` RequestScopedErrors *[]config.RequestScopedErrorRule `json:"request-scoped-errors"` } @@ -1341,6 +1358,9 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { if body.Value.ExcludedModels != nil { entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) } + if !applyDisableCoolingPatch(c, body.Value.DisableCooling, &entry.DisableCooling) { + return + } if body.Value.RequestRetry != nil { entry.RequestRetry = body.Value.RequestRetry } @@ -1459,7 +1479,7 @@ func (h *Handler) PatchXAIKey(c *gin.Context) { Models *[]config.XAIModel `json:"models"` Headers *map[string]string `json:"headers"` ExcludedModels *[]string `json:"excluded-models"` - DisableCooling *bool `json:"disable-cooling"` + DisableCooling json.RawMessage `json:"disable-cooling"` RequestRetry *int `json:"request-retry"` RequestScopedErrors *[]config.RequestScopedErrorRule `json:"request-scoped-errors"` } @@ -1536,8 +1556,8 @@ func (h *Handler) PatchXAIKey(c *gin.Context) { if body.Value.ExcludedModels != nil { entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) } - if body.Value.DisableCooling != nil { - entry.DisableCooling = *body.Value.DisableCooling + if !applyDisableCoolingPatch(c, body.Value.DisableCooling, &entry.DisableCooling) { + return } if body.Value.RequestRetry != nil { entry.RequestRetry = body.Value.RequestRetry @@ -1604,6 +1624,23 @@ func (h *Handler) DeleteXAIKey(c *gin.Context) { c.JSON(400, gin.H{"error": "missing api-key or index"}) } +func applyDisableCoolingPatch(c *gin.Context, raw json.RawMessage, target **bool) bool { + if len(raw) == 0 { + return true + } + if strings.TrimSpace(string(raw)) == "null" { + *target = nil + return true + } + var value bool + if errUnmarshal := json.Unmarshal(raw, &value); errUnmarshal != nil { + c.JSON(400, gin.H{"error": "disable-cooling must be a boolean or null"}) + return false + } + *target = &value + return true +} + func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) { if entry == nil { return diff --git a/internal/api/handlers/management/config_openai_compat_test.go b/internal/api/handlers/management/config_openai_compat_test.go index 5d609b482..5d787d3d3 100644 --- a/internal/api/handlers/management/config_openai_compat_test.go +++ b/internal/api/handlers/management/config_openai_compat_test.go @@ -14,6 +14,7 @@ func TestGetOpenAICompatIncludesDisableCooling(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "") requestRetry := 0 + disableCooling := true h := NewHandlerWithoutConfigFilePath(&config.Config{ OpenAICompatibility: []config.OpenAICompatibility{ { @@ -26,7 +27,7 @@ func TestGetOpenAICompatIncludesDisableCooling(t *testing.T) { {Name: "mimo-v2.5", Alias: ""}, }, SupportPromptCacheKey: true, - DisableCooling: true, + DisableCooling: &disableCooling, RequestRetry: &requestRetry, }, }, diff --git a/internal/api/handlers/management/config_xai_key_test.go b/internal/api/handlers/management/config_xai_key_test.go index d575c07af..74897a1d9 100644 --- a/internal/api/handlers/management/config_xai_key_test.go +++ b/internal/api/handlers/management/config_xai_key_test.go @@ -11,13 +11,14 @@ import ( ) func TestPatchXAIKeyUpdatesExecutionFields(t *testing.T) { + disableCooling := false h := &Handler{ cfg: &config.Config{XAIKey: []config.XAIKey{{ APIKey: "xai-key", Priority: 1, BaseURL: "https://api.x.ai/v1", Websockets: true, - DisableCooling: false, + DisableCooling: &disableCooling, }}}, configFilePath: writeTestConfigFile(t), } @@ -47,8 +48,8 @@ func TestPatchXAIKeyUpdatesExecutionFields(t *testing.T) { if entry.Websockets { t.Fatal("websockets = true, want false") } - if !entry.DisableCooling { - t.Fatal("disable-cooling = false, want true") + if entry.DisableCooling == nil || !*entry.DisableCooling { + t.Fatalf("disable-cooling = %v, want true", entry.DisableCooling) } if entry.RequestRetry == nil || *entry.RequestRetry != 0 { t.Fatalf("request-retry = %v, want 0", entry.RequestRetry) diff --git a/internal/config/config.go b/internal/config/config.go index 4718f253b..6e0fa6559 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,7 +62,7 @@ type Config struct { // Default: 60. Max: 3600. RedisUsageQueueRetentionSeconds int `yaml:"redis-usage-queue-retention-seconds" json:"redis-usage-queue-retention-seconds"` - // DisableCooling disables quota cooldown scheduling when true. + // DisableCooling disables auth/model cooldown scheduling when true unless a credential or provider overrides it. DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"` // SaveCooldownStatus persists runtime cooldown status next to auth files when true. diff --git a/internal/config/config_types.go b/internal/config/config_types.go index b70602e7e..9a20d48df 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -364,8 +364,9 @@ type ClaudeKey struct { // RebuildMidSystemMessage moves Claude messages with role "system" into the top-level system field. RebuildMidSystemMessage bool `yaml:"rebuild-mid-system-message,omitempty" json:"rebuild-mid-system-message,omitempty"` - // DisableCooling disables auth/model cooldown scheduling for this credential when true. - DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` + // DisableCooling overrides the global cooling policy for this credential when set. + // True disables auth/model cooldowns; false explicitly enables them. + DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` // RequestRetry optionally overrides the global request-retry for this credential. // Nil or a negative value means "use the global request-retry". 0 disables retries. @@ -466,8 +467,9 @@ type CodexKey struct { // ExcludedModels lists model IDs that should be excluded for this provider. ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` - // DisableCooling disables auth/model cooldown scheduling for this credential when true. - DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` + // DisableCooling overrides the global cooling policy for this credential when set. + // True disables auth/model cooldowns; false explicitly enables them. + DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` // RequestRetry optionally overrides the global request-retry for this credential. // Nil or a negative value means "use the global request-retry". 0 disables retries. @@ -562,8 +564,9 @@ type GeminiKey struct { // ExcludedModels lists model IDs that should be excluded for this provider. ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` - // DisableCooling disables auth/model cooldown scheduling for this credential when true. - DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` + // DisableCooling overrides the global cooling policy for this credential when set. + // True disables auth/model cooldowns; false explicitly enables them. + DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` // RequestRetry optionally overrides the global request-retry for this credential. // Nil or a negative value means "use the global request-retry". 0 disables retries. @@ -648,8 +651,9 @@ type OpenAICompatibility struct { // SupportPromptCacheKey enables derived prompt_cache_key injection for supported requests. SupportPromptCacheKey bool `yaml:"support-prompt-cache-key,omitempty" json:"support-prompt-cache-key,omitempty"` - // DisableCooling disables auth/model cooldown scheduling for this provider when true. - DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` + // DisableCooling overrides the global cooling policy for this provider when set. + // True disables auth/model cooldowns; false explicitly enables them. + DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` // RequestRetry optionally overrides the global request-retry for this provider. // Nil or a negative value means "use the global request-retry". 0 disables retries. diff --git a/internal/config/cooling_override_test.go b/internal/config/cooling_override_test.go new file mode 100644 index 000000000..30c8f9930 --- /dev/null +++ b/internal/config/cooling_override_test.go @@ -0,0 +1,54 @@ +package config + +import "testing" + +func TestParseConfigBytesPreservesCoolingOverridePresence(t *testing.T) { + cfg, errParse := ParseConfigBytes([]byte(` +disable-cooling: true +gemini-api-key: + - api-key: gemini-key + disable-cooling: false +interactions-api-key: + - api-key: interactions-key + disable-cooling: false +claude-api-key: + - api-key: claude-key + disable-cooling: false +codex-api-key: + - api-key: codex-key + base-url: https://codex.example.com + disable-cooling: false +xai-api-key: + - api-key: xai-key + base-url: https://api.x.ai/v1 + disable-cooling: false +openai-compatibility: + - name: compat + base-url: https://compat.example.com + disable-cooling: false + api-key-entries: + - api-key: compat-key +vertex-api-key: + - api-key: vertex-key + base-url: https://vertex.example.com + disable-cooling: false +`)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + + overrides := map[string]*bool{ + "gemini": cfg.GeminiKey[0].DisableCooling, + "interactions": cfg.InteractionsKey[0].DisableCooling, + "claude": cfg.ClaudeKey[0].DisableCooling, + "codex": cfg.CodexKey[0].DisableCooling, + "xai": cfg.XAIKey[0].DisableCooling, + "openai compatibility": cfg.OpenAICompatibility[0].DisableCooling, + "vertex": cfg.VertexCompatAPIKey[0].DisableCooling, + } + for name, override := range overrides { + if override == nil || *override { + t.Errorf("%s disable-cooling = %v, want explicit false", name, override) + } + } +} diff --git a/internal/config/vertex_compat.go b/internal/config/vertex_compat.go index 93909107a..0adefb493 100644 --- a/internal/config/vertex_compat.go +++ b/internal/config/vertex_compat.go @@ -46,6 +46,10 @@ type VertexCompatKey struct { // ExcludedModels lists model IDs that should be excluded for this provider. ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` + // DisableCooling overrides the global cooling policy for this credential when set. + // True disables auth/model cooldowns; false explicitly enables them. + DisableCooling *bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"` + // RequestRetry optionally overrides the global request-retry for this credential. // Nil or a negative value means "use the global request-retry". 0 disables retries. RequestRetry *int `yaml:"request-retry,omitempty" json:"request-retry,omitempty"` diff --git a/internal/config/xai_api_key_test.go b/internal/config/xai_api_key_test.go index f6f7f4b14..940ffb437 100644 --- a/internal/config/xai_api_key_test.go +++ b/internal/config/xai_api_key_test.go @@ -73,8 +73,8 @@ func TestParseConfigBytesXAIAPIKeyMatchesCodexShape(t *testing.T) { if entry.ProxyURL != " http://proxy.local " { t.Fatalf("proxy-url = %q, want original Codex-compatible value", entry.ProxyURL) } - if !entry.DisableCooling { - t.Fatal("disable-cooling = false, want true") + if entry.DisableCooling == nil || !*entry.DisableCooling { + t.Fatalf("disable-cooling = %v, want true", entry.DisableCooling) } if entry.RequestRetry == nil || *entry.RequestRetry != 0 { t.Fatalf("request-retry = %v, want 0", entry.RequestRetry) diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 57b3a3d8a..90049d1ac 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -173,6 +173,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { 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)) } @@ -207,6 +208,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { 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)) } @@ -243,6 +245,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { 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)) } @@ -299,6 +302,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { 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)) } @@ -341,9 +345,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if o.Websockets != n.Websockets { changes = append(changes, fmt.Sprintf("xai[%d].websockets: %t -> %t", i, o.Websockets, n.Websockets)) } - if o.DisableCooling != n.DisableCooling { - changes = append(changes, fmt.Sprintf("xai[%d].disable-cooling: %t -> %t", i, o.DisableCooling, n.DisableCooling)) - } + 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)) @@ -421,6 +423,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { 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)) } @@ -482,6 +485,30 @@ func appendOptionalIntChange(changes []string, field string, oldVal, newVal *int 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 diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index b76906ca2..f355b1ef4 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -207,6 +207,8 @@ func TestBuildConfigChangeDetails_CodexAlphaSearch(t *testing.T) { func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { oldRetry := 1 newRetry := 0 + oldDisableCooling := false + newDisableCooling := true oldCfg := &config.Config{XAIKey: []config.XAIKey{{ APIKey: "old-key", Priority: 1, @@ -214,7 +216,7 @@ func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { BaseURL: "https://old.example.com/v1", ProxyURL: "http://old-proxy", Websockets: false, - DisableCooling: false, + DisableCooling: &oldDisableCooling, RequestRetry: &oldRetry, Headers: map[string]string{"X-Test": "old"}, Models: []config.XAIModel{{Name: "grok-old", Alias: "grok"}}, @@ -227,7 +229,7 @@ func TestBuildConfigChangeDetails_XAIKeys(t *testing.T) { BaseURL: "https://new.example.com/v1", ProxyURL: "http://new-proxy", Websockets: true, - DisableCooling: true, + DisableCooling: &newDisableCooling, RequestRetry: &newRetry, Headers: map[string]string{"X-Test": "new"}, Models: []config.XAIModel{{Name: "grok-new", Alias: "grok"}}, diff --git a/internal/watcher/diff/cooling_override_test.go b/internal/watcher/diff/cooling_override_test.go new file mode 100644 index 000000000..6faec72cc --- /dev/null +++ b/internal/watcher/diff/cooling_override_test.go @@ -0,0 +1,75 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestBuildConfigChangeDetailsIncludesAllCoolingOverrides(t *testing.T) { + disabled := true + enabled := false + tests := []struct { + name string + oldCfg *config.Config + newCfg *config.Config + want string + }{ + { + name: "gemini inherit to false", + oldCfg: &config.Config{GeminiKey: []config.GeminiKey{{APIKey: "gemini-key"}}}, + newCfg: &config.Config{GeminiKey: []config.GeminiKey{{APIKey: "gemini-key", DisableCooling: &enabled}}}, + want: "gemini[0].disable-cooling: inherit -> false", + }, + { + name: "interactions false to true", + oldCfg: &config.Config{InteractionsKey: []config.GeminiKey{{APIKey: "interactions-key", DisableCooling: &enabled}}}, + newCfg: &config.Config{InteractionsKey: []config.GeminiKey{{APIKey: "interactions-key", DisableCooling: &disabled}}}, + want: "interactions[0].disable-cooling: false -> true", + }, + { + name: "claude false to true", + oldCfg: &config.Config{ClaudeKey: []config.ClaudeKey{{APIKey: "claude-key", DisableCooling: &enabled}}}, + newCfg: &config.Config{ClaudeKey: []config.ClaudeKey{{APIKey: "claude-key", DisableCooling: &disabled}}}, + want: "claude[0].disable-cooling: false -> true", + }, + { + name: "codex true to inherit", + oldCfg: &config.Config{CodexKey: []config.CodexKey{{APIKey: "codex-key", DisableCooling: &disabled}}}, + newCfg: &config.Config{CodexKey: []config.CodexKey{{APIKey: "codex-key"}}}, + want: "codex[0].disable-cooling: true -> inherit", + }, + { + name: "xai inherit to true", + oldCfg: &config.Config{XAIKey: []config.XAIKey{{APIKey: "xai-key"}}}, + newCfg: &config.Config{XAIKey: []config.XAIKey{{APIKey: "xai-key", DisableCooling: &disabled}}}, + want: "xai[0].disable-cooling: inherit -> true", + }, + { + name: "openai compatibility false to inherit", + oldCfg: &config.Config{OpenAICompatibility: []config.OpenAICompatibility{{ + Name: "compat", BaseURL: "https://compat.example.com", DisableCooling: &enabled, + }}}, + newCfg: &config.Config{OpenAICompatibility: []config.OpenAICompatibility{{ + Name: "compat", BaseURL: "https://compat.example.com", + }}}, + want: "disable-cooling false -> inherit", + }, + { + name: "vertex inherit to false", + oldCfg: &config.Config{VertexCompatAPIKey: []config.VertexCompatKey{{APIKey: "vertex-key"}}}, + newCfg: &config.Config{VertexCompatAPIKey: []config.VertexCompatKey{{APIKey: "vertex-key", DisableCooling: &enabled}}}, + want: "vertex[0].disable-cooling: inherit -> false", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + changes := strings.Join(BuildConfigChangeDetails(tc.oldCfg, tc.newCfg), "\n") + if !strings.Contains(changes, tc.want) { + t.Fatalf("changes missing %q:\n%s", tc.want, changes) + } + }) + } +} diff --git a/internal/watcher/diff/openai_compat.go b/internal/watcher/diff/openai_compat.go index 2d88444d2..9598a3931 100644 --- a/internal/watcher/diff/openai_compat.go +++ b/internal/watcher/diff/openai_compat.go @@ -83,6 +83,9 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi if oldEntry.SupportPromptCacheKey != newEntry.SupportPromptCacheKey { details = append(details, fmt.Sprintf("support-prompt-cache-key %t -> %t", oldEntry.SupportPromptCacheKey, newEntry.SupportPromptCacheKey)) } + if !optionalBoolEqual(oldEntry.DisableCooling, newEntry.DisableCooling) { + details = append(details, fmt.Sprintf("disable-cooling %s -> %s", formatOptionalBool(oldEntry.DisableCooling), formatOptionalBool(newEntry.DisableCooling))) + } if !optionalIntEqual(oldEntry.RequestRetry, newEntry.RequestRetry) { details = append(details, fmt.Sprintf("request-retry %s -> %s", formatOptionalInt(oldEntry.RequestRetry), formatOptionalInt(newEntry.RequestRetry))) } diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 1ab6ae53b..489c42ce4 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -92,8 +92,8 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, en "config_index": strconv.Itoa(i), } metadata := map[string]any{} - if entry.DisableCooling { - metadata["disable_cooling"] = true + if entry.DisableCooling != nil { + metadata["disable_cooling"] = *entry.DisableCooling } addRequestRetryToMetadata(entry.RequestRetry, metadata) addRequestScopedErrorsToMetadata(entry.RequestScopedErrors, metadata) @@ -151,8 +151,8 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea "config_index": strconv.Itoa(i), } metadata := map[string]any{} - if ck.DisableCooling { - metadata["disable_cooling"] = true + if ck.DisableCooling != nil { + metadata["disable_cooling"] = *ck.DisableCooling } addRequestRetryToMetadata(ck.RequestRetry, metadata) addRequestScopedErrorsToMetadata(ck.RequestScopedErrors, metadata) @@ -223,8 +223,8 @@ func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entr "config_index": strconv.Itoa(i), } metadata := map[string]any{} - if entry.DisableCooling { - metadata["disable_cooling"] = true + if entry.DisableCooling != nil { + metadata["disable_cooling"] = *entry.DisableCooling } addRequestRetryToMetadata(entry.RequestRetry, metadata) addRequestScopedErrorsToMetadata(entry.RequestScopedErrors, metadata) @@ -303,8 +303,8 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "config_index": strconv.Itoa(i), } metadata := map[string]any{} - if disableCooling { - metadata["disable_cooling"] = true + if disableCooling != nil { + metadata["disable_cooling"] = *disableCooling } addRequestRetryToMetadata(compat.RequestRetry, metadata) addRequestScopedErrorsToMetadata(compat.RequestScopedErrors, metadata) @@ -349,8 +349,8 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "config_index": strconv.Itoa(i), } metadata := map[string]any{} - if disableCooling { - metadata["disable_cooling"] = true + if disableCooling != nil { + metadata["disable_cooling"] = *disableCooling } addRequestRetryToMetadata(compat.RequestRetry, metadata) addRequestScopedErrorsToMetadata(compat.RequestScopedErrors, metadata) @@ -416,6 +416,9 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor } addConfigHeadersToAttrs(compat.Headers, attrs) metadata := map[string]any{} + if compat.DisableCooling != nil { + metadata["disable_cooling"] = *compat.DisableCooling + } addRequestRetryToMetadata(compat.RequestRetry, metadata) a := &coreauth.Auth{ ID: id, diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go index 24b1e83d3..ecb26e23b 100644 --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -81,7 +81,7 @@ func TestConfigSynthesizer_GeminiKeys(t *testing.T) { { name: "gemini key disable cooling", geminiKeys: []config.GeminiKey{ - {APIKey: "test-key-123", Prefix: "team-a", DisableCooling: true}, + {APIKey: "test-key-123", Prefix: "team-a", DisableCooling: boolPointer(true)}, }, wantLen: 1, validate: func(t *testing.T, auths []*coreauth.Auth) { @@ -227,7 +227,7 @@ func TestConfigSynthesizer_ClaudeKeys(t *testing.T) { APIKey: "sk-ant-api-xxx", Prefix: "main", BaseURL: "https://api.anthropic.com", - DisableCooling: true, + DisableCooling: boolPointer(true), RebuildMidSystemMessage: true, Models: []config.ClaudeModel{ {Name: "claude-3-opus"}, @@ -312,7 +312,7 @@ func TestConfigSynthesizer_CodexKeys(t *testing.T) { ProxyURL: "http://proxy.local", Websockets: true, AlphaSearch: true, - DisableCooling: true, + DisableCooling: boolPointer(true), }, }, }, @@ -359,7 +359,7 @@ func TestConfigSynthesizer_XAIKeys(t *testing.T) { ProxyURL: "http://proxy.local", Websockets: true, AlphaSearch: true, - DisableCooling: true, + DisableCooling: boolPointer(true), Headers: map[string]string{"X-Custom": "value"}, Models: []config.XAIModel{{Name: "grok-4.5", Alias: "grok-latest"}}, }}, @@ -446,7 +446,7 @@ func TestConfigSynthesizer_OpenAICompat(t *testing.T) { { Name: "CustomProvider", BaseURL: "https://custom.api.com", - DisableCooling: true, + DisableCooling: boolPointer(true), APIKeyEntries: []config.OpenAICompatibilityAPIKey{ {APIKey: "key-1"}, {APIKey: "key-2"}, diff --git a/internal/watcher/synthesizer/cooling_override_test.go b/internal/watcher/synthesizer/cooling_override_test.go new file mode 100644 index 000000000..b091961d5 --- /dev/null +++ b/internal/watcher/synthesizer/cooling_override_test.go @@ -0,0 +1,95 @@ +package synthesizer + +import ( + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func boolPointer(value bool) *bool { + return &value +} + +func TestConfigSynthesizerPreservesExplicitFalseCoolingOverrides(t *testing.T) { + disableCooling := false + tests := []struct { + name string + cfg *config.Config + }{ + { + name: "gemini", + cfg: &config.Config{GeminiKey: []config.GeminiKey{{ + APIKey: "gemini-key", + DisableCooling: &disableCooling, + }}}, + }, + { + name: "interactions", + cfg: &config.Config{InteractionsKey: []config.GeminiKey{{ + APIKey: "interactions-key", + DisableCooling: &disableCooling, + }}}, + }, + { + name: "claude", + cfg: &config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "claude-key", + DisableCooling: &disableCooling, + }}}, + }, + { + name: "codex", + cfg: &config.Config{CodexKey: []config.CodexKey{{ + APIKey: "codex-key", + BaseURL: "https://codex.example.com", + DisableCooling: &disableCooling, + }}}, + }, + { + name: "xai", + cfg: &config.Config{XAIKey: []config.XAIKey{{ + APIKey: "xai-key", + BaseURL: "https://api.x.ai/v1", + DisableCooling: &disableCooling, + }}}, + }, + { + name: "openai compatibility", + cfg: &config.Config{OpenAICompatibility: []config.OpenAICompatibility{{ + Name: "compat", + BaseURL: "https://compat.example.com", + DisableCooling: &disableCooling, + APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: "compat-key"}}, + }}}, + }, + { + name: "vertex", + cfg: &config.Config{VertexCompatAPIKey: []config.VertexCompatKey{{ + APIKey: "vertex-key", + BaseURL: "https://vertex.example.com", + DisableCooling: &disableCooling, + }}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + auths, errSynthesize := NewConfigSynthesizer().Synthesize(&SynthesisContext{ + Config: tc.cfg, + Now: time.Unix(100, 0).UTC(), + IDGenerator: NewStableIDGenerator(), + }) + if errSynthesize != nil { + t.Fatalf("Synthesize() error = %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("auth count = %d, want 1", len(auths)) + } + disabled, present := auths[0].DisableCoolingOverride() + if !present || disabled { + t.Fatalf("DisableCoolingOverride() = %t, %t, want false, true", disabled, present) + } + }) + } +} diff --git a/sdk/cliproxy/auth/conductor_cooldown.go b/sdk/cliproxy/auth/conductor_cooldown.go index 802803d68..90b2da3d2 100644 --- a/sdk/cliproxy/auth/conductor_cooldown.go +++ b/sdk/cliproxy/auth/conductor_cooldown.go @@ -24,7 +24,7 @@ var quotaCooldownDisabled atomic.Bool var transientErrorCooldownSeconds atomic.Int64 -// SetQuotaCooldownDisabled toggles quota cooldown scheduling globally. +// SetQuotaCooldownDisabled toggles auth/model cooldown scheduling globally. func SetQuotaCooldownDisabled(disable bool) { quotaCooldownDisabled.Store(disable) } @@ -40,12 +40,16 @@ func quotaCooldownDisabledForAuth(auth *Auth) bool { } func quotaCooldownDisabledForAuthWithConfig(auth *Auth, cfg *internalconfig.Config) bool { + // Home owns cooldown state, so downstream instances must not schedule local cooldowns. + if cfg != nil && cfg.Home.Enabled { + return true + } if auth != nil { if override, ok := auth.DisableCoolingOverride(); ok { return override } - if providerCoolingDisabledForAuth(auth, cfg) { - return true + if override, ok := providerCoolingOverrideForAuth(auth, cfg); ok { + return override } } if cfg != nil && cfg.DisableCooling { @@ -54,13 +58,13 @@ func quotaCooldownDisabledForAuthWithConfig(auth *Auth, cfg *internalconfig.Conf return quotaCooldownDisabled.Load() } -func providerCoolingDisabledForAuth(auth *Auth, cfg *internalconfig.Config) bool { +func providerCoolingOverrideForAuth(auth *Auth, cfg *internalconfig.Config) (bool, bool) { if auth == nil || cfg == nil { - return false + return false, false } provider := strings.ToLower(strings.TrimSpace(auth.Provider)) if provider == "" { - return false + return false, false } providerKey := "" compatName := "" @@ -69,13 +73,16 @@ func providerCoolingDisabledForAuth(auth *Auth, cfg *internalconfig.Config) bool compatName = strings.TrimSpace(auth.Attributes["compat_name"]) } if providerKey == "" && compatName == "" && provider != "openai-compatibility" { - return false + return false, false } if providerKey == "" { providerKey = provider } entry := resolveOpenAICompatConfig(cfg, providerKey, compatName, provider) - return entry != nil && entry.DisableCooling + if entry == nil || entry.DisableCooling == nil { + return false, false + } + return *entry.DisableCooling, true } func nextTransientErrorRetryAfter(now time.Time) time.Time { diff --git a/sdk/cliproxy/auth/conductor_cooling_precedence_test.go b/sdk/cliproxy/auth/conductor_cooling_precedence_test.go new file mode 100644 index 000000000..eadf65cf7 --- /dev/null +++ b/sdk/cliproxy/auth/conductor_cooling_precedence_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "net/http" + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestManagerMarkResultUsesCredentialCoolingPrecedence(t *testing.T) { + previousGlobal := quotaCooldownDisabled.Load() + quotaCooldownDisabled.Store(false) + t.Cleanup(func() { quotaCooldownDisabled.Store(previousGlobal) }) + + disabled := true + enabled := false + tests := []struct { + name string + homeEnabled bool + globalDisable bool + credential *bool + providerOverride *bool + wantCooldown bool + }{ + {name: "credential true overrides global false", credential: &disabled}, + {name: "credential false overrides global true", globalDisable: true, credential: &enabled, wantCooldown: true}, + {name: "unset inherits global true", globalDisable: true}, + {name: "unset inherits global false", wantCooldown: true}, + {name: "provider false overrides global true", globalDisable: true, providerOverride: &enabled, wantCooldown: true}, + {name: "provider true overrides global false", providerOverride: &disabled}, + {name: "credential false overrides provider true", credential: &enabled, providerOverride: &disabled, wantCooldown: true}, + {name: "home mode disables local cooling despite credential false", homeEnabled: true, credential: &enabled}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + manager := NewManager(nil, nil, nil) + cfg := &internalconfig.Config{ + DisableCooling: tc.globalDisable, + Home: internalconfig.HomeConfig{Enabled: tc.homeEnabled}, + } + auth := &Auth{ID: tc.name, Provider: "claude", Status: StatusActive} + if tc.credential != nil { + auth.Metadata = map[string]any{"disable_cooling": *tc.credential} + } + if tc.providerOverride != nil { + auth.Provider = "openai-compatibility" + auth.Attributes = map[string]string{ + "provider_key": "compat", + "compat_name": "compat", + } + cfg.OpenAICompatibility = []internalconfig.OpenAICompatibility{{ + Name: "compat", + BaseURL: "https://compat.example.com", + DisableCooling: tc.providerOverride, + }} + } + manager.SetConfig(cfg) + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("Register() error = %v", errRegister) + } + + const model = "test-model" + manager.MarkResult(context.Background(), Result{ + AuthID: auth.ID, + Provider: auth.Provider, + Model: model, + Error: &Error{HTTPStatus: http.StatusInternalServerError, Message: "upstream failed"}, + }) + + updated, ok := manager.GetByID(auth.ID) + if !ok || updated == nil || updated.ModelStates[model] == nil { + t.Fatalf("updated auth/model state missing: %#v", updated) + } + gotCooldown := !updated.ModelStates[model].NextRetryAfter.IsZero() + if gotCooldown != tc.wantCooldown { + t.Fatalf("cooldown present = %t, want %t", gotCooldown, tc.wantCooldown) + } + }) + } +} diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 008a0574f..0a9099e0e 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -431,28 +431,20 @@ func (a *Auth) ProxyInfo() string { return "via proxy" } -// DisableCoolingOverride returns the auth scoped disable_cooling override when present. +// DisableCoolingOverride returns the auth-scoped disable_cooling override when present. // The value is read from metadata key "disable_cooling" (or legacy "disable-cooling"). -// -// NOTE: This override is intentionally "true-only". When the metadata value is false, it is treated -// as "not set" so the global disable-cooling flag can still take effect. +// The second return value distinguishes explicit false from an absent override. func (a *Auth) DisableCoolingOverride() (bool, bool) { if a == nil || a.Metadata == nil { return false, false } if val, ok := a.Metadata["disable_cooling"]; ok { if parsed, okParse := parseBoolAny(val); okParse { - if !parsed { - return false, false - } return parsed, true } } if val, ok := a.Metadata["disable-cooling"]; ok { if parsed, okParse := parseBoolAny(val); okParse { - if !parsed { - return false, false - } return parsed, true } } diff --git a/sdk/cliproxy/auth/types_cooling_test.go b/sdk/cliproxy/auth/types_cooling_test.go new file mode 100644 index 000000000..c76199542 --- /dev/null +++ b/sdk/cliproxy/auth/types_cooling_test.go @@ -0,0 +1,28 @@ +package auth + +import "testing" + +func TestDisableCoolingOverrideSupportsExplicitFalse(t *testing.T) { + tests := []struct { + name string + auth *Auth + want bool + wantPresent bool + }{ + {name: "unset", auth: &Auth{}}, + {name: "canonical true", auth: &Auth{Metadata: map[string]any{"disable_cooling": true}}, want: true, wantPresent: true}, + {name: "canonical false", auth: &Auth{Metadata: map[string]any{"disable_cooling": false}}, wantPresent: true}, + {name: "legacy false", auth: &Auth{Metadata: map[string]any{"disable-cooling": false}}, wantPresent: true}, + {name: "string false", auth: &Auth{Metadata: map[string]any{"disable_cooling": "false"}}, wantPresent: true}, + {name: "invalid", auth: &Auth{Metadata: map[string]any{"disable_cooling": "invalid"}}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, present := tc.auth.DisableCoolingOverride() + if got != tc.want || present != tc.wantPresent { + t.Fatalf("DisableCoolingOverride() = %t, %t, want %t, %t", got, present, tc.want, tc.wantPresent) + } + }) + } +} diff --git a/sdk/cliproxy/service_stale_state_test.go b/sdk/cliproxy/service_stale_state_test.go index 60349f4aa..3047004f0 100644 --- a/sdk/cliproxy/service_stale_state_test.go +++ b/sdk/cliproxy/service_stale_state_test.go @@ -76,6 +76,7 @@ func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeSt func TestForceHomeRuntimeConfigEnablesUsageStatistics(t *testing.T) { cfg := &config.Config{ UsageStatisticsEnabled: false, + DisableCooling: false, SaveCooldownStatus: true, } @@ -84,6 +85,9 @@ func TestForceHomeRuntimeConfigEnablesUsageStatistics(t *testing.T) { if !cfg.UsageStatisticsEnabled { t.Fatal("expected home runtime config to force usage statistics enabled") } + if !cfg.DisableCooling { + t.Fatal("expected home runtime config to force cooling disabled") + } if cfg.SaveCooldownStatus { t.Fatal("expected home runtime config to force cooldown status persistence disabled") }