diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts index ce5c74e702..15e82adf4e 100644 --- a/open-sse/config/providerErrorRules.ts +++ b/open-sse/config/providerErrorRules.ts @@ -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([ ["minimax-passthrough", buildMinimaxRules()], ["cloudflare-ai", buildCloudflareAiRules()], ["openrouter", buildOpenrouterRules()], + ["agentrouter", buildAgentrouterRules()], ]); /** diff --git a/tests/unit/agentrouter-error-rules.test.ts b/tests/unit/agentrouter-error-rules.test.ts new file mode 100644 index 0000000000..0923f3e83d --- /dev/null +++ b/tests/unit/agentrouter-error-rules.test.ts @@ -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); +});