Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
441a17a
fix(sse): scope Claude rate limits per model
riez Sep 16, 2026
2a967d7
docs: add Claude quota fix changelog fragment
riez Sep 16, 2026
68429e2
fix(sse): use Claude scoped quota evidence
riez Sep 17, 2026
99a19fc
Merge remote-tracking branch 'upstream/release/v3.8.51' into fix/clau…
riez Sep 17, 2026
8a3c3ce
chore(quality): reconcile release validation baselines
riez Sep 17, 2026
9c5f41a
test(integration): stabilize routing validation
riez Sep 17, 2026
4ae95fa
Merge remote-tracking branch 'upstream/release/v3.8.51' into fix/clau…
riez Sep 17, 2026
bd0bd36
chore(quality): restore file-size gate
riez Sep 17, 2026
d7db9fb
Merge remote-tracking branch 'upstream/release/v3.8.51' into fix/clau…
riez Sep 17, 2026
39069b7
Merge remote-tracking branch 'upstream/release/v3.8.51' into fix/clau…
riez Sep 17, 2026
3f5d7c9
fix(claude): honor authoritative quota scope
riez Sep 17, 2026
c481124
Merge upstream release/v3.8.51
riez Sep 17, 2026
a91bba7
Merge remote-tracking branch 'upstream/release/v3.8.51' into fix/clau…
riez Sep 17, 2026
5991b3d
merge: sync upstream release/v3.8.51
riez Sep 19, 2026
0d9299d
merge: sync latest upstream release fixes
riez Sep 19, 2026
6b2a0f7
fix(sse): preserve unresolved Claude quotas and exact versions
riez Sep 22, 2026
27d5161
fix(usage): stabilize Claude surface quota identities
riez Sep 22, 2026
9a2ae7a
merge: reconcile upstream release/v3.8.51 quota routing changes
riez Sep 22, 2026
935d4fc
Merge remote-tracking branch 'origin/release/v3.8.51' into HEAD
diegosouzapw Sep 24, 2026
879b20f
test(combo): make stale Claude quota fixture robust to fetchedAt jitter
riez Sep 24, 2026
c124179
chore: merge upstream release/v3.8.51 into quota-scope fix
riez Sep 24, 2026
e4d9644
chore: merge release/v3.8.51 into fix/claude-model-quota-scope
diegosouzapw Sep 29, 2026
6a01c6d
chore: merge release/v3.8.51 into fix/claude-model-quota-scope
diegosouzapw Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/13888-claude-model-scoped-429.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** Use fresh Anthropic scoped-limit evidence to isolate an exhausted native Claude model while keeping global or unproven quota failures connection-wide ([#13888](https://github.com/diegosouzapw/OmniRoute/pull/13888)) - thanks @riez
61 changes: 26 additions & 35 deletions open-sse/services/combo/executeTargetAttempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
recordModelLockoutFailure,
recordProviderFailure,
recordProviderSuccess,
retryHintBypassesMaxCooldownMs,
selectLockoutCooldownMs,
} from "../accountFallback.ts";
import {
Expand Down Expand Up @@ -860,34 +859,7 @@ export async function executeTargetAttempt(opts: {
await resolveComboDailyReset(provider)
);
const { cooldownMs } = fallbackResult;
// #6863: a parsed upstream quota reset (e.g. Antigravity "Resets in 92h27m28s")
// arrives in `quotaResetHintMs` — it bypasses the operator-gated
// `useUpstreamRetryHints` connection-cooldown setting. Mirror the
// single-model path (src/sse/services/auth.ts): when the retry hint was
// already honored, `cooldownMs` IS the upstream value; otherwise prefer the
// parsed quota reset — even when it is SHORTER than the fallback cooldown
// (e.g. subscription-quota 1h default vs a real "resets in 10m").
// `selectLockoutCooldownMs` still ignores hints at/below the base cooldown,
// so absent/tiny hints keep the #1308 exponential-backoff behavior.
const lockoutHintMs =
fallbackResult.usedUpstreamRetryHint === true
? cooldownMs
: (fallbackResult.quotaResetHintMs ?? 0);
// Only a transport header or google.rpc.RetryInfo is authoritative enough
// to bypass maxCooldownMs. Prose and generic JSON remain useful exact hints,
// but the operator cap still bounds them.
const lockoutHintVerified = retryHintBypassesMaxCooldownMs(fallbackResult.retryHintSource);
const selectedConnectionId =
result.headers?.get("X-OmniRoute-Selected-Connection-Id") ||
result.headers?.get("x-omniroute-selected-connection-id") ||
undefined;
const targetWithConnection = selectedConnectionId
? { ...target, connectionId: selectedConnectionId }
: target;

// #1731 / #1731v2: classify the upstream error and update the exhaustion sets
// (shared with handleRoundRobinCombo). Returns whether the provider is fully exhausted.
const providerExhausted = applyComboTargetExhaustion(targetWithConnection, {
const targetFailure = applyComboTargetExhaustion(target, {
result,
fallbackResult,
errorText,
Expand All @@ -905,6 +877,15 @@ export async function executeTargetAttempt(opts: {
exhaustedLogLevel: "info",
structuredError,
});
const {
target: targetWithConnection,
providerExhausted,
isModelScopedClaudeQuota,
isConnectionScopedClaudeQuota,
modelScopedClaudeCooldownMs,
lockoutHintMs,
lockoutHintVerified,
} = targetFailure;
// #6692: this connection was just classified as provider/connection-level
// exhausted — if it's the currently sticky-bound one, release the pin now
// rather than waiting for the next turn's lazy headroom/status recheck.
Expand Down Expand Up @@ -1096,7 +1077,14 @@ export async function executeTargetAttempt(opts: {
// once the model is cooling down, retrying it would waste an upstream
// call and extend the cooldown via exponential backoff.
let lockoutRecorded = false;
if (!protectedPriorityTarget && provider && rawModel && retry === 0 && !scopedFailure) {
if (
!protectedPriorityTarget &&
provider &&
rawModel &&
retry === 0 &&
!scopedFailure &&
!isConnectionScopedClaudeQuota
) {
const mlSettings = resolveModelLockoutSettings(deps.settings);
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
recordModelLockoutFailure(
Expand All @@ -1111,8 +1099,9 @@ export async function executeTargetAttempt(opts: {
// #1308/#6863: honor a long upstream reset (e.g. "Resets in 160h") over
// the short base cooldown / exponential backoff when present. #7940's
// maxCooldownMs cap only applies to synthetic values — a verified
// upstream reset (lockoutHintVerified) bypasses it.
exactCooldownMs: selectLockoutCooldownMs(lockoutHintMs, mlSettings),
// upstream reset (including a cached Claude reset) bypasses it.
exactCooldownMs:
modelScopedClaudeCooldownMs ?? selectLockoutCooldownMs(lockoutHintMs, mlSettings),
maxCooldownMs: mlSettings.maxCooldownMs,
// Preserve authoritative structured/header resets; clamp body prose.
exactCooldownIsUpstreamReset: lockoutHintVerified,
Expand Down Expand Up @@ -1182,7 +1171,7 @@ export async function executeTargetAttempt(opts: {
if (i > 0) state.fallbackCount++;
// Wire combo failures into the resilience dashboard (model-level lockout)
// alongside the provider-level cooldown below — they govern different scopes.
if (provider && rawModel && !scopedFailure) {
if (provider && rawModel && !scopedFailure && !isConnectionScopedClaudeQuota) {
const mlSettings = resolveModelLockoutSettings(deps.settings);
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
recordModelLockoutFailure(
Expand All @@ -1196,8 +1185,9 @@ export async function executeTargetAttempt(opts: {
{
// #1308/#6863: honor a long upstream reset over base/exponential cooldown.
// #7940's maxCooldownMs cap only applies to synthetic values — a verified
// upstream reset (lockoutHintVerified) bypasses it.
exactCooldownMs: selectLockoutCooldownMs(lockoutHintMs, mlSettings),
// upstream reset (including a cached Claude reset) bypasses it.
exactCooldownMs:
modelScopedClaudeCooldownMs ?? selectLockoutCooldownMs(lockoutHintMs, mlSettings),
maxCooldownMs: mlSettings.maxCooldownMs,
// Preserve authoritative structured/header resets; clamp body prose.
exactCooldownIsUpstreamReset: lockoutHintVerified,
Expand All @@ -1219,6 +1209,7 @@ export async function executeTargetAttempt(opts: {
provider &&
provider !== "unknown" &&
!scopedFailure &&
!isModelScopedClaudeQuota &&
!((result.status === 500 || result.status === 429) && hasPerModelQuota(provider, rawModel))
) {
recordProviderCooldown(
Expand Down
38 changes: 18 additions & 20 deletions open-sse/services/combo/roundRobinCombo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,31 +963,18 @@ export async function handleRoundRobinCombo({
await resolveComboDailyReset(provider)
);
const { cooldownMs } = fallbackResult;
const selectedConnectionId =
result.headers?.get("X-OmniRoute-Selected-Connection-Id") ||
result.headers?.get("x-omniroute-selected-connection-id") ||
undefined;
const targetWithConnection = selectedConnectionId
? { ...target, connectionId: selectedConnectionId }
: target;

const rawModel = parseModel(modelStr).model || modelStr;
const isAllAccountsRateLimited = isAllAccountsRateLimitedResponse(
result.status,
result.headers?.get("content-type") ?? null,
errorText
);

// #1731: If the entire provider quota is exhausted, mark it so subsequent
// same-provider targets are skipped immediately. API-key 429s still use
// the short resilience cooldown, but explicit quota text should stop the
// combo from trying another target for the same provider in this request.
// #1731 / #1731v2: classify the upstream error and update the exhaustion sets
// (shared with handleComboChat). Returns whether the provider is fully exhausted.
const providerExhausted = applyComboTargetExhaustion(targetWithConnection, {
const targetFailure = applyComboTargetExhaustion(target, {
result,
fallbackResult,
errorText,
rawModel: parseModel(modelStr).model || modelStr,
rawModel,
isTokenLimitBreach,
allAccountsRateLimited: isAllAccountsRateLimited,
requestScopedFailure: scopedFailure,
Expand All @@ -997,6 +984,12 @@ export async function handleRoundRobinCombo({
exhaustedLogLevel: "debug",
structuredError,
});
const {
target: targetWithConnection,
providerExhausted,
isModelScopedClaudeQuota,
effectiveTargetCooldownMs: targetCooldownMs,
} = targetFailure;
// #6692: mirrors handleComboChat's exhaustion-point release above.
releaseStickyPinOnFailure(
_rrSessionSticky.messageHash,
Expand Down Expand Up @@ -1024,10 +1017,13 @@ export async function handleRoundRobinCombo({
!isTokenLimitBreach &&
!scopedFailure &&
TRANSIENT_FOR_SEMAPHORE.includes(result.status) &&
cooldownMs > 0
targetCooldownMs > 0
) {
semaphore.markRateLimited(semaphoreKey, cooldownMs);
log.warn("COMBO-RR", `${modelStr} error ${result.status}, cooldown ${cooldownMs}ms`);
semaphore.markRateLimited(semaphoreKey, targetCooldownMs);
log.warn(
"COMBO-RR",
`${modelStr} error ${result.status}, cooldown ${targetCooldownMs}ms`
);
}

if (isAllAccountsRateLimited) {
Expand Down Expand Up @@ -1056,6 +1052,7 @@ export async function handleRoundRobinCombo({
retry < maxRetries &&
isTransient &&
!providerExhausted &&
!isModelScopedClaudeQuota &&
(!config.failoverBeforeRetryExplicit || !hasNextRrTarget)
) {
continue;
Expand Down Expand Up @@ -1102,9 +1099,10 @@ export async function handleRoundRobinCombo({
provider &&
provider !== "unknown" &&
!scopedFailure &&
!isModelScopedClaudeQuota &&
!(
(result.status === 500 || result.status === 429) &&
hasPerModelQuota(provider, parseModel(modelStr).model || modelStr)
hasPerModelQuota(provider, rawModel)
)
) {
recordProviderCooldown(
Expand Down
Loading