Skip to content

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

Merged
diegosouzapw merged 9 commits into
release/v3.8.50from
feat/agentrouter-lock-scope
Aug 14, 2026
Merged

feat(sse): honor provider-rule lock scope for agentrouter (connection vs model)#10419
diegosouzapw merged 9 commits into
release/v3.8.50from
feat/agentrouter-lock-scope

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #10334.

What was wrong

The scope field that provider error rules declare (open-sse/config/providerErrorRules.ts) was informational only — no production code read it. For agentrouter (passthroughModels: truehasPerModelQuota), an account-wide quota exhaustion (额度不足, restated 403/400→429 by #10335) therefore landed in per-model lockout: every remaining model of the exhausted account still burned one upstream call before locking itself. And agentrouter-model-access-denied (403 + 无权访问模型) never fired at all, because checkFallbackError's apikey-FORBIDDEN branch returned before the provider-rule lookup.

What changed (agentrouter-only, by owner decision)

The issue suggested wiring this generically; the owner scoped it to agentrouter so no other provider changes behavior. Exclusivity is double-gated and covered by tests.

  1. checkFallbackError surfaces ruleScope — populated only when honorsRuleLockScope(provider) is true (new HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlist, deliberately separate from FULL_TEXT_RULE_PROVIDERS: one controls what a rule matches against, the other whether the matched scope changes caller behavior). The 403 path now consults the rules before the generic apikey early-return.
  2. markAccountUnavailable honors scope: "connection" — applies a temporary connection cooldown (rateLimitedUntil + testStatus: "unavailable") instead of a per-model lockout, and wins over the combo path that would otherwise downgrade to lockModel. Guarded by isAgentrouterConnectionQuotaScope(), which additionally requires reason === QUOTA_EXHAUSTED && !permanent && !creditsExhausted so a future permanent-state rule with scope: "connection" can never be silently downgraded to a 3s transient retry loop.
  3. Combo skips the exhausted account within the same requestapplyComboTargetExhaustion marks the connection into exhaustedConnections. The early return also deliberately stops marking transientRateLimitedProviders, which is what previously became allowRateLimitedConnection: true and force-reused the connection that had just been cooled down.

Documented in docs/architecture/RESILIENCE_GUIDE.md §7, including the honest limits: disableCooling: true connections keep the old per-model behavior, the rule's declared 6h model-access cooldown is clamped by mlSettings.maxCooldownMs (30 min by default), and the same-request skip only applies to targets that carry their own connectionId.

Validation

  • 88/88 across the focused suite (new file + agentrouter rules + restatement + every markAccountUnavailable guard-rail + combo target-exhaustion).
  • Position-guard tests pin the new branch below the terminal-status and anti-thundering-herd guards (each verified to fail when its upstream guard is disabled).
  • Exclusivity tests: ollama-cloud / vertex keep per-model lockout with the connection active (asserted positively, not just negatively).
  • A log spy discriminates the new branch from markAuthLevelExhaustion on the raw-403 path (their set effects are identical).
  • Sentinel test pins that classifyProviderError(429, "用户额度不足", "agentrouter") never becomes quota_exhausted — otherwise chatCore's terminal credits_exhausted branch would become reachable.
  • Gates: typecheck:core, check:cycles, check:docs-all, check:mutation-test-coverage, eslint on touched files — all clean.

⚠️ base-red inherited: #9985

…agentrouter-only)

Add honorsRuleLockScope() as an exclusive allowlist (agentrouter today) and
surface the matched ProviderErrorRule's scope as checkFallbackError's new
ruleScope field. The agentrouter 403 path now consults the provider rules
before the generic apikey-FORBIDDEN early-return, so a recognized body like
"无权访问模型" carries the rule's declared reason/cooldown/scope instead of
the generic short auth cooldown. Every other provider's behavior is
unchanged — ruleScope stays undefined outside the allowlist.
markAccountUnavailable now consults checkFallbackError's ruleScope
(#10334 Task 1) before the generic per-model-quota branch: when
honorsRuleLockScope(provider) && ruleScope === "connection" (agentrouter
account-wide "额度不足" quota exhaustion today), it cools the whole
connection instead of locking a single passthrough model — including
when the caller is combo (isCombo/persistUnavailableState:false), which
would otherwise downgrade to a per-model lock. Never sets a terminal
status. Exclusive to agentrouter; every other passthroughModels
provider (ollama-cloud, vertex, ...) keeps today's per-model lockout
byte-for-byte. The existing #3027 model-lockout branch for
agentrouter's 403 "无权访问模型" (ruleScope "model") is unmodified.
…er states

Adversarial review of the #10334 Task 2 connection-scope branch found the
"never terminal" invariant relied only on ruleScope === "connection",
which is not structurally guaranteed against a future agentrouter rule
pairing that scope with a permanent/credits-exhausted reason. Extract the
guard into an exported, independently-testable predicate
(isAgentrouterConnectionQuotaScope) that also requires
reason === QUOTA_EXHAUSTED and !permanent/!creditsExhausted.

Also: document the disableCooling(#2997) interaction and the
providerErrorRules.ts 6h-vs-30min-cap discrepancy the review flagged, and
add position-guard + exclusivity-positive tests so a future refactor that
reorders the branch or stops locking non-agentrouter providers cannot pass
silently.
…equest

applyComboTargetExhaustion now marks an agentrouter connection into the
in-memory exhaustedConnections set when checkFallbackError reports a
connection-scope quota result (isAgentrouterConnectionQuotaScope, reused
from the persistence layer), so remaining same-connection targets in the
SAME combo request are skipped instead of each burning its own upstream
call before the persisted cooldown takes effect on the next request. Gated
strictly to the agentrouter allowlist — every other provider is unaffected.

Also updates RESILIENCE_GUIDE.md §7 to correct two stale claims: the
agentrouter-model-access-denied rule does fire in production now (feeds the
per-model lockout's cooldown), and rule scope is consumed end-to-end for
providers in HONORS_RULE_LOCK_SCOPE_PROVIDERS instead of staying purely
informational.
…p claims

Fix round 1 from review of the agentrouter same-request combo skip (#10334):
document and test that the new branch deliberately never populates
transientRateLimitedProviders (it would re-open the connection the branch
just exhausted via combo.ts's allowRateLimitedConnection force-allow),
correct a code comment claiming the branch is 429-only (the quota rule also
matches a raw 403, which lands in the same set via the same guard), and fix
two RESILIENCE_GUIDE.md claims about the same-request skip: it only matches
targets that already carry the exhausted connectionId, and the persisted
cooldown was never gated on "next request" timing.
…typing

The raw-403 test for the agentrouter connection-scope combo-skip branch only
asserted Set contents, which markAuthLevelExhaustion produces identically for
a 403 with a connectionId — deleting the new branch would have left it green.
Add a local log spy and assert the emitted message is the new branch's
(#10334 / "account quota exhausted"), not markAuthLevelExhaustion's (#8133 /
"auth failure").

Also fixes a pre-existing tsc error in the same file: getProviderConnectionById
returns rateLimitedUntil as unknown, which new Date() can't accept directly.
@diegosouzapw
diegosouzapw merged commit e05ac34 into release/v3.8.50 Aug 14, 2026
22 checks passed
@diegosouzapw
diegosouzapw deleted the feat/agentrouter-lock-scope branch August 15, 2026 05:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: honor ProviderErrorRule lock scope (connection/model) in the persistence path

2 participants