pi-anthropic-usage/index.ts

183 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Anthropic Subscription Usage Extension
*
* Zeigt die aktuellen Rate-Limit-Auslastungen für Anthropic claude.ai
* Pro/Max Subscriptions live im TUI Footer an.
*
* Endpunkt: https://api.anthropic.com/api/oauth/usage
* Nur aktiv wenn OAuth-Token vorhanden (sk-ant-oat... = Subscription).
*
* Anzeige: Footer-Status via ctx.ui.setStatus()
* Update: nach agent_end + model_select + /usage Command
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
const STATUS_KEY = "anthropic-usage";
const FETCH_TIMEOUT_MS = 8000;
// ─── Typen ───────────────────────────────────────────────────────────────────
interface RateWindow {
utilization: number; // 0100
resets_at: string | null; // ISO-8601 oder null
}
interface UsageResponse {
five_hour?: RateWindow | null;
seven_day?: RateWindow | null;
seven_day_opus?: RateWindow | null;
seven_day_sonnet?: RateWindow | null;
[key: string]: RateWindow | null | undefined;
}
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
function formatTimeUntil(isoString: string | null): string {
if (!isoString) return "—";
const diffMs = new Date(isoString).getTime() - Date.now();
if (diffMs <= 0) return "jetzt";
const totalMin = Math.floor(diffMs / 60_000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
const d = Math.floor(h / 24);
if (d > 0) return `${d}d${h % 24}h`;
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h${m}m`;
}
function bar(pct: number, width = 8): string {
const filled = Math.round((pct / 100) * width);
return "█".repeat(filled) + "░".repeat(width - filled);
}
function colorForPct(pct: number, text: string, theme: any): string {
if (pct >= 90) return theme.fg("error", text);
if (pct >= 70) return theme.fg("warning", text);
return theme.fg("success", text);
}
function fmtWindowShort(label: string, w: RateWindow, theme: any): string {
const pct = Math.round(w.utilization);
const reset = formatTimeUntil(w.resets_at);
const indicator = colorForPct(pct, `${bar(pct)} ${pct}%`, theme);
return `${theme.fg("dim", label)} ${indicator} ${theme.fg("dim", `${reset}`)}`;
}
function formatStatus(data: UsageResponse, theme: any): string {
const parts: string[] = [];
if (data.five_hour?.utilization != null)
parts.push(fmtWindowShort("5h", data.five_hour, theme));
if (data.seven_day?.utilization != null)
parts.push(fmtWindowShort("7d", data.seven_day, theme));
// Optionale Modell-spezifische Fenster (falls belegt)
if (data.seven_day_opus?.utilization)
parts.push(fmtWindowShort("opus", data.seven_day_opus, theme));
if (data.seven_day_sonnet?.utilization)
parts.push(fmtWindowShort("sonnet", data.seven_day_sonnet, theme));
if (parts.length === 0) return theme.fg("dim", "☁ keine Usage-Daten");
return `${parts.join(" ")}`;
}
// ─── Fetch ───────────────────────────────────────────────────────────────────
async function fetchUsage(token: string): Promise<UsageResponse | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(USAGE_URL, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
if (!res.ok) return null;
return (await res.json()) as UsageResponse;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
// ─── Extension ───────────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
async function getOAuthToken(ctx: any): Promise<string | null> {
const model = ctx.model;
if (!model || model.provider !== "anthropic") return null;
if (!ctx.modelRegistry.isUsingOAuth(model)) return null;
return (await ctx.modelRegistry.getApiKeyForProvider("anthropic")) ?? null;
}
async function refresh(ctx: any): Promise<void> {
if (!ctx.hasUI) return;
const token = await getOAuthToken(ctx);
if (!token) {
ctx.ui.setStatus(STATUS_KEY, undefined);
return;
}
const data = await fetchUsage(token);
if (!data) {
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", "☁ usage: Fehler"));
return;
}
ctx.ui.setStatus(STATUS_KEY, formatStatus(data, ctx.ui.theme));
}
pi.on("session_start", async (_e, ctx) => { await refresh(ctx); });
pi.on("agent_end", async (_e, ctx) => { await refresh(ctx); });
pi.on("model_select", async (_e, ctx) => { await refresh(ctx); });
// ── /usage Command ────────────────────────────────────────────────────────
pi.registerCommand("usage", {
description: "Anthropic Subscription Usage anzeigen (Pro/Max)",
handler: async (_args, ctx) => {
const token = await getOAuthToken(ctx);
if (!token) {
ctx.ui.notify(
"Kein OAuth-Token — nur für claude.ai Pro/Max Subscriptions",
"warning"
);
return;
}
ctx.ui.notify("Lade Usage-Daten…", "info");
const data = await fetchUsage(token);
if (!data) {
ctx.ui.notify("Fetch fehlgeschlagen", "error");
return;
}
const lines: string[] = ["Anthropic Subscription Usage\n"];
const fmtDetail = (label: string, w: RateWindow) => {
const pct = w.utilization.toFixed(1);
const reset = formatTimeUntil(w.resets_at);
lines.push(`${label.padEnd(12)} ${bar(w.utilization, 14)} ${pct}% ↺ ${reset}`);
};
if (data.five_hour?.utilization != null) fmtDetail("5h-Fenster", data.five_hour);
if (data.seven_day?.utilization != null) fmtDetail("7d-Fenster", data.seven_day);
if (data.seven_day_opus?.utilization) fmtDetail("7d Opus", data.seven_day_opus);
if (data.seven_day_sonnet?.utilization) fmtDetail("7d Sonnet", data.seven_day_sonnet);
if (lines.length === 1) lines.push("Keine Daten verfügbar");
ctx.ui.notify(lines.join("\n"), "info");
ctx.ui.setStatus(STATUS_KEY, formatStatus(data, ctx.ui.theme));
},
});
}