1/**
2 * Codex subscription usage. The `account/rateLimits/read` response and the
3 * `account/rateLimits/updated` notification carry the same snapshot shape, so
4 * one mapper serves the status probe and the turn-driven update; both emit
5 * windows with the same ids so they merge onto the same rows.
6 *
7 * @module provider/codexUsageLimits
8 */
9import type {
10 ProviderUsageLimitsUpdate,
11 ServerProviderResetCredits,
12 ServerProviderUsageLimits,
13 ServerProviderUsageWindow,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16import * as Option from "effect/Option";
17import type * as CodexErrors from "effect-codex-app-server/errors";
18
19import { clampPercent, makeUsageLimits } from "@t3tools/provider-core/server/usageLimits";
20
21interface CodexRateLimitWindow {
22 readonly usedPercent: number;
23 readonly resetsAt?: number | null;
24 readonly windowDurationMins?: number | null;
25}
26
27/** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */
28export interface CodexRateLimitSnapshot {
29 readonly limitId?: string | null;
30 readonly planType?: string | null;
31 readonly rateLimitReachedType?: string | null;
32 readonly primary?: CodexRateLimitWindow | null;
33 readonly secondary?: CodexRateLimitWindow | null;
34}
35
36/** Structural view of the read response's `rateLimitResetCredits`. */
37export interface CodexResetCreditsSummary {
38 readonly availableCount: number;
39 readonly credits?: ReadonlyArray<{
40 readonly status: string;
41 readonly expiresAt?: number | null;
42 }> | null;
43}
44
45const SESSION_MINS = 5 * 60;
46const WEEK_MINS = 7 * 24 * 60;
47const MONTH_MINS = 30 * 24 * 60;
48
49function isoFromEpochSeconds(value: number | null | undefined): string | undefined {
50 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
51 const dt = DateTime.make(value * 1000);
52 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
53}
54
55function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] {
56 if (mins >= MONTH_MINS) return "monthly";
57 if (mins >= WEEK_MINS) return "weekly";
58 return "session";
59}
60
61function labelForKind(kind: ServerProviderUsageWindow["kind"]): string {
62 return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly";
63}
64
65/**
66 * `primary` / `secondary` are positions, not durations. Codex usually sends
67 * `windowDurationMins`; when it does not, paid plans expose the 5-hour and
68 * weekly pair and Free/Go expose one monthly allowance.
69 */
70function codexRateLimitsToWindows(
71 snapshot: CodexRateLimitSnapshot,
72): ReadonlyArray<ServerProviderUsageWindow> {
73 // Show the main allowance only. Model-specific notifications (such as Spark)
74 // must not replace its primary/secondary rows. Older CLIs omit the limit id.
75 if (snapshot.limitId && snapshot.limitId !== "codex") return [];
76 const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go";
77 const positions = [
78 ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS],
79 ["secondary", snapshot.secondary, WEEK_MINS],
80 ] as const;
81 const windows: ServerProviderUsageWindow[] = [];
82 for (const [id, window, fallbackMins] of positions) {
83 if (!window || !Number.isFinite(window.usedPercent)) continue;
84 const windowDurationMins =
85 typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins;
86 const kind = kindForDuration(windowDurationMins);
87 const resetsAt = isoFromEpochSeconds(window.resetsAt);
88 windows.push({
89 id,
90 kind,
91 label: labelForKind(kind),
92 usedPercent: clampPercent(window.usedPercent),
93 windowDurationMins,
94 ...(resetsAt ? { resetsAt } : {}),
95 });
96 }
97 return windows;
98}
99
100export function codexResetCreditsToContract(
101 summary: CodexResetCreditsSummary | null | undefined,
102): ServerProviderResetCredits | undefined {
103 if (!summary) return undefined;
104 const expiries = (summary.credits ?? [])
105 .filter((credit) => credit.status === "available")
106 .map((credit) => credit.expiresAt)
107 .filter((value): value is number => typeof value === "number");
108 const nextExpiresAt =
109 expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined;
110 return {
111 availableCount: Math.max(0, summary.availableCount),
112 ...(nextExpiresAt ? { nextExpiresAt } : {}),
113 };
114}
115
116export function codexRateLimitsToLimits(input: {
117 readonly snapshot: CodexRateLimitSnapshot;
118 readonly rateLimitsByLimitId?:
119 | Readonly<Record<string, CodexRateLimitSnapshot>>
120 | null
121 | undefined;
122 readonly resetCredits?: CodexResetCreditsSummary | null | undefined;
123 readonly checkedAt: string;
124}): ServerProviderUsageLimits {
125 const resetCredits = codexResetCreditsToContract(input.resetCredits);
126 // Select the main bucket explicitly; the legacy snapshot can name another limit.
127 const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot);
128 return {
129 ...makeUsageLimits({
130 checkedAt: input.checkedAt,
131 windows,
132 }),
133 ...(resetCredits ? { resetCredits } : {}),
134 };
135}
136
137export function codexRateLimitsToUpdate(
138 snapshot: CodexRateLimitSnapshot,
139): ProviderUsageLimitsUpdate | undefined {
140 const windows = codexRateLimitsToWindows(snapshot);
141 return windows.length > 0 ? { windows } : undefined;
142}
143
144/**
145 * A bounded, client-safe reason for a failed `account/rateLimits/read`. The
146 * raw error is for the log; only the category and, for a JSON-RPC failure,
147 * the code reach the Limits view.
148 */
149export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string {
150 switch (error._tag) {
151 case "CodexAppServerRequestError":
152 return `Codex could not read usage (JSON-RPC ${error.code}).`;
153 case "CodexAppServerSpawnError":
154 return "Codex could not be started to read usage.";
155 case "CodexAppServerProcessExitedError":
156 return "Codex exited before it could report usage.";
157 default:
158 return "Codex did not answer the usage request.";
159 }
160}
161
162/**
163 * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a
164 * field the update omits keeps the value observed earlier in the session, so a
165 * later notification that only names the limit it reached must not drop the
166 * windows an earlier one carried.
167 */
168export function mergeCodexRateLimits(
169 previous: CodexRateLimitSnapshot | undefined,
170 update: CodexRateLimitSnapshot,
171): CodexRateLimitSnapshot | undefined {
172 // Model-specific snapshots (such as Spark) describe a different allowance
173 // and must not overwrite the main one, the same rule the usage rows apply.
174 if (update.limitId && update.limitId !== "codex") return previous;
175 if (!previous) return update;
176 return {
177 ...previous,
178 ...(update.limitId !== undefined ? { limitId: update.limitId } : {}),
179 ...(update.planType !== undefined ? { planType: update.planType } : {}),
180 ...(update.rateLimitReachedType !== undefined
181 ? { rateLimitReachedType: update.rateLimitReachedType }
182 : {}),
183 ...(update.primary !== undefined ? { primary: update.primary } : {}),
184 ...(update.secondary !== undefined ? { secondary: update.secondary } : {}),
185 };
186}
187
188/** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */
189function formatCodexUsageLimitWait(waitMs: number): string {
190 const totalMinutes = Math.ceil(waitMs / 60_000);
191 const days = Math.floor(totalMinutes / (24 * 60));
192 const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
193 const minutes = totalMinutes % 60;
194 if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`;
195 if (hours === 0) return `${totalMinutes}m`;
196 return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
197}
198
199function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string {
200 switch (rateLimitReachedType) {
201 case "workspace_owner_credits_depleted":
202 case "workspace_member_credits_depleted":
203 return " The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.";
204 case "workspace_owner_usage_limit_reached":
205 case "workspace_member_usage_limit_reached":
206 return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.";
207 default:
208 return " Send the message again once the limit resets.";
209 }
210}
211
212/**
213 * The message a usage-limit stop shows instead of the provider sentence, which
214 * on a Business workspace blames credits for a window that simply ran out. The
215 * window named is the exhausted one that has yet to reset, latest first; `atIso`
216 * is the stopping event's timestamp, not the wall clock.
217 */
218export function codexUsageLimitMessage(
219 snapshot: CodexRateLimitSnapshot | undefined,
220 atIso: string,
221): string {
222 const atMs = Date.parse(atIso);
223 const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : [];
224 let reset = "";
225 let latestResetMs = Number.NEGATIVE_INFINITY;
226 for (const window of windows) {
227 if (window.usedPercent < 100 || !window.resetsAt) continue;
228 const resetMs = Date.parse(window.resetsAt);
229 if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue;
230 latestResetMs = resetMs;
231 reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`;
232 }
233 return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`;
234}
235
236/** All exhausted windows must reset before a continuation can run. */
237export function codexUsageLimitResetAt(
238 snapshot: CodexRateLimitSnapshot | undefined,
239): string | null {
240 if (!snapshot) return null;
241 const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100);
242 if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null;
243 return windows.reduce<string | null>(
244 (latest, window) =>
245 latest === null || Date.parse(window.resetsAt!) > Date.parse(latest)
246 ? window.resetsAt!
247 : latest,
248 null,
249 );
250}