diff --git a/utils/providerErrors.test.ts b/utils/providerErrors.test.ts index bdc4d39..45421e5 100644 --- a/utils/providerErrors.test.ts +++ b/utils/providerErrors.test.ts @@ -7,7 +7,13 @@ describe("detectProviderError", () => { expect(detectProviderError("commit f609cc89e84596ab125d60dac568bfb2ef398396 429")).toBeNull(); }); - it("returns null for x-ratelimit-* response headers in 401 error JSON", () => { + it("classifies 401 + x-ratelimit-* headers as auth, not rate-limited", () => { + // OpenRouter 401 responses bundle `x-ratelimit-*` rate-limit headers + // alongside the auth error. the auth patterns must win — pre-fix this + // got tagged as `rate limited` because of the loose `\brate[_ ]limit` + // match against header names like `ratelimit-limit-requests`. note: in + // OpenRouter's actual format the header name is `ratelimit` (one word), + // but the dumped JSON sometimes contains `rate-limit` separators too. const stderr = JSON.stringify({ error: { name: "APIError", statusCode: 401, message: "Invalid authentication credentials" }, headers: { @@ -16,7 +22,7 @@ describe("detectProviderError", () => { "x-ratelimit-reset-tokens": "2025-01-01T00:00:00Z", }, }); - expect(detectProviderError(stderr)).toBeNull(); + expect(detectProviderError(stderr)).toBe("auth error (401)"); }); it("returns null for INTERNAL_SERVER_ERROR substring", () => { @@ -29,6 +35,37 @@ describe("detectProviderError", () => { }); }); + describe("auth errors", () => { + it("detects 401 / 403 status codes as auth errors", () => { + expect(detectProviderError('{"statusCode": 401}')).toBe("auth error (401)"); + expect(detectProviderError('{"statusCode": 403}')).toBe("auth error (403)"); + expect(detectProviderError("status_code: 401")).toBe("auth error (401)"); + }); + + it("detects OpenRouter 'User not found' (disabled/invalid key)", () => { + // bare `"code":401` lacks a status-key prefix so the 401 status pattern + // intentionally doesn't fire; the User-not-found pattern catches it. + expect(detectProviderError('{"error":{"message":"User not found","code":401}}')).toBe( + "auth error (invalid/disabled key)" + ); + expect(detectProviderError("APIError: User not found.")).toBe( + "auth error (invalid/disabled key)" + ); + }); + + it("detects 'Invalid authentication' phrasing", () => { + expect(detectProviderError("Invalid authentication credentials")).toBe( + "auth error (invalid credentials)" + ); + }); + + it("detects 'No auth credentials found' phrasing", () => { + expect(detectProviderError("AI_APICallError: No auth credentials found")).toBe( + "auth error (missing credentials)" + ); + }); + }); + describe("real provider errors", () => { it("detects 429 only when adjacent to a status key", () => { expect(detectProviderError('{"statusCode": 429}')).toBe("rate limited (429)"); diff --git a/utils/providerErrors.ts b/utils/providerErrors.ts index 6a6b962..c2d40c0 100644 --- a/utils/providerErrors.ts +++ b/utils/providerErrors.ts @@ -6,6 +6,17 @@ type ProviderErrorPattern = { regex: RegExp; label: string }; const statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`; const PROVIDER_ERROR_PATTERNS: ProviderErrorPattern[] = [ + // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error + // payloads carry `x-ratelimit-*` response headers in the dump, and the + // free-form rate-limit regex below would otherwise win on word-boundary + // matches inside header names. canonical 401 messages: OpenRouter returns + // `{"error":{"message":"User not found","code":401}}` for disabled or + // invalid keys (https://openai.luzhipeng.com/docs/api/reference/errors-and-debugging). + { regex: new RegExp(`${statusKey}401\\b`, "i"), label: "auth error (401)" }, + { regex: new RegExp(`${statusKey}403\\b`, "i"), label: "auth error (403)" }, + { regex: /\bUser not found\b/i, label: "auth error (invalid/disabled key)" }, + { regex: /\bInvalid authentication\b/i, label: "auth error (invalid credentials)" }, + { regex: /\bNo auth credentials found\b/i, label: "auth error (missing credentials)" }, { regex: new RegExp(`${statusKey}429\\b`, "i"), label: "rate limited (429)" }, { regex: new RegExp(`${statusKey}500\\b`, "i"), label: "provider 500 error" }, { regex: new RegExp(`${statusKey}503\\b`, "i"), label: "provider unavailable (503)" },