diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 84c78b91453c2..7bb39ee28af45 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5597,6 +5597,15 @@ describe("CodexAdapterV2 post-settle continuation", () => { expectedClass: "usage_limit", }, { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, + // A proxy such as CLIProxyAPI strips the snapshot, so only the error text carries the reset. + { + name: "message-reset", + code: "usageLimitExceeded", + message: + "You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Nov 11th, 2033 3:37 PM.", + notification: false, + expectedClass: "usage_limit", + }, { name: "exhausted-429", code: "responseTooManyFailedAttempts", @@ -5859,6 +5868,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(terminal.failure.class, scenario.expectedClass); assert.equal(terminal.threadDisposition, "reusable"); if (scenario.name === "deferred-reset") assert.equal(terminal.failure.resetAt, resetAt); + if (scenario.name === "message-reset") + assert.equal( + terminal.failure.resetAt, + DateTime.formatIso( + DateTime.makeZonedUnsafe( + { year: 2033, month: 11, day: 11, hour: 15, minute: 38 }, + { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true }, + ), + ), + ); if (scenario.name === "matching-details") assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); if ("resetAfter" in scenario) { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 369608a289d2e..371962c74f13f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -25,6 +25,8 @@ import { import { codexRateLimitsToUpdate, mergeCodexRateLimits, + codexMessageResetAt, + codexTimeZone, codexUsageLimitResetAt, type CodexRateLimitSnapshot, } from "../../provider/codexUsageLimits.ts"; @@ -1169,11 +1171,15 @@ function withCodexManagedFailure( function failureResetAt( failure: OrchestrationV2ProviderFailure, snapshot: CodexRateLimitSnapshot | undefined, + /** Read the failure text against when Codex reported it; omit to use the snapshot only. */ + text?: { readonly failedAt: DateTime.Utc; readonly zone: DateTime.TimeZone }, ) { // Sharing caps have no reset timestamp in the ordinary Codex allowance snapshot. - return classifyCodexManagedError(failure.code)?.usageLimit - ? null - : codexUsageLimitResetAt(snapshot); + if (classifyCodexManagedError(failure.code)?.usageLimit) return null; + return ( + codexUsageLimitResetAt(snapshot) ?? + (text === undefined ? null : codexMessageResetAt(failure.message, text.failedAt, text.zone)) + ); } interface ActiveCodexTurnContext { @@ -1754,6 +1760,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( settings: resolvedRuntime?.config ?? adapterOptions.settings, environment: resolvedRuntime?.environment ?? adapterOptions.environment, }); + const codexZone = codexTimeZone(resolvedRuntime?.environment ?? adapterOptions.environment); const additionalContextByThread = yield* Ref.make( new Map< string, @@ -5795,6 +5802,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( function* (input: { readonly context: ActiveCodexTurnContext; readonly status: OrchestrationV2ProviderTurn["status"]; + readonly completedAt: DateTime.Utc; readonly failureMessage?: string; readonly failureErrorInfo?: CodexErrorInfo; readonly providerRetry?: ActiveCodexProviderRetry; @@ -5843,7 +5851,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( failure.class === "usage_limit" ? { ...failure, - resetAt: failureResetAt(failure, yield* Ref.get(rateLimitSnapshot)), + resetAt: failureResetAt(failure, yield* Ref.get(rateLimitSnapshot), { + failedAt: input.completedAt, + zone: codexZone, + }), } : failure, ...(input.providerRetry === undefined @@ -5928,6 +5939,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly context: ActiveCodexTurnContext; readonly nativeTurnId: string; readonly status: OrchestrationV2ProviderTurn["status"]; + readonly completedAt: DateTime.Utc; readonly failureMessage?: string; readonly failureErrorInfo?: CodexErrorInfo; readonly providerRetry?: ActiveCodexProviderRetry; diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 9c863f54c0867..ece01d406554a 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -1,10 +1,13 @@ +import * as DateTime from "effect/DateTime"; import * as CodexErrors from "effect-codex-app-server/errors"; import { describe, expect, it } from "vite-plus/test"; import { + codexMessageResetAt, codexRateLimitsFailureMessage, codexRateLimitsToLimits, codexRateLimitsToUpdate, + codexTimeZone, mergeCodexRateLimits, } from "./codexUsageLimits.ts"; @@ -277,3 +280,61 @@ describe("codexUsageLimitResetAt", () => { ).toBeNull(); }); }); + +describe("codexMessageResetAt", () => { + const dhaka = DateTime.zoneMakeNamedUnsafe("Asia/Dhaka"); + const at = (iso: string) => DateTime.makeUnsafe(iso); + const failedAt = at("2026-11-07T03:00:00Z"); // 09:00 in Dhaka + + // Codex prints local time without seconds, so the parsed reset is the minute after. + it("reads Codex's same-day and dated reset text in Codex's zone", () => { + expect( + codexMessageResetAt("You’ve hit your usage limit. Try again at 3:37 PM.", failedAt, dhaka), + ).toBe("2026-11-07T09:38:00.000Z"); + expect( + codexMessageResetAt("Upgrade, or try again at Dec 1st, 2026 12:05 AM.", failedAt, dhaka), + ).toBe("2026-11-30T18:06:00.000Z"); + expect(codexMessageResetAt("Try again at 12:30 PM.", failedAt, dhaka)).toBe( + "2026-11-07T06:31:00.000Z", + ); + }); + + it("dates today's form by when Codex failed, across local midnight", () => { + expect(codexMessageResetAt("Try again at 11:59 PM.", at("2026-11-07T17:59:59Z"), dhaka)).toBe( + "2026-11-07T18:00:00.000Z", + ); + }); + + it("takes the later instant of a repeated DST hour", () => { + // 1:30 AM occurs twice in New York on Nov 1st, 2026: 05:30Z (EDT) and 06:30Z (EST). + const newYork = DateTime.zoneMakeNamedUnsafe("America/New_York"); + for (const iso of ["2026-11-01T05:15:00Z", "2026-11-01T06:15:00Z"]) { + expect(codexMessageResetAt("Try again at 1:30 AM.", at(iso), newYork)).toBe( + "2026-11-01T06:31:00.000Z", + ); + } + }); + + it("leaves the reset unknown without reset text or once it has passed", () => { + expect( + codexMessageResetAt("You’ve hit your usage limit. Try again later.", failedAt, dhaka), + ).toBeNull(); + expect( + codexMessageResetAt( + "exceeded retry limit, last status: 429 Too Many Requests", + failedAt, + dhaka, + ), + ).toBeNull(); + expect(codexMessageResetAt("Try again at 8:15 AM.", failedAt, dhaka)).toBeNull(); + }); +}); + +describe("codexTimeZone", () => { + it("uses Codex's TZ and falls back to the server's zone", () => { + const local = DateTime.zoneToString(DateTime.zoneMakeLocal()); + expect(DateTime.zoneToString(codexTimeZone({ TZ: ":Asia/Dhaka" }))).toBe("Asia/Dhaka"); + expect(DateTime.zoneToString(codexTimeZone({ TZ: "not/a-zone" }))).toBe(local); + expect(DateTime.zoneToString(codexTimeZone({}))).toBe(local); + }); +}); diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index d4f9c621119df..526852332033d 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -201,3 +201,51 @@ export function codexUsageLimitResetAt( null, ); } + +const CODEX_RESET_TEXT = + /[Tt]ry again at (?:([A-Z][a-z]{2}) (\d{1,2})(?:st|nd|rd|th), (\d{4}) )?(\d{1,2}):(\d{2}) ([AP]M)\./; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** The zone Codex prints reset times in: its own `TZ`, else the server's. */ +export function codexTimeZone( + environment: Readonly>, +): DateTime.TimeZone { + return Option.getOrElse( + DateTime.zoneMakeNamed(environment.TZ?.replace(/^:/, "") ?? ""), + DateTime.zoneMakeLocal, + ); +} + +/** + * Codex writes a usage limit's reset into its error text in its own local time: + * "Try again at 3:37 PM." for today, else "try again at Nov 11th, 2026 3:37 PM.". + * It is the only reset left when a proxy such as CLIProxyAPI strips the rate-limit snapshot. + * `failedAt` is when Codex reported the failure, which fixes the date of today's form. + */ +export function codexMessageResetAt( + message: string, + failedAt: DateTime.DateTime, + zone: DateTime.TimeZone, +): string | null { + const match = CODEX_RESET_TEXT.exec(message); + if (!match) return null; + const [, month, day, year, hour, minute, meridiem] = match; + const monthIndex = month === undefined ? undefined : MONTHS.indexOf(month); + if (monthIndex === -1) return null; + const today = DateTime.toParts(DateTime.setZone(failedAt, zone)); + const reset = DateTime.makeZoned( + { + year: monthIndex === undefined ? today.year : Number(year), + month: monthIndex === undefined ? today.month : monthIndex + 1, + day: monthIndex === undefined ? today.day : Number(day), + hour: (Number(hour) % 12) + (meridiem === "PM" ? 12 : 0), + minute: Number(minute), + }, + // The repeated hour at a DST fall-back takes its later instant so a resume never fires early. + { timeZone: zone, adjustForTimeZone: true, disambiguation: "later" }, + ); + if (Option.isNone(reset)) return null; + // Codex drops the seconds, so the next minute is the first one past the reset. + const resetAt = DateTime.add(reset.value, { minutes: 1 }); + return DateTime.isGreaterThan(resetAt, failedAt) ? DateTime.formatIso(resetAt) : null; +}