feat(sse): agentrouter quota/model-access error rules with correct lock scopes

This commit is contained in:
Xiangzhe
2026-08-13 16:31:14 -03:00
parent 30b226c44e
commit 246135088b
2 changed files with 114 additions and 0 deletions

View File

@@ -176,6 +176,41 @@ function buildOpenrouterRules(): ProviderErrorRule[] {
];
}
// ─── AgentRouter ────────────────────────────────────────────────────────────
// 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).
// - "额度不足": 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
// the model so the connection keeps serving the rest (Model Lockout tier).
function buildAgentrouterRules(): ProviderErrorRule[] {
const AGENTROUTER_ERROR_STATUSES = new Set([400, 403, 429]);
return [
{
id: "agentrouter-user-quota-exhausted",
match: ({ status, body }) => {
if (!AGENTROUTER_ERROR_STATUSES.has(status)) return null;
const text = JSON.stringify(body ?? "").toLowerCase();
if (!text.includes("额度不足")) return null;
return { reason: "quota_exhausted", scope: "connection" };
},
},
{
id: "agentrouter-model-access-denied",
match: ({ status, body }) => {
if (status !== 403) return null;
const text = JSON.stringify(body ?? "").toLowerCase();
if (!text.includes("无权访问模型")) return null;
// 6h: effectively "until the operator fixes the key's model grants",
// without being an unrecoverable terminal state.
return { reason: "auth_error", scope: "model", cooldownMs: 6 * 60 * 60 * 1000 };
},
},
];
}
/**
* Global registry. Provider name → ordered list of rules (first match wins).
* Add new providers here; the matcher in classifyError will pick them up
@@ -189,6 +224,7 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
["minimax-passthrough", buildMinimaxRules()],
["cloudflare-ai", buildCloudflareAiRules()],
["openrouter", buildOpenrouterRules()],
["agentrouter", buildAgentrouterRules()],
]);
/**

View File

@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
/**
* agentrouter.org quota model:
* - "额度不足" (quota insufficient) is ACCOUNT-wide and temporary → lock the
* connection (scope "connection") so combo routing moves to another
* account/provider instead of hammering the same key.
* - "无权访问模型" (no access to this model) is permanent PER MODEL → lock only
* the model (scope "model"); the connection keeps serving other models.
* Status matching accepts both the raw upstream 403 AND the restated 429
* (upstreamStatusRestatement.ts rewrites 403→429 before classification).
*/
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 (model lockout, not connection)", () => {
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 integration — quota text wins over the 403→AUTH_ERROR status fallback", () => {
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", () => {
const result = checkFallbackError(429, "用户额度不足", 0, null, "agentrouter", null);
assert.equal(result.shouldFallback, true);
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", () => {
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);
});