Files
OmniRoute/open-sse/config/upstreamStatusRestatement.ts
Diego Rodrigues de Sa e Souza 20ea78c943 feat(sse): restate agentrouter quota 403/400 as retryable 429 with provider-scoped error rules (#10335)
agentrouter.org signals temporary quota exhaustion with HTTP 403/400 and a Chinese body (用户额度不足) instead of 429, so clients like Claude Code treat it as permanent and abort, and the fallback engine classified it as a generic apikey AUTH_ERROR.

New registry open-sse/config/upstreamStatusRestatement.ts restates those statuses to 429 with a synthetic Retry-After at a single hook in chatCore's providerFailure block (after parseUpstreamError), so classification, combo aggregation and the client response all see a retryable error. 无权访问模型 (permanently no model access) is veto-listed and never restated.

agentrouter classification rules are registered in providerErrorRules.ts and reach the real checkFallbackError path through resolveRuleMatchBody() with an exclusive FULL_TEXT_RULE_PROVIDERS allowlist — every other provider keeps its previous behavior byte-for-byte.

Known limitations tracked in #10334: the rules' scope field is informational (persistence applies per-model lockout for agentrouter), the 403-only model-access rule has no production path yet, and errors embedded in 200 SSE streams are not restated.

Refs #10334
2026-08-14 12:42:58 -03:00

130 lines
5.4 KiB
TypeScript

/**
* Upstream status restatement — registry of gateways that MISSTATE temporary
* quota exhaustion as a non-retryable HTTP status.
*
* agentrouter.org signals "user quota exhausted" with 403 (sometimes 400) and
* a Chinese body ("用户额度不足") instead of the standard 429. Clients like
* Claude Code treat 403 as permanent and abort the whole session, and our own
* fallback engine classifies it as AUTH_ERROR instead of a quota event.
*
* applyStatusRestatement() is called from exactly ONE place — the
* `providerFailure:` block in open-sse/handlers/chatCore.ts, right after
* parseUpstreamError() parses an upstream response with an error HTTP status
* (!providerResponse.ok), and before any classification runs — so every
* downstream consumer (checkFallbackError, combo aggregation, the client
* response) sees the corrected status. Errors embedded inside a 200 SSE
* stream follow a separate, later stream-parsing path and are NOT covered by
* this hook today (known limitation; not yet needed for agentrouter's
* misstatus, which surfaces as an error HTTP status). 429 is
* Retry-After-eligible in
* open-sse/services/combo/unavailableRetryGate.ts, so the client also gets a
* retry window instead of a dead 403.
*
* Adding a future gateway with the same defect = register ONE rule array
* below (and, for cooldown-scope refinement, one entry in
* providerErrorRules.ts). No pipeline changes.
*
* Marker discipline: keep textMarkers provider-specific (the Chinese strings
* are upstream error literals, not UI copy). Generic English phrases like
* "insufficient_quota" are in CREDITS_EXHAUSTED_SIGNALS
* (accountFallback.ts) and would flip the connection into a terminal
* credits_exhausted state — never use them as markers here.
*
* Accepted trade-off: matching only on response body text means a
* legitimate 400 whose body ECHOES user-supplied content containing a
* marker (e.g. a prompt that itself contains "额度不足") would be restated to
* 429 and lose the combo's 400 stop-guard. This is treated as an acceptable
* risk because these markers are rare outside a genuine upstream error;
* keeping markers short, provider-specific, and non-generic (as above)
* minimizes false-positive restatement.
*/
export type UpstreamStatusRestatementRule = {
id: string;
fromStatuses: ReadonlySet<number>;
toStatus: number;
/** Lowercase markers matched against lowercased `message` + JSON(body). Any hit → restate. */
textMarkers: readonly string[];
/** Lowercase markers that VETO the rule even when textMarkers hit (permanent errors). */
excludeMarkers?: readonly string[];
/** Synthetic Retry-After used ONLY when the upstream provided none. */
defaultRetryAfterMs?: number;
};
export type StatusRestatementInput = {
provider: string | null | undefined;
status: number;
message: string | null | undefined;
body?: unknown;
retryAfterMs?: number | null;
};
export type StatusRestatementResult = {
status: number;
retryAfterMs: number | null;
ruleId: string | null;
fromStatus: number;
};
// ─── agentrouter ────────────────────────────────────────────────────────────
// Observed misstatus (ClaudeShield field reports + upstream behavior):
// 403 "用户额度不足" / "额度不足" → temporary user-quota exhaustion → 429
// 400 variants carrying the same quota text → 429
// 403 "无权访问模型" (no access to this model) → genuinely permanent, NEVER
// restated — it must keep flowing as 403 so nothing retries it forever.
const AGENTROUTER_RULES: UpstreamStatusRestatementRule[] = [
{
id: "agentrouter-quota-misstatus",
fromStatuses: new Set([403, 400]),
toStatus: 429,
textMarkers: ["额度不足"],
excludeMarkers: ["无权访问"],
defaultRetryAfterMs: 60_000,
},
];
/** Provider id (lowercase) → ordered rules; first match wins. */
export const statusRestatementRegistry = new Map<string, UpstreamStatusRestatementRule[]>([
["agentrouter", AGENTROUTER_RULES],
]);
function stringifyBody(body: unknown): string {
if (body === null || body === undefined) return "";
if (typeof body === "string") return body;
try {
return JSON.stringify(body);
} catch {
return "";
}
}
export function applyStatusRestatement(input: StatusRestatementInput): StatusRestatementResult {
const passthrough: StatusRestatementResult = {
status: input.status,
retryAfterMs: input.retryAfterMs ?? null,
ruleId: null,
fromStatus: input.status,
};
if (!input.provider) return passthrough;
const rules = statusRestatementRegistry.get(input.provider.toLowerCase());
if (!rules) return passthrough;
const haystack = `${input.message ?? ""} ${stringifyBody(input.body)}`.toLowerCase();
if (!haystack.trim()) return passthrough;
for (const rule of rules) {
if (!rule.fromStatuses.has(input.status)) continue;
if (!rule.textMarkers.some((marker) => haystack.includes(marker))) continue;
if (rule.excludeMarkers?.some((marker) => haystack.includes(marker))) continue;
const upstreamRetryAfterMs =
typeof input.retryAfterMs === "number" && input.retryAfterMs > 0 ? input.retryAfterMs : null;
return {
status: rule.toStatus,
retryAfterMs: upstreamRetryAfterMs ?? rule.defaultRetryAfterMs ?? null,
ruleId: rule.id,
fromStatus: input.status,
};
}
return passthrough;
}