Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,8 @@ func main() {
redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
coreauth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
coreauth.SetQuotaCooldownFloorSeconds(cfg.QuotaCooldownFloorSeconds)
coreauth.SetTransientCooldownByStatus(cfg.TransientCooldownByStatus)

if err = logging.ConfigureLogOutput(cfg); err != nil {
log.Errorf("failed to configure log output: %v", err)
Expand Down
14 changes: 14 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ save-cooldown-status: false
# Set to 0 to keep the legacy 60-second cooldown; set to -1 to disable transient error cooldowns.
transient-error-cooldown-seconds: 0

# Per-status overrides for transient error cooldowns.
# Statuses not listed fall back to transient-error-cooldown-seconds.
# Example:
# transient-cooldown-by-status:
# - status: 408
# cooldown-seconds: 2
# - status: 503
# cooldown-seconds: 10

# Minimum base in seconds for the quota cooldown ladder.
# Sub-second Retry-After hints are never allowed below this floor. Default 1.
# Values above 1800 (30 minutes) are clamped to 1800 because the ladder is capped at 30 minutes.
quota-cooldown-floor-seconds: 1

# When true, globally disable Claude request cloaking (the Claude Code CLI disguise and
# system prompt replacement), so the original system prompt is passed through to Claude as-is.
# Individual credentials can still override this: a claude-api-key entry via its "cloak.mode",
Expand Down
2 changes: 2 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
managementasset.SetCurrentConfig(cfg)
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
auth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
auth.SetQuotaCooldownFloorSeconds(cfg.QuotaCooldownFloorSeconds)
auth.SetTransientCooldownByStatus(cfg.TransientCooldownByStatus)
applySignatureCacheConfig(nil, cfg)
// Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
Expand Down
19 changes: 19 additions & 0 deletions internal/api/server_reload.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"fmt"
"reflect"
"time"

"github.com/router-for-me/CLIProxyAPI/v7/internal/access"
Expand All @@ -19,6 +20,18 @@ import (
"gopkg.in/yaml.v3"
)

func transientCooldownByStatusMap(rules []config.TransientCooldownByStatusRule) map[int]int {
m := make(map[int]int, len(rules))
for _, r := range rules {
m[r.Status] = r.CooldownSeconds
}
return m
}

func transientCooldownByStatusEqual(a, b []config.TransientCooldownByStatusRule) bool {
return reflect.DeepEqual(transientCooldownByStatusMap(a), transientCooldownByStatusMap(b))
}

func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) bool {
if s == nil || s.accessManager == nil || newCfg == nil {
return false
Expand Down Expand Up @@ -111,6 +124,12 @@ func (s *Server) UpdateClientsContext(ctx context.Context, cfg *config.Config) b
if oldCfg == nil || oldCfg.TransientErrorCooldownSeconds != cfg.TransientErrorCooldownSeconds {
auth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
}
if oldCfg == nil || oldCfg.QuotaCooldownFloorSeconds != cfg.QuotaCooldownFloorSeconds {
auth.SetQuotaCooldownFloorSeconds(cfg.QuotaCooldownFloorSeconds)
}
if oldCfg == nil || !transientCooldownByStatusEqual(oldCfg.TransientCooldownByStatus, cfg.TransientCooldownByStatus) {
auth.SetTransientCooldownByStatus(cfg.TransientCooldownByStatus)
}

if oldCfg != nil && oldCfg.DisableImageGeneration != cfg.DisableImageGeneration {
log.Infof("disable-image-generation updated: %v -> %v", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration)
Expand Down
48 changes: 48 additions & 0 deletions internal/api/server_reload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package api

import (
"testing"

"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)

func TestTransientCooldownByStatusEqual(t *testing.T) {
tests := []struct {
name string
a []config.TransientCooldownByStatusRule
b []config.TransientCooldownByStatusRule
want bool
}{
{
name: "identical",
a: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 2}, {Status: 503, CooldownSeconds: 10}},
b: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 2}, {Status: 503, CooldownSeconds: 10}},
want: true,
},
{
name: "different values",
a: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 2}},
b: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 5}},
want: false,
},
{
name: "new drops a status",
a: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 2}, {Status: 503, CooldownSeconds: 10}},
b: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 2}},
want: false,
},
{
name: "new duplicates a status",
a: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 2}, {Status: 503, CooldownSeconds: 10}},
b: []config.TransientCooldownByStatusRule{{Status: 408, CooldownSeconds: 2}, {Status: 408, CooldownSeconds: 2}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := transientCooldownByStatusEqual(tt.a, tt.b); got != tt.want {
t.Fatalf("transientCooldownByStatusEqual(%+v, %+v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ type Config struct {
// 0 keeps the legacy default cooldown. Negative values disable these cooldowns.
TransientErrorCooldownSeconds int `yaml:"transient-error-cooldown-seconds" json:"transient-error-cooldown-seconds"`

// QuotaCooldownFloorSeconds is the minimum base for the quota cooldown ladder.
// Sub-second Retry-After hints are never allowed below this floor. Default 1.
QuotaCooldownFloorSeconds int `yaml:"quota-cooldown-floor-seconds" json:"quota-cooldown-floor-seconds"`

// TransientCooldownByStatus lets operators override the transient cooldown per HTTP status.
// Statuses not listed fall back to TransientErrorCooldownSeconds.
TransientCooldownByStatus []TransientCooldownByStatusRule `yaml:"transient-cooldown-by-status,omitempty" json:"transient-cooldown-by-status,omitempty"`

// AuthAutoRefreshWorkers overrides the size of the core auth auto-refresh worker pool.
// When <= 0, the default worker count is used.
AuthAutoRefreshWorkers int `yaml:"auth-auto-refresh-workers" json:"auth-auto-refresh-workers"`
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.DisableCooling = false
cfg.SaveCooldownStatus = false
cfg.TransientErrorCooldownSeconds = 0
cfg.QuotaCooldownFloorSeconds = 1
cfg.DisableImageGeneration = DisableImageGenerationOff
cfg.WebsocketAuth = true
cfg.Pprof.Enable = false
Expand Down
10 changes: 10 additions & 0 deletions internal/config/config_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ type KiroRateLimitConfig struct {
SuspendCooldown string `yaml:"suspend-cooldown,omitempty" json:"suspend-cooldown,omitempty"`
}

// TransientCooldownByStatusRule overrides the transient cooldown duration for a single HTTP status.
// Statuses not listed fall back to the global TransientErrorCooldownSeconds.
type TransientCooldownByStatusRule struct {
// Status is the HTTP status code to match (e.g. 408, 500, 502, 503, 504).
Status int `yaml:"status" json:"status"`
// CooldownSeconds is the cooldown applied when this status is seen.
// 0 keeps the legacy default for this status; negative values disable the cooldown.
CooldownSeconds int `yaml:"cooldown-seconds" json:"cooldown-seconds"`
}

// RequestScopedErrorRule configures custom classification and handling for upstream errors.
type RequestScopedErrorRule struct {
// Status matches the HTTP status code of the upstream response (e.g. 400).
Expand Down
60 changes: 60 additions & 0 deletions internal/config/cooldown_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package config

import "testing"

func TestCooldownConfigDefaults(t *testing.T) {
data := []byte(`
host: "127.0.0.1"
port: 8080
`)
cfg, err := ParseConfigBytes(data)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.TransientErrorCooldownSeconds != 0 {
t.Fatalf("TransientErrorCooldownSeconds default = %d, want 0", cfg.TransientErrorCooldownSeconds)
}
if cfg.QuotaCooldownFloorSeconds != 1 {
t.Fatalf("QuotaCooldownFloorSeconds default = %d, want 1", cfg.QuotaCooldownFloorSeconds)
}
if cfg.TransientCooldownByStatus != nil {
t.Fatalf("TransientCooldownByStatus default = %v, want nil", cfg.TransientCooldownByStatus)
}
}

func TestCooldownConfigParse(t *testing.T) {
data := []byte(`
host: "127.0.0.1"
port: 8080
transient-error-cooldown-seconds: 10
quota-cooldown-floor-seconds: 5
transient-cooldown-by-status:
- status: 408
cooldown-seconds: 2
- status: 503
cooldown-seconds: 15
`)
cfg, err := ParseConfigBytes(data)
if err != nil {
t.Fatalf("parse config: %v", err)
}
if cfg.TransientErrorCooldownSeconds != 10 {
t.Fatalf("TransientErrorCooldownSeconds = %d, want 10", cfg.TransientErrorCooldownSeconds)
}
if cfg.QuotaCooldownFloorSeconds != 5 {
t.Fatalf("QuotaCooldownFloorSeconds = %d, want 5", cfg.QuotaCooldownFloorSeconds)
}
if len(cfg.TransientCooldownByStatus) != 2 {
t.Fatalf("TransientCooldownByStatus len = %d, want 2", len(cfg.TransientCooldownByStatus))
}
found := map[int]int{}
for _, r := range cfg.TransientCooldownByStatus {
found[r.Status] = r.CooldownSeconds
}
if found[408] != 2 {
t.Fatalf("status 408 cooldown = %d, want 2", found[408])
}
if found[503] != 15 {
t.Fatalf("status 503 cooldown = %d, want 15", found[503])
}
}
1 change: 1 addition & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
cfg.DisableCooling = false
cfg.SaveCooldownStatus = false
cfg.TransientErrorCooldownSeconds = 0
cfg.QuotaCooldownFloorSeconds = 1
cfg.DisableImageGeneration = DisableImageGenerationOff
cfg.WebsocketAuth = true
cfg.Pprof.Enable = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package executor

import (
"context"
"errors"
"testing"
"time"

"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)

// TestAntigravityShortCooldownErrorIsTransient pins the classification of the
// synthetic 429 the executor raises while an auth sits in a short cooldown.
// The cooldown is a local, self-imposed pause of at most a few minutes, so the
// conductor has to read it as a transient rate limit and rotate to the next
// auth. Unclassified, the same error looks like an exhausted quota carrying a
// retry hint, and the conductor escalates BackoffLevel toward the 30 minute
// ceiling — parking an account that was never actually throttled upstream.
func TestAntigravityShortCooldownErrorIsTransient(t *testing.T) {
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
client := newFakeAntigravityKVClient()
useFakeAntigravityKVClient(t, client, true, nil)

exec := NewAntigravityExecutor(&config.Config{})
opts := cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatGemini,
ResponseFormat: sdktranslator.FormatGemini,
}
payload := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)

for _, tc := range []struct {
name string
model string
call func(auth *cliproxyauth.Auth, model string) error
}{
{
name: "execute",
model: "gemini-3.6-flash",
call: func(auth *cliproxyauth.Auth, model string) error {
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{Model: model, Payload: payload}, opts)
return err
},
},
{
name: "execute-claude",
model: "claude-sonnet-4-5",
call: func(auth *cliproxyauth.Auth, model string) error {
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{Model: model, Payload: payload}, opts)
return err
},
},
{
name: "execute-stream",
model: "gemini-3.6-flash",
call: func(auth *cliproxyauth.Auth, model string) error {
_, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{Model: model, Payload: payload}, opts)
return err
},
},
} {
t.Run(tc.name, func(t *testing.T) {
auth := &cliproxyauth.Auth{ID: "cooldown-transient-" + tc.name}
if errMark := markAntigravityShortCooldownRequired(context.Background(), auth, tc.model, time.Now(), 30*time.Second); errMark != nil {
t.Fatalf("markAntigravityShortCooldownRequired() error = %v", errMark)
}

err := tc.call(auth, tc.model)
if err == nil {
t.Fatal("expected the short cooldown to surface a 429")
}

var classified interface{ TransientRateLimit() bool }
if !errors.As(err, &classified) {
t.Fatalf("short-cooldown error carries no 429 classification: %T", err)
}
if !classified.TransientRateLimit() {
t.Fatal("expected the synthetic short-cooldown 429 to be transient so the conductor rotates instead of escalating backoff")
}

var hinted interface{ RetryAfter() *time.Duration }
if !errors.As(err, &hinted) || hinted.RetryAfter() == nil || *hinted.RetryAfter() <= 0 {
t.Fatalf("expected a positive retry hint on the short-cooldown 429, got %v", err)
}
})
}
}
9 changes: 9 additions & 0 deletions internal/runtime/executor/antigravity_executor_credits.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,15 @@ func newAntigravityStatusErr(statusCode int, body []byte) statusErr {
if retryAfter, parseErr := helps.ParseRetryDelay(body); parseErr == nil && retryAfter != nil {
err.retryAfter = retryAfter
}
// Only a decisively rate-limited 429 may keep its raw retry hint downstream;
// exhausted quota and unclassified bodies stay on the escalating cooldown ladder.
// A RATE_LIMIT_EXCEEDED reason without a RetryInfo hint is still a short-lived
// throttle, not an exhausted quota, so it is transient too — but only when the
// classification comes from the ErrorInfo reason, not from the bare
// "too many requests" message heuristic.
category := classifyAntigravity429(body)
err.transientRateLimit = category == antigravity429RateLimited ||
(category == antigravity429SoftRateLimit && strings.EqualFold(decideAntigravity429(body).reason, "RATE_LIMIT_EXCEEDED"))
}
return err
}
Expand Down
Loading
Loading