Skip to content

Commit e05ac34

Browse files
authored
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
1 parent 7bb3bc7 commit e05ac34

8 files changed

Lines changed: 1060 additions & 81 deletions

File tree

docs/architecture/RESILIENCE_GUIDE.md

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -330,32 +330,75 @@ excludeMarkers, defaultRetryAfterMs}`), matched via `applyStatusRestatement()`.
330330

331331
Permanent errors (agentrouter's `无权访问模型` — no access to this model) are
332332
NEVER restated: `excludeMarkers` vetoes the rule even when `textMarkers` hit,
333-
so the error keeps its original status and nothing retries it forever. A
334-
separate provider classification rule
335-
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`)
336-
declares an `auth_error`/scope-`model` match for this text, but it does not
337-
fire on the live production path today: the rule only matches `status ===
338-
403`, and `checkFallbackError`'s apikey-category `FORBIDDEN` branch
339-
(`open-sse/services/accountFallback.ts`) returns early for a plain 403
340-
*before* the provider-rule lookup ever runs. In practice a `无权访问模型` 403
341-
is handled the same way as the base apikey-provider 403 path (see Connection
342-
Cooldown, §2), not as a 6h model lockout. The rule still exists as a
343-
declarative classification consumable by future callers of `classifyError`
344-
with context — wiring it into the production `checkFallbackError` path is
345-
tracked as a follow-up, not yet done.
346-
347-
Restated quota errors (`额度不足`) do reach a provider rule in production
348-
(`agentrouter-user-quota-exhausted`, scope `"connection"`), but `scope` on
349-
`ProviderErrorRuleMatch` is currently informational — the persistence path
350-
(`checkFallbackError``combo.ts`) only consumes `reason` and `cooldownMs`,
351-
never `scope`. What actually happens for agentrouter (`passthroughModels:
352-
true``hasPerModelQuota()` returns `true`) is a **per-model** lockout via
353-
`recordModelLockoutFailure()`: the connection itself is never cooled down for
354-
this error (`combo.ts` skips `recordProviderCooldown` for 429 when
355-
`hasPerModelQuota` is true), so other models on the same account keep being
356-
tried — each one burns one call and its own lockout before combo routing
357-
moves on. Honoring `scope` end-to-end (so a `"connection"` match actually
358-
locks the connection) is tracked as a follow-up.
333+
so the error keeps its original status and nothing retries it forever. The
334+
matching provider classification rule
335+
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`:
336+
`reason: "auth_error"`, `scope: "model"`, a `6h` declared base cooldown) is
337+
consulted by `checkFallbackError` (`open-sse/services/accountFallback.ts`)
338+
*before* the generic apikey-category `FORBIDDEN` early-return, gated on
339+
`honorsRuleLockScope(provider)` (#10334 — currently agentrouter-exclusive via
340+
the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in
341+
`providerErrorRules.ts`). The rule's declared 6h cooldown flows through as
342+
`fallbackResult.baseCooldownMs`, but it still feeds the pre-existing
343+
per-model-quota lockout path (`lockModelIfPerModelQuota()` /
344+
`recordModelLockoutFailure()`, unchanged by #10334 except for the cooldown
345+
source): it is clamped down to the operator's `mlSettings.maxCooldownMs`
346+
(default `1_800_000ms` / 30min), like every other model lockout, and the
347+
*persisted lockout reason* stays the pre-existing hardcoded `"forbidden"`,
348+
not the rule's `"auth_error"` — only the cooldown duration is honored
349+
end-to-end, not the reason string. The connection itself stays active;
350+
sibling models on the same connection are unaffected.
351+
352+
Restated quota errors (`额度不足`) reach a provider rule in production
353+
(`agentrouter-user-quota-exhausted`: `reason: "quota_exhausted"`, `scope:
354+
"connection"`, no declared cooldown of its own — the persistence layer's
355+
scaled backoff default applies). Since #10334, `scope` on
356+
`ProviderErrorRuleMatch` IS consumed end-to-end, but **only** for providers in
357+
the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist (`providerErrorRules.ts`
358+
today only `"agentrouter"`, gated via `honorsRuleLockScope()`). For every
359+
other provider `scope` remains informational, exactly as before #10334.
360+
`checkFallbackError` surfaces the matched rule's scope as
361+
`fallbackResult.ruleScope`; `isAgentrouterConnectionQuotaScope()`
362+
(`src/sse/services/auth.ts`) is the shared guard that confirms a
363+
`ruleScope` is genuinely safe to honor as a connection-wide, self-recovering
364+
signal (scope `"connection"`, reason `quota_exhausted`, never `permanent`,
365+
never `creditsExhausted` — a defense against a future rule pairing scope
366+
`"connection"` with a permanent account state). Two consumers call it:
367+
368+
- **Persistence** (`markAccountUnavailable()`, `src/sse/services/auth.ts`):
369+
instead of falling into the passthrough-provider **per-model** lockout
370+
branch (agentrouter is `passthroughModels: true``hasPerModelQuota()`
371+
returns `true`), it applies a **temporary connection cooldown**
372+
`testStatus: "unavailable"` + `rateLimitedUntil`, never a terminal status
373+
(`credits_exhausted`/`banned`/`expired`) — so the connection self-recovers
374+
once the cooldown lapses instead of requiring a manual credential reset.
375+
Skipped for connections with `disableCooling: true` (#2997): that opt-out
376+
falls through to the per-model lockout instead (a documented trade-off —
377+
see the code comment above the branch).
378+
- **Same-request combo routing** (`applyComboTargetExhaustion()`,
379+
`open-sse/services/combo/targetExhaustion.ts`): the same guard marks the
380+
connection into the in-memory `exhaustedConnections` set, keyed
381+
`${provider}:${connectionId}`. This only skips a remaining SAME-REQUEST
382+
target that *itself already carries that exact `connectionId`* on its own
383+
target object (`getExhaustedTargetSkipReason()`,
384+
`open-sse/services/combo/comboPredicates.ts`, `if (provider &&
385+
connectionId)` before the `exhaustedConnections` lookup) — a plain
386+
model-list combo, where sibling targets carry no pinned `connectionId` of
387+
their own and one is only resolved per-dispatch from the response's
388+
`X-OmniRoute-Selected-Connection-Id` header, never hits that key match. For
389+
that common case, the real protection against a remaining leg reusing the
390+
just-exhausted account is NOT this Set — it is the persistence layer above
391+
(the connection's `rateLimitedUntil` is now in the future) combined with
392+
this same guard suppressing `transientRateLimitedProviders` for the
393+
failure (see "Two-stage design" and the code comment on the
394+
`isAgentrouterConnectionQuotaScope` branch in `targetExhaustion.ts`): with
395+
that Set left unmarked, `combo.ts`'s `allowRateLimitedConnection` force-allow
396+
(`open-sse/services/combo.ts:1005-1013`, `:2734-2738`) does NOT kick in for
397+
the provider's remaining legs, so credential selection's `rateLimitedUntil`
398+
filter (`src/sse/services/auth.ts:1238`) is honored normally and a
399+
remaining leg either picks a different, still-eligible agentrouter
400+
connection or fails with no credentials available — it does not force its
401+
way back onto the connection this branch just cooled down.
359402

360403
### Two-stage design: status restatement, then classification
361404

@@ -380,6 +423,15 @@ allowlisted providers, the structured error otherwise. Adding a provider to
380423
that the default path for every provider not on the list stays
381424
byte-for-byte unchanged.
382425

426+
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
427+
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
428+
`fallbackResult.ruleScope`, and downstream consumers only honor it as
429+
anything other than an informational label, for providers in the
430+
`HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in the same file (`gated via
431+
honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
432+
errors" above for what a `scope: "connection"` match actually does once a
433+
provider is on that allowlist.
434+
383435
### Adding a new quota-misstating gateway
384436

385437
1. Register one rule array in `statusRestatementRegistry`
@@ -395,7 +447,15 @@ byte-for-byte unchanged.
395447
`checkFallbackError` only ever hands the rule the structured
396448
`{code, type}` error and a body-text rule will never match live traffic.
397449
Rules that match purely on `status`/`headers` (like Opencode's or
398-
Minimax's) do not need this opt-in.
450+
Minimax's) do not need this opt-in. Separately, if the rule declares
451+
`scope: "connection"` and the intent is an actual connection-wide cooldown
452+
plus same-request combo skip (not just an informational label), add the
453+
provider id to `HONORS_RULE_LOCK_SCOPE_PROVIDERS` in the same file — this
454+
is what gates `isAgentrouterConnectionQuotaScope()`-style consumption in
455+
`markAccountUnavailable()` (`src/sse/services/auth.ts`) and
456+
`applyComboTargetExhaustion()`
457+
(`open-sse/services/combo/targetExhaustion.ts`); without it, `scope`
458+
still flows through `fallbackResult.ruleScope` but nothing acts on it.
399459
3. Add unit tests mirroring `tests/unit/upstream-status-restatement.test.ts`
400460
and `tests/unit/agentrouter-error-rules.test.ts` (including the
401461
not-permanent / not-creditsExhausted guards, and — if the provider needs

open-sse/config/providerErrorRules.ts

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,15 @@ export type ProviderErrorRule = {
3030
export type ProviderErrorRuleMatch = {
3131
reason: ConfiguredErrorReason;
3232
/**
33-
* Intended lock scope. NOTE: this field is currently INFORMATIONAL — no
34-
* consumer of `getProviderErrorRuleMatch` (checkFallbackError, combo.ts)
35-
* reads `scope` today; only `reason` and `cooldownMs` are consulted. The
36-
* actual lock scope applied at runtime is decided independently by each
37-
* call site (e.g. `hasPerModelQuota()` deciding model- vs connection-level
38-
* lockout). Honoring this field end-to-end is tracked as a follow-up —
39-
* see `docs/architecture/RESILIENCE_GUIDE.md` §7.
33+
* Intended lock scope. #10334: this field is CONSUMED end-to-end only for
34+
* providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive
35+
* today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError`
36+
* surfaces it as `ruleScope` on its return value for the persistence layer
37+
* to honor instead of re-deriving scope from `hasPerModelQuota()`. For
38+
* every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch`
39+
* callers still read only `reason`/`cooldownMs`, and the actual lock scope
40+
* is decided independently by each call site. Widening the allowlist is
41+
* tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7.
4042
*/
4143
scope: "model" | "provider" | "connection";
4244
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */
@@ -188,31 +190,29 @@ function buildOpenrouterRules(): ProviderErrorRule[] {
188190
// agentrouter.org misstates temporary quota exhaustion as 403/400 with a
189191
// Chinese body. upstreamStatusRestatement.ts rewrites the status to 429
190192
// BEFORE classification, so rules here accept both the raw 403/400 and the
191-
// restated 429 (text is the real discriminator either way). In production,
192-
// the raw 403 path is what actually matters here: checkFallbackError's
193-
// apikey-category FORBIDDEN branch (~line 1699) returns EARLY for a plain
194-
// 403, before these rules are ever consulted — these rules fire on the
195-
// RESTATED 429 (chatCore's upstreamStatusRestatement hook runs first) via
196-
// resolveRuleMatchBody, which is the only path in checkFallbackError that
197-
// hands these rules the full error text instead of just {code, type}.
193+
// restated 429 (text is the real discriminator either way). Both the raw 403
194+
// path AND the restated 429 path reach these rules in production:
195+
// checkFallbackError's `honorsRuleLockScope("agentrouter")` pre-check
196+
// (#10334) consults these rules BEFORE the generic apikey-category FORBIDDEN
197+
// branch, and the restated 429 reaches them via the existing provider-rule
198+
// lookup in the configured-rule branch. Both paths use resolveRuleMatchBody,
199+
// the only mechanism in checkFallbackError that hands agentrouter's rules the
200+
// full error text instead of just {code, type}.
198201
// - "额度不足": account-wide temporary quota → quota_exhausted, scope
199202
// "connection" (mirror of the Opencode account-wide rationale above).
200-
// NOTE: `scope` on ProviderErrorRuleMatch is currently informational —
201-
// checkFallbackError/combo.ts only consume `reason` and `cooldownMs`, not
202-
// `scope`. For agentrouter specifically (passthroughModels: true →
203-
// hasPerModelQuota() is true), this quota_exhausted match actually
204-
// resolves to a PER-MODEL lockout (recordModelLockoutFailure), not a
205-
// connection-wide lock — other models on the same account keep being
206-
// tried by combo routing (each burning one call) until they lock out
207-
// individually. Honoring `scope` end-to-end is tracked as a follow-up.
203+
// `scope` on ProviderErrorRuleMatch is CONSUMED for agentrouter (#10334,
204+
// exclusive allowlist via `honorsRuleLockScope`): checkFallbackError
205+
// surfaces it as `ruleScope` on its return value. Whether the persistence
206+
// layer (markAccountUnavailable / combo target exhaustion) actually
207+
// switches from `hasPerModelQuota()`-derived scope to honoring `ruleScope`
208+
// is Tasks 2/3 of #10334 — this task only surfaces the field.
208209
// - "无权访问模型": declares auth_error/scope "model" (intent: lock only the
209210
// model so the connection keeps serving the rest — Model Lockout tier).
210-
// This rule does NOT fire on the production path today: it only matches
211-
// `status === 403`, but checkFallbackError's apikey FORBIDDEN branch
212-
// returns early for a plain 403 before this rule is ever consulted (see
213-
// the note above). A live `无权访问模型` 403 is handled like the base
214-
// apikey-provider 403 today. Wiring this rule into that path is tracked
215-
// as a follow-up.
211+
// This rule now fires on the production 403 path (#10334): the
212+
// `honorsRuleLockScope` pre-check matches it and returns its declared
213+
// reason/cooldown/scope before the generic apikey-FORBIDDEN early-return
214+
// ever runs. A live `无权访问模型` 403 therefore no longer falls through to
215+
// the base apikey-provider 403 handling.
216216
function buildAgentrouterRules(): ProviderErrorRule[] {
217217
const AGENTROUTER_ERROR_STATUSES = new Set([400, 403, 429]);
218218
return [
@@ -231,8 +231,15 @@ function buildAgentrouterRules(): ProviderErrorRule[] {
231231
if (status !== 403) return null;
232232
const text = JSON.stringify(body ?? "").toLowerCase();
233233
if (!text.includes("无权访问模型")) return null;
234-
// 6h: effectively "until the operator fixes the key's model grants",
235-
// without being an unrecoverable terminal state.
234+
// Declares a 6h cooldown, but the effective cooldown is NOT 6h: the
235+
// model-lockout persistence layer (recordModelLockoutFailure, called from
236+
// markAccountUnavailable) clamps every base cooldown — this one included —
237+
// to the configured model-lockout maxCooldownMs, which defaults to
238+
// 1_800_000ms / 30min (src/lib/resilience/modelLockoutSettings.ts,
239+
// DEFAULT_MODEL_LOCKOUT_SETTINGS.maxCooldownMs). So in practice this is
240+
// "locked for ~30min by default (up to 6h if an operator raises the model-
241+
// lockout cap in settings)", not "until the operator fixes the key's model
242+
// grants" — it is a recoverable window, not a real fix-driven unlock.
236243
return { reason: "auth_error", scope: "model", cooldownMs: 6 * 60 * 60 * 1000 };
237244
},
238245
},
@@ -255,6 +262,21 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
255262
["agentrouter", buildAgentrouterRules()],
256263
]);
257264

265+
/**
266+
* Providers whose ProviderErrorRuleMatch.scope is actually CONSUMED at the
267+
* persistence layer (markAccountUnavailable / combo target exhaustion) to pick
268+
* connection-vs-model lock scope. EXCLUSIVE allowlist by owner decision
269+
* (2026-08-14, issue #10334) — deliberately SEPARATE from
270+
* FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against
271+
* (input), this one controls whether the matched scope changes caller behavior
272+
* (output). A provider could need one without the other.
273+
*/
274+
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
275+
276+
export function honorsRuleLockScope(provider: string | null | undefined): boolean {
277+
return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase());
278+
}
279+
258280
/**
259281
* Providers whose rules match on the FULL upstream error text.
260282
* checkFallbackError's rule lookup normally passes only the structured

0 commit comments

Comments
 (0)