Initial release: Anthropic subscription usage footer extension

This commit is contained in:
Johannes Merz 2026-05-14 17:09:31 +02:00
commit 4d57e293e6
3 changed files with 225 additions and 0 deletions

32
README.md Normal file
View File

@ -0,0 +1,32 @@
# pi-anthropic-usage
Pi agent extension that shows your **Anthropic claude.ai Pro/Max subscription usage** live in the TUI footer.
## What it shows
```
☁ 5h █████░░░ 62% ↺1h23m 7d ██░░░░░░ 18% ↺4d2h
```
- **5h** — 5-hour rolling rate limit window
- **7d** — 7-day rolling rate limit window
- Color-coded: green → yellow (≥70%) → red (≥90%)
- Reset timer shows when each window clears
Only active when using an Anthropic claude.ai subscription (OAuth token). Regular API keys → no display.
## Install
```bash
pi install git:git.vpsj.de/jay/pi-anthropic-usage
```
## Commands
| Command | Description |
|----------|--------------------------------------|
| `/usage` | Manual refresh with detailed output |
## Auto-updates
Usage refreshes automatically after every model response and on model switch.

182
index.ts Normal file
View File

@ -0,0 +1,182 @@
/**
* 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));
},
});
}

11
package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "pi-anthropic-usage",
"version": "1.0.0",
"description": "Pi agent extension: shows Anthropic claude.ai Pro/Max subscription usage (5h + 7d rate limits) in the TUI footer",
"keywords": ["pi-package", "pi", "pi-coding-agent", "anthropic", "usage", "subscription"],
"author": "jay",
"license": "MIT",
"pi": {
"extensions": ["./index.ts"]
}
}