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
116 changes: 109 additions & 7 deletions open-sse/services/quotaResetParsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,121 @@ export function shouldPreserveQuotaSignals(
}

/**
* Parse a day-granularity quota reset countdown ("Your quota will reset in
* 3 days.", "Resets in 13 days") out of an upstream 429 body.
* Parse a day-granularity quota reset countdown (\"Your quota will reset in
* 3 days.\", \"Resets in 13 days\") out of an upstream 429 body.
*
* Companion to the Xh/Ym/Zs countdown parsing already handled inline by
* `parseRetryFromErrorText` — none of those patterns match when the upstream
* expresses the reset window in whole days rather than hours/minutes/seconds,
* so a multi-day quota reset previously parsed to `null` and fell back to the
* engine's ~seconds-scale default cooldown.
*
* Delegates to `parseIsoDateTimeResetMs` (absolute \"reset at YYYY-MM-DD HH:MM:SS\")
* and then `parseMonthDayResetMs` (year-less \"reset at MM-DD HH:MM:SS UTC\") so
* every absolute-reset shape an upstream uses resolves to the real wait.
*/
export function parseDayGranularityResetMs(msg: string, maxMs: number): number | null {
export function parseDayGranularityResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const dayMatch = /reset(?:s)?\s+in\s+(\d+)\s*day(?:s)?/i.exec(msg);
if (!dayMatch) return null;
const days = Number.parseInt(dayMatch[1], 10);
if (!Number.isFinite(days) || days <= 0) return null;
return Math.min(days * 24 * 3600 * 1000, maxMs);
if (dayMatch) {
const days = Number.parseInt(dayMatch[1], 10);
if (Number.isFinite(days) && days > 0) {
return Math.min(days * 24 * 3600 * 1000, maxMs);
}
}
const isoMs = parseIsoDateTimeResetMs(msg, maxMs, nowMs);
if (isoMs !== null) return isoMs;
return parseMonthDayResetMs(msg, maxMs, nowMs);
}

/**
* Z.AI (GLM) reports an exhausted weekly/monthly cap with a FULL absolute
* datetime rather than a countdown:
*
* \"[1310][Weekly/Monthly Limit Exhausted. … Your limit will reset at
* 2026-08-29 21:01:21]\"
*
* `parseRetryFromErrorText` (accountFallback.ts) has an equivalent ISO matcher,
* but `buildWeeklyQuotaFallback` never reaches it: it calls
* `parseDayGranularityResetMs` directly, and neither the \"reset in N days\" nor
* the year-less MM-DD parser matched this shape. The weekly fallback therefore
* fell back to WEEKLY_QUOTA_COOLDOWN_MS (24h) and the connection was dispatched
* again — into a real upstream 429 — every day until the true reset ~6 days out.
*
* The datetime may use a `T` or a space separator, and may carry `Z` or a
* `±HH:MM` offset. A NAIVE datetime (no zone) is interpreted as UTC: Z.AI
* reports in UTC, and treating it as local time would shift the cooldown by the
* host offset. Returns null when the instant is not in the future.
*/
export function parseIsoDateTimeResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const match =
/\b(?:try again at|wait until|reset(?:s)?\s+at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)\s*(Z|[+-]\d{2}:?\d{2})?/i.exec(
msg
);
if (!match) return null;
const stamp = match[1].replace(/[Tt ]/, "T");
// No zone in the body → UTC (see doc comment). Normalize \"+0200\" to \"+02:00\":
// the bare-offset form is not part of the ES Date.parse grammar.
const rawZone = match[2] ? match[2].toUpperCase() : "Z";
const zone = /^[+-]\d{4}$/.test(rawZone)
? `${rawZone.slice(0, 3)}:${rawZone.slice(3)}`
: rawZone;
const resetMs = Date.parse(`${stamp}${zone}`);
if (!Number.isFinite(resetMs)) return null;
const waitMs = resetMs - nowMs;
if (waitMs <= 0) return null;
return Math.min(waitMs, maxMs);
}

/**
* Qwen token-plan (and similar apikey providers) report the weekly reset as
* \"The quota will reset at 08-29 15:29:00 UTC\" without a year. Treat that as
* the next occurrence of MM-DD HH:MM[:SS] UTC; if the date already passed this
* year, roll to next year. Returns null when the parsed instant is not in the
* future or the wait would exceed maxMs.
*/
export function parseMonthDayResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const match =
/reset(?:s)?\s+at\s+(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s*(?:UTC|Z)?/i.exec(
msg
);
if (!match) return null;
const month = Number.parseInt(match[1], 10);
const day = Number.parseInt(match[2], 10);
const hour = Number.parseInt(match[3], 10);
const minute = Number.parseInt(match[4], 10);
const second = match[5] ? Number.parseInt(match[5], 10) : 0;
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hour > 23 ||
minute > 59 ||
second > 59
) {
return null;
}
const now = new Date(nowMs);
let year = now.getUTCFullYear();
let resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
if (!Number.isFinite(resetMs)) return null;
if (resetMs <= nowMs) {
year += 1;
resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
}
const waitMs = resetMs - nowMs;
if (!Number.isFinite(waitMs) || waitMs <= 0) return null;
return Math.min(waitMs, maxMs);
}
18 changes: 16 additions & 2 deletions open-sse/services/quotaTextCooldowns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import { RateLimitReason } from "../config/constants.ts";
import { parseDayGranularityResetMs } from "./quotaResetParsing.ts";

type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason];

Expand Down Expand Up @@ -97,16 +98,29 @@ export function isWeeklyUsageLimitText(lower: string): boolean {
return (
lower.includes("weekly usage limit") ||
lower.includes("weekly limit reached") ||
lower.includes("reached your weekly")
lower.includes("reached your weekly") ||
lower.includes("1-week quota") ||
lower.includes("week quota") ||
lower.includes("weekly/monthly limit") ||
(lower.includes("weekly") && lower.includes("quota") && lower.includes("exhaust"))
);
}

const MAX_WEEKLY_QUOTA_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;

export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback | null {
if (!isWeeklyUsageLimitText(errorStr.toLowerCase())) return null;
const parsedResetMs = parseDayGranularityResetMs(errorStr, MAX_WEEKLY_QUOTA_COOLDOWN_MS);
const cooldownMs =
typeof parsedResetMs === "number" && parsedResetMs > 0
? parsedResetMs
: WEEKLY_QUOTA_COOLDOWN_MS;
return {
shouldFallback: true,
cooldownMs: WEEKLY_QUOTA_COOLDOWN_MS,
cooldownMs,
reason: RateLimitReason.QUOTA_EXHAUSTED,
usedUpstreamRetryHint: typeof parsedResetMs === "number" && parsedResetMs > 0,
quotaResetHintMs: typeof parsedResetMs === "number" && parsedResetMs > 0 ? parsedResetMs : undefined,
};
}

Expand Down
135 changes: 135 additions & 0 deletions tests/unit/repro-glm-iso-reset-24h-cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Regression: Z.AI (GLM) weekly quota was capped at a 24h cooldown instead of
* the real ~6-day reset the upstream reported.
*
* Body from production (connection zai/glm-5.3):
* "[1310][Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-08-29 21:01:21]"
*
* looksLikeQuotaExhausted() and isWeeklyUsageLimitText() both matched, so the
* weekly branch was taken — but buildWeeklyQuotaFallback() calls
* parseDayGranularityResetMs() FIRST and that only knew "reset in N days" and
* the year-less "reset at MM-DD HH:MM:SS UTC" shape (#qwen). A full ISO
* datetime parsed to null, so the weekly fallback used its
* WEEKLY_QUOTA_COOLDOWN_MS default of 24h. The ISO matcher that DOES handle
* this shape lives in parseRetryFromErrorText() and is never reached from the
* weekly branch.
*
* Result: rate_limited_until was written 24h out instead of the true reset,
* and the connection was dispatched into a real upstream 429 every day for
* the rest of the week.
*/

import { describe, it } from "node:test";
import assert from "node:assert/strict";

import { looksLikeQuotaExhausted } from "../../src/shared/utils/classify429.ts";
import {
isWeeklyUsageLimitText,
buildWeeklyQuotaFallback,
} from "../../open-sse/services/quotaTextCooldowns.ts";
import {
parseDayGranularityResetMs,
parseIsoDateTimeResetMs,
parseMonthDayResetMs,
shouldPreserveQuotaSignals,
} from "../../open-sse/services/quotaResetParsing.ts";
import { RateLimitReason } from "../../open-sse/config/constants.ts";

const GLM_BODY =
"[1310][Weekly/Monthly Limit Exhausted. Your current plan has run out of its weekly/monthly quota. " +
"Your limit will reset at 2026-08-29 21:01:21]";
const MAX_MS = 30 * 24 * 60 * 60 * 1000; // MAX_WEEKLY_QUOTA_COOLDOWN_MS
const DAY_MS = 24 * 60 * 60 * 1000;
const NOW = Date.UTC(2026, 7, 23, 20, 30, 56); // 2026-08-23 20:30:56 UTC
const RESET = Date.UTC(2026, 7, 29, 21, 1, 21); // 2026-08-29 21:01:21 UTC

describe("Z.AI GLM weekly quota — absolute ISO reset", () => {
it("looksLikeQuotaExhausted matches the [1310] weekly/monthly body", () => {
assert.equal(looksLikeQuotaExhausted(GLM_BODY), true);
});

it("shouldPreserveQuotaSignals is true for zai with this body", () => {
assert.equal(shouldPreserveQuotaSignals("zai", GLM_BODY), true);
});

it("isWeeklyUsageLimitText matches weekly/monthly limit wording", () => {
assert.equal(isWeeklyUsageLimitText(GLM_BODY.toLowerCase()), true);
});

it("parseIsoDateTimeResetMs reads a space-separated naive datetime as UTC", () => {
assert.equal(parseIsoDateTimeResetMs(GLM_BODY, MAX_MS, NOW), RESET - NOW);
});

it("parseIsoDateTimeResetMs accepts the T separator and an explicit Z", () => {
assert.equal(
parseIsoDateTimeResetMs("reset at 2026-08-29T21:01:21Z", MAX_MS, NOW),
RESET - NOW
);
});

it("parseIsoDateTimeResetMs honours an explicit UTC offset", () => {
// 23:01:21+02:00 is the same instant as 21:01:21Z.
assert.equal(
parseIsoDateTimeResetMs("reset at 2026-08-29 23:01:21+02:00", MAX_MS, NOW),
RESET - NOW
);
assert.equal(
parseIsoDateTimeResetMs("reset at 2026-08-29 23:01:21+0200", MAX_MS, NOW),
RESET - NOW
);
});

it("parseIsoDateTimeResetMs returns null for a past reset and caps at maxMs", () => {
assert.equal(parseIsoDateTimeResetMs("reset at 2026-08-22 10:00:00", MAX_MS, NOW), null);
assert.equal(parseIsoDateTimeResetMs("reset at 2027-08-29 21:01:21", MAX_MS, NOW), MAX_MS);
});

it("parseDayGranularityResetMs returns the real reset, not the 24h cap", () => {
const waitMs = parseDayGranularityResetMs(GLM_BODY, MAX_MS, NOW);
assert.equal(waitMs, RESET - NOW);
assert.ok(waitMs! > DAY_MS, `expected more than 24h, got ${waitMs}`);
});

it("keeps the Qwen year-less MM-DD parser working", () => {
const qwenBody =
"Your token-plan 1-week quota has been exhausted. The quota will reset at 08-29 15:29:00 UTC.";
const expected = Date.UTC(2026, 7, 29, 15, 29, 0) - NOW;
assert.equal(parseMonthDayResetMs(qwenBody, MAX_MS, NOW), expected);
assert.equal(parseDayGranularityResetMs(qwenBody, MAX_MS, NOW), expected);
});

it("keeps the 'reset in N days' parser winning over the ISO branch", () => {
assert.equal(parseDayGranularityResetMs("quota will reset in 3 days", MAX_MS, NOW), 3 * DAY_MS);
});

it("buildWeeklyQuotaFallback uses the parsed ISO reset, not the 24h default", () => {
const result = buildWeeklyQuotaFallback(GLM_BODY);
assert.ok(result);
assert.equal(result!.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.equal(result!.usedUpstreamRetryHint, true);
assert.ok(
result!.cooldownMs > 5 * DAY_MS,
`expected a multi-day cooldown, got ${result!.cooldownMs}`
);
assert.ok(result!.cooldownMs <= MAX_MS);
assert.ok(result!.cooldownMs !== DAY_MS, "must not fall back to WEEKLY_QUOTA_COOLDOWN_MS (24h)");
});

it("checkFallbackError classifies the GLM 429 as QUOTA_EXHAUSTED with the real wait", async () => {
const { checkFallbackError, parseRetryFromErrorText } = await import(
"../../open-sse/services/accountFallback.ts"
);

const parsed = parseRetryFromErrorText(GLM_BODY);
assert.ok(parsed && parsed > 5 * DAY_MS, `parsed reset was ${parsed}`);

const out = checkFallbackError(429, GLM_BODY, 0, "glm-5.3", "zai", null, null, null);
assert.equal(out.shouldFallback, true);
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.ok(
(out.cooldownMs ?? 0) > 5 * DAY_MS,
`expected a multi-day cooldown, got ${out.cooldownMs}`
);
assert.ok((out.cooldownMs ?? 0) !== DAY_MS, "must not land on the 24h weekly default");
});
});