fix(sse): thread full error text to agentrouter provider rules in checkFallbackError

This commit is contained in:
Xiangzhe
2026-08-13 17:42:36 -03:00
parent 246135088b
commit 711a1dbce7
3 changed files with 99 additions and 5 deletions

View File

@@ -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<string, ProviderErrorRule[]>([
["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

View File

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

View File

@@ -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);
});