Files
paperclip/packages/adapters/claude-local/src/server/quota.ts

532 lines
17 KiB
TypeScript

import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
const execFileAsync = promisify(execFile);
const CLAUDE_USAGE_SOURCE_OAUTH = "anthropic-oauth";
const CLAUDE_USAGE_SOURCE_CLI = "claude-cli";
export function claudeConfigDir(): string {
const fromEnv = process.env.CLAUDE_CONFIG_DIR;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".claude");
}
function hasNonEmptyProcessEnv(key: string): boolean {
const value = process.env[key];
return typeof value === "string" && value.trim().length > 0;
}
function createClaudeQuotaEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (typeof value !== "string") continue;
if (key.startsWith("ANTHROPIC_")) continue;
env[key] = value;
}
return env;
}
function stripBackspaces(text: string): string {
let out = "";
for (const char of text) {
if (char === "\b") {
out = out.slice(0, -1);
} else {
out += char;
}
}
return out;
}
function stripAnsi(text: string): string {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function cleanTerminalText(text: string): string {
return stripAnsi(stripBackspaces(text))
.replace(/\u0000/g, "")
.replace(/\r/g, "\n");
}
function normalizeForLabelSearch(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function trimToLatestUsagePanel(text: string): string | null {
const lower = text.toLowerCase();
const settingsIndex = lower.lastIndexOf("settings:");
if (settingsIndex < 0) return null;
let tail = text.slice(settingsIndex);
const tailLower = tail.toLowerCase();
if (!tailLower.includes("usage")) return null;
if (!tailLower.includes("current session") && !tailLower.includes("loading usage")) return null;
const stopMarkers = [
"status dialog dismissed",
"checking for updates",
"press ctrl-c again to exit",
];
let stopIndex = -1;
for (const marker of stopMarkers) {
const markerIndex = tailLower.indexOf(marker);
if (markerIndex >= 0 && (stopIndex === -1 || markerIndex < stopIndex)) {
stopIndex = markerIndex;
}
}
if (stopIndex >= 0) {
tail = tail.slice(0, stopIndex);
}
return tail;
}
async function readClaudeTokenFromFile(credPath: string): Promise<string | null> {
let raw: string;
try {
raw = await fs.readFile(credPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
const oauth = obj["claudeAiOauth"];
if (typeof oauth !== "object" || oauth === null) return null;
const token = (oauth as Record<string, unknown>)["accessToken"];
return typeof token === "string" && token.length > 0 ? token : null;
}
interface ClaudeAuthStatus {
loggedIn: boolean;
authMethod: string | null;
subscriptionType: string | null;
}
export async function readClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
try {
const { stdout } = await execFileAsync("claude", ["auth", "status"], {
env: process.env,
timeout: 5_000,
maxBuffer: 1024 * 1024,
});
const parsed = JSON.parse(stdout) as Record<string, unknown>;
return {
loggedIn: parsed.loggedIn === true,
authMethod: typeof parsed.authMethod === "string" ? parsed.authMethod : null,
subscriptionType: typeof parsed.subscriptionType === "string" ? parsed.subscriptionType : null,
};
} catch {
return null;
}
}
function describeClaudeSubscriptionAuth(status: ClaudeAuthStatus | null): string | null {
if (!status?.loggedIn || status.authMethod !== "claude.ai") return null;
return status.subscriptionType
? `Claude is logged in via claude.ai (${status.subscriptionType})`
: "Claude is logged in via claude.ai";
}
export async function readClaudeToken(): Promise<string | null> {
const configDir = claudeConfigDir();
for (const filename of [".credentials.json", "credentials.json"]) {
const token = await readClaudeTokenFromFile(path.join(configDir, filename));
if (token) return token;
}
return null;
}
interface AnthropicUsageWindow {
utilization?: number | null;
resets_at?: string | null;
}
interface AnthropicExtraUsage {
is_enabled?: boolean | null;
monthly_limit?: number | null;
used_credits?: number | null;
utilization?: number | null;
currency?: string | null;
}
interface AnthropicUsageResponse {
five_hour?: AnthropicUsageWindow | null;
seven_day?: AnthropicUsageWindow | null;
seven_day_sonnet?: AnthropicUsageWindow | null;
seven_day_opus?: AnthropicUsageWindow | null;
extra_usage?: AnthropicExtraUsage | null;
}
function formatCurrencyAmount(value: number, currency: string | null | undefined): string {
const code = typeof currency === "string" && currency.trim().length > 0 ? currency.trim().toUpperCase() : "USD";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
}).format(value);
}
function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null {
const monthlyLimit = extraUsage.monthly_limit;
const usedCredits = extraUsage.used_credits;
if (
typeof monthlyLimit !== "number" ||
!Number.isFinite(monthlyLimit) ||
typeof usedCredits !== "number" ||
!Number.isFinite(usedCredits)
) {
return null;
}
return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`;
}
/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */
export function toPercent(utilization: number | null | undefined): number | null {
if (utilization == null) return null;
return Math.min(100, Math.round(utilization * 100));
}
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
export async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
headers: {
Authorization: `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
},
});
if (!resp.ok) throw new Error(`anthropic usage api returned ${resp.status}`);
const body = (await resp.json()) as AnthropicUsageResponse;
const windows: QuotaWindow[] = [];
if (body.five_hour != null) {
windows.push({
label: "Current session",
usedPercent: toPercent(body.five_hour.utilization),
resetsAt: body.five_hour.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.seven_day != null) {
windows.push({
label: "Current week (all models)",
usedPercent: toPercent(body.seven_day.utilization),
resetsAt: body.seven_day.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.seven_day_sonnet != null) {
windows.push({
label: "Current week (Sonnet only)",
usedPercent: toPercent(body.seven_day_sonnet.utilization),
resetsAt: body.seven_day_sonnet.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.seven_day_opus != null) {
windows.push({
label: "Current week (Opus only)",
usedPercent: toPercent(body.seven_day_opus.utilization),
resetsAt: body.seven_day_opus.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.extra_usage != null) {
windows.push({
label: "Extra usage",
usedPercent: body.extra_usage.is_enabled === false ? null : toPercent(body.extra_usage.utilization),
resetsAt: null,
valueLabel:
body.extra_usage.is_enabled === false
? "Not enabled"
: formatExtraUsageLabel(body.extra_usage),
detail:
body.extra_usage.is_enabled === false
? "Extra usage not enabled"
: "Monthly extra usage pool",
});
}
return windows;
}
function usageOutputLooksRelevant(text: string): boolean {
const normalized = normalizeForLabelSearch(text);
return normalized.includes("currentsession")
|| normalized.includes("currentweek")
|| normalized.includes("loadingusage")
|| normalized.includes("failedtoloadusagedata")
|| normalized.includes("tokenexpired")
|| normalized.includes("authenticationerror")
|| normalized.includes("ratelimited");
}
function usageOutputLooksComplete(text: string): boolean {
const normalized = normalizeForLabelSearch(text);
if (
normalized.includes("failedtoloadusagedata")
|| normalized.includes("tokenexpired")
|| normalized.includes("authenticationerror")
|| normalized.includes("ratelimited")
) {
return true;
}
return normalized.includes("currentsession")
&& (normalized.includes("currentweek") || normalized.includes("extrausage"))
&& /[0-9]{1,3}(?:\.[0-9]+)?%/i.test(text);
}
function extractUsageError(text: string): string | null {
const lower = text.toLowerCase();
const compact = lower.replace(/\s+/g, "");
if (lower.includes("token_expired") || lower.includes("token has expired")) {
return "Claude CLI token expired. Run `claude login` to refresh.";
}
if (lower.includes("authentication_error")) {
return "Claude CLI authentication error. Run `claude login`.";
}
if (lower.includes("rate_limit_error") || lower.includes("rate limited") || compact.includes("ratelimited")) {
return "Claude CLI usage endpoint is rate limited right now. Please try again later.";
}
if (lower.includes("failed to load usage data") || compact.includes("failedtoloadusagedata")) {
return "Claude CLI could not load usage data. Open the CLI and retry `/usage`.";
}
return null;
}
function percentFromLine(line: string): number | null {
const match = line.match(/([0-9]{1,3}(?:\.[0-9]+)?)\s*%/i);
if (!match) return null;
const rawValue = Number(match[1]);
if (!Number.isFinite(rawValue)) return null;
const clamped = Math.min(100, Math.max(0, rawValue));
const lower = line.toLowerCase();
if (lower.includes("remaining") || lower.includes("left") || lower.includes("available")) {
return Math.max(0, Math.min(100, Math.round(100 - clamped)));
}
return Math.round(clamped);
}
function isQuotaLabel(line: string): boolean {
const normalized = normalizeForLabelSearch(line);
return normalized === "currentsession"
|| normalized === "currentweekallmodels"
|| normalized === "currentweeksonnetonly"
|| normalized === "currentweeksonnet"
|| normalized === "currentweekopusonly"
|| normalized === "currentweekopus"
|| normalized === "extrausage";
}
function canonicalQuotaLabel(line: string): string {
switch (normalizeForLabelSearch(line)) {
case "currentsession":
return "Current session";
case "currentweekallmodels":
return "Current week (all models)";
case "currentweeksonnetonly":
case "currentweeksonnet":
return "Current week (Sonnet only)";
case "currentweekopusonly":
case "currentweekopus":
return "Current week (Opus only)";
case "extrausage":
return "Extra usage";
default:
return line;
}
}
function formatClaudeCliDetail(label: string, lines: string[]): string | null {
const normalizedLabel = normalizeForLabelSearch(label);
if (normalizedLabel === "extrausage") {
const compact = lines.join(" ").replace(/\s+/g, "").toLowerCase();
if (compact.includes("extrausagenotenabled")) {
return "Extra usage not enabled • /extra-usage to enable";
}
const firstLine = lines.find((line) => line.trim().length > 0) ?? null;
return firstLine;
}
const resetLine = lines.find((line) => /^resets/i.test(line) || normalizeForLabelSearch(line).startsWith("resets"));
if (!resetLine) return null;
return resetLine
.replace(/^Resets/i, "Resets ")
.replace(/([A-Z][a-z]{2})(\d)/g, "$1 $2")
.replace(/(\d)at(\d)/g, "$1 at $2")
.replace(/(am|pm)\(/gi, "$1 (")
.replace(/([A-Za-z])\(/g, "$1 (")
.replace(/\s+/g, " ")
.trim();
}
export function parseClaudeCliUsageText(text: string): QuotaWindow[] {
const cleaned = trimToLatestUsagePanel(cleanTerminalText(text)) ?? cleanTerminalText(text);
const usageError = extractUsageError(cleaned);
if (usageError) throw new Error(usageError);
const lines = cleaned
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const sections: Array<{ label: string; lines: string[] }> = [];
let current: { label: string; lines: string[] } | null = null;
for (const line of lines) {
if (isQuotaLabel(line)) {
if (current) sections.push(current);
current = { label: canonicalQuotaLabel(line), lines: [] };
continue;
}
if (current) current.lines.push(line);
}
if (current) sections.push(current);
const windows = sections.map<QuotaWindow>((section) => {
const usedPercent = section.lines.map(percentFromLine).find((value) => value != null) ?? null;
return {
label: section.label,
usedPercent,
resetsAt: null,
valueLabel: null,
detail: formatClaudeCliDetail(section.label, section.lines),
};
});
if (!windows.some((window) => normalizeForLabelSearch(window.label) === "currentsession")) {
throw new Error("Could not parse Claude CLI usage output.");
}
return windows;
}
function quoteForShell(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function buildClaudeCliShellProbeCommand(): string {
const feed = "(sleep 2; printf '/usage\\r'; sleep 6; printf '\\033'; sleep 1; printf '\\003')";
const claudeCommand = "claude --tools \"\"";
if (process.platform === "darwin") {
return `${feed} | script -q /dev/null ${claudeCommand}`;
}
return `${feed} | script -q -e -f -c ${quoteForShell(claudeCommand)} /dev/null`;
}
export async function captureClaudeCliUsageText(timeoutMs = 12_000): Promise<string> {
const command = buildClaudeCliShellProbeCommand();
try {
const { stdout, stderr } = await execFileAsync("sh", ["-c", command], {
env: createClaudeQuotaEnv(),
timeout: timeoutMs,
maxBuffer: 8 * 1024 * 1024,
});
const output = `${stdout}${stderr}`;
const cleaned = cleanTerminalText(output);
if (usageOutputLooksComplete(cleaned)) return output;
throw new Error("Claude CLI usage probe ended before rendering usage.");
} catch (error) {
const stdout =
typeof error === "object" && error !== null && "stdout" in error && typeof error.stdout === "string"
? error.stdout
: "";
const stderr =
typeof error === "object" && error !== null && "stderr" in error && typeof error.stderr === "string"
? error.stderr
: "";
const output = `${stdout}${stderr}`;
const cleaned = cleanTerminalText(output);
if (usageOutputLooksComplete(cleaned)) return output;
if (usageOutputLooksRelevant(cleaned)) {
throw new Error("Claude CLI usage probe ended before rendering usage.");
}
throw error instanceof Error ? error : new Error(String(error));
}
}
export async function fetchClaudeCliQuota(): Promise<QuotaWindow[]> {
const rawText = await captureClaudeCliUsageText();
return parseClaudeCliUsageText(rawText);
}
function formatProviderError(source: string, error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return `${source}: ${message}`;
}
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
const authStatus = await readClaudeAuthStatus();
const authDescription = describeClaudeSubscriptionAuth(authStatus);
const token = await readClaudeToken();
const errors: string[] = [];
if (token) {
try {
const windows = await fetchClaudeQuota(token);
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_OAUTH, ok: true, windows };
} catch (error) {
errors.push(formatProviderError("Anthropic OAuth usage", error));
}
}
try {
const windows = await fetchClaudeCliQuota();
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_CLI, ok: true, windows };
} catch (error) {
errors.push(formatProviderError("Claude CLI /usage", error));
}
if (hasNonEmptyProcessEnv("ANTHROPIC_API_KEY") && !authDescription) {
return {
provider: "anthropic",
ok: false,
error:
errors[0]
?? "ANTHROPIC_API_KEY is set and no local Claude subscription session is available for quota polling",
windows: [],
};
}
if (authDescription) {
return {
provider: "anthropic",
ok: false,
error:
errors.length > 0
? `${authDescription}, but quota polling failed (${errors.join("; ")})`
: `${authDescription}, but Paperclip could not load subscription quota data`,
windows: [],
};
}
return {
provider: "anthropic",
ok: false,
error: errors[0] ?? "no local claude auth token",
windows: [],
};
}