vps-health-api/agent/claude-usage/limits.js
le king fu 2c95330ffa feat(agent): add the claude-usage agent: plan limits, local cache, push
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>
2026-10-04 09:28:33 -04:00

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,
};