// Claude plan limits for the claude-usage agent (issue #50). // // One GET to Anthropic's (undocumented) OAuth usage endpoint, with the access // token Claude Code already stored in ~/.claude/.credentials.json. Returns the // `limits` block of the POST /claude-usage contract (schemaVersion 1), minus // the "last good values" fallback, which state.js owns. // // Properties that are load-bearing and easy to break: // // * THE CREDENTIALS FILE IS READ, NEVER WRITTEN. Refresh tokens are single // use: refreshing here would log Claude Code out. An expired token means // `token_expired` and no call at all. The file is only ever passed to // fs.readFileSync (no flag), and __tests__/agent-claude-usage.test.js // spies on every writing function of node:fs to prove it. // * THE BEARER ONLY GOES TO USAGE_URL. The same process also holds a // configurable push URL; the OAuth token must never follow it. The URL is // a constant (no environment variable changes it) and redirects are // refused (`redirect: "error"`), so the token cannot be bounced elsewhere. // * `detail` IS AN ENUM. It comes from DETAILS below and nothing else, never // from an error message or a response body: both can carry upstream text, // and this value ends up in the admin dashboard. // * ONE CALL, NO RETRY. The endpoint rate-limits hard and sends no // Retry-After; a failed call waits for the next cron beat (15 min). const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const ANTHROPIC_BETA = "oauth-2025-04-20"; const LIMITS_TIMEOUT_MS = 10000; // Far beyond the ~2 KB the endpoint answers. Counted in bytes while reading: // past it the stream is cancelled, neither read further nor parsed (#54). const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_ITEMS = 12; const MAX_STRING = 128; const DETAILS = { NO_CREDENTIALS: "no_credentials", PARSE_ERROR: "parse_error", NO_VERSION: "no_version", TIMEOUT: "timeout", NETWORK: "network", BAD_RESPONSE: "bad_response", }; // Same shape as the server sanitizer's timestamp check: a value outside it // would be dropped there, so it is normalized (or nulled) here instead. const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/; function defaultCredentialsPath(home = os.homedir()) { return path.join(home, ".claude", ".credentials.json"); } function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function failure(status, detail) { const out = { status, fetchedAt: null, items: [] }; if (detail) out.detail = detail; return out; } function cleanString(value) { return typeof value === "string" ? value.slice(0, MAX_STRING) : null; } // The contract wants an integer 0-100. Upstream sends a number that can be // fractional (utilization) and could overshoot 100 once a limit is exceeded. function cleanPercent(value) { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; return Math.min(100, Math.max(0, Math.round(value))); } // ISO string kept as sent when it already has the contract's shape; any other // parseable string, or an epoch number (seconds or milliseconds), is // re-rendered with toISOString; anything else is null. function cleanResetsAt(value) { if (typeof value === "string") { if (value.length <= MAX_STRING && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value))) { return value; } const ms = Date.parse(value); return Number.isNaN(ms) ? null : new Date(ms).toISOString(); } if (typeof value === "number" && Number.isFinite(value) && value > 0) { const ms = value < 1e11 ? value * 1000 : value; const date = new Date(ms); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } return null; } function item({ kind, group, percent, severity, resetsAt, model }) { const out = { kind, group }; if (percent !== undefined) out.percent = percent; if (typeof severity === "string") out.severity = severity; out.resetsAt = resetsAt; out.model = model; return out; } // `limits[]` entries: { kind, group, percent, severity, resets_at, // scope: { model: { display_name } } | null, is_active }. Built field by field, // so no upstream key reaches the snapshot unless it is listed here. function itemsFromLimits(list) { const items = []; for (const entry of list) { if (items.length >= MAX_ITEMS) break; if (!isPlainObject(entry) || typeof entry.kind !== "string" || !entry.kind) continue; const scope = isPlainObject(entry.scope) ? entry.scope : null; const model = scope && isPlainObject(scope.model) ? cleanString(scope.model.display_name) : null; items.push( item({ kind: entry.kind.slice(0, MAX_STRING), group: typeof entry.group === "string" ? entry.group.slice(0, MAX_STRING) : entry.kind.slice(0, MAX_STRING), percent: cleanPercent(entry.percent), severity: cleanString(entry.severity), resetsAt: cleanResetsAt(entry.resets_at), model, }), ); } return items; } // Older shape: five_hour / seven_day { utilization, resets_at }. function itemsFromWindows(body) { const items = []; const windows = [ ["five_hour", "session", "session"], ["seven_day", "weekly_all", "weekly"], ]; for (const [key, kind, group] of windows) { const w = body[key]; if (!isPlainObject(w)) continue; const percent = cleanPercent(w.utilization); if (percent === undefined) continue; items.push(item({ kind, group, percent, severity: null, resetsAt: cleanResetsAt(w.resets_at), model: null })); } return items; } function parseUsage(body) { if (!isPlainObject(body)) return []; if (Array.isArray(body.limits)) { const items = itemsFromLimits(body.limits); if (items.length) return items; } return itemsFromWindows(body); } // Read the OAuth credentials. Never throws, never logs the content. function readCredentials(credentialsPath, readFile) { let raw; try { raw = readFile(credentialsPath); } catch (err) { if (err && err.code === "ENOENT") return { detail: DETAILS.NO_CREDENTIALS }; return { detail: DETAILS.PARSE_ERROR }; } let parsed; try { // Claude Code rewrites this file in place: a half-written JSON is a normal // occurrence, and the next beat will read the finished one. parsed = JSON.parse(String(raw)); } catch { return { detail: DETAILS.PARSE_ERROR }; } const oauth = isPlainObject(parsed) && isPlainObject(parsed.claudeAiOauth) ? parsed.claudeAiOauth : null; if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) { return { detail: DETAILS.NO_CREDENTIALS }; } return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt }; } async function readBody(res) { if (!res.body || typeof res.body.getReader !== "function") throw new Error("no body"); const reader = res.body.getReader(); const chunks = []; let total = 0; for (;;) { // A timeout surfaces here as a TimeoutError/AbortError, mapped by the caller. const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_RESPONSE_BYTES) { reader.cancel().catch(() => {}); throw new Error("too large"); } chunks.push(value); } return JSON.parse(Buffer.concat(chunks).toString("utf-8")); } function discardBody(res) { try { if (res && res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {}); } catch { // nothing to release } } /** * Fetch the plan limits once. * * @param {object} opts * @param {object} [opts.env] process environment (only CLAUDE_USAGE_LIMITS is read) * @param {string} [opts.credentialsPath] ~/.claude/.credentials.json by default * @param {Function} [opts.readFile] (path) => string, read-only — injectable for tests * @param {Function} [opts.fetch] fetch implementation — injectable for tests * @param {string|null} opts.claudeCodeVersion version for the User-Agent (from the transcripts) * @param {number} [opts.now] epoch ms * @param {number} [opts.timeoutMs] * @returns {Promise<{status: string, detail?: string, fetchedAt: string|null, items: object[]}>} */ async function fetchLimits({ env = process.env, credentialsPath = defaultCredentialsPath(), readFile = (file) => fs.readFileSync(file, "utf-8"), fetch: fetchImpl = globalThis.fetch, claudeCodeVersion = null, now = Date.now(), timeoutMs = LIMITS_TIMEOUT_MS, } = {}) { if (String(env.CLAUDE_USAGE_LIMITS || "").trim().toLowerCase() === "off") { return failure("disabled"); } const creds = readCredentials(credentialsPath, readFile); if (creds.detail) return failure("unavailable", creds.detail); // Never refreshed: the refresh token is single use and belongs to Claude // Code. A missing expiresAt is left to the server to judge (a 401 then). if (typeof creds.expiresAt === "number" && Number.isFinite(creds.expiresAt) && creds.expiresAt <= now) { return failure("token_expired"); } if (typeof claudeCodeVersion !== "string" || !claudeCodeVersion) { return failure("unavailable", DETAILS.NO_VERSION); } let res; try { res = await fetchImpl(USAGE_URL, { method: "GET", headers: { Authorization: `Bearer ${creds.accessToken}`, "anthropic-beta": ANTHROPIC_BETA, "User-Agent": `claude-code/${claudeCodeVersion}`, Accept: "application/json", }, redirect: "error", signal: AbortSignal.timeout(timeoutMs), }); } catch (err) { const name = err && err.name; return failure("unavailable", name === "TimeoutError" || name === "AbortError" ? DETAILS.TIMEOUT : DETAILS.NETWORK); } const status = res && Number.isInteger(res.status) ? res.status : 0; if (status < 200 || status > 299) { discardBody(res); return failure("http_error", status >= 100 && status <= 599 ? `http_${status}` : DETAILS.BAD_RESPONSE); } let body; try { body = await readBody(res); } catch (err) { const name = err && err.name; if (name === "TimeoutError" || name === "AbortError") return failure("unavailable", DETAILS.TIMEOUT); return failure("unavailable", DETAILS.BAD_RESPONSE); } const items = parseUsage(body); if (!items.length) return failure("unavailable", DETAILS.BAD_RESPONSE); return { status: "ok", fetchedAt: new Date(now).toISOString(), items }; } module.exports = { fetchLimits, parseUsage, defaultCredentialsPath, USAGE_URL, ANTHROPIC_BETA, LIMITS_TIMEOUT_MS, DETAILS, };