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
3 changes: 2 additions & 1 deletion open-sse/services/accountFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1578,7 +1578,8 @@ export function checkFallbackError(
const subResult = buildSubscriptionQuotaFallback(
errorStr,
getUpstreamRetryHintMs,
parseRetryFromErrorText
parseRetryFromErrorText,
provider
);
if (subResult) return subResult;
}
Expand Down
14 changes: 10 additions & 4 deletions open-sse/services/quotaTextCooldowns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,18 @@ export interface QuotaTextFallback {
// for the 5-hour subscription quota. Without a dedicated branch the request
// falls through to the generic 429 retry path (~5s base cooldown).

export function isSubscriptionQuotaText(lower: string): boolean {
export function isSubscriptionQuotaText(lower: string, provider?: string | null): boolean {
return (
lower.includes("usage limit reached") ||
lower.includes("usage limit has been") ||
lower.includes("claude pro usage limit") ||
lower.includes("you've reached your usage limit") ||
lower.includes("you have reached your usage limit")
lower.includes("you have reached your usage limit") ||
// 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"))
);
}

Expand All @@ -55,9 +60,10 @@ const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
export function buildSubscriptionQuotaFallback(
errorStr: string,
getUpstreamRetryHintMs: () => number | null,
parseRetryFromErrorText: (text: string) => number | null
parseRetryFromErrorText: (text: string) => number | null,
provider?: string | null
): QuotaTextFallback | null {
if (!isSubscriptionQuotaText(errorStr.toLowerCase())) return null;
if (!isSubscriptionQuotaText(errorStr.toLowerCase(), provider)) return null;
const hintMs = getUpstreamRetryHintMs();
const bodyHint = parseRetryFromErrorText(errorStr);
return {
Expand Down
5 changes: 4 additions & 1 deletion src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import { getComboFailureLogError } from "./comboFailureLogging";

// Pipeline integration — wired modules
import { classify429FromError, type FailureKind } from "@/shared/utils/classify429";
import { isSubscriptionQuotaText } from "@omniroute/open-sse/services/quotaTextCooldowns.ts";
import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints";
import { getCircuitBreaker, isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker";
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
Expand Down Expand Up @@ -1725,7 +1726,9 @@ async function handleSingleModelChat(
const errorStr = String(result.rawMessage ?? result.error ?? "");
const failureKind =
result.status === 429
? classify429FromError({ status: result.status, message: errorStr })
? isSubscriptionQuotaText(errorStr.toLowerCase(), provider)
? "quota_exhausted"
: classify429FromError({ status: result.status, message: errorStr })
: undefined;
if (result.status === 429 && isDailyQuotaExhausted(errorStr)) {
// Parse which model is quota-limited
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/account-fallback-anthropic-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import assert from "node:assert/strict";

const { classifyErrorText, parseRetryFromErrorText, checkFallbackError, getProviderProfile } =
await import("../../open-sse/services/accountFallback.ts");
const { isSubscriptionQuotaText } =
await import("../../open-sse/services/quotaTextCooldowns.ts");
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");

test("#2321 classifyErrorText flags 'Usage Limit Reached' as QUOTA_EXHAUSTED", () => {
Expand All @@ -34,6 +36,12 @@ test("#2321 classifyErrorText still returns RATE_LIMIT_EXCEEDED for generic rate
assert.equal(out, RateLimitReason.RATE_LIMIT_EXCEEDED);
});

test("Claude native account rate-limit text is subscription quota only for Claude", () => {
const text = "This request would exceed your account's rate limit. Please try again later.";
assert.equal(isSubscriptionQuotaText(text.toLowerCase(), "claude"), true);
assert.equal(isSubscriptionQuotaText(text.toLowerCase(), "antigravity"), false);
});

test("#2321 parseRetryFromErrorText extracts an ISO timestamp", () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const ms = parseRetryFromErrorText(`Usage Limit Reached. Try again at ${future}`);
Expand Down Expand Up @@ -70,6 +78,18 @@ test("#2321 checkFallbackError returns ~1h cooldown for OAuth 429 + Usage Limit
);
});

test("Claude native account rate-limit text gets subscription quota cooldown", () => {
const out = checkFallbackError(
429,
"This request would exceed your account's rate limit. Please try again later.",
0,
null,
"claude"
);
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.ok(out.cooldownMs >= 5 * 60 * 1000, `expected long cooldown, got ${out.cooldownMs}ms`);
});

test("#2321 checkFallbackError ignores ISO timestamp when upstream retry hints are disabled", () => {
const future = new Date(Date.now() + 45 * 60 * 1000).toISOString();
const out = checkFallbackError(
Expand Down
Loading