Open History to see the last commit
1import type {
2 ProviderUsageLimitsUpdate,
3 ServerProviderResetCredits,
4 ServerProviderUsageLimits,
5 ServerProviderUsageWindow,
6} from "@t3tools/contracts";
7import * as DateTime from "effect/DateTime";
8import * as Option from "effect/Option";
9import type * as CodexErrors from "effect-codex-app-server/errors";
10
11import { clampPercent, makeUsageLimits } from "@t3tools/provider-core/server/usageLimits";
12
13interface CodexRateLimitWindow {
14 readonly usedPercent: number;
15 readonly resetsAt?: number | null;
16 readonly windowDurationMins?: number | null;
17}
18
19export interface CodexRateLimitSnapshot {
20 readonly limitId?: string | null;
21 readonly planType?: string | null;
22 readonly rateLimitReachedType?: string | null;
23 readonly primary?: CodexRateLimitWindow | null;
24 readonly secondary?: CodexRateLimitWindow | null;
25 readonly individualLimit?: {
26 readonly remainingPercent: number;
27 readonly resetsAt: number;
28 } | null;
29}
30
31export interface CodexResetCreditsSummary {
32 readonly availableCount: number;
33 readonly credits?: ReadonlyArray<{
34 readonly status: string;
35 readonly expiresAt?: number | null;
36 }> | null;
37}
38
39const SESSION_MINS = 5 * 60;
40const WEEK_MINS = 7 * 24 * 60;
41const MONTH_MINS = 30 * 24 * 60;
42
43function isoFromEpochSeconds(value: number | null | undefined): string | undefined {
44 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
45 const dt = DateTime.make(value * 1000);
46 return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;
47}
48
49function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] {
50 if (mins >= MONTH_MINS) return "monthly";
51 if (mins >= WEEK_MINS) return "weekly";
52 return "session";
53}
54
55function labelForKind(kind: ServerProviderUsageWindow["kind"]): string {
56 return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly";
57}
58
59/** primary/secondary are positions; without windowDurationMins, paid plans use 5-hour/weekly windows and Free/Go use monthly. */
60function codexRateLimitsToWindows(
61 snapshot: CodexRateLimitSnapshot,
62): ReadonlyArray<ServerProviderUsageWindow> {
63 // Keep model-specific limits out of main allowance rows; older CLIs omit limit IDs.
64 if (snapshot.limitId && snapshot.limitId !== "codex") return [];
65 const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go";
66 const positions = [
67 ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS],
68 ["secondary", snapshot.secondary, WEEK_MINS],
69 ] as const;
70 const windows: ServerProviderUsageWindow[] = [];
71 for (const [id, window, fallbackMins] of positions) {
72 if (!window || !Number.isFinite(window.usedPercent)) continue;
73 const windowDurationMins =
74 typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins;
75 const kind = kindForDuration(windowDurationMins);
76 const resetsAt = isoFromEpochSeconds(window.resetsAt);
77 windows.push({
78 id,
79 kind,
80 label: labelForKind(kind),
81 usedPercent: clampPercent(window.usedPercent),
82 windowDurationMins,
83 ...(resetsAt ? { resetsAt } : {}),
84 });
85 }
86 const individualLimit = snapshot.individualLimit;
87 if (individualLimit && Number.isFinite(individualLimit.remainingPercent)) {
88 const resetsAt = isoFromEpochSeconds(individualLimit.resetsAt);
89 windows.push({
90 id: "individual",
91 kind: "monthly",
92 label: "Monthly credit limit",
93 usedPercent: clampPercent(100 - individualLimit.remainingPercent),
94 ...(resetsAt ? { resetsAt } : {}),
95 });
96 }
97 return windows;
98}
99
100function 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 const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot);
127 return {
128 ...makeUsageLimits({
129 checkedAt: input.checkedAt,
130 windows,
131 }),
132 ...(resetCredits ? { resetCredits } : {}),
133 };
134}
135
136export function codexRateLimitsToUpdate(
137 snapshot: CodexRateLimitSnapshot,
138 previous?: CodexRateLimitSnapshot,
139): ProviderUsageLimitsUpdate | undefined {
140 const previousWindows = previous ? codexRateLimitsToWindows(previous) : [];
141 // Token ticks repeat session-cached limits that may predate an account probe or reset.
142 const windows = codexRateLimitsToWindows(snapshot).filter((window) => {
143 const existing = previousWindows.find((candidate) => candidate.id === window.id);
144 return (
145 !existing ||
146 existing.kind !== window.kind ||
147 existing.usedPercent !== window.usedPercent ||
148 existing.resetsAt !== window.resetsAt ||
149 existing.windowDurationMins !== window.windowDurationMins
150 );
151 });
152 return windows.length > 0 ? { windows } : undefined;
153}
154
155export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string {
156 switch (error._tag) {
157 case "CodexAppServerRequestError":
158 return `Codex could not read usage (JSON-RPC ${error.code}).`;
159 case "CodexAppServerSpawnError":
160 return "Codex could not be started to read usage.";
161 case "CodexAppServerProcessExitedError":
162 return "Codex exited before it could report usage.";
163 default:
164 return "Codex did not answer the usage request.";
165 }
166}
167
168/** Rate-limit notifications are partial: omitted fields preserve earlier session values. */
169export function mergeCodexRateLimits(
170 previous: CodexRateLimitSnapshot | undefined,
171 update: CodexRateLimitSnapshot,
172): CodexRateLimitSnapshot | undefined {
173 // Model-specific snapshots must not overwrite the main allowance.
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 ...(update.individualLimit !== undefined ? { individualLimit: update.individualLimit } : {}),
186 };
187}
188
189/** All exhausted windows must reset before a continuation can run. */
190export function codexUsageLimitResetAt(
191 snapshot: CodexRateLimitSnapshot | undefined,
192): string | null {
193 if (!snapshot) return null;
194 const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100);
195 if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null;
196 return windows.reduce<string | null>(
197 (latest, window) =>
198 latest === null || Date.parse(window.resetsAt!) > Date.parse(latest)
199 ? window.resetsAt!
200 : latest,
201 null,
202 );
203}