mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-27 11:21:02 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
129
internal/api/handlers/management/config_disable_cooling_test.go
Normal file
129
internal/api/handlers/management/config_disable_cooling_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
54
internal/config/cooling_override_test.go
Normal file
54
internal/config/cooling_override_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
75
internal/watcher/diff/cooling_override_test.go
Normal file
75
internal/watcher/diff/cooling_override_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
95
internal/watcher/synthesizer/cooling_override_test.go
Normal file
95
internal/watcher/synthesizer/cooling_override_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
82
sdk/cliproxy/auth/conductor_cooling_precedence_test.go
Normal file
82
sdk/cliproxy/auth/conductor_cooling_precedence_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
28
sdk/cliproxy/auth/types_cooling_test.go
Normal file
28
sdk/cliproxy/auth/types_cooling_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user