mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
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
130 lines
5.4 KiB
TypeScript
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;
|
|
}
|