From 6b9c7d14df118ae5999575eb9e79f54ee1842e01 Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:03:28 -0700 Subject: [PATCH] fix(fallback): normalize provider error rule headers (#5473) Co-authored-by: KooshaPari --- open-sse/config/providerErrorRules.ts | 13 ++++++---- tests/unit/provider-error-rules.test.ts | 32 ++++++++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts index d9f2797fe3..46b082b0c0 100644 --- a/open-sse/config/providerErrorRules.ts +++ b/open-sse/config/providerErrorRules.ts @@ -92,9 +92,7 @@ function buildMinimaxRules(): ProviderErrorRule[] { // If any model reports 0 remaining, the request was rejected for that // model. We classify as quota_exhausted so lockModel is called with // scope=model instead of poisoning the whole connection. - const exhausted = headerVal - .split(",") - .some((pair) => pair.split("=")[1]?.trim() === "0"); + const exhausted = headerVal.split(",").some((pair) => pair.split("=")[1]?.trim() === "0"); if (exhausted) { return { reason: "quota_exhausted", scope: "model" }; } @@ -129,7 +127,7 @@ export function getProviderErrorRuleMatch( body?: unknown ): ProviderErrorRuleMatch | null { if (!provider) return null; - const rules = providerRuleRegistry.get(provider); + const rules = providerRuleRegistry.get(provider.toLowerCase()); if (!rules) return null; // Normalize headers: accept either a `Headers` object (from `fetch()`) or // a plain record. Provider rules access headers via plain object indexing. @@ -137,7 +135,12 @@ export function getProviderErrorRuleMatch( ? {} : typeof (headers as Headers).get === "function" ? Object.fromEntries((headers as Headers).entries()) - : (headers as Record); + : Object.fromEntries( + Object.entries(headers as Record).map(([key, value]) => [ + key.toLowerCase(), + value, + ]) + ); for (const rule of rules) { const match = rule.match({ status, headers: safeHeaders, body }); if (match) return match; diff --git a/tests/unit/provider-error-rules.test.ts b/tests/unit/provider-error-rules.test.ts index aac4f820a1..c431592828 100644 --- a/tests/unit/provider-error-rules.test.ts +++ b/tests/unit/provider-error-rules.test.ts @@ -14,12 +14,9 @@ import assert from "node:assert/strict"; * - Anything else: falls back to global ERROR_RULES. */ -const { classifyError, checkFallbackError } = await import( - "../../open-sse/services/accountFallback.ts" -); -const { RateLimitReason } = await import( - "../../open-sse/config/constants.ts" -); +const { classifyError, checkFallbackError } = + await import("../../open-sse/services/accountFallback.ts"); +const { RateLimitReason } = await import("../../open-sse/config/constants.ts"); test("S1: Opencode 429 with x-ratelimit-remaining-requests=0 → QUOTA_EXHAUSTED, not RATE_LIMIT_EXCEEDED", () => { // Opencode uses account-wide quota. The header `x-ratelimit-remaining-requests: 0` @@ -43,9 +40,8 @@ test("S2: Minimax 429 with x-model-quota-remaining header → QUOTA_EXHAUSTED wi // signals ONLY that model is locked; other models on the same connection must // remain available. classifyError returns the reason; the caller (combo.ts) // reads the scope from providerRuleMatch to decide lockModel vs updateProviderConnection. - const { providerRuleRegistry, getProviderErrorRuleMatch } = await import( - "../../open-sse/config/providerErrorRules.ts" - ); + const { providerRuleRegistry, getProviderErrorRuleMatch } = + await import("../../open-sse/config/providerErrorRules.ts"); // The registry must be loaded for minimax const minimaxRules = providerRuleRegistry.get("minimax"); @@ -67,6 +63,24 @@ test("S2: Minimax 429 with x-model-quota-remaining header → QUOTA_EXHAUSTED wi ); }); +test("S2b: provider error rules match canonical-cased plain header records", async () => { + const { getProviderErrorRuleMatch } = await import("../../open-sse/config/providerErrorRules.ts"); + + const opencodeMatch = getProviderErrorRuleMatch("OpenCode", 429, { + "X-RateLimit-Remaining-Requests": "0", + }); + assert.ok(opencodeMatch, "Opencode quota headers must be case-insensitive"); + assert.equal(opencodeMatch.reason, "quota_exhausted"); + assert.equal(opencodeMatch.scope, "provider"); + + const minimaxMatch = getProviderErrorRuleMatch("Minimax", 429, { + "X-Model-Quota-Remaining": "haiku=0,sonnet=42", + }); + assert.ok(minimaxMatch, "Minimax quota headers must be case-insensitive"); + assert.equal(minimaxMatch.reason, "quota_exhausted"); + assert.equal(minimaxMatch.scope, "model"); +}); + test("S3: Regression — provider with no rules falls back to global ERROR_RULES unchanged", () => { // A provider not in the registry (e.g. "unknown-vendor") must NOT cause // classifyError to crash or return a different result. It must behave