183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
/**
|
||
* 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<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));
|
||
},
|
||
});
|
||
}
|