Files
CLIProxyAPI/test/codex_quota_failover_test.go
Luis Pater 5ab0bca040 fix(codex): scope usage limit errors to credentials and parse flexible quota resets
- Mark Codex usage limit errors as credential-scoped across HTTP and WebSocket executors.
- Support both top-level and nested error structures with case-insensitive matching when parsing retry-after resets.
- Propagate prevalidated candidate context to session affinity and built-in selectors during auth selection.

Closes: #5529
2026-09-06 06:20:20 +08:00

146 lines
5.8 KiB
Go

package test
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
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"
)
func TestCodexTerminalQuotaCoolsAccountAcrossModels(t *testing.T) {
for _, transport := range []string{"sse", "websocket-error", "websocket-failed"} {
t.Run(transport, func(t *testing.T) {
const model, siblingModel = "gpt-5.4", "gpt-5.4-mini"
const created = `{"type":"response.created","response":{"id":"quota-test-response"}}`
const quota = `{"type":"usage_limit_reached","message":"You've hit your usage limit.","resets_in_seconds":3600}`
const completed = `{"type":"response.completed","response":{"id":"quota-test-success","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`
attempts := make(chan string, 8)
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
attempts <- account
terminal := completed
if account == "quota-high" {
terminal = `{"type":"error","status":429,"error":` + quota + `}`
if transport == "websocket-failed" {
terminal = `{"type":"response.failed","response":{"error":` + quota + `}}`
}
}
if transport == "sse" {
w.Header().Set("Content-Type", "text/event-stream")
if _, errWrite := fmt.Fprintf(w, "data: %s\n\ndata: %s\n\n", created, terminal); errWrite != nil {
t.Errorf("write SSE: %v", errWrite)
}
return
}
conn, errUpgrade := upgrader.Upgrade(w, r, nil)
if errUpgrade != nil {
t.Errorf("upgrade websocket: %v", errUpgrade)
return
}
defer func() {
if errClose := conn.Close(); errClose != nil {
t.Errorf("close websocket: %v", errClose)
}
}()
if _, _, errRead := conn.ReadMessage(); errRead != nil {
t.Errorf("read websocket request: %v", errRead)
return
}
for _, event := range []string{created, terminal} {
if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(event)); errWrite != nil {
t.Errorf("write websocket event: %v", errWrite)
return
}
}
}))
defer server.Close()
manager := cliproxyauth.NewManager(nil, &cliproxyauth.RoundRobinSelector{}, nil)
manager.SetRetryConfig(0, 0, 0)
cfg := &config.Config{}
if transport == "sse" {
manager.RegisterExecutor(runtimeexecutor.NewCodexExecutor(cfg))
} else {
manager.RegisterExecutor(runtimeexecutor.NewCodexWebsocketsExecutor(cfg))
}
highID, lowID := "quota-high-"+transport, "quota-low-"+transport
for i, id := range []string{highID, lowID} {
key := "quota-high"
if i == 1 {
key = "quota-low"
}
registry.GetGlobalRegistry().RegisterClient(id, "codex", []*registry.ModelInfo{{ID: model}, {ID: siblingModel}})
t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(id) })
if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
ID: id, Provider: "codex", Status: cliproxyauth.StatusActive,
Attributes: map[string]string{"priority": fmt.Sprint(4 - i), "base_url": server.URL, "api_key": key},
Metadata: map[string]any{"disable_cooling": false},
}); errRegister != nil {
t.Fatal(errRegister)
}
}
run := func(requestModel string) ([]byte, error) {
t.Helper()
result, errStream := manager.ExecuteStream(context.Background(), []string{"codex"}, cliproxyexecutor.Request{
Model: requestModel, Payload: []byte(fmt.Sprintf(`{"model":%q,"input":"hello"}`, requestModel)),
}, cliproxyexecutor.Options{Stream: true, SourceFormat: sdktranslator.FromString("openai-response")})
if errStream != nil {
t.Fatalf("stream must start before the terminal quota error: %v", errStream)
}
var payload []byte
var terminalErr error
for chunk := range result.Chunks {
payload = append(payload, chunk.Payload...)
if chunk.Err != nil {
terminalErr = chunk.Err
}
}
return payload, terminalErr
}
before := time.Now()
payload, quotaErr := run(model)
if !strings.Contains(string(payload), "response.created") || quotaErr == nil {
t.Fatalf("expected payload then terminal quota error: payload=%s error=%v", payload, quotaErr)
}
var scoped interface{ IsCredentialScoped() bool }
if !errors.As(quotaErr, &scoped) || !scoped.IsCredentialScoped() {
t.Errorf("real Codex error is missing credential scope: %T %v", quotaErr, quotaErr)
}
high, _ := manager.GetByID(highID)
if high.Quota.Reason != "credential_quota" || high.Quota.NextRecoverAt.Before(before.Add(time.Hour)) {
t.Errorf("account cooldown missing scope or upstream reset: %+v", high.Quota)
}
payload, errSibling := run(siblingModel)
if errSibling != nil || !strings.Contains(string(payload), "response.completed") {
t.Errorf("sibling model did not fail over to the healthy account: payload=%s error=%v", payload, errSibling)
}
for _, want := range []string{"quota-high", "quota-low"} {
select {
case got := <-attempts:
if got != want {
t.Errorf("upstream account = %s, want %s", got, want)
}
default:
t.Fatalf("missing upstream attempt for %s", want)
}
}
if len(attempts) != 0 {
t.Errorf("unexpected extra upstream attempts: %d", len(attempts))
}
})
}
}