| 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 | */ |
| 9 | import type { |
| 10 | ProviderUsageLimitsUpdate, |
| 11 | ServerProviderResetCredits, |
| 12 | ServerProviderUsageLimits, |
| 13 | ServerProviderUsageWindow, |
| 14 | } from "@t3tools/contracts"; |
| 15 | import * as DateTime from "effect/DateTime"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import type * as CodexErrors from "effect-codex-app-server/errors"; |
| 18 | |
| 19 | import { clampPercent, makeUsageLimits } from "@t3tools/provider-core/server/usageLimits"; |
| 20 | |
| 21 | interface 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. */ |
| 28 | export 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`. */ |
| 37 | export interface CodexResetCreditsSummary { |
| 38 | readonly availableCount: number; |
| 39 | readonly credits?: ReadonlyArray<{ |
| 40 | readonly status: string; |
| 41 | readonly expiresAt?: number | null; |
| 42 | }> | null; |
| 43 | } |
| 44 | |
| 45 | const SESSION_MINS = 5 * 60; |
| 46 | const WEEK_MINS = 7 * 24 * 60; |
| 47 | const MONTH_MINS = 30 * 24 * 60; |
| 48 | |
| 49 | function 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 | |
| 55 | function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] { |
| 56 | if (mins >= MONTH_MINS) return "monthly"; |
| 57 | if (mins >= WEEK_MINS) return "weekly"; |
| 58 | return "session"; |
| 59 | } |
| 60 | |
| 61 | function 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 | */ |
| 70 | function 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 | |
| 100 | export 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 | |
| 116 | export 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 | |
| 137 | export 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 | */ |
| 149 | export 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 | */ |
| 168 | export 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`. */ |
| 189 | function 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 | |
| 199 | function 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 | */ |
| 218 | export 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. */ |
| 237 | export 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 | } |