Skip to content

fix(sse): honor native Claude quota scope - #13888

Merged
diegosouzapw merged 23 commits into
cheaper-inference:release/v3.8.51from
riez:fix/claude-model-quota-scope
Sep 29, 2026
Merged

diegosouzapw merged 23 commits into
cheaper-inference:release/v3.8.51from
riez:fix/claude-model-quota-scope

Conversation

@riez

@riez riez commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Use native Claude quota metadata to distinguish model-scoped exhaustion from connection-wide exhaustion.
  • Keep sibling Claude models routable when the active weekly_scoped limit identifies only the failed model.
  • Preserve connection-wide cooldowns for active session or weekly account limits, missing or stale evidence, and scoped limits that cannot be resolved safely.

Why this PR exists

This PR was opened after Fable 5.1 exhausted its model quota, then switching to Opus 5 still produced the following failure even though the Claude quota dashboard showed roughly 40% remaining overall:

Error: 429 [claude/claude-opus-5-high] [429]: This request would exceed your account's rate limit. Please try again later. (reset after 59m 24s) retry-after-ms=3564000

Error: Retry failed after 1 attempts: Provider requested 3564000ms wait, exceeds retry.maxDelayMs (300000ms). Original error: 429 [claude/claude-opus-5-high] [429]: This request would exceed your account's rate limit. Please try again later. (reset after 59m 24s) retry-after-ms=3564000

OmniRoute previously interpreted that native Claude 429 without using the upstream quota scope. A model-specific limit could therefore cool the whole OAuth connection and prevent a sibling model from using its remaining quota.

Behavior

The normalizer handles Anthropic payloads that contain either or both quota generations:

  • Current data comes from limits[], including weekly_scoped, scope.model, isActive or is_active, and resetsAt or resets_at.
  • Previous data comes from five_hour, seven_day, and seven_day_* fields.
  • Current entries override previous data only for the same semantic window. Valid previous fields fill windows omitted from limits[], and empty or invalid current data does not erase valid session or weekly data.
  • Account-wide windows are stored in quotas; scoped model windows are stored separately in modelQuotas, so an exhausted Fable pool cannot reduce Opus connection headroom.
  • Upstream isActive and severity determine whether a current limit blocks. Percent utilization is display metadata and may be absent.
  • Missing-percent windows remain available to routing but are omitted from the dashboard rather than shown as fabricated zero-percent rows.

For a native Claude subscription quota 429 with fresh quota evidence:

Active quota evidence Routing action
Resolvable weekly_scoped model limit Lock only the matching model until the upstream reset
Unresolvable weekly_scoped limit Cool the connection using that limit's reset
Active session or account-wide weekly limit Cool the connection using the latest relevant reset
Proven blocker without a usable future reset Cool the connection using the classified fallback, never an unrelated quota window
Missing or stale evidence Keep the existing connection-wide behavior without adding a model lock

Additional safeguards:

  • Quota evidence must be fresh and comes from the live cache, not a persisted snapshot.
  • The real chat failure path preserves typed quota evidence until auth persists the cooldown.
  • Connection-scoped evidence does not create a redundant model lock or same-model retry.
  • Model matching uses upstream scope metadata and registered model display names, without hardcoding Fable, Sonnet, or Opus.
  • cc and claude aliases resolve to the same native Claude identity while request-local combo keys keep their original provider identity.
  • Direct, priority, round-robin, and target-exhaustion paths use the same scope decision.
  • The separate anthropic API-key provider is unchanged.
  • TPM/RPM throttles, 402, 404, and 5xx behavior are unchanged.

Release-base validation repairs

While reconciling with the active release branch, the required routing checks exposed unrelated stale test state. This PR also makes those checks deterministic without changing their product behavior:

  • Replaces a retired Claude matrix fixture with its declared replacement, claude-sonnet-4-6.
  • Prevents the weighted distribution test from exercising production request-queue limits for its mock connections.
  • Detects the context-handoff summary by actual upstream call order because internal markers are removed before fetch.
  • Replaces a live OpenAI invalid-key request with a deterministic local 401 response.

ESLint suppression cleanup is intentionally excluded from this PR. config/quality/eslint-suppressions.json has zero net diff from the current release base. Unrelated upstream comments are retained, and quota policy was extracted into focused owners instead of deleting rationale or raising frozen baselines.

Upstream conflict reconciliation (9a2ae7a)

Merged upstream/release/v3.8.51 at 6f1d4beabca5a9ffb81857705003caba048fa584 into this branch without rewriting history.

Verification:

  • 25-file isolated Node regression suite: 224 passed, 0 failed.
  • Rendered quota UI Vitest: 2 passed, 0 failed.
  • npm run typecheck:core.
  • Focused uncached ESLint and Prettier on all manually reconciled files.
  • npm run lint -- --pass-on-unpruned-suppressions.
  • npm run check:file-size -- --base-ref upstream/release/v3.8.51.
  • git diff --check upstream/release/v3.8.51; no unmerged index entries.
  • Identity, docs-sync, explicit-any budget, tracked-artifact and commit-message attribution checks.
  • Independent final review: no remaining code findings.

Known inherited checks:

  • Plain npm run lint reports only stale suppression entries. They are not pruned in this PR.
  • npm run check:open-sse-typecheck reports eight errors in open-sse/executors/auggie.ts (TS2769 x4, TS18047 x4). That file, the relevant typecheck configurations, and lockfile are identical to upstream. No baseline widening or unrelated fix was added.
  • Diff checking against the old first parent also reports three whitespace lines in incoming translated documentation; those files are unchanged from upstream.
  • Full build/coverage/CI matrix remains for CI.

Commit gates were run manually with operator approval for a one-commit hook exception, avoiding the repository-prohibited lint-staged auto-stash.

Post-reconciliation regression commands
env -u DATA_DIR node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=4 \
  tests/unit/claude-quota-normalization.test.ts \
  tests/unit/claude-quota-model-matching.test.ts \
  tests/unit/claude-quota-surface-labels.test.ts \
  tests/unit/claude-scoped-quota-auth.test.ts \
  tests/unit/claude-fable-usage.test.ts \
  tests/unit/combo/claude-scoped-quota-alias-round-robin.test.ts \
  tests/unit/account-fallback-anthropic-quota.test.ts \
  tests/unit/claude-extra-usage.test.ts \
  tests/unit/claude-oauth-usage-429-backoff.test.ts \
  tests/unit/quota-parsing-claude-extra-usage.test.ts \
  tests/unit/providerlimits-quotanormalize-split.test.ts \
  tests/unit/quota-cache-is-exhausted-per-window-5923.test.ts \
  tests/unit/usage-quota-row-visibility.test.ts \
  tests/integration/claude-quota-chat-ordering.test.ts \
  tests/unit/combo/combo-target-exhaustion.test.ts \
  tests/unit/quota-cache-unknown-limits.test.ts \
  tests/unit/quota-cache-antigravity-fraction-reported-10095.test.ts \
  tests/unit/antigravity-quota-skipping.test.ts \
  tests/unit/combo/execute-target-attempt.test.ts \
  tests/unit/native-codex-auto-resume.test.ts \
  tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts \
  tests/unit/chat-noauth-model-cooldown.test.ts \
  tests/unit/native-codex-auto-resume-guards.test.ts \
  tests/unit/codex-cooldown-recovery.test.ts \
  tests/unit/native-codex-turn-pin-10379.test.ts

npx vitest run --config vitest.config.ts tests/unit/fable-quota-rendering.test.tsx

Previous surface-label follow-up (27d5161)

Addressed the remaining optional surface-label concern:

  • Parse only scope.surface.display_name, the nullish object shape verified in the installed first-party Claude Code 2.1.272 cached-usage schema. Test surface names are synthetic; this is not a claim that a live account returned those names.
  • Give distinct named surfaces stable, collision-safe row keys and readable labels across payload reordering and utilization/reset changes. Case and punctuation are preserved.
  • Preserve every duplicate or anonymous quota window. Duplicate surface names receive occurrence suffixes in their internal keys; anonymous or invalid surfaces retain numbered keys. Neither can guarantee individual identity across reordered refreshes without a unique upstream identity. A renamed surface also has a new identity.
  • Keep surface labels out of model-matching metadata. A surface named Fable is not evidence for a Fable-only lock. Unresolved blocking scopes still cool the connection using the actual blocker reset; known model labels and routing remain unchanged.

Verification:

  • Correct-schema normalization and rendered-dashboard regressions failed before the fix and passed afterward.
  • Focused Node unit/integration suite: 103 passed, 0 failed.
  • Rendered-dashboard Vitest: 2 passed, 0 failed.
  • Isolated normalization -> dashboard parsing -> quota-cache probe: identical named-surface row identity and exact 600000ms connection cooldown in both payload orders, including a surface named Fable; duplicate windows retained.
  • Real chat-handler integration with isolated database and mocked upstream 429: exact connection reset persisted, no model lock.
  • Changed-file ESLint with --no-cache --pass-on-unpruned-suppressions, Prettier, and git diff --check.
  • npm run check:open-sse-typecheck: 0 errors.
  • npm run check:file-size: passed.
  • Commit identity, docs-sync, explicit-any budget, and tracked-artifact checks run manually. Automatic commit hooks were disabled to avoid lint-staged's prohibited auto-stash.
  • Independent review: no remaining findings.
  • ESLint suppression file remains identical to the PR release base.
Surface follow-up regression commands
env -u DATA_DIR node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=4 \
  tests/unit/claude-quota-normalization.test.ts \
  tests/unit/claude-quota-model-matching.test.ts \
  tests/unit/claude-quota-surface-labels.test.ts \
  tests/unit/claude-scoped-quota-auth.test.ts \
  tests/unit/claude-fable-usage.test.ts \
  tests/unit/combo/claude-scoped-quota-alias-round-robin.test.ts \
  tests/unit/account-fallback-anthropic-quota.test.ts \
  tests/unit/claude-extra-usage.test.ts \
  tests/unit/claude-oauth-usage-429-backoff.test.ts \
  tests/unit/quota-parsing-claude-extra-usage.test.ts \
  tests/unit/providerlimits-quotanormalize-split.test.ts \
  tests/unit/quota-cache-is-exhausted-per-window-5923.test.ts \
  tests/unit/usage-quota-row-visibility.test.ts \
  tests/integration/claude-quota-chat-ordering.test.ts

npx vitest run --config vitest.config.ts tests/unit/fable-quota-rendering.test.tsx

No live Anthropic requests, account/configuration changes, or deployment. Full lint/build/CI matrix was not rerun for this four-file follow-up; the previously documented release-base lint failures and suppression cleanup remain outside this change.

Previous review follow-up (6b2a0f7)

Addressed both remaining review findings:

  • Preserve distinct unresolved weekly_scoped windows through the final output map, including missing model metadata and non-tokenizable display names. An inactive window can no longer erase the active window's reset, regardless of payload order.
  • Require an exact ordered numeric version when a scope supplies a versioned display name. Opus 4 no longer matches Opus 4.5. Family-only names such as Fable remain supported, exact upstream model IDs retain precedence, and only a literal trailing eight-digit snapshot date is ignored for display-version comparison.

Regressions were demonstrated failing before the fixes, including the real chat handler with an isolated database and mocked upstream 429 responses. No live provider quota was consumed.

Tests added or extended:

  • tests/unit/claude-quota-model-matching.test.ts (new)
  • tests/unit/claude-quota-normalization.test.ts
  • tests/integration/claude-quota-chat-ordering.test.ts

Verification for 6b2a0f7:

  • Focused unit/integration suite below: 92 passed, 0 failed
  • Focused ESLint on the four changed TypeScript files, with --no-cache --pass-on-unpruned-suppressions
  • Focused Prettier check
  • npm run check:open-sse-typecheck: 0 errors
  • git diff HEAD --check
  • Commit identity, docs-sync, explicit-any budget, and tracked-artifact checks
  • npm run check:file-size -- --base-ref 0d9299df9b0dc85275958e2591ceb8d6eabded82: passed
  • Independent follow-up review: no remaining findings
  • config/quality/eslint-suppressions.json remains identical to the PR release base
Focused regression command
node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=4 \
  tests/unit/claude-quota-normalization.test.ts \
  tests/unit/claude-quota-model-matching.test.ts \
  tests/unit/claude-scoped-quota-auth.test.ts \
  tests/unit/claude-fable-usage.test.ts \
  tests/unit/combo/claude-scoped-quota-alias-round-robin.test.ts \
  tests/unit/account-fallback-anthropic-quota.test.ts \
  tests/unit/claude-extra-usage.test.ts \
  tests/unit/claude-oauth-usage-429-backoff.test.ts \
  tests/unit/quota-parsing-claude-extra-usage.test.ts \
  tests/unit/providerlimits-quotanormalize-split.test.ts \
  tests/unit/quota-cache-is-exhausted-per-window-5923.test.ts \
  tests/integration/claude-quota-chat-ordering.test.ts

Full npm run lint is still base-red. A fresh uncached run also reports 92 existing rule errors in five files unchanged from the PR release base, in addition to stale suppression entries in the plain lint run. Those files and the suppression baseline are intentionally untouched. The full build/CI matrix and a live Anthropic test were not rerun for this follow-up.

Earlier validation (before this follow-up)

  • Final focused Claude quota, auth, combo, Fable usage, and real chat-path suite: 221 passed, 0 failed
  • Ordered changed integration suite: 48 passed, 0 failed
  • Post-latest-upstream quota regression subset: 96 passed, 0 failed
  • Latest upstream merge regression subset: 18 passed, 0 failed
  • Fable rendering Vitest: 1 passed, 0 failed
  • Focused ESLint on all changed TypeScript files with --no-cache and --pass-on-unpruned-suppressions
  • npm run typecheck:core
  • npm run check:open-sse-typecheck
  • npm run check:cycles
  • Focused Prettier check
  • git diff --check upstream/release/v3.8.51
  • Suppression file has zero combined diff from upstream/release/v3.8.51
  • Independent code review: approved with no Critical, Important, or Minor findings
  • npm run check:file-size -- --base-ref upstream/release/v3.8.51

The file-size command is base-red only for tests/unit/chatcore-translation-paths.test.ts: checker count 3449 exceeds its frozen 3447 baseline. The PR copy is byte-identical to current upstream (b5ef7a62babcafb6b83251fc881cef929de1f6f0), so this PR does not change that file or its baseline.

Plain npm run lint also remains base-red because stale suppression entries are still present. This PR leaves that cleanup to upstream or a separate reconciliation PR. All ESLint rules for changed TypeScript files pass with unpruned suppressions tolerated.

Reviewer notes

  • No database migration, feature flag, provider catalog change, or operator configuration change is required. Surface-scoped quota labels and row identities are improved without changing dashboard components.
  • Current and previous quota fields are merged by semantic window because Anthropic can return both in the same payload.
  • Unknown scoped model names deliberately fail safe to connection-wide cooling.

@riez
riez requested a review from diegosouzapw as a code owner September 16, 2026 14:36

@abhisheksharma2411 abhisheksharma2411 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-model direction is right for one of Claude's two auth modes, and the test is a good one — asserting the sibling stays selectable, not just that the lock exists. But I think the OAuth case it actually tests is the case where it may be wrong, and I'd rather raise it before it lands than after.

The message in your test is one the repo already classifies as account-wide.

open-sse/services/quotaTextCooldowns.ts:47:

// Native Claude OAuth uses this otherwise-generic 429 wording for an
// exhausted subscription window. Keep it provider-scoped: other upstreams
// can use the same phrase for a short RPM throttle.
(provider === "claude" && lower.includes("this request would exceed your account's rate limit"))

That is isSubscriptionQuotaText — #2321, the Claude Pro/Team 5-hour subscription window — and it carries SUBSCRIPTION_QUOTA_COOLDOWN_MS = 1 hour. Your test sends that exact string with authType: "oauth".

A subscription window is spent per account, not per model. claude-opus-5 does not have its own five-hour bucket, so after the lock the sibling is routable into a quota that is already exhausted — one upstream 429 per remaining model.

Both tests pass on your branch, which is what makes this worth flagging rather than obvious. I ran them on 2a967d7:

tests/unit/account-fallback-anthropic-quota.test.ts        13 pass, 0 fail
"markAccountUnavailable scopes native Claude account..."    1 pass, 0 fail

The existing suite still gets QUOTA_EXHAUSTED with a ~1h cooldown from classifyError, while markAccountUnavailable now locks a model and leaves the connection active — your test asserts rateLimitedUntil === undefined, which is precisely the part that changes. The reason still says account-wide exhaustion; the scope no longer agrees with it.

The codebase already spells out this failure mode for the analogous provider. src/sse/services/auth.ts ~L2800:

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.

That connection-scope branch is keyed on isAgentrouterConnectionQuotaScope, so Claude would not reach it.

Where I think you're right: Anthropic API-key rate limits genuinely are per-model (separate RPM/TPM per model family), so scoping those to the model is the correct fix and siblings really are routable. The issue is only that canonicalId === "claude" covers both auth modes, and the subscription case is the one your test exercises.

Two shapes that would keep the win without the regression — whichever fits the codebase better:

  1. Gate on auth mode, so OAuth/subscription connections keep the connection-level cooldown and API-key connections get the per-model lock.
  2. Keep the one-liner but let isSubscriptionQuotaText take precedence, the way the agentrouter connection-scope branch runs ahead of the per-model block.

Worth a regression test either way: a Claude OAuth connection that takes the subscription message should leave the sibling unroutable, which is the inverse of the assertion you have now. Happy to be wrong if OAuth quota is in fact per-model — if you have a 429 body from a real subscription exhaustion showing the model scoped, that settles it.

@riez

riez commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

I think you are right this is to broad, the isolated model can be changed depending on their policy, I remembered there is one time that the isolated model was Sonnet and now Fable.

I think a way to define the isolated model rather than broad logic like this better:

{
  "limits": [
    {
      "kind": "session",
      "group": "session",
      "percent": 0,
      "severity": "normal",
      "resetsAt": null,
      "scope": null,
      "isActive": false
    },
    {
      "kind": "weekly_all",
      "group": "weekly",
      "percent": 67,
      "severity": "normal",
      "resetsAt": "2026-09-17T23:59:59.846145+00:00",
      "scope": null,
      "isActive": false
    },
    {
      "kind": "weekly_scoped",
      "group": "weekly",
      "percent": 100,
      "severity": "critical",
      "resetsAt": "2026-09-17T23:59:59.846419+00:00",
      "scope": {
        "model": {
          "id": null,
          "displayName": "Fable"
        },
        "surface": null
      },
      "isActive": true
    }
  ]
}

I've pulled this, and I think we can use this as the logic, will try to create changes and let you review again. Thanks @abhisheksharma2411

@abhisheksharma2411

Copy link
Copy Markdown
Contributor

That payload is a much better answer than either option I offered — it removes the guesswork entirely. The scope shouldn't come from a hardcoded provider list or from the auth mode; it should come from the response, because the upstream is already telling you:

weekly_all      percent 67   isActive false   scope null
weekly_scoped   percent 100  isActive true    scope.model.displayName "Fable"   severity critical
session         percent 0    isActive false   scope null

That's exactly the distinction the current hasPerModelQuota("claude") can't make: one model is exhausted while the account-wide weekly bucket still has a third left. Locking the model is right here — and would be wrong if weekly_all or session were the active one, which is the case my review was worried about.

Two things worth designing in from the start:

1. scope.model.id is null — only displayName is populated. Matching a limit to the model the request asked for would mean mapping "Fable" to a model id, and display names are precisely what changed under you before (Sonnet → Fable). If that mapping misses, the failure is silent and lands on the wrong side: an unmatched scoped limit reads as "no model limit," and you're back to the current behaviour with extra steps. Worth deciding explicitly what happens when id is null and the display name doesn't resolve — I'd argue fall back to cooling the connection, since an active critical limit you can't attribute is safer treated as account-wide than ignored.

2. Branch on isActive / severity, not percent. weekly_all at 67% is not a limit condition, and a percent threshold would need a magic number that the upstream can move. isActive: true is the upstream's own verdict. severity then distinguishes "critical" from a soft warning, if you want to avoid parking a connection on a normal.

Roughly:

active limit action
weekly_scoped with a resolvable model lock that model only — what your PR does today
weekly_scoped unresolvable (id: null, unknown name) cool the connection (fail safe)
weekly_all or session cool the connection — do not lock a model
none active current behaviour

Worth checking against quotaTextCooldowns.ts:47 while you're in there: "this request would exceed your account's rate limit" is currently classified as a #2321 subscription window with a 1h account cooldown, and that path and this one need to agree about scope, or the reason and the action will disagree the way they do now.

Happy to review the follow-up — and thanks for taking the point rather than defending the one-liner. resetsAt being present on both means you also get a real park-until instead of a fixed cooldown, which is a bonus neither option I suggested would have given you.

@riez riez changed the title fix(sse): scope Claude rate limits per model fix(sse): honor native Claude quota scope Sep 17, 2026
@riez

riez commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @abhisheksharma2411, I revisited the implementation and addressed your concerns.

I’ve also updated this branch with the upstream release/v3.8.51 changes. Among those, #13266 adds the separate Fable quota display, and #12957 fixes model-lock scope for 5xx errors.

Those help, but #13266 deliberately leaves routing and cooldowns unchanged, while #12957 covers 5xx failures. The remaining issue is the subscription-quota 429: Fable can be exhausted while the shared quota still has room, but a connection-wide cooldown also prevents switching to Opus.

This PR now takes the scope from Anthropic’s payload:

  • Active, resolvable weekly_scoped limits lock the matching model.
  • Active session/weekly-all limits or unresolved scoped limits cool the connection.
  • Blocking uses isActive and severity; percentage is display metadata.
  • Cooldowns preserve the relevant upstream reset without picking an unrelated window’s reset.

The parser also handles the mixed payload shown in #13266, where limits[] arrives alongside five_hour and seven_day. Current entries override the same window, while other valid windows are retained. Model quotas stay separate from account quotas so exhausted Fable usage does not reduce Opus headroom.

Added regression coverage for missing percentages, unresolved scopes, cold/stale cache, reset propagation, and sibling-model availability. The focused suite passed 221 tests, and the changed integration suite passed 48.

Could you take another look?

@riez

riez commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

@diegosouzapw if you have time kindly take a look as well for code review. Thanks.

@abhisheksharma2411 abhisheksharma2411 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@riez this is a much better shape than what either of us started with, and the parts I was worried about are genuinely handled. Walking getCachedClaudeQuotaScopeDecision against the table from last week:

  • active weekly_all / session → scope: "connection" ✅
  • active weekly_scoped that resolves → scope: "model" ✅
  • active weekly_scoped that doesn't resolve → falls to scope: "connection" ✅ — this is the fail-safe I cared about most and you built it exactly right
  • isActive + severity decide blocking, percent is display-only ✅
  • real resetsAt propagated as cooldownMs instead of a fixed window ✅

quotaTextCooldowns also lines up now — isExplicitClaudeQuota429Text runs through isSubscriptionQuotaText, so the reason and the action can't disagree the way they used to. That was the loose end I flagged and it's tied off.

I pulled the branch and probed it rather than just reading. Two things came out, one of which I think matters.


1. Two scoped limits that don't name a model collapse into one — and the survivor can be the inactive one.

normalizeCurrentLimits is careful to keep them distinct in semanticKey:

: (metadata.scopeKey ?? `weekly_scoped:unknown:${index}`),

…but the output map at the end of normalizeClaudeUsageQuotas re-keys everything by displayKey, and quotaKey() renders every model-less scoped limit as the same string:

return `weekly ${(displayName || "scoped").toLowerCase()} (7d)`;   // -> "weekly scoped (7d)"

So the index disambiguation is thrown away one line later, and last-write-wins. With two model-less scoped limits where the first is the active critical one:

PROBE A keys:   ["weekly scoped (7d)"]
PROBE A values: [{"active":false,"severity":"normal"}]      <- the active/critical one is gone

End to end, with a control:

both model-less : {"scope":"connection","evidence":"none",    "resetAt":null,          "cooldownMs":null}
active alone    : {"scope":"connection","evidence":"blocking","resetAt":"...17:16:51Z","cooldownMs":599999}

Adding an unrelated, inactive limit to the payload erases an active critical one, and the fail-safe you built silently stops firing — evidence drops to "none", so it's back to the generic cooldown with no reset. That's the "an unmatched scoped limit reads as no model limit" failure landing on the wrong side.

Fixture I used (scope.surface non-null, model null) is inferred from the shape in your own paste — "surface": null is right there, so the field exists and can presumably be populated. I can't prove Anthropic sends two of these today. But the collision holds for any two scoped limits that don't resolve to a model, and since you already anticipated duplicates in semanticKey, I'd just carry that through: key the output on semanticKey and keep displayKey for presentation, or fold a surface/index discriminator into quotaKey().

2. Display-name matching crosses model generations.

const requestedTokens = new Set(normalizedTokens(requestedKey));
const displayTokens = normalizedTokens(metadata.modelDisplayName);
return displayTokens.length > 0 && displayTokens.every((token) => requestedTokens.has(token));

Subset, not equality — so a display name is satisfied by any model whose tokens are a superset:

display="Claude Opus 4"    requested=claude-opus-4-5      -> true
display="Claude Sonnet 4"  requested=claude-sonnet-4-5    -> true
display="Opus"             requested=claude-opus-5        -> true
display="Fable"            requested=claude-fable-5-1     -> true

The last two are the behaviour you want. The first two aren't: an exhausted Opus 4 window captures a request for Opus 4.5, which has its own untouched quota.

PROBE D requested=claude-opus-4     -> scope=model      evidence=blocking
PROBE D requested=claude-opus-4-5   -> scope=model      evidence=blocking   <- locks a model that isn't limited
PROBE D requested=claude-sonnet-5   -> scope=connection evidence=blocking   <- correct fail-safe

Three things go wrong at once there: the wrong model gets locked, it gets locked with another window's resetsAt, and whatever actually caused the 429 goes unattributed. claude-sonnet-5 shows the right answer for comparison — unresolved, so cool the connection.

Note the modelId branch is exact (requestedKey === normalizedRequestedModel(metadata.modelId)), so this is only the displayName fallback — the path that exists precisely because id comes back null, which is the case that'll actually be hit.

I don't think plain set-equality is the fix, since it'd break the "Fable" → claude-fable-5-1 case that's the whole point. Two options that both work:

  • resolve the display name against getProviderModels("claude") and only treat it as a match if it lands on the requested model's family and the version segments don't contradict; or
  • keep the subset rule but reject when the display name carries a version token that isn't the requested model's version token — ["claude","opus","4"] vs claude-opus-4-5 fails, ["fable"] vs claude-fable-5-1 still passes because it names no version.

Either way the unresolved case already does the right thing, so the safe answer is available for free.


None of this is a rewrite — (1) is a key choice at one line and (2) is the tail of one predicate. The architecture underneath is right, and the isActive/severity/resetsAt plumbing is cleaner than what I suggested. Happy to re-check once you've had a look.

@riez

riez commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Fixed both in 6b2a0f7. unresolved scoped windows now stay distinct and preserve the active reset regardless of order. versioned display names require an exact version match, while family-only names like Fable still work.

added regression coverage for both cases, including the chat path.

could you take another look?

@abhisheksharma2411 abhisheksharma2411 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed, and I re-ran the exact probes from my last review against 6b2a0f75 rather than reading the diff.

Finding 1 — the collapsing scoped windows. Was one key with the inactive window surviving; now:

keys: ["weekly scoped (7d) #1","weekly scoped (7d) #2"]
active first  -> {"scope":"connection","evidence":"blocking","cooldownMs":599999}
active second -> {"scope":"connection","evidence":"blocking","cooldownMs":599999}

Previously the second ordering gave evidence:"none" with no reset. Both orders now preserve the active critical window and its real reset, so the fail-safe fires regardless of the order upstream happens to send them in — which is the property that matters, and you tested it in both directions rather than just the one I demonstrated.

Finding 2 — version-crossing display names. The numeric-token comparison does exactly what was needed:

display="Claude Opus 4"    requested=claude-opus-4-5    -> false   (was true)
display="Claude Opus 4"    requested=claude-opus-4      -> true
display="Claude Sonnet 4"  requested=claude-sonnet-4-5  -> false   (was true)
display="Fable"            requested=claude-fable-5-1   -> true
display="Opus"             requested=claude-opus-5      -> true

The two family-only labels still match, which was the constraint that ruled out plain set-equality. Requiring equal length and order on the version tokens is stricter than the "same version prefix" I had in mind and is the safer direction — Opus 4 can't absorb Opus 4.5 and vice versa. The -\d{8} strip before comparing is a good catch I hadn't considered; a dated snapshot id would otherwise have read as an extra version token.

claude-quota-model-matching, claude-quota-normalization, claude-scoped-quota-auth and claude-fable-usage together: 40 pass / 0 fail.

Nothing blocking. Two small notes, both optional:

  • #1/#2 is positional, so it is not stable across polls. It doesn't affect routing — the decision iterates Object.values(entry.modelQuotas) and never reads the key, and quotaSnapshotChanged only looks at quotas, not modelQuotas — so this is a display concern only. But if upstream reorders its limits[] between polls, the same logical limit moves between #1 and #2 in the UI. scope.surface is sitting right there in the payload and is still unread; keying on it when present (falling back to the index) would give a stable label for free.
  • Worth a line in the PR body that an unresolved scoped limit now cools the connection rather than being ignored, since that is the user-visible behaviour change for anyone whose account starts receiving surface-scoped limits.

Good iteration on this one — the shape you landed on is better than what either of us started with.

@riez

riez commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Fxed in 27d5161. we now use scope.surface.display_name for stable row keys and readable labels when available. anonymous or duplicate surfaces remain separately numbered so no quota window gets dropped.

probed both payload orders locally: labels stay stable, and connection-scoped cooldowns retain the correct reset without creating a model lock. 103 unit/integration tests and 2 rendering tests pass.

could you take another look @abhisheksharma2411?

diegosouzapw and others added 3 commits September 24, 2026 00:18
# Conflicts:
#	tests/integration/combo-matrix/context-relay-handoff.test.ts
#	tests/integration/combo-matrix/weighted.test.ts
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Reconcile markAccountUnavailable with the tip's OAuth 401 backoff (cheaper-inference#14917):
the Claude-scope cooldown now feeds resolvedCooldownMs, the OAuth backoff
still overrides it, and a Claude quota resetAt is only used when that
backoff did not apply.
The tip moved the quota-cache types/state into quotaCacheState.ts (cheaper-inference#14820);
carry this PR's QuotaInfo.claudeQuota and QuotaCacheEntry.modelQuotas fields
there (type-only import keeps the leaf free of runtime imports).
@diegosouzapw
diegosouzapw merged commit d2c950b into cheaper-inference:release/v3.8.51 Sep 29, 2026
0 of 3 checks passed
@riez
riez deleted the fix/claude-model-quota-scope branch September 29, 2026 14:29
diegosouzapw added a commit that referenced this pull request Sep 29, 2026
…#14190, proxyLogs import side effect (#15116)

Release-captain base-red fix (v3.8.51 release PR #11442, unit shards 1-2 + integration): production defect — OmniRoute's own local-cooldown 429 was classified as CLIProxyAPI quota exhaustion since #14190 and made combos skip same-provider targets (now tagged X-OmniRoute-Local-Cooldown); the proxyLogHost extraction reconciled with #15109; contract propagations for #14627/#14873/#13888/#14876/#15001 and two harness isolation fixes. Focused suites green on the reconciled tip, typecheck clean.
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.

3 participants