Refactor cooling management across configuration and handlers

- Changed `DisableCooling` from a boolean to a pointer in various config types to allow explicit inheritance.
- Updated tests to reflect the new pointer usage for `DisableCooling`.
- Enhanced the `BuildConfigChangeDetails` function to handle optional boolean changes for `DisableCooling`.
- Added new tests to ensure proper handling of cooling overrides in configurations.
- Refactored the `SetQuotaCooldownDisabled` function and related logic to clarify the purpose of cooldown management.
- Introduced new tests for cooling override precedence in the auth manager.
- Ensured that all relevant handlers and synthesizers correctly manage the `DisableCooling` setting.
This commit is contained in:
hkfires
2026-08-17 10:54:07 +08:00
parent b8fbe70b37
commit 5bffd1514f
23 changed files with 616 additions and 66 deletions

View File

@@ -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

View File

@@ -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"`

View File

@@ -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)
}
})
}
}

View File

@@ -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

View File

@@ -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,
},
},

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)
}
}
}

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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

View File

@@ -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"}},

View File

@@ -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)
}
})
}
}

View File

@@ -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)))
}

View File

@@ -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,

View File

@@ -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"},

View File

@@ -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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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)
}
})
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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")
}