vps-health-api/agent/claude-usage/limits.js
le king fu 123b3c3c8c fix(agent): no double count across midnight, no freeze on a read error, bounded limits read
- aggregate: frozen days are dropped after deduplication, not before. A
  request straddling midnight whose best line sat on a day frozen since
  was counted again on the open day. On equal output the earliest line now
  wins (it was the first one read), so the choice no longer depends on
  which files a beat reads.
- aggregate: unreadable directories, failed stats (ENOENT included) and
  stream errors are counted in `readErrors`; a missing root is still an
  empty scan. The beat then freezes no new day (nextState freeze: false),
  still pushes, and logs `read_errors=N` with no path.
- limits: the response is read chunk by chunk and counted in bytes; past
  256 KiB the stream is cancelled instead of being read in full and
  measured in characters.
- state: a test now covers the chmodSync of a leftover temp file.

Each fix was checked by mutation (13 mutations, 13 red).

Refs #54

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:29:29 -04:00

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