234 lines · 9.3 KB · TypeScript
RawDownload
/** * Codex subscription usage. The `account/rateLimits/read` response and the * `account/rateLimits/updated` notification carry the same snapshot shape, so * one mapper serves the status probe and the turn-driven update; both emit * windows with the same ids so they merge onto the same rows. * * @module provider/Layers/codexUsageLimits */import type {  ProviderUsageLimitsUpdate,  ServerProviderResetCredits,  ServerProviderUsageLimits,  ServerProviderUsageWindow,} from "@t3tools/contracts";import * as DateTime from "effect/DateTime";import * as Option from "effect/Option";import type * as CodexErrors from "effect-codex-app-server/errors"; import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts"; interface CodexRateLimitWindow {  readonly usedPercent: number;  readonly resetsAt?: number | null;  readonly windowDurationMins?: number | null;} /** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */export interface CodexRateLimitSnapshot {  readonly limitId?: string | null;  readonly planType?: string | null;  readonly rateLimitReachedType?: string | null;  readonly primary?: CodexRateLimitWindow | null;  readonly secondary?: CodexRateLimitWindow | null;} /** Structural view of the read response's `rateLimitResetCredits`. */export interface CodexResetCreditsSummary {  readonly availableCount: number;  readonly credits?: ReadonlyArray<{    readonly status: string;    readonly expiresAt?: number | null;  }> | null;} const SESSION_MINS = 5 * 60;const WEEK_MINS = 7 * 24 * 60;const MONTH_MINS = 30 * 24 * 60; function isoFromEpochSeconds(value: number | null | undefined): string | undefined {  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;  const dt = DateTime.make(value * 1000);  return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined;} function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] {  if (mins >= MONTH_MINS) return "monthly";  if (mins >= WEEK_MINS) return "weekly";  return "session";} function labelForKind(kind: ServerProviderUsageWindow["kind"]): string {  return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly";} /** * `primary` / `secondary` are positions, not durations. Codex usually sends * `windowDurationMins`; when it does not, paid plans expose the 5-hour and * weekly pair and Free/Go expose one monthly allowance. */function codexRateLimitsToWindows(  snapshot: CodexRateLimitSnapshot,): ReadonlyArray<ServerProviderUsageWindow> {  // Show the main allowance only. Model-specific notifications (such as Spark)  // must not replace its primary/secondary rows. Older CLIs omit the limit id.  if (snapshot.limitId && snapshot.limitId !== "codex") return [];  const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go";  const positions = [    ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS],    ["secondary", snapshot.secondary, WEEK_MINS],  ] as const;  const windows: ServerProviderUsageWindow[] = [];  for (const [id, window, fallbackMins] of positions) {    if (!window || !Number.isFinite(window.usedPercent)) continue;    const windowDurationMins =      typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins;    const kind = kindForDuration(windowDurationMins);    const resetsAt = isoFromEpochSeconds(window.resetsAt);    windows.push({      id,      kind,      label: labelForKind(kind),      usedPercent: clampPercent(window.usedPercent),      windowDurationMins,      ...(resetsAt ? { resetsAt } : {}),    });  }  return windows;} export function codexResetCreditsToContract(  summary: CodexResetCreditsSummary | null | undefined,): ServerProviderResetCredits | undefined {  if (!summary) return undefined;  const expiries = (summary.credits ?? [])    .filter((credit) => credit.status === "available")    .map((credit) => credit.expiresAt)    .filter((value): value is number => typeof value === "number");  const nextExpiresAt =    expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined;  return {    availableCount: Math.max(0, summary.availableCount),    ...(nextExpiresAt ? { nextExpiresAt } : {}),  };} export function codexRateLimitsToLimits(input: {  readonly snapshot: CodexRateLimitSnapshot;  readonly rateLimitsByLimitId?:    | Readonly<Record<string, CodexRateLimitSnapshot>>    | null    | undefined;  readonly resetCredits?: CodexResetCreditsSummary | null | undefined;  readonly checkedAt: string;}): ServerProviderUsageLimits {  const resetCredits = codexResetCreditsToContract(input.resetCredits);  // Select the main bucket explicitly; the legacy snapshot can name another limit.  const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot);  return {    ...makeUsageLimits({      checkedAt: input.checkedAt,      windows,    }),    ...(resetCredits ? { resetCredits } : {}),  };} export function codexRateLimitsToUpdate(  snapshot: CodexRateLimitSnapshot,): ProviderUsageLimitsUpdate | undefined {  const windows = codexRateLimitsToWindows(snapshot);  return windows.length > 0 ? { windows } : undefined;} /** * A bounded, client-safe reason for a failed `account/rateLimits/read`. The * raw error is for the log; only the category and, for a JSON-RPC failure, * the code reach the Limits view. */export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string {  switch (error._tag) {    case "CodexAppServerRequestError":      return `Codex could not read usage (JSON-RPC ${error.code}).`;    case "CodexAppServerSpawnError":      return "Codex could not be started to read usage.";    case "CodexAppServerProcessExitedError":      return "Codex exited before it could report usage.";    default:      return "Codex did not answer the usage request.";  }} /** * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a * field the update omits keeps the value observed earlier in the session, so a * later notification that only names the limit it reached must not drop the * windows an earlier one carried. */export function mergeCodexRateLimits(  previous: CodexRateLimitSnapshot | undefined,  update: CodexRateLimitSnapshot,): CodexRateLimitSnapshot | undefined {  // Model-specific snapshots (such as Spark) describe a different allowance  // and must not overwrite the main one, the same rule the usage rows apply.  if (update.limitId && update.limitId !== "codex") return previous;  if (!previous) return update;  return {    ...previous,    ...(update.limitId !== undefined ? { limitId: update.limitId } : {}),    ...(update.planType !== undefined ? { planType: update.planType } : {}),    ...(update.rateLimitReachedType !== undefined      ? { rateLimitReachedType: update.rateLimitReachedType }      : {}),    ...(update.primary !== undefined ? { primary: update.primary } : {}),    ...(update.secondary !== undefined ? { secondary: update.secondary } : {}),  };} /** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */function formatCodexUsageLimitWait(waitMs: number): string {  const totalMinutes = Math.ceil(waitMs / 60_000);  const days = Math.floor(totalMinutes / (24 * 60));  const hours = Math.floor((totalMinutes % (24 * 60)) / 60);  const minutes = totalMinutes % 60;  if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`;  if (hours === 0) return `${totalMinutes}m`;  return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;} function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string {  switch (rateLimitReachedType) {    case "workspace_owner_credits_depleted":    case "workspace_member_credits_depleted":      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.";    case "workspace_owner_usage_limit_reached":    case "workspace_member_usage_limit_reached":      return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.";    default:      return " Send the message again once the limit resets.";  }} /** * The message a usage-limit stop shows instead of the provider sentence, which * on a Business workspace blames credits for a window that simply ran out. The * window named is the exhausted one that has yet to reset, latest first; `atIso` * is the stopping event's timestamp, not the wall clock. */export function codexUsageLimitMessage(  snapshot: CodexRateLimitSnapshot | undefined,  atIso: string,): string {  const atMs = Date.parse(atIso);  const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : [];  let reset = "";  let latestResetMs = Number.NEGATIVE_INFINITY;  for (const window of windows) {    if (window.usedPercent < 100 || !window.resetsAt) continue;    const resetMs = Date.parse(window.resetsAt);    if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue;    latestResetMs = resetMs;    reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`;  }  return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`;}