mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 11:12:17 +03:00
fix(sse): thread full error text to agentrouter provider rules in checkFallbackError
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user