fix(fallback): normalize provider error rule headers (#5473)

Co-authored-by: KooshaPari <koosha@example.com>
This commit is contained in:
KooshaPari
2026-06-29 18:03:28 -07:00
committed by GitHub
parent 3f8995deb6
commit 6b9c7d14df
2 changed files with 31 additions and 14 deletions

View File

@@ -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<string, string>);
: Object.fromEntries(
Object.entries(headers as Record<string, string>).map(([key, value]) => [
key.toLowerCase(),
value,
])
);
for (const rule of rules) {
const match = rule.match({ status, headers: safeHeaders, body });
if (match) return match;

View File

@@ -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