mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
Makes the ProviderErrorRule `scope` field real at the persistence layer, exclusively for agentrouter (owner decision; every other provider keeps byte-identical behavior). checkFallbackError now surfaces `ruleScope` behind the HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlist, and the agentrouter 403 path consults the rules before the generic apikey-FORBIDDEN early-return. markAccountUnavailable honors scope "connection" with a temporary connection cooldown instead of a per-model lockout — guarded so a permanent state can never be downgraded to a transient retry loop — and combo now skips the exhausted account within the same request, which also stops force-reusing the just-cooled connection via allowRateLimitedConnection. Documented in RESILIENCE_GUIDE §7 with the honest limits (disableCooling connections keep per-model behavior; the 6h model-access cooldown is clamped by mlSettings.maxCooldownMs, 30min by default; same-request skip needs targets carrying their own connectionId). Closes #10334
178 lines
8.6 KiB
TypeScript
178 lines
8.6 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
/**
|
|
* agentrouter.org quota model, as declared by the `providerErrorRules.ts`
|
|
* classification layer:
|
|
* - "额度不足" (quota insufficient) is ACCOUNT-wide and temporary → the rule
|
|
* declares scope "connection".
|
|
* - "无权访问模型" (no access to this model) is permanent PER MODEL → the rule
|
|
* declares scope "model".
|
|
* Status matching accepts both the raw upstream 403 AND the restated 429
|
|
* (upstreamStatusRestatement.ts rewrites 403→429 before classification).
|
|
*
|
|
* #10334 — `ProviderErrorRuleMatch.scope` is now CONSUMED for agentrouter:
|
|
* `checkFallbackError` surfaces it as `ruleScope` on its return value (see
|
|
* A11/A12 below), and a raw 403 is no longer an early-return dead end for
|
|
* this provider — `honorsRuleLockScope("agentrouter")` gates a dedicated
|
|
* pre-check that consults the provider rules BEFORE the generic apikey
|
|
* FORBIDDEN branch (see A7/A12). This is an EXCLUSIVE allowlist
|
|
* (`honorsRuleLockScope`, A14): every other provider's `scope` stays
|
|
* declared-but-unconsumed exactly as before (A13). See
|
|
* `docs/architecture/RESILIENCE_GUIDE.md` §7 for the full writeup — Tasks 2/3
|
|
* of #10334 wire the surfaced `ruleScope` into the persistence layer
|
|
* (markAccountUnavailable / combo target exhaustion).
|
|
*/
|
|
|
|
const { providerRuleRegistry, getProviderErrorRuleMatch } = await import(
|
|
"../../open-sse/config/providerErrorRules.ts"
|
|
);
|
|
const { classifyError, checkFallbackError } = await import(
|
|
"../../open-sse/services/accountFallback.ts"
|
|
);
|
|
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
|
|
|
test("A1: agentrouter is registered in providerRuleRegistry", () => {
|
|
const rules = providerRuleRegistry.get("agentrouter");
|
|
assert.ok(rules && rules.length > 0);
|
|
});
|
|
|
|
test("A2: quota body → quota_exhausted scope connection (restated 429)", () => {
|
|
const match = getProviderErrorRuleMatch("agentrouter", 429, {}, {
|
|
error: { message: "用户额度不足,请充值" },
|
|
});
|
|
assert.ok(match, "quota body must match");
|
|
assert.equal(match.reason, "quota_exhausted");
|
|
assert.equal(match.scope, "connection");
|
|
});
|
|
|
|
test("A3: quota body also matches the raw (pre-restatement) 403", () => {
|
|
const match = getProviderErrorRuleMatch("agentrouter", 403, {}, "用户额度不足");
|
|
assert.ok(match);
|
|
assert.equal(match.reason, "quota_exhausted");
|
|
});
|
|
|
|
test("A4: 无权访问模型 → auth_error scope model, at the RULE layer (getProviderErrorRuleMatch directly) — since #10334 this rule DOES receive production traffic for agentrouter via the honorsRuleLockScope pre-check in checkFallbackError (see A12)", () => {
|
|
const match = getProviderErrorRuleMatch("agentrouter", 403, {}, {
|
|
error: { message: "无权访问模型 claude-sonnet-4" },
|
|
});
|
|
assert.ok(match);
|
|
assert.equal(match.reason, "auth_error");
|
|
assert.equal(match.scope, "model");
|
|
});
|
|
|
|
test("A5: classifyError layer guard — quota text wins over the 403→AUTH_ERROR status fallback (classifyError itself has no production caller today; the production guard is A6/checkFallbackError)", () => {
|
|
const reason = classifyError(403, "用户额度不足", {
|
|
provider: "agentrouter",
|
|
headers: {},
|
|
body: { error: { message: "用户额度不足" } },
|
|
});
|
|
assert.equal(reason, RateLimitReason.QUOTA_EXHAUSTED);
|
|
});
|
|
|
|
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", () => {
|
|
// Since #10334, a raw (pre-restatement) 403 for agentrouter DOES reach the
|
|
// provider rules: checkFallbackError's honorsRuleLockScope pre-check runs
|
|
// BEFORE the generic apikey-category FORBIDDEN branch and matches the
|
|
// "额度不足" rule here (reason quota_exhausted, scope connection — see A11).
|
|
// In the real pipeline, chatCore's upstreamStatusRestatement hook (Task 2)
|
|
// still converts 403→429 before checkFallbackError sees it, so this raw-403
|
|
// path is what a hook-bypassed request hits — and it must still not be
|
|
// misclassified as permanent account deactivation, regardless of which
|
|
// branch (pre-check or the old apikey-FORBIDDEN fallback) ultimately fires.
|
|
const result = checkFallbackError(403, "用户额度不足", 0, null, "agentrouter", null);
|
|
assert.equal(result.shouldFallback, true);
|
|
assert.ok(!result.permanent);
|
|
});
|
|
|
|
test("A8: plain agentrouter 403 (no quota text) keeps the default apikey auth path", () => {
|
|
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);
|
|
});
|
|
|
|
test("A11: checkFallbackError surfaces ruleScope=connection for agentrouter quota", () => {
|
|
const result = checkFallbackError(429, "用户额度不足", 0, null, "agentrouter", null);
|
|
assert.equal(result.ruleScope, "connection");
|
|
assert.equal(result.reason, "quota_exhausted");
|
|
assert.ok(!result.permanent);
|
|
});
|
|
|
|
test("A12: checkFallbackError 403 无权访问模型 carries the rule's scope + cooldown", () => {
|
|
const result = checkFallbackError(403, "无权访问模型 claude-opus-5", 0, null, "agentrouter", null);
|
|
assert.equal(result.ruleScope, "model");
|
|
assert.equal(result.reason, "auth_error");
|
|
assert.equal(result.baseCooldownMs, 6 * 60 * 60 * 1000);
|
|
});
|
|
|
|
test("A13: exclusivity — ruleScope stays undefined for other providers", () => {
|
|
const opencode = checkFallbackError(
|
|
429,
|
|
'{"error":{"message":"organization_quota_exceeded"}}',
|
|
0,
|
|
null,
|
|
"opencode",
|
|
null
|
|
);
|
|
assert.equal(opencode.ruleScope, undefined);
|
|
const openrouter = checkFallbackError(402, "credits exhausted", 0, null, "openrouter", null);
|
|
assert.equal(openrouter.ruleScope, undefined);
|
|
});
|
|
|
|
test("A14: honorsRuleLockScope allowlist is agentrouter-only", async () => {
|
|
const { honorsRuleLockScope } = await import("../../open-sse/config/providerErrorRules.ts");
|
|
assert.equal(honorsRuleLockScope("agentrouter"), true);
|
|
assert.equal(honorsRuleLockScope("AgentRouter"), true);
|
|
assert.equal(honorsRuleLockScope("opencode"), false);
|
|
assert.equal(honorsRuleLockScope(null), false);
|
|
});
|