diff --git a/src/lib/accounts/errors.ts b/src/lib/accounts/errors.ts index db8383a..b6f6da7 100644 --- a/src/lib/accounts/errors.ts +++ b/src/lib/accounts/errors.ts @@ -21,7 +21,8 @@ export type ErrorCode = | "E_SNAPSHOT_CLOBBERED" | "E_DAEMON_UNSUPPORTED_OS" | "E_PROVIDER_NOT_INSTALLED" - | "E_USAGE_FETCH_FAILED"; + | "E_USAGE_FETCH_FAILED" + | "E_PROXY_INSECURE_URL"; export type ErrorSeverity = "fatal" | "warn" | "info"; @@ -199,3 +200,19 @@ export class AutoSwitchConfigError extends CodexAuthError { super(message, "E_AUTOSWITCH_CONFIG", "fatal"); } } + +export class ProxyInsecureUrlError extends CodexAuthError { + constructor(url: string, hostname: string) { + super( + `Refusing to send dashboard credentials to non-loopback proxy URL "${url}" ` + + `(host="${hostname}"). The proxy auth flow only runs against loopback ` + + `(127.0.0.0/8, ::1, localhost). Set AUTHMUX_PROXY_INSECURE=1 to override ` + + `temporarily; the override will be removed in the next minor release.`, + "E_PROXY_INSECURE_URL", + "fatal", + `Point CODEX_LB_URL / CODEX_LB_DASHBOARD_URL at a loopback address, ` + + `or set AUTHMUX_PROXY_INSECURE=1 to acknowledge the risk.`, + { url, hostname }, + ); + } +} diff --git a/src/lib/accounts/usage/_internal/snapshot-parsers.ts b/src/lib/accounts/usage/_internal/snapshot-parsers.ts new file mode 100644 index 0000000..1f55b01 --- /dev/null +++ b/src/lib/accounts/usage/_internal/snapshot-parsers.ts @@ -0,0 +1,87 @@ +// Shared snapshot/rate-limit parsing helpers used by both the API client +// (`usage/api-client.ts`) and the local rollout walker +// (`usage/local-rollout.ts`). Kept internal — not re-exported from +// `usage/index.ts` — because callers should consume the typed fetchers +// rather than the parsers. + +import { RateLimitWindow, UsageSnapshot } from "../../types"; + +export function coerceWindow(raw: unknown): RateLimitWindow | undefined { + if (!raw || typeof raw !== "object") return undefined; + + const value = raw as Record; + const usedRaw = value.used_percent; + if (typeof usedRaw !== "number" || !Number.isFinite(usedRaw)) return undefined; + + const windowMinutes = typeof value.window_minutes === "number" + ? Math.round(value.window_minutes) + : typeof value.limit_window_seconds === "number" + ? Math.ceil(value.limit_window_seconds / 60) + : undefined; + + const resetsAt = typeof value.resets_at === "number" + ? Math.round(value.resets_at) + : typeof value.reset_at === "number" + ? Math.round(value.reset_at) + : undefined; + + return { + usedPercent: Math.max(0, Math.min(100, usedRaw)), + windowMinutes, + resetsAt, + }; +} + +export function buildSnapshotFromRateLimits( + rateLimits: unknown, + source: UsageSnapshot["source"], +): UsageSnapshot | null { + if (!rateLimits || typeof rateLimits !== "object") return null; + const input = rateLimits as Record; + + const primary = coerceWindow(input.primary_window ?? input.primary); + const secondary = coerceWindow(input.secondary_window ?? input.secondary); + if (!primary && !secondary) return null; + + const planType = typeof input.plan_type === "string" ? input.plan_type : undefined; + return { + primary, + secondary, + planType, + fetchedAt: new Date().toISOString(), + source, + }; +} + +export function findNestedRateLimits(input: unknown): unknown { + if (!input || typeof input !== "object") return null; + const root = input as Record; + if (root.rate_limits) return root.rate_limits; + if (root.payload && typeof root.payload === "object") { + const payload = root.payload as Record; + if (payload.rate_limits) return payload.rate_limits; + if (payload.event && typeof payload.event === "object") { + const event = payload.event as Record; + if (event.rate_limits) return event.rate_limits; + } + } + return null; +} + +export function parseTimestampSeconds(input: unknown): number { + if (typeof input === "number" && Number.isFinite(input)) { + if (input > 1_000_000_000_000) { + return Math.floor(input / 1000); + } + return Math.floor(input); + } + + if (typeof input === "string") { + const parsed = Date.parse(input); + if (!Number.isNaN(parsed)) { + return Math.floor(parsed / 1000); + } + } + + return Math.floor(Date.now() / 1000); +} diff --git a/src/lib/accounts/usage/api-client.ts b/src/lib/accounts/usage/api-client.ts new file mode 100644 index 0000000..e1e6b8f --- /dev/null +++ b/src/lib/accounts/usage/api-client.ts @@ -0,0 +1,47 @@ +// Public ChatGPT backend-api client for usage. One fetch with a 5s +// timeout, no retries, no auth dance. Extracted from `accounts/usage.ts` +// in Theme X2. + +import { ParsedAuthSnapshot, UsageSnapshot } from "../types"; +import { buildSnapshotFromRateLimits } from "./_internal/snapshot-parsers"; + +const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage"; +const REQUEST_TIMEOUT_MS = 5000; + +export async function fetchUsageFromApi( + snapshotInfo: ParsedAuthSnapshot, +): Promise { + if (snapshotInfo.authMode !== "chatgpt" || !snapshotInfo.accessToken || !snapshotInfo.accountId) { + return null; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(USAGE_ENDPOINT, { + method: "GET", + headers: { + Authorization: `Bearer ${snapshotInfo.accessToken}`, + "ChatGPT-Account-Id": snapshotInfo.accountId, + "User-Agent": "authmux", + }, + signal: controller.signal, + }); + + if (!response.ok) return null; + + const data = (await response.json()) as Record; + const snapshot = buildSnapshotFromRateLimits(data.rate_limit, "api"); + if (!snapshot) return null; + + if (!snapshot.planType && typeof data.plan_type === "string") { + snapshot.planType = data.plan_type; + } + + return snapshot; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/src/lib/accounts/usage/index.ts b/src/lib/accounts/usage/index.ts new file mode 100644 index 0000000..d922ef3 --- /dev/null +++ b/src/lib/accounts/usage/index.ts @@ -0,0 +1,17 @@ +// Barrel for the usage subsystem. Public surface only — internal helpers +// in `_internal/` are deliberately not re-exported. + +export { fetchUsageFromApi } from "./api-client"; +export { fetchUsageFromLocal } from "./local-rollout"; +export { fetchUsageFromProxy, type ProxyUsageIndex } from "./proxy-client"; +export { + remainingPercent, + resolveRateWindow, + shouldSwitchCurrent, + usageScore, +} from "./math"; + +// Re-export the underlying snapshot types so callers can keep using +// `import { UsageSnapshot } from "lib/accounts/usage"` without a second +// import from `types`. +export type { RateLimitWindow, UsageSnapshot } from "../types"; diff --git a/src/lib/accounts/usage/local-rollout.ts b/src/lib/accounts/usage/local-rollout.ts new file mode 100644 index 0000000..fe45f4f --- /dev/null +++ b/src/lib/accounts/usage/local-rollout.ts @@ -0,0 +1,107 @@ +// Walk `~/.codex/sessions/` for the most recent `rollout-*.jsonl` records +// and parse usage snapshots out of them. No HTTP, no env-var auth. +// Extracted from `accounts/usage.ts` in Theme X2. + +import fsp from "node:fs/promises"; +import path from "node:path"; +import { UsageSnapshot } from "../types"; +import { + buildSnapshotFromRateLimits, + findNestedRateLimits, + parseTimestampSeconds, +} from "./_internal/snapshot-parsers"; + +const ROLLOUT_FILE_LIMIT = 5; + +async function collectRolloutFiles(sessionsDir: string): Promise { + const pending: string[] = [sessionsDir]; + const rolloutFiles: Array<{ filePath: string; mtimeMs: number }> = []; + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + + let entries; + try { + entries = await fsp.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(fullPath); + continue; + } + + if (!entry.isFile()) continue; + if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl")) continue; + + try { + const stat = await fsp.stat(fullPath); + rolloutFiles.push({ filePath: fullPath, mtimeMs: stat.mtimeMs }); + } catch { + // ignore unreadable files + } + } + } + + rolloutFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); + return rolloutFiles.slice(0, ROLLOUT_FILE_LIMIT).map((entry) => entry.filePath); +} + +async function parseRolloutForUsage( + filePath: string, +): Promise<{ snapshot: UsageSnapshot; timestampSeconds: number } | null> { + let raw: string; + try { + raw = await fsp.readFile(filePath, "utf8"); + } catch { + return null; + } + + let latest: { snapshot: UsageSnapshot; timestampSeconds: number } | null = null; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let record: unknown; + try { + record = JSON.parse(trimmed) as unknown; + } catch { + continue; + } + + const rateLimits = findNestedRateLimits(record); + const snapshot = buildSnapshotFromRateLimits(rateLimits, "local"); + if (!snapshot) continue; + + const row = record as Record; + const timestampSeconds = parseTimestampSeconds( + row.event_timestamp_ms ?? row.timestamp_ms ?? row.timestamp, + ); + + if (!latest || timestampSeconds >= latest.timestampSeconds) { + latest = { + snapshot, + timestampSeconds, + }; + } + } + + return latest; +} + +export async function fetchUsageFromLocal(codexDir: string): Promise { + const sessionsDir = path.join(codexDir, "sessions"); + const files = await collectRolloutFiles(sessionsDir); + for (const filePath of files) { + const latest = await parseRolloutForUsage(filePath); + if (latest) { + return latest.snapshot; + } + } + + return null; +} diff --git a/src/lib/accounts/usage/math.ts b/src/lib/accounts/usage/math.ts new file mode 100644 index 0000000..fbddae1 --- /dev/null +++ b/src/lib/accounts/usage/math.ts @@ -0,0 +1,80 @@ +// Pure scoring / threshold math for usage snapshots. No I/O, no env access. +// Extracted from `accounts/usage.ts` in Theme X2. + +import { RateLimitWindow, UsageSnapshot } from "../types"; + +/** + * Choose the rate-limit window from a snapshot matching the requested + * window length (minutes). Falls back to `primary` or `secondary` based + * on `fallbackPrimary` when no exact match exists. + */ +export function resolveRateWindow( + snapshot: UsageSnapshot | undefined, + minutes: number, + fallbackPrimary: boolean, +): RateLimitWindow | undefined { + if (!snapshot) return undefined; + + if (snapshot.primary && snapshot.primary.windowMinutes === minutes) { + return snapshot.primary; + } + + if (snapshot.secondary && snapshot.secondary.windowMinutes === minutes) { + return snapshot.secondary; + } + + return fallbackPrimary ? snapshot.primary : snapshot.secondary; +} + +/** + * Remaining capacity for a window as an integer percent in [0, 100]. + * Returns `undefined` when no window is available. Returns 100 when the + * window's reset timestamp has already passed. + */ +export function remainingPercent( + window: RateLimitWindow | undefined, + nowSeconds: number, +): number | undefined { + if (!window) return undefined; + if (typeof window.resetsAt === "number" && window.resetsAt <= nowSeconds) return 100; + + const remaining = 100 - window.usedPercent; + if (remaining <= 0) return 0; + if (remaining >= 100) return 100; + return Math.trunc(remaining); +} + +/** + * Composite score = min(5h remaining %, weekly remaining %). Returns + * whichever single window is available, or `undefined` when neither is. + */ +export function usageScore( + snapshot: UsageSnapshot | undefined, + nowSeconds: number, +): number | undefined { + const fiveHour = remainingPercent(resolveRateWindow(snapshot, 300, true), nowSeconds); + const weekly = remainingPercent(resolveRateWindow(snapshot, 10080, false), nowSeconds); + + if (typeof fiveHour === "number" && typeof weekly === "number") return Math.min(fiveHour, weekly); + if (typeof fiveHour === "number") return fiveHour; + if (typeof weekly === "number") return weekly; + return undefined; +} + +/** + * True iff either the 5h or weekly remaining percent has crossed the + * caller-supplied threshold. + */ +export function shouldSwitchCurrent( + snapshot: UsageSnapshot | undefined, + thresholds: { threshold5hPercent: number; thresholdWeeklyPercent: number }, + nowSeconds: number, +): boolean { + const remaining5h = remainingPercent(resolveRateWindow(snapshot, 300, true), nowSeconds); + const remainingWeekly = remainingPercent(resolveRateWindow(snapshot, 10080, false), nowSeconds); + + return ( + (typeof remaining5h === "number" && remaining5h < thresholds.threshold5hPercent) || + (typeof remainingWeekly === "number" && remainingWeekly < thresholds.thresholdWeeklyPercent) + ); +} diff --git a/src/lib/accounts/usage.ts b/src/lib/accounts/usage/proxy-client.ts similarity index 57% rename from src/lib/accounts/usage.ts rename to src/lib/accounts/usage/proxy-client.ts index bbf3b43..9f87164 100644 --- a/src/lib/accounts/usage.ts +++ b/src/lib/accounts/usage/proxy-client.ts @@ -1,20 +1,26 @@ +// Localhost dashboard proxy client (Codex LB). Owns its own session, +// password env, TOTP helper, and retry profile. Extracted from +// `accounts/usage.ts` in Theme X2 with one hardening change: by default +// the client refuses to send credentials to non-loopback URLs. Set +// `AUTHMUX_PROXY_INSECURE=1` to opt back into the legacy behavior for +// one minor release. + import { exec as execCallback } from "node:child_process"; -import fsp from "node:fs/promises"; -import path from "node:path"; import { promisify } from "node:util"; -import { ParsedAuthSnapshot, RateLimitWindow, UsageSnapshot } from "./types"; +import { ProxyInsecureUrlError } from "../errors"; +import { RateLimitWindow, UsageSnapshot } from "../types"; -const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage"; const DEFAULT_PROXY_URL = "http://127.0.0.1:2455"; const DASHBOARD_SESSION_PATH = "/api/dashboard-auth/session"; const PASSWORD_LOGIN_PATH = "/api/dashboard-auth/password/login"; const TOTP_VERIFY_PATH = "/api/dashboard-auth/totp/verify"; const ACCOUNTS_PATH = "/api/accounts"; -const REQUEST_TIMEOUT_MS = 5000; const PROXY_REQUEST_TIMEOUT_MS = 2000; const DASHBOARD_PASSWORD_ENV = "CODEX_LB_DASHBOARD_PASSWORD"; const DASHBOARD_TOTP_CODE_ENV = "CODEX_LB_DASHBOARD_TOTP_CODE"; const DASHBOARD_TOTP_COMMAND_ENV = "CODEX_LB_DASHBOARD_TOTP_COMMAND"; +const PROXY_URL_ENVS = ["CODEX_LB_DASHBOARD_URL", "CODEX_LB_URL"] as const; +const PROXY_INSECURE_OVERRIDE_ENV = "AUTHMUX_PROXY_INSECURE"; const execAsync = promisify(execCallback); @@ -69,66 +75,11 @@ type ProxyAccountsPayload = { accounts?: unknown; }; -function coerceWindow(raw: unknown): RateLimitWindow | undefined { - if (!raw || typeof raw !== "object") return undefined; - - const value = raw as Record; - const usedRaw = value.used_percent; - if (typeof usedRaw !== "number" || !Number.isFinite(usedRaw)) return undefined; - - const windowMinutes = typeof value.window_minutes === "number" - ? Math.round(value.window_minutes) - : typeof value.limit_window_seconds === "number" - ? Math.ceil(value.limit_window_seconds / 60) - : undefined; - - const resetsAt = typeof value.resets_at === "number" - ? Math.round(value.resets_at) - : typeof value.reset_at === "number" - ? Math.round(value.reset_at) - : undefined; - - return { - usedPercent: Math.max(0, Math.min(100, usedRaw)), - windowMinutes, - resetsAt, - }; -} - -function buildSnapshotFromRateLimits(rateLimits: unknown, source: UsageSnapshot["source"]): UsageSnapshot | null { - if (!rateLimits || typeof rateLimits !== "object") return null; - const input = rateLimits as Record; - - const primary = coerceWindow(input.primary_window ?? input.primary); - const secondary = coerceWindow(input.secondary_window ?? input.secondary); - if (!primary && !secondary) return null; - - const planType = typeof input.plan_type === "string" ? input.plan_type : undefined; - return { - primary, - secondary, - planType, - fetchedAt: new Date().toISOString(), - source, - }; -} - -function findNestedRateLimits(input: unknown): unknown { - if (!input || typeof input !== "object") return null; - const root = input as Record; - if (root.rate_limits) return root.rate_limits; - if (root.payload && typeof root.payload === "object") { - const payload = root.payload as Record; - if (payload.rate_limits) return payload.rate_limits; - if (payload.event && typeof payload.event === "object") { - const event = payload.event as Record; - if (event.rate_limits) return event.rate_limits; - } +function parseOptionalTimestampSeconds(input: unknown): number | undefined { + if (input === undefined || input === null || input === "") { + return undefined; } - return null; -} -function parseTimestampSeconds(input: unknown): number { if (typeof input === "number" && Number.isFinite(input)) { if (input > 1_000_000_000_000) { return Math.floor(input / 1000); @@ -146,14 +97,6 @@ function parseTimestampSeconds(input: unknown): number { return Math.floor(Date.now() / 1000); } -function parseOptionalTimestampSeconds(input: unknown): number | undefined { - if (input === undefined || input === null || input === "") { - return undefined; - } - - return parseTimestampSeconds(input); -} - function coerceRemainingPercent(remainingRaw: unknown): number | undefined { if (typeof remainingRaw !== "number" || !Number.isFinite(remainingRaw)) { return undefined; @@ -231,7 +174,11 @@ function buildProxyAccountRecord(payload: ProxyAccountPayload): ProxyAccountReco }; } -function storeUsageIndexEntry(map: Map, rawKey: string | undefined, usage: UsageSnapshot): void { +function storeUsageIndexEntry( + map: Map, + rawKey: string | undefined, + usage: UsageSnapshot, +): void { const normalized = normalizeLookupKey(rawKey); if (!normalized || map.has(normalized)) { return; @@ -424,17 +371,97 @@ async function ensureDashboardSession(client: DashboardProxyClient): Promise Number(octet)); + if (octets.some((value) => value < 0 || value > 255)) return false; + return octets[0] === 127; + } + + return false; +} + +function isInsecureOverrideEnabled(): boolean { + return process.env[PROXY_INSECURE_OVERRIDE_ENV]?.trim() === "1"; +} + +/** + * Parse + gate the proxy URL. On insecure URL: + * - default: throw `ProxyInsecureUrlError` + * - with `AUTHMUX_PROXY_INSECURE=1`: emit a process warning and proceed + * + * Returns the normalized URL string when allowed, or throws/returns null: + * - returns `null` when the URL is unparseable or protocol is unsupported + * - throws `ProxyInsecureUrlError` when non-loopback and override is off + */ function resolveProxyBaseUrl(): string | null { - const raw = process.env.CODEX_LB_URL?.trim() || DEFAULT_PROXY_URL; + const raw = resolveRawProxyUrl(); + let url: URL; try { - const url = new URL(raw); - if (url.protocol !== "http:" && url.protocol !== "https:") { - return null; - } - return url.toString(); + url = new URL(raw); } catch { return null; } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + + if (!isLoopbackHostname(url.hostname)) { + if (!isInsecureOverrideEnabled()) { + throw new ProxyInsecureUrlError(url.toString(), url.hostname); + } + process.emitWarning( + "Proxy non-loopback URL allowed — credentials sent over non-loopback. " + + "Will be hard-blocked next release.", + ); + } + + return url.toString(); } export async function fetchUsageFromProxy(): Promise { @@ -484,177 +511,8 @@ export async function fetchUsageFromProxy(): Promise { return index; } -export function resolveRateWindow(snapshot: UsageSnapshot | undefined, minutes: number, fallbackPrimary: boolean): RateLimitWindow | undefined { - if (!snapshot) return undefined; - - if (snapshot.primary && snapshot.primary.windowMinutes === minutes) { - return snapshot.primary; - } - - if (snapshot.secondary && snapshot.secondary.windowMinutes === minutes) { - return snapshot.secondary; - } - - return fallbackPrimary ? snapshot.primary : snapshot.secondary; -} - -export function remainingPercent(window: RateLimitWindow | undefined, nowSeconds: number): number | undefined { - if (!window) return undefined; - if (typeof window.resetsAt === "number" && window.resetsAt <= nowSeconds) return 100; - - const remaining = 100 - window.usedPercent; - if (remaining <= 0) return 0; - if (remaining >= 100) return 100; - return Math.trunc(remaining); -} - -export function usageScore(snapshot: UsageSnapshot | undefined, nowSeconds: number): number | undefined { - const fiveHour = remainingPercent(resolveRateWindow(snapshot, 300, true), nowSeconds); - const weekly = remainingPercent(resolveRateWindow(snapshot, 10080, false), nowSeconds); - - if (typeof fiveHour === "number" && typeof weekly === "number") return Math.min(fiveHour, weekly); - if (typeof fiveHour === "number") return fiveHour; - if (typeof weekly === "number") return weekly; - return undefined; -} - -export function shouldSwitchCurrent( - snapshot: UsageSnapshot | undefined, - thresholds: { threshold5hPercent: number; thresholdWeeklyPercent: number }, - nowSeconds: number, -): boolean { - const remaining5h = remainingPercent(resolveRateWindow(snapshot, 300, true), nowSeconds); - const remainingWeekly = remainingPercent(resolveRateWindow(snapshot, 10080, false), nowSeconds); - - return ( - (typeof remaining5h === "number" && remaining5h < thresholds.threshold5hPercent) || - (typeof remainingWeekly === "number" && remainingWeekly < thresholds.thresholdWeeklyPercent) - ); -} - -export async function fetchUsageFromApi(snapshotInfo: ParsedAuthSnapshot): Promise { - if (snapshotInfo.authMode !== "chatgpt" || !snapshotInfo.accessToken || !snapshotInfo.accountId) { - return null; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const response = await fetch(USAGE_ENDPOINT, { - method: "GET", - headers: { - Authorization: `Bearer ${snapshotInfo.accessToken}`, - "ChatGPT-Account-Id": snapshotInfo.accountId, - "User-Agent": "authmux", - }, - signal: controller.signal, - }); - - if (!response.ok) return null; - - const data = (await response.json()) as Record; - const snapshot = buildSnapshotFromRateLimits(data.rate_limit, "api"); - if (!snapshot) return null; - - if (!snapshot.planType && typeof data.plan_type === "string") { - snapshot.planType = data.plan_type; - } - - return snapshot; - } catch { - return null; - } finally { - clearTimeout(timer); - } -} - -async function collectRolloutFiles(sessionsDir: string): Promise { - const pending: string[] = [sessionsDir]; - const rolloutFiles: Array<{ filePath: string; mtimeMs: number }> = []; - - while (pending.length > 0) { - const current = pending.pop(); - if (!current) continue; - - let entries; - try { - entries = await fsp.readdir(current, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - pending.push(fullPath); - continue; - } - - if (!entry.isFile()) continue; - if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl")) continue; - - try { - const stat = await fsp.stat(fullPath); - rolloutFiles.push({ filePath: fullPath, mtimeMs: stat.mtimeMs }); - } catch { - // ignore unreadable files - } - } - } - - rolloutFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); - return rolloutFiles.slice(0, 5).map((entry) => entry.filePath); -} - -async function parseRolloutForUsage(filePath: string): Promise<{ snapshot: UsageSnapshot; timestampSeconds: number } | null> { - let raw: string; - try { - raw = await fsp.readFile(filePath, "utf8"); - } catch { - return null; - } - - let latest: { snapshot: UsageSnapshot; timestampSeconds: number } | null = null; - for (const line of raw.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - - let record: unknown; - try { - record = JSON.parse(trimmed) as unknown; - } catch { - continue; - } - - const rateLimits = findNestedRateLimits(record); - const snapshot = buildSnapshotFromRateLimits(rateLimits, "local"); - if (!snapshot) continue; - - const row = record as Record; - const timestampSeconds = parseTimestampSeconds( - row.event_timestamp_ms ?? row.timestamp_ms ?? row.timestamp, - ); - - if (!latest || timestampSeconds >= latest.timestampSeconds) { - latest = { - snapshot, - timestampSeconds, - }; - } - } - - return latest; -} - -export async function fetchUsageFromLocal(codexDir: string): Promise { - const sessionsDir = path.join(codexDir, "sessions"); - const files = await collectRolloutFiles(sessionsDir); - for (const filePath of files) { - const latest = await parseRolloutForUsage(filePath); - if (latest) { - return latest.snapshot; - } - } - - return null; -} +// Exposed for tests only. +export const __testing = { + isLoopbackHostname, + resolveProxyBaseUrl, +}; diff --git a/src/tests/usage-math.test.ts b/src/tests/usage-math.test.ts new file mode 100644 index 0000000..a292d6d --- /dev/null +++ b/src/tests/usage-math.test.ts @@ -0,0 +1,344 @@ +// Exhaustive coverage of the pure usage-math helpers extracted in +// Theme X2. These functions have zero I/O and zero env access, so we +// cover them via direct inputs only. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + remainingPercent, + resolveRateWindow, + shouldSwitchCurrent, + usageScore, +} from "../lib/accounts/usage/math"; +import type { RateLimitWindow, UsageSnapshot } from "../lib/accounts/types"; + +const NOW = 1_700_000_000; // arbitrary fixed `nowSeconds` for reset-time math + +function snapshot(parts: Partial): UsageSnapshot { + return { + source: "api", + fetchedAt: "2025-01-01T00:00:00.000Z", + ...parts, + }; +} + +function window(parts: Partial & Pick): RateLimitWindow { + return { ...parts }; +} + +// ----------------------------------------------------------------------- +// remainingPercent +// ----------------------------------------------------------------------- + +test("remainingPercent: undefined window -> undefined", () => { + assert.equal(remainingPercent(undefined, NOW), undefined); +}); + +test("remainingPercent: 0% used -> 100", () => { + assert.equal(remainingPercent(window({ usedPercent: 0 }), NOW), 100); +}); + +test("remainingPercent: 100% used -> 0", () => { + assert.equal(remainingPercent(window({ usedPercent: 100 }), NOW), 0); +}); + +test("remainingPercent: 50% used -> 50", () => { + assert.equal(remainingPercent(window({ usedPercent: 50 }), NOW), 50); +}); + +test("remainingPercent: fractional used is truncated", () => { + // 100 - 33.7 = 66.3, truncated -> 66 + assert.equal(remainingPercent(window({ usedPercent: 33.7 }), NOW), 66); +}); + +test("remainingPercent: negative used clamped to 100", () => { + // 100 - (-10) = 110, clamped to 100 by the upper guard + assert.equal(remainingPercent(window({ usedPercent: -10 }), NOW), 100); +}); + +test("remainingPercent: >100 used clamped to 0", () => { + // 100 - 150 = -50, clamped to 0 by the lower guard + assert.equal(remainingPercent(window({ usedPercent: 150 }), NOW), 0); +}); + +test("remainingPercent: resetsAt in the past forces 100", () => { + // Even with usedPercent at the cap, an expired reset means a full window. + assert.equal( + remainingPercent(window({ usedPercent: 99, resetsAt: NOW - 1 }), NOW), + 100, + ); +}); + +test("remainingPercent: resetsAt exactly at now still forces 100", () => { + // Boundary case: `resetsAt <= nowSeconds` is `true` when equal. + assert.equal( + remainingPercent(window({ usedPercent: 80, resetsAt: NOW }), NOW), + 100, + ); +}); + +test("remainingPercent: resetsAt in the future does not short-circuit", () => { + assert.equal( + remainingPercent(window({ usedPercent: 70, resetsAt: NOW + 60 }), NOW), + 30, + ); +}); + +test("remainingPercent: NaN usedPercent yields NaN, but resetsAt expiry still 100", () => { + // When the window's reset is past, the early return wins regardless of NaN. + assert.equal( + remainingPercent(window({ usedPercent: Number.NaN, resetsAt: NOW - 1 }), NOW), + 100, + ); +}); + +test("remainingPercent: NaN usedPercent without expiry falls to the trunc branch (NaN)", () => { + // 100 - NaN = NaN. NaN <= 0 is false, NaN >= 100 is false, Math.trunc(NaN) = NaN. + // We don't assert behavior here as "correct"; we lock in the observable. + const result = remainingPercent(window({ usedPercent: Number.NaN }), NOW); + assert.equal(typeof result, "number"); + assert.ok(Number.isNaN(result)); +}); + +// ----------------------------------------------------------------------- +// resolveRateWindow +// ----------------------------------------------------------------------- + +test("resolveRateWindow: undefined snapshot -> undefined", () => { + assert.equal(resolveRateWindow(undefined, 300, true), undefined); +}); + +test("resolveRateWindow: exact primary match", () => { + const primary = window({ usedPercent: 10, windowMinutes: 300 }); + const result = resolveRateWindow(snapshot({ primary }), 300, true); + assert.equal(result, primary); +}); + +test("resolveRateWindow: exact secondary match", () => { + const secondary = window({ usedPercent: 20, windowMinutes: 10080 }); + const result = resolveRateWindow(snapshot({ secondary }), 10080, false); + assert.equal(result, secondary); +}); + +test("resolveRateWindow: primary windowMinutes mismatch + fallbackPrimary=true returns primary", () => { + // No exact match, fallback says primary. + const primary = window({ usedPercent: 10, windowMinutes: 60 }); + const secondary = window({ usedPercent: 20, windowMinutes: 60 }); + const result = resolveRateWindow(snapshot({ primary, secondary }), 300, true); + assert.equal(result, primary); +}); + +test("resolveRateWindow: primary windowMinutes mismatch + fallbackPrimary=false returns secondary", () => { + const primary = window({ usedPercent: 10, windowMinutes: 60 }); + const secondary = window({ usedPercent: 20, windowMinutes: 60 }); + const result = resolveRateWindow(snapshot({ primary, secondary }), 300, false); + assert.equal(result, secondary); +}); + +test("resolveRateWindow: missing primary, fallbackPrimary=true returns undefined", () => { + const secondary = window({ usedPercent: 20, windowMinutes: 60 }); + const result = resolveRateWindow(snapshot({ secondary }), 300, true); + assert.equal(result, undefined); +}); + +test("resolveRateWindow: missing secondary, fallbackPrimary=false returns undefined", () => { + const primary = window({ usedPercent: 10, windowMinutes: 60 }); + const result = resolveRateWindow(snapshot({ primary }), 300, false); + assert.equal(result, undefined); +}); + +test("resolveRateWindow: undefined windowMinutes on primary never matches a request", () => { + const primary = window({ usedPercent: 10 }); + const secondary = window({ usedPercent: 20, windowMinutes: 300 }); + const result = resolveRateWindow(snapshot({ primary, secondary }), 300, true); + // Exact secondary match should win over the unlabelled primary. + assert.equal(result, secondary); +}); + +// ----------------------------------------------------------------------- +// usageScore +// ----------------------------------------------------------------------- + +test("usageScore: undefined snapshot -> undefined", () => { + assert.equal(usageScore(undefined, NOW), undefined); +}); + +test("usageScore: snapshot with no windows -> undefined", () => { + assert.equal(usageScore(snapshot({}), NOW), undefined); +}); + +test("usageScore: only 5h window -> 5h remaining", () => { + const result = usageScore( + snapshot({ primary: window({ usedPercent: 30, windowMinutes: 300 }) }), + NOW, + ); + assert.equal(result, 70); +}); + +test("usageScore: only weekly window -> weekly remaining", () => { + const result = usageScore( + snapshot({ secondary: window({ usedPercent: 10, windowMinutes: 10080 }) }), + NOW, + ); + assert.equal(result, 90); +}); + +test("usageScore: 5h and weekly both present -> min(remaining)", () => { + const result = usageScore( + snapshot({ + primary: window({ usedPercent: 30, windowMinutes: 300 }), + secondary: window({ usedPercent: 10, windowMinutes: 10080 }), + }), + NOW, + ); + // Remaining: 5h=70, weekly=90. min -> 70. + assert.equal(result, 70); +}); + +test("usageScore: weekly tighter than 5h still returns the min", () => { + const result = usageScore( + snapshot({ + primary: window({ usedPercent: 10, windowMinutes: 300 }), + secondary: window({ usedPercent: 95, windowMinutes: 10080 }), + }), + NOW, + ); + // Remaining: 5h=90, weekly=5. min -> 5. + assert.equal(result, 5); +}); + +test("usageScore: 5h at 0% used -> 100", () => { + const result = usageScore( + snapshot({ primary: window({ usedPercent: 0, windowMinutes: 300 }) }), + NOW, + ); + assert.equal(result, 100); +}); + +test("usageScore: 5h at 100% used -> 0", () => { + const result = usageScore( + snapshot({ primary: window({ usedPercent: 100, windowMinutes: 300 }) }), + NOW, + ); + assert.equal(result, 0); +}); + +test("usageScore: primary 5h + secondary weekly with weekly reset expired -> weekly forced to 100", () => { + const result = usageScore( + snapshot({ + primary: window({ usedPercent: 30, windowMinutes: 300 }), + secondary: window({ usedPercent: 95, windowMinutes: 10080, resetsAt: NOW - 1 }), + }), + NOW, + ); + // 5h=70, weekly=100 (reset expired). min -> 70. + assert.equal(result, 70); +}); + +test("usageScore: windows present but neither labelled with 300/10080 -> falls back, picks min", () => { + // primary fallback for 5h, secondary fallback for weekly. + const result = usageScore( + snapshot({ + primary: window({ usedPercent: 25, windowMinutes: 60 }), + secondary: window({ usedPercent: 80, windowMinutes: 60 }), + }), + NOW, + ); + // 5h fallback -> primary -> remaining 75. Weekly fallback -> secondary -> remaining 20. min -> 20. + assert.equal(result, 20); +}); + +// ----------------------------------------------------------------------- +// shouldSwitchCurrent +// ----------------------------------------------------------------------- + +const THRESHOLDS = { threshold5hPercent: 10, thresholdWeeklyPercent: 5 }; + +test("shouldSwitchCurrent: undefined snapshot -> false", () => { + assert.equal(shouldSwitchCurrent(undefined, THRESHOLDS, NOW), false); +}); + +test("shouldSwitchCurrent: snapshot with no windows -> false", () => { + assert.equal(shouldSwitchCurrent(snapshot({}), THRESHOLDS, NOW), false); +}); + +test("shouldSwitchCurrent: 5h above threshold + weekly above threshold -> false", () => { + const usage = snapshot({ + primary: window({ usedPercent: 50, windowMinutes: 300 }), + secondary: window({ usedPercent: 50, windowMinutes: 10080 }), + }); + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), false); +}); + +test("shouldSwitchCurrent: 5h remaining below threshold5hPercent -> true", () => { + // remaining = 9 < 10 + const usage = snapshot({ + primary: window({ usedPercent: 91, windowMinutes: 300 }), + }); + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), true); +}); + +test("shouldSwitchCurrent: 5h remaining at threshold (not strictly less) -> false", () => { + // remaining = 10, threshold = 10, predicate is `<` not `<=`. + const usage = snapshot({ + primary: window({ usedPercent: 90, windowMinutes: 300 }), + }); + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), false); +}); + +test("shouldSwitchCurrent: weekly remaining below threshold -> true", () => { + // weekly remaining = 4 < 5 + const usage = snapshot({ + secondary: window({ usedPercent: 96, windowMinutes: 10080 }), + }); + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), true); +}); + +test("shouldSwitchCurrent: both windows ok, then weekly trips -> true", () => { + const usage = snapshot({ + primary: window({ usedPercent: 50, windowMinutes: 300 }), + secondary: window({ usedPercent: 99, windowMinutes: 10080 }), + }); + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), true); +}); + +test("shouldSwitchCurrent: expired 5h reset forces remaining to 100 -> does not trigger 5h", () => { + // Used near cap, but the reset already passed; remaining = 100. + const usage = snapshot({ + primary: window({ usedPercent: 99, windowMinutes: 300, resetsAt: NOW - 1 }), + }); + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), false); +}); + +test("shouldSwitchCurrent: threshold of 0 never trips", () => { + // Threshold 0 means "switch when remaining < 0", which is unreachable. + const usage = snapshot({ + primary: window({ usedPercent: 100, windowMinutes: 300 }), + }); + assert.equal( + shouldSwitchCurrent( + usage, + { threshold5hPercent: 0, thresholdWeeklyPercent: 0 }, + NOW, + ), + false, + ); +}); + +test("shouldSwitchCurrent: missing window does not contribute to the OR", () => { + // Only a weekly window present, 5h missing — must not crash, weekly decides. + const usage = snapshot({ + secondary: window({ usedPercent: 50, windowMinutes: 10080 }), + }); + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), false); +}); + +test("shouldSwitchCurrent: fallback windows are honored when exact match is absent", () => { + // No window labelled 300/10080, but primary/secondary still resolve via fallback. + const usage = snapshot({ + primary: window({ usedPercent: 95, windowMinutes: 60 }), + secondary: window({ usedPercent: 50, windowMinutes: 60 }), + }); + // 5h fallback -> primary -> remaining=5, below threshold 10 -> trigger. + assert.equal(shouldSwitchCurrent(usage, THRESHOLDS, NOW), true); +});