import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { normalizeThinkingLevel } from "./thinking.js"; import type { ModelInfo, ProviderLimitInfo, UsageLimitWindow } from "./types.js"; import { clamp, numberValue } from "./utils.js"; const RATE_LIMIT_CACHE_TTL_MS = 700; const CODEX_THINKING_CACHE_TTL_MS = 700; let codexLimitCache: { checkedAt: number; info: ProviderLimitInfo | null } = { checkedAt: 0, info: null, }; let codexThinkingCache: { checkedAt: number; level: string | null } = { checkedAt: 0, level: null, }; export function getProviderLimitInfo(model: ModelInfo): ProviderLimitInfo | null { if (model.family === "openai") return readLatestCodexLimits(model); if (model.family === "claude") { return { source: "unavailable", windows: [], note: "Claude Code exposes 5h/weekly limits only to its statusLine stdin; Pi does not expose equivalent subscription windows.", }; } return null; } function readLatestCodexLimits(model: ModelInfo): ProviderLimitInfo | null { const now = Date.now(); if (now - codexLimitCache.checkedAt < RATE_LIMIT_CACHE_TTL_MS) { return codexLimitCache.info; } codexLimitCache.checkedAt = now; codexLimitCache.info = readLatestCodexLimitsUncached(model); return codexLimitCache.info; } function readLatestCodexLimitsUncached(model: ModelInfo): ProviderLimitInfo | null { return readLatestCodexRolloutLimits(model) ?? readLatestCodexTelemetryLimits(model); } function readLatestCodexRolloutLimits(model: ModelInfo): ProviderLimitInfo | null { for (const file of findLatestCodexRolloutFiles(12)) { const snapshot = readCodexRolloutLimitSnapshot(file); if (!snapshot) continue; const limits = selectCodexRolloutLimits(snapshot, model); const windows = windowsFromCodexLimitPayload(limits); if (windows.length > 0) return { source: "codex-rollout", windows }; } return null; } function findLatestCodexRolloutFiles(maxFiles: number): string[] { const sessionsDir = path.join(os.homedir(), ".codex", "sessions"); if (!fs.existsSync(sessionsDir)) return []; const results: Array<{ file: string; mtime: number }> = []; const stack = [sessionsDir]; while (stack.length > 0) { const dir = stack.pop(); if (!dir) continue; let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { stack.push(fullPath); continue; } if (!entry.isFile() || !entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl")) { continue; } try { results.push({ file: fullPath, mtime: fs.statSync(fullPath).mtimeMs }); } catch { // Ignore files that disappeared while scanning. } } } return results .sort((a, b) => b.mtime - a.mtime) .slice(0, maxFiles) .map((entry) => entry.file); } function readCodexRolloutLimitSnapshot(filePath: string): { sessionModel?: string; defaultLimits?: any; sparkLimits?: any; } | null { let lines: string[]; try { lines = fs.readFileSync(filePath, "utf8").split("\n"); } catch { return null; } let sessionModel: string | undefined; let defaultLimits: any; let sparkLimits: any; for (const line of lines) { if (!line.trim()) continue; let item: any; try { item = JSON.parse(line); } catch { continue; } const payload = item?.payload; if (item?.type === "turn_context" && payload && typeof payload === "object") { if (typeof payload.model === "string") sessionModel = payload.model; continue; } if (item?.type !== "event_msg" || !payload || typeof payload !== "object") continue; if (payload.type !== "token_count") continue; const parsed = parseCodexRatePayload(payload.rate_limits); if (!parsed) continue; if (isSparkLimitPayload(parsed)) sparkLimits = parsed; else defaultLimits = parsed; } return defaultLimits || sparkLimits ? { sessionModel, defaultLimits, sparkLimits } : null; } function parseCodexRatePayload(value: any): any | null { if (!value || typeof value !== "object") return null; if (!value.primary && !value.secondary) return null; return { limit_id: typeof value.limit_id === "string" ? value.limit_id : undefined, limit_name: typeof value.limit_name === "string" ? value.limit_name : undefined, primary: value.primary, secondary: value.secondary, }; } function selectCodexRolloutLimits( snapshot: { sessionModel?: string; defaultLimits?: any; sparkLimits?: any }, model: ModelInfo, ): any | null { const wantsSpark = isSparkModelName(model.label) || ((model.label === "No model" || model.label.trim() === "") && isSparkModelName(snapshot.sessionModel)); return wantsSpark ? snapshot.sparkLimits ?? snapshot.defaultLimits ?? null : snapshot.defaultLimits ?? snapshot.sparkLimits ?? null; } function isSparkModelName(value: unknown): boolean { return typeof value === "string" && value.toLowerCase().includes("spark"); } function isSparkLimitPayload(value: any): boolean { return /spark/i.test(String(value?.limit_id ?? "")) || /spark/i.test(String(value?.limit_name ?? "")); } function windowsFromCodexLimitPayload(limits: any): UsageLimitWindow[] { if (!limits || typeof limits !== "object") return []; return [limits.primary, limits.secondary] .map((window: any) => normalizeCodexLimitWindow(window)) .filter((window: UsageLimitWindow | null): window is UsageLimitWindow => window !== null); } function readLatestCodexTelemetryLimits(model: ModelInfo): ProviderLimitInfo | null { const codexDir = path.join(os.homedir(), ".codex"); const files = [ path.join(codexDir, "logs_2.sqlite"), path.join(codexDir, "logs_2.sqlite-wal"), ]; const events: any[] = []; for (const file of files) { events.push(...extractCodexLimitEvents(readTailText(file, 16 * 1024 * 1024))); } if (events.length === 0) return null; const event = events.reduce((latest, candidate) => { const latestLimits = selectCodexLimitPayload(latest, model); const candidateLimits = selectCodexLimitPayload(candidate, model); const latestReset = numberValue(latestLimits?.primary?.reset_at, latestLimits?.primary?.resets_at); const candidateReset = numberValue(candidateLimits?.primary?.reset_at, candidateLimits?.primary?.resets_at); return candidateReset >= latestReset ? candidate : latest; }, events[0]); const limits = selectCodexLimitPayload(event, model); const windows = windowsFromCodexLimitPayload(limits); return windows.length > 0 ? { source: "codex-log", windows } : null; } function readTailText(filePath: string, maxBytes: number): string { try { const stat = fs.statSync(filePath); const start = Math.max(0, stat.size - maxBytes); const length = stat.size - start; const fd = fs.openSync(filePath, "r"); try { const buffer = Buffer.alloc(length); fs.readSync(fd, buffer, 0, length, start); return buffer.toString("utf8"); } finally { fs.closeSync(fd); } } catch { return ""; } } function extractCodexLimitEvents(text: string): any[] { const marker = '{"type":"codex.rate_limits"'; const events: any[] = []; let index = 0; while (index < text.length) { const start = text.indexOf(marker, index); if (start < 0) break; const jsonText = extractJsonObjectAt(text, start); if (!jsonText) { index = start + marker.length; continue; } try { events.push(JSON.parse(jsonText)); } catch { // Ignore telemetry fragments in partially written SQLite/WAL pages. } index = start + jsonText.length; } return events; } function extractJsonObjectAt(text: string, start: number): string | null { let depth = 0; let inString = false; let escaped = false; for (let i = start; i < text.length; i++) { const char = text[i]; if (inString) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === '"') inString = false; continue; } if (char === '"') { inString = true; continue; } if (char === "{") depth++; else if (char === "}") { depth--; if (depth === 0) return text.slice(start, i + 1); } } return null; } function selectCodexLimitPayload(event: any, model: ModelInfo): any | null { const additional = event?.additional_rate_limits; if (additional && typeof additional === "object") { const modelKey = normalizeLimitModelKey(model.label); for (const [key, value] of Object.entries(additional)) { const normalizedKey = normalizeLimitModelKey(key); if (normalizedKey && (modelKey.includes(normalizedKey) || normalizedKey.includes(modelKey))) { return value; } } } return event?.rate_limits ?? null; } function normalizeLimitModelKey(value: unknown): string { return String(value ?? "") .toLowerCase() .replace(/[^a-z0-9]+/g, "") .replace(/^gpt/, "gpt"); } function normalizeCodexLimitWindow(value: any): UsageLimitWindow | null { if (!value || typeof value !== "object") return null; const usedPercent = typeof value.used_percent === "number" && Number.isFinite(value.used_percent) ? value.used_percent : null; if (usedPercent === null) return null; const windowMinutes = numberValue(value.window_minutes); const directResetAt = numberValue(value.reset_at, value.resets_at); const resetAfterSeconds = numberValue(value.reset_after_seconds); const resetAt = directResetAt || (resetAfterSeconds > 0 ? Math.round(Date.now() / 1000 + resetAfterSeconds) : undefined); return { label: formatLimitWindowLabel(windowMinutes), usedPercent: clamp(usedPercent, 0, 100), resetAt, limitReached: value.limit_reached === true, allowed: typeof value.allowed === "boolean" ? value.allowed : undefined, }; } function formatLimitWindowLabel(windowMinutes: number): string { if (windowMinutes === 300) return "5h"; if (windowMinutes === 10080) return "1w"; if (windowMinutes > 0 && windowMinutes % 10080 === 0) return `${windowMinutes / 10080}w`; if (windowMinutes > 0 && windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; if (windowMinutes > 0 && windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; return windowMinutes > 0 ? `${windowMinutes}m` : "limit"; } export function readCodexReasoningEffort(): string | null { const now = Date.now(); if (now - codexThinkingCache.checkedAt < CODEX_THINKING_CACHE_TTL_MS) { return codexThinkingCache.level; } codexThinkingCache.checkedAt = now; codexThinkingCache.level = readCodexReasoningEffortFromConfig() ?? readCodexReasoningEffortFromTelemetry(); return codexThinkingCache.level; } function readCodexReasoningEffortFromConfig(): string | null { try { const text = fs.readFileSync(path.join(os.homedir(), ".codex", "config.toml"), "utf8"); const match = text.match(/^\s*model_reasoning_effort\s*=\s*["']?([A-Za-z0-9_-]+)/m); return normalizeThinkingLevel(match?.[1]); } catch { return null; } } function readCodexReasoningEffortFromTelemetry(): string | null { const codexDir = path.join(os.homedir(), ".codex"); let level: string | null = null; for (const file of [path.join(codexDir, "logs_2.sqlite"), path.join(codexDir, "logs_2.sqlite-wal")]) { const text = readTailText(file, 4 * 1024 * 1024); const matches = text.matchAll(/codex\.turn\.reasoning_effort=([A-Za-z0-9_-]+)/g); for (const match of matches) { level = normalizeThinkingLevel(match[1]) ?? level; } } return level; }