UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

208 lines (207 loc) • 8.36 kB
// Rate-limit extraction from provider response headers. // // Reading these turns the meter from "how much have I used" into "how much is // left" — the real answer to problem ① (CONCEPT.md §6a). Only the usage // limits of a subscription are read: the 5-hour and weekly windows. // // Claude Pro / Max (unified quota windows): // anthropic-ratelimit-unified-{5h,7d}-{status,utilization,reset} // anthropic-ratelimit-unified-{status,representative-claim,overage-status,reset} // ChatGPT plans (Codex): see parseCodex below. // // An API key has no such windows, only limits per minute that refill within // seconds and that the agent waits out by itself; they are not read. // // The unified window's `utilization` (0..1) is the fraction of the window used, // and `reset` is a Unix timestamp (seconds) — no per-token dollar cost exists on // a subscription, so this utilization IS the limit prognosis. const UNIFIED_WINDOWS = ["5h", "7d"]; function num(v) { if (v == null) return null; const n = Number(v); return Number.isFinite(n) ? n : null; } function parseUnified(raw) { const windows = []; for (const key of UNIFIED_WINDOWS) { const status = raw[`anthropic-ratelimit-unified-${key}-status`]; const util = raw[`anthropic-ratelimit-unified-${key}-utilization`]; const reset = raw[`anthropic-ratelimit-unified-${key}-reset`]; if (status == null && util == null && reset == null) continue; windows.push({ key, status: status ?? null, utilization: num(util), resetUnix: num(reset), }); } const top = raw["anthropic-ratelimit-unified-status"]; if (windows.length === 0 && top == null) return undefined; return { status: top ?? null, representativeClaim: raw["anthropic-ratelimit-unified-representative-claim"] ?? null, overageStatus: raw["anthropic-ratelimit-unified-overage-status"] ?? null, windows, }; } // Codex on a ChatGPT plan reports its usage limits the same way in spirit: // x-codex-{primary,secondary}-{used-percent,window-minutes,reset-at} // primary is the 5-hour window and secondary the weekly one; used-percent is // 0..100 and reset-at epoch seconds. Over a WebSocket the same numbers come // as a "codex.rate_limits" event (see rateLimitFromEvent). function windowKey(minutes, fallback) { if (minutes === 300) return "5h"; if (minutes === 10080) return "7d"; if (minutes && minutes % 1440 === 0) return `${minutes / 1440}d`; if (minutes && minutes % 60 === 0) return `${minutes / 60}h`; return minutes ? `${minutes}m` : fallback; } function parseCodex(raw) { const windows = []; for (const [slot, fallback] of [["primary", "5h"], ["secondary", "7d"]]) { const used = num(raw[`x-codex-${slot}-used-percent`]); if (used == null) continue; const minutes = num(raw[`x-codex-${slot}-window-minutes`]); windows.push({ key: windowKey(minutes, fallback), status: used >= 100 ? "rejected" : null, utilization: used / 100, resetUnix: num(raw[`x-codex-${slot}-reset-at`]), }); } if (windows.length === 0) return undefined; return { status: null, representativeClaim: null, overageStatus: null, windows }; } // A "codex.rate_limits" WebSocket event, as the same snapshot the headers // give (with the header names, so the event log reads back the same way). export function rateLimitFromEvent(event) { const e = event; if (!e || e.type !== "codex.rate_limits" || !e.rate_limits) return null; const headers = {}; for (const slot of ["primary", "secondary"]) { const w = e.rate_limits[slot]; if (!w || typeof w.used_percent !== "number") continue; headers[`x-codex-${slot}-used-percent`] = String(w.used_percent); if (w.window_minutes != null) headers[`x-codex-${slot}-window-minutes`] = String(w.window_minutes); if (w.reset_at != null) headers[`x-codex-${slot}-reset-at`] = String(w.reset_at); } return extractRateLimit(headers); } // Returns null when the response carries no subscription limits (an API // key, or a provider that reports none). export function extractRateLimit(headers) { const raw = {}; for (const [k, v] of Object.entries(headers)) { const key = k.toLowerCase(); if (key.startsWith("anthropic-ratelimit-unified-") || /^x-codex-(?:primary|secondary)-/.test(key) || key === "retry-after") { raw[key] = Array.isArray(v) ? v.join(", ") : String(v ?? ""); } } const unified = parseUnified(raw) ?? parseCodex(raw); if (!unified) return null; return { unified, retryAfterSec: num(raw["retry-after"]), raw }; } export function relFromSeconds(secs) { const s = Math.max(0, Math.round(secs)); if (s < 90) return `${s}s`; const m = Math.round(s / 60); if (m < 90) return `${m}m`; const h = Math.floor(m / 60); const rem = m % 60; return rem ? `${h}h${rem}m` : `${h}h`; } // A short human hint like "quota 5h 50% used, reset 1h23m · 7d 6%", or null // when there are no windows to show. export function formatRateLimit(s, now = Date.now()) { if (s.unified.windows.length === 0) return null; const order = (w) => (s.unified.representativeClaim?.startsWith(w.key === "5h" ? "five" : "seven") ? 0 : 1); const sorted = [...s.unified.windows].sort((a, z) => order(a) - order(z)); const parts = sorted.map((w) => { const seg = [w.key]; if (w.utilization != null) seg.push(`${Math.round(w.utilization * 100)}% used`); if (w.resetUnix != null) seg.push(`reset ${relFromSeconds(w.resetUnix - now / 1000)}`); return seg.join(" "); }); let out = `quota ${parts.join(" · ")}`; if (s.unified.status && s.unified.status !== "allowed") out += ` [${s.unified.status}]`; if (s.retryAfterSec != null) out += ` · retry-after ${s.retryAfterSec}s`; return out; } export class QuotaWatcher { warned = new Set(); threshold; /** threshold is a fraction 0..1 of a window's utilization (default 0.9). */ constructor(threshold = 0.9) { this.threshold = threshold; } update(s, now = Date.now()) { const out = []; const once = (key, make) => { if (!this.warned.has(key)) { this.warned.add(key); out.push(make()); } }; const clear = (key) => void this.warned.delete(key); const resetHint = (unix) => unix != null ? ` (resets in ${relFromSeconds(unix - now / 1000)})` : ""; const label = (key) => (key === "5h" ? "5-hour" : key === "7d" ? "weekly" : key); for (const w of s.unified.windows) { // Only "rejected" blocks requests; "allowed_warning" still lets them // through and is covered by the utilization warning below. if (w.status === "rejected") { once(`rej:${w.key}`, () => ({ key: w.key, level: "critical", message: `${label(w.key)} limit reached (rejected) — requests are blocked${resetHint(w.resetUnix)}`, })); } else { clear(`rej:${w.key}`); } if (w.utilization != null) { if (w.utilization >= this.threshold) { once(`util:${w.key}`, () => ({ key: w.key, level: "warn", message: `${label(w.key)} limit ${Math.round(w.utilization * 100)}% used — getting close${resetHint(w.resetUnix)}`, })); } else { clear(`util:${w.key}`); } } } if (s.retryAfterSec != null) { once("retry", () => ({ key: "retry-after", level: "critical", message: `rate limit hit — retry after ${s.retryAfterSec}s`, })); } else { clear("retry"); } return out; } }