limits.js reads the Claude Code OAuth credentials read-only and calls the pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no retry, never a refresh); detail codes come from a fixed enum only. state.js freezes every Toronto day before yesterday, empty ones included, prunes past 35 days, keeps the last good limits, and writes atomically in mode 600. push-claude-usage.js writes the cache before pushing, refuses a non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and prints no token. run-claude-usage.sh takes a non-blocking flock so a beat that overlaps the previous one exits 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
10 KiB
JavaScript
276 lines
10 KiB
JavaScript
// 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; past it the body is not parsed.
|
|
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) {
|
|
const text = await res.text();
|
|
if (text.length > MAX_RESPONSE_BYTES) throw new Error("too large");
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
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,
|
|
};
|