feat(sse): honor provider-rule lock scope for agentrouter (connection vs model) (#10419)

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
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-14 20:52:53 -03:00
committed by GitHub
parent 7bb3bc7e32
commit e05ac345da
8 changed files with 1059 additions and 80 deletions

View File

@@ -45,6 +45,7 @@ import {
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts";
import { honorsRuleLockScope } from "@omniroute/open-sse/config/providerErrorRules.ts";
import {
preflightQuota,
isQuotaPreflightEnabled,
@@ -1981,6 +1982,46 @@ export async function getProviderCredentialsWithQuotaPreflight(
}
}
/**
* #10334 — Guard for the agentrouter-exclusive "connection scope" quota
* cooldown branch in markAccountUnavailable. The "never terminal" invariant of
* that branch is NOT structurally guaranteed by `ruleScope === "connection"`
* alone — it also depends on the provider rule table only ever pairing scope
* "connection" with a genuinely transient reason. Today
* (`buildAgentrouterRules()` in providerErrorRules.ts) that is true: the only
* rule declaring scope "connection" is the quota-exhausted one. But a FUTURE
* agentrouter rule for a permanent account state (e.g. "账号已封禁") — or a 402
* added to `AGENTROUTER_ERROR_STATUSES` with scope "connection", a natural-
* looking choice for an account ban — would otherwise be silently downgraded
* to a transient cooldown here instead of going through
* resolveTerminalConnectionStatus()/auto-disable below. Require the
* reason/permanent/creditsExhausted signals checkFallbackError already
* computes to explicitly confirm "this is quota, not a permanent state"
* before taking the early return.
*
* Exported (not just inlined) so a synthetic permanent/credits-exhausted
* `fallbackResult` can be tested directly — no rule in the table produces
* that combination today, so this predicate is the only way to pin the guard
* without editing the (production) rule table just for a test.
*/
export function isAgentrouterConnectionQuotaScope(
provider: string | null | undefined,
fallbackResult: {
ruleScope?: "model" | "provider" | "connection";
reason?: string;
permanent?: boolean;
creditsExhausted?: boolean;
}
): boolean {
return (
honorsRuleLockScope(provider) &&
fallbackResult.ruleScope === "connection" &&
fallbackResult.reason === RateLimitReason.QUOTA_EXHAUSTED &&
!fallbackResult.permanent &&
!fallbackResult.creditsExhausted
);
}
/** Persist exponential-backoff state for an unavailable provider connection. */
export async function markAccountUnavailable(
connectionId: string,
@@ -2101,6 +2142,53 @@ export async function markAccountUnavailable(
const disableCooling = connProviderSpecificData.disableCooling === true;
const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels);
// #10334 — agentrouter EXCLUSIVE: the matched provider rule declared scope
// "connection" for account-wide quota exhaustion ("额度不足"). agentrouter is
// a passthroughModels provider (isPerModelQuotaProvider === true), so without
// this branch the next `if` would treat it like any other passthrough 429 and
// lock a SINGLE model — leaving combo routing to burn one upstream call per
// remaining model of the same exhausted account. Must run BEFORE that block.
// Deliberately ignores persistUnavailableState/isCombo: for combo the caller
// downgrades persistUnavailableState to false, and the generic path further
// below would then lock per MODEL instead of cooling the connection — exactly
// what this scope must override. NEVER sets a terminal status: this is a
// renewing quota window, not "credits_exhausted"/"banned"/"expired".
//
// The "never terminal" invariant above is NOT structurally guaranteed by
// ruleScope === "connection" alone — see isAgentrouterConnectionQuotaScope's
// doc comment for why (a future permanent-state rule could pair scope
// "connection" with a non-quota reason). That predicate is the actual guard.
const ruleScopeIsConnection = isAgentrouterConnectionQuotaScope(provider, fallbackResult);
// #2997's disableCooling opt-out is respected here (`!disableCooling` below):
// a connection with disableCooling=true skips this branch entirely and falls
// into the per-model-quota block further down, which locks the model for up
// to ~30min (mlSettings.maxCooldownMs) instead of cooling the connection for
// the rule's shorter transient window. That is a deliberate, if counter-
// intuitive, consequence of #2997's scope (opt-out was designed only for the
// CONNECTION-level cooldown, never extended to model lockout) — "opting out
// of cooldown" ends up producing a LONGER effective block for this one rule.
// Not addressed here; flagged for a future #2997 follow-up if it proves to be
// a real operator complaint.
if (ruleScopeIsConnection && provider && !disableCooling) {
const connectionCooldownMs =
fallbackResult.cooldownMs > 0 ? fallbackResult.cooldownMs : COOLDOWN_MS.rateLimit;
await updateProviderConnection(connectionId, {
lastErrorType: fallbackResult.reason || RateLimitReason.QUOTA_EXHAUSTED,
lastError: `Account quota exhausted (${provider})`,
lastErrorAt: new Date().toISOString(),
errorCode: status,
backoffLevel: fallbackResult.newBackoffLevel ?? backoffLevel,
rateLimitedUntil: getUnavailableUntil(connectionCooldownMs),
testStatus: "unavailable",
});
log.info(
"AUTH",
`Connection-scoped cooldown for ${provider}:${connectionId.slice(0, 8)}${status} ${fallbackResult.reason} ${Math.ceil(connectionCooldownMs / 1000)}s (rule scope=connection, overrides per-model lockout)`
);
return { shouldFallback: true, cooldownMs: connectionCooldownMs };
}
const isNvidiaModelGone = provider === "nvidia" && status === 410;
const modelLockoutOptions = { maxCooldownMs: effectiveProviderProfile?.maxCooldownMs };
if (