diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 71b9f921..24a520cc 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -144,6 +144,9 @@ func codexTerminalStreamErrShouldHandle(body []byte) bool { if codexTerminalErrorIsContextLength(body) { return true } + if isCodexUsageLimitError(body) || isCodexModelCapacityError(body) { + return true + } code, _, ok := codexStatusErrorClassification(http.StatusBadRequest, body) return ok && code == "thinking_signature_invalid" } @@ -1672,7 +1675,7 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s func newCodexStatusErr(statusCode int, body []byte) statusErr { errCode := statusCode - if isCodexModelCapacityError(body) { + if isCodexModelCapacityError(body) || isCodexUsageLimitError(body) { errCode = http.StatusTooManyRequests } body = classifyCodexStatusError(errCode, body) @@ -1819,6 +1822,28 @@ func isCodexModelCapacityError(errorBody []byte) bool { return false } +// isCodexUsageLimitError reports whether the error body represents a Codex +// quota/plan-limit exhaustion (error.type == "usage_limit_reached"). This is the +// signal Codex emits when a credential's usage quota is depleted, and it carries +// reset timing (resets_at/resets_in_seconds) parsed by parseCodexRetryAfter. +// Transient per-minute rate limits (rate_limit_error/rate_limit_exceeded) are +// intentionally excluded, as they should be retried rather than cooled down. +func isCodexUsageLimitError(errorBody []byte) bool { + if len(errorBody) == 0 { + return false + } + candidates := []string{ + gjson.GetBytes(errorBody, "error.type").String(), + gjson.GetBytes(errorBody, "type").String(), + } + for _, candidate := range candidates { + if strings.EqualFold(strings.TrimSpace(candidate), "usage_limit_reached") { + return true + } + } + return false +} + func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration { if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 { return nil diff --git a/internal/runtime/executor/codex_executor_retry_test.go b/internal/runtime/executor/codex_executor_retry_test.go index 7207d573..2162b7bb 100644 --- a/internal/runtime/executor/codex_executor_retry_test.go +++ b/internal/runtime/executor/codex_executor_retry_test.go @@ -74,6 +74,60 @@ func TestNewCodexStatusErrTreatsCapacityAsRetryableRateLimit(t *testing.T) { } } +func TestNewCodexStatusErrTreatsUsageLimitAsRetryableRateLimit(t *testing.T) { + body := []byte(`{"error":{"type":"usage_limit_reached","message":"You've hit your usage limit.","resets_in_seconds":120}}`) + + err := newCodexStatusErr(http.StatusBadRequest, body) + + if got := err.StatusCode(); got != http.StatusTooManyRequests { + t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests) + } + retryAfter := err.RetryAfter() + if retryAfter == nil { + t.Fatalf("expected retryAfter from usage_limit_reached, got nil") + } + if *retryAfter != 120*time.Second { + t.Fatalf("retryAfter = %v, want %v", *retryAfter, 120*time.Second) + } +} + +func TestIsCodexUsageLimitError(t *testing.T) { + tests := []struct { + name string + body []byte + want bool + }{ + { + name: "nested usage_limit_reached", + body: []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`), + want: true, + }, + { + name: "top-level usage_limit_reached", + body: []byte(`{"type":"usage_limit_reached"}`), + want: true, + }, + { + name: "transient rate limit is excluded", + body: []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded"}}`), + want: false, + }, + { + name: "empty body", + body: nil, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isCodexUsageLimitError(tc.body); got != tc.want { + t.Fatalf("isCodexUsageLimitError = %v, want %v", got, tc.want) + } + }) + } +} + func TestNewCodexStatusErrClassifiesKnownCodexFailures(t *testing.T) { tests := []struct { name string diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go index 46a22792..f495d3c1 100644 --- a/internal/runtime/executor/codex_executor_stream_output_test.go +++ b/internal/runtime/executor/codex_executor_stream_output_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" @@ -166,6 +167,36 @@ func TestCodexTerminalStreamErrIgnoresRateLimitTerminalErrors(t *testing.T) { } } +func TestCodexTerminalStreamErrHandlesUsageLimitErrorEvent(t *testing.T) { + streamErr, _, ok := codexTerminalStreamErr([]byte(`{"type":"error","error":{"type":"usage_limit_reached","message":"You've hit your usage limit.","resets_in_seconds":300}}`)) + if !ok { + t.Fatal("expected usage_limit_reached terminal error to be handled") + } + if got := statusCodeFromTestError(t, streamErr); got != http.StatusTooManyRequests { + t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests) + } + retryAfter := streamErr.RetryAfter() + if retryAfter == nil { + t.Fatal("expected retryAfter from usage_limit_reached terminal error") + } + if *retryAfter != 300*time.Second { + t.Fatalf("retryAfter = %v, want %v", *retryAfter, 300*time.Second) + } +} + +func TestCodexTerminalStreamErrHandlesUsageLimitResponseFailed(t *testing.T) { + streamErr, _, ok := codexTerminalStreamErr([]byte(`{"type":"response.failed","response":{"error":{"type":"usage_limit_reached","message":"usage limit reached","resets_in_seconds":60}}}`)) + if !ok { + t.Fatal("expected usage_limit_reached response.failed terminal error to be handled") + } + if got := statusCodeFromTestError(t, streamErr); got != http.StatusTooManyRequests { + t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests) + } + if streamErr.RetryAfter() == nil { + t.Fatal("expected retryAfter from usage_limit_reached response.failed terminal error") + } +} + func statusCodeFromTestError(t *testing.T, err error) int { t.Helper()