@@ -330,32 +330,75 @@ excludeMarkers, defaultRetryAfterMs}`), matched via `applyStatusRestatement()`.
330330
331331Permanent errors (agentrouter's ` 无权访问模型 ` — no access to this model) are
332332NEVER 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
380423that the default path for every provider not on the list stays
381424byte-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
3854371 . 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.
3994593 . 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
0 commit comments