Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/15624-claude-quota-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(providers):** Keep Claude accounts routable when active critical usage warnings still report remaining quota; report the actual reset of the blocking account and model windows. ([#15624](https://github.com/diegosouzapw/OmniRoute/pull/15624)).
37 changes: 24 additions & 13 deletions src/domain/quotaCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ function activeClaudeResetMs(
}

function isActiveClaudeExhaustion(quota: QuotaInfo, now: number): boolean {
// Active critical limits can be predictive warnings while quota remains.
// Explicit upstream 429s still use activeClaudeResetMs without this preflight guard.
return activeClaudeResetMs(quota, now, isPreflightBlockingClaudeQuota) !== null;
}

Expand Down Expand Up @@ -615,22 +617,31 @@ export function getCachedClaudeQuotaScopeDecision(input: {

export function getClaudeQuotaPreflightResetAt(
connectionId: string,
now = Date.now()
requestedModel: string | null,
providerSpecificData?: unknown
): string | null {
const entry = getState().cache.get(connectionId);
if (!entry || resolveProviderId(entry.provider) !== "claude") return null;
const windows = [...Object.values(entry.quotas), ...Object.values(entry.modelQuotas)].filter(
(quota) => isPreflightBlockingClaudeQuota(quota)
);
let earliest: string | null = null;
let earliestMs = Infinity;
for (const quota of windows) {
const resetMs = activeClaudeResetMs(quota, now);
if (resetMs === null || !quota.resetAt || resetMs >= earliestMs) continue;
earliestMs = resetMs;
earliest = quota.resetAt;
}
return earliest;
const now = Date.now();
const config = readClaudeUsageLimitConfig(providerSpecificData);
const sessionRecoveryEnabled = config.lowPriorityMode || config.autoLimitReset;
const windows = [
...Object.values(entry.quotas).filter(
(quota) =>
quota.claudeQuota &&
quota.claudeQuota.kind !== "weekly_scoped" &&
(quota.claudeQuota.kind !== "session" || !sessionRecoveryEnabled)
),
...Object.values(entry.modelQuotas).filter(
(quota) =>
requestedModel &&
quota.claudeQuota?.kind === "weekly_scoped" &&
claudeQuotaMatchesModel(quota.claudeQuota, requestedModel)
),
].filter((quota) => isActiveClaudeExhaustion(quota, now));
// All blocking windows on this account must reset before it can serve this model.
// The caller then chooses the earliest available account, not the cache park TTL.
return decisionForLatestClaudeReset(windows, "connection", now)?.resetAt ?? null;
}

export function isQuotaExhaustedForRequest(
Expand Down
12 changes: 7 additions & 5 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1914,14 +1914,16 @@ export async function getProviderCredentials(
}

if (withQuota.length === 0 && exhaustedQuota.length > 0) {
// All remaining eligible accounts are exhausted
const earliestResetAt = getEarliestFutureDate(
exhaustedQuota.map((c) => {
if (resolveProviderId(provider) === "claude") {
return getClaudeQuotaPreflightResetAt(c.id) || getQuotaCache(c.id)?.nextResetAt || null;
return (
getClaudeQuotaPreflightResetAt(c.id, requestedModel, c.providerSpecificData) ||
getQuotaCache(c.id)?.nextResetAt ||
null
);
}
const entry = getQuotaCache(c.id);
return entry?.nextResetAt || null;
return getQuotaCache(c.id)?.nextResetAt || null;
})
);
const earliestResetMs = parseFutureDateMs(earliestResetAt);
Expand All @@ -1934,7 +1936,7 @@ export async function getProviderCredentials(
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: `All ${provider} accounts have exhausted their quota (cached quota state, no upstream attempt; earliest reset ${formatRetryAfter(retryAfter)})`,
lastError: `All ${provider} accounts have exhausted their quota (cached quota state, no upstream attempt; earliest ${formatRetryAfter(retryAfter)})`,
lastErrorCode: 429,
};
}
Expand Down
137 changes: 137 additions & 0 deletions tests/unit/claude-scoped-quota-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,143 @@ test("Claude session preflight allows upstream recovery opt-ins without bypassin
);
});

for (const percent of [91, 93]) {
test(`Claude critical weekly warning at ${percent}% still selects an account`, async () => {
const connection = await seedConnection("claude");
const resetAt = new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString();
const { quotas, modelQuotas } = normalizeClaudeUsageQuotas({
limits: [
{ kind: "session", percent: 0, is_active: false, severity: "normal" },
{ kind: "weekly_all", percent, resets_at: resetAt, is_active: true, severity: "critical" },
],
});
quotaCache.setQuotaCache(connection.id, "claude", quotas, modelQuotas);
assert.equal(
quotaCache.isQuotaExhaustedForRequest(connection.id, "claude", "claude-opus-5-5"),
false
);
const selected = await auth.getProviderCredentials("claude", null, null, "claude-opus-5-5");
assert.equal(selected?.connectionId, connection.id);
assert.notEqual(selected?.allRateLimited, true);

const policy = auth.evaluateQuotaLimitPolicy("claude", {
id: connection.id,
providerSpecificData: {
limitPolicy: { enabled: true, thresholdPercent: 90, windows: ["weekly"] },
},
});
assert.equal(policy.blocked, true, "an explicit operator cutoff still applies");
assert.equal(policy.resetAt, resetAt);
});
}

test("Claude scoped warnings allow requests while exhausted scopes block only matching models", () => {
const resetAt = new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString();
for (const percent of [93, 100]) {
const { quotas, modelQuotas } = normalizeClaudeUsageQuotas({
limits: [
{
kind: "weekly_scoped",
percent,
resets_at: resetAt,
is_active: true,
severity: "critical",
scope: { model: { display_name: "Fable" } },
},
],
});
quotaCache.setQuotaCache("claude-scoped-warning", "claude", quotas, modelQuotas);
assert.equal(
quotaCache.isQuotaExhaustedForRequest("claude-scoped-warning", "claude", "claude-fable-5-1"),
percent === 100
);
assert.equal(
quotaCache.isQuotaExhaustedForRequest("claude-scoped-warning", "claude", "claude-opus-5-5"),
false
);
}
});

test("Claude exhaustion reports the last applicable reset rather than the cache park deadline", async () => {
const connection = await seedConnection("claude");
const now = Date.now();
const sessionReset = new Date(now + 60 * 60_000).toISOString();
const weeklyReset = new Date(now + 3 * 24 * 60 * 60_000).toISOString();
const unrelatedReset = new Date(now + 5 * 24 * 60 * 60_000).toISOString();
const { quotas, modelQuotas } = normalizeClaudeUsageQuotas({
limits: [
{
kind: "session",
percent: 100,
resets_at: sessionReset,
is_active: true,
severity: "critical",
},
{
kind: "weekly_all",
percent: 100,
resets_at: weeklyReset,
is_active: true,
severity: "critical",
},
{
kind: "weekly_scoped",
percent: 100,
resets_at: unrelatedReset,
is_active: true,
severity: "critical",
scope: { model: { display_name: "Fable" } },
},
],
});
quotaCache.setQuotaCache(connection.id, "claude", quotas, modelQuotas);
const blocked = await auth.getProviderCredentials("claude", null, null, "claude-opus-5-5");
assert.equal(blocked.allRateLimited, true);
assert.equal(blocked.retryAfter, weeklyReset);
assert.equal(String(blocked.lastError).includes("reset after 5m"), false);
const fable = await auth.getProviderCredentials("claude", null, null, "claude-fable-5-1");
assert.equal(fable.retryAfter, unrelatedReset);
});

test("Claude reset ignores recoverable sessions and selects the earliest eligible account reset", async () => {
const first = await seedConnection("claude");
const second = await seedConnection("claude");
const now = Date.now();
const sessionReset = new Date(now + 4 * 60 * 60_000).toISOString();
const firstReset = new Date(now + 60 * 60_000).toISOString();
const secondReset = new Date(now + 2 * 60 * 60_000).toISOString();
for (const [connection, resetAt] of [
[first, firstReset],
[second, secondReset],
] as const) {
await providersDb.updateProviderConnection(connection.id, {
providerSpecificData: { lowPriorityMode: true },
});
const { quotas, modelQuotas } = normalizeClaudeUsageQuotas({
limits: [
{
kind: "session",
percent: 100,
resets_at: sessionReset,
is_active: true,
severity: "critical",
},
{
kind: "weekly_all",
percent: 100,
resets_at: resetAt,
is_active: true,
severity: "critical",
},
],
});
quotaCache.setQuotaCache(connection.id, "claude", quotas, modelQuotas);
}
const blocked = await auth.getProviderCredentials("claude", null, null, "claude-opus-5-5");
assert.equal(blocked.allRateLimited, true);
assert.equal(blocked.retryAfter, firstReset);
});

test("predictive critical weekly warning with quota remaining is not preflight exhaustion", async () => {
const connection = await seedConnection("claude", {
name: "claude-predictive-weekly-warning",
Expand Down
Loading