diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts index 15e82adf4e..ae2f495573 100644 --- a/open-sse/config/providerErrorRules.ts +++ b/open-sse/config/providerErrorRules.ts @@ -180,7 +180,13 @@ function buildOpenrouterRules(): ProviderErrorRule[] { // agentrouter.org misstates temporary quota exhaustion as 403/400 with a // Chinese body. upstreamStatusRestatement.ts rewrites the status to 429 // BEFORE classification, so rules here accept both the raw 403/400 and the -// restated 429 (text is the real discriminator either way). +// restated 429 (text is the real discriminator either way). In production, +// the raw 403 path is what actually matters here: checkFallbackError's +// apikey-category FORBIDDEN branch (~line 1699) returns EARLY for a plain +// 403, before these rules are ever consulted — these rules fire on the +// RESTATED 429 (chatCore's upstreamStatusRestatement hook runs first) via +// resolveRuleMatchBody, which is the only path in checkFallbackError that +// hands these rules the full error text instead of just {code, type}. // - "额度不足": account-wide temporary quota → quota_exhausted, scope // "connection" (mirror of the Opencode account-wide rationale above). // - "无权访问模型": this key permanently lacks access to ONE model → lock only @@ -227,6 +233,34 @@ export const providerRuleRegistry = new Map([ ["agentrouter", buildAgentrouterRules()], ]); +/** + * Providers whose rules match on the FULL upstream error text. + * checkFallbackError's rule lookup normally passes only the structured + * error ({code, type} — message stripped by the combo callers), which is + * enough for header/status/code rules but blind to body-text markers like + * agentrouter's "额度不足". Providers in this set get the raw error text as + * the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13): + * adding a provider here is an explicit opt-in — the default path for every + * other provider must remain byte-for-byte unchanged. + */ +const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]); + +/** + * Resolve the body handed to getProviderErrorRuleMatch inside + * checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS, + * the structured error for everyone else. + */ +export function resolveRuleMatchBody( + provider: string | null | undefined, + structuredError: unknown, + errorText: string | null | undefined +): unknown { + if (provider && FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) && errorText) { + return errorText; + } + return structuredError ?? null; +} + /** * Returns the first matching rule for a provider, or null if none match. * Callers use this to (a) classify the reason and (b) decide whether to diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d8175272c9..0f2c43a28f 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -14,7 +14,7 @@ import { serviceSupervisorCooldown, isNimFunctionDegraded, } from "../config/errorConfig.ts"; -import { getProviderErrorRuleMatch } from "../config/providerErrorRules.ts"; +import { getProviderErrorRuleMatch, resolveRuleMatchBody } from "../config/providerErrorRules.ts"; import * as rot from "./rotationConfig.ts"; import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; import { @@ -1727,7 +1727,12 @@ export function checkFallbackError( // specific configured reasons (e.g. 503 → SERVER_ERROR would be // shadowed by 503 → MODEL_CAPACITY). const providerMatch = provider - ? getProviderErrorRuleMatch(provider, status, headers, structuredError ?? null) + ? getProviderErrorRuleMatch( + provider, + status, + headers, + resolveRuleMatchBody(provider, structuredError ?? null, errorStr) + ) : null; const reason = providerMatch ? providerMatch.reason @@ -1760,7 +1765,12 @@ export function checkFallbackError( // generic zero-cooldown default. Mirror the backoff branch above so // provider rules win on cooldown/reason regardless of `backoff`. const providerMatch = provider - ? getProviderErrorRuleMatch(provider, status, headers, structuredError ?? null) + ? getProviderErrorRuleMatch( + provider, + status, + headers, + resolveRuleMatchBody(provider, structuredError ?? null, errorStr) + ) : null; const cooldownMs = providerMatch?.cooldownMs ?? configuredRule.cooldownMs ?? 0; return { diff --git a/tests/unit/agentrouter-error-rules.test.ts b/tests/unit/agentrouter-error-rules.test.ts index 0923f3e83d..d9cecd78fd 100644 --- a/tests/unit/agentrouter-error-rules.test.ts +++ b/tests/unit/agentrouter-error-rules.test.ts @@ -58,15 +58,29 @@ test("A5: classifyError integration — quota text wins over the 403→AUTH_ERRO assert.equal(reason, RateLimitReason.QUOTA_EXHAUSTED); }); -test("A6: guard — restated quota error is retryable, never terminal", () => { +test("A6: guard — restated quota error is retryable, never terminal, and now actually classified as quota_exhausted", () => { + // Status 429 (post-restatement) reaches checkFallbackError's provider-rule + // lookup. resolveRuleMatchBody() hands agentrouter the full error text + // (instead of just the stripped {code, type} structuredError every other + // provider gets), so the "额度不足" rule actually fires here — this is the + // production path the restatement hook (Task 2) feeds into. const result = checkFallbackError(429, "用户额度不足", 0, null, "agentrouter", null); assert.equal(result.shouldFallback, true); + assert.equal(result.reason, "quota_exhausted"); assert.ok(!result.permanent, "quota misstatus must never be permanent"); assert.ok(!result.creditsExhausted, "must not trip CREDITS_EXHAUSTED_SIGNALS"); assert.ok(result.cooldownMs > 0, "must carry a real cooldown"); }); test("A7: guard — raw 403 quota (hook bypassed) is still not account-deactivation", () => { + // A raw (pre-restatement) 403 never actually reaches the agentrouter provider + // rules in production: checkFallbackError's apikey-category FORBIDDEN branch + // (status === 403 && getProviderCategory(provider) === "apikey") returns + // EARLY via resolveApiKeyForbiddenFallback before the provider-rule lookup + // is ever consulted. In the real pipeline, chatCore's upstreamStatusRestatement + // hook (Task 2) already converts 403→429 before checkFallbackError ever sees + // it, so this early-return path is what a hook-bypassed raw 403 hits — and it + // must still not be misclassified as permanent account deactivation. const result = checkFallbackError(403, "用户额度不足", 0, null, "agentrouter", null); assert.equal(result.shouldFallback, true); assert.ok(!result.permanent); @@ -76,3 +90,39 @@ test("A8: plain agentrouter 403 (no quota text) keeps the default apikey auth pa const match = getProviderErrorRuleMatch("agentrouter", 403, {}, "Invalid API key"); assert.equal(match, null); }); + +test("A9: resolveRuleMatchBody hands full text ONLY to allowlisted providers", async () => { + const { resolveRuleMatchBody } = await import( + "../../open-sse/config/providerErrorRules.ts" + ); + const structured = { code: "rate_limited", type: "requests" }; + assert.equal(resolveRuleMatchBody("agentrouter", structured, "用户额度不足"), "用户额度不足"); + assert.equal(resolveRuleMatchBody("opencode", structured, "monthly usage limit reached"), structured); + assert.equal(resolveRuleMatchBody("openrouter", null, "some error text"), null); + assert.equal(resolveRuleMatchBody("agentrouter", structured, ""), structured); +}); + +test("A10: other providers' checkFallbackError behavior is unchanged (exclusivity)", () => { + // opencode's body-text rule ("organization_quota_exceeded") must still NOT + // fire through checkFallbackError — the allowlist is agentrouter-only, so + // opencode keeps getting only the stripped structuredError as the match + // body (null here, since no structuredError arg is passed), same as before + // this fix. Baseline captured on the pre-fix code with this exact input: + // { shouldFallback: true, cooldownMs: 3000, baseCooldownMs: 3000, + // newBackoffLevel: 1, usedUpstreamRetryHint: false, + // reason: "rate_limit_exceeded" } + // i.e. it falls through to the generic 429 configured rule, NOT the + // opencode-quota-exhausted-body provider rule — asserting `reason` here is + // exactly what proves the allowlist didn't leak to opencode. + const result = checkFallbackError( + 429, + '{"error":{"message":"organization_quota_exceeded"}}', + 0, + null, + "opencode", + null + ); + assert.ok(result.shouldFallback); + assert.equal(result.reason, "rate_limit_exceeded"); + assert.equal(result.cooldownMs, 3000); +});