/** * 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; // 0–100 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 { 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 { 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 { 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)); }, }); }