Skip to content
Open
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
19 changes: 19 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 16 additions & 4 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
import {
codexRateLimitsToUpdate,
mergeCodexRateLimits,
codexMessageResetAt,
codexTimeZone,
codexUsageLimitResetAt,
type CodexRateLimitSnapshot,
} from "../../provider/codexUsageLimits.ts";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions apps/server/src/provider/codexUsageLimits.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
48 changes: 48 additions & 0 deletions apps/server/src/provider/codexUsageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | undefined>>,
): 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;
}
Loading