pi-credits 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026—PRESENT Zilong Liang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # pi-credits
2
+
3
+ A [pi](https://pi.dev/) extension that shows the active model provider's credit balance or rate-limit usage as a footer status.
4
+
5
+ - The status appears only while a supported provider is active and uses that provider's stored credential or API key.
6
+ - The status refreshes on session start, model switch, and after each turn that incurs usage.
7
+
8
+ ## Supported providers
9
+
10
+ - **OpenAI Codex:** rate-limit usage of the 5-hour and weekly windows, as used percentages.
11
+ - **OpenRouter:** remaining credit balance in dollars.
12
+ - **Vercel AI Gateway:** remaining credit balance in dollars.
13
+
14
+ ## Install
15
+
16
+ Install from npm:
17
+
18
+ ```bash
19
+ pi install npm:pi-credits
20
+ ```
21
+
22
+ Install from git:
23
+
24
+ ```bash
25
+ pi install git:github.com/zlliang/pi-credits
26
+ ```
27
+
28
+ ## Other pi packages
29
+
30
+ - [pi-spark](https://github.com/zlliang/pi-spark): a small, opinionated collection of pi extensions.
package/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { CreditsManager } from "./src/manager";
2
+ import { isUsage } from "./src/utils";
3
+
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
6
+
7
+ function hasCost(message: AgentMessage): boolean {
8
+ const usage = (message as { usage?: unknown }).usage;
9
+ if (!isUsage(usage)) return false;
10
+
11
+ return usage.cost.total > 0 || usage.input > 0 || usage.output > 0;
12
+ }
13
+
14
+ export default function (pi: ExtensionAPI) {
15
+ let creditsManager: CreditsManager | undefined = undefined;
16
+
17
+ pi.on("session_start", (_event, ctx) => {
18
+ if (!ctx.hasUI) return;
19
+
20
+ creditsManager = new CreditsManager();
21
+ creditsManager.refresh(ctx);
22
+ });
23
+
24
+ pi.on("model_select", (_event, ctx) => {
25
+ creditsManager?.refresh(ctx);
26
+ });
27
+
28
+ pi.on("turn_end", (event, ctx) => {
29
+ if (!hasCost(event.message)) return;
30
+
31
+ creditsManager?.refresh(ctx);
32
+ });
33
+
34
+ pi.on("session_compact", (_event, ctx) => {
35
+ creditsManager?.refresh(ctx);
36
+ });
37
+
38
+ pi.on("session_tree", (event, ctx) => {
39
+ if (!event.summaryEntry) return;
40
+
41
+ creditsManager?.refresh(ctx);
42
+ });
43
+
44
+ pi.on("session_shutdown", () => {
45
+ creditsManager = undefined;
46
+ });
47
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "pi-credits",
3
+ "version": "0.1.0",
4
+ "description": "A pi extension that shows the active provider's credit balance or rate-limit usage",
5
+ "keywords": [
6
+ "pi-coding-agent",
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/zlliang/pi-credits.git"
12
+ },
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "files": [
16
+ "index.ts",
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ],
25
+ "image": "https://raw.githubusercontent.com/zlliang/pi-credits/main/assets/cover.png"
26
+ },
27
+ "scripts": {
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-agent-core": "*",
32
+ "@earendil-works/pi-ai": "*",
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@earendil-works/pi-agent-core": "*",
38
+ "@earendil-works/pi-ai": "*",
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*",
41
+ "@types/node": "*",
42
+ "typebox": "*",
43
+ "typescript": "^6.0.3"
44
+ }
45
+ }
package/src/manager.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { findProvider } from "./providers";
2
+ import { renderCredits, renderError } from "./status";
3
+
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { CreditsProvider } from "./types";
6
+
7
+ const STATUS_KEY = "credits";
8
+ const REQUEST_TIMEOUT_MS = 30_000;
9
+
10
+ export class CreditsManager {
11
+ private inflight: AbortController | undefined = undefined;
12
+
13
+ async refresh(ctx: ExtensionContext): Promise<void> {
14
+ this.inflight?.abort();
15
+
16
+ const provider = findProvider(ctx.model?.provider);
17
+ if (!provider) {
18
+ this.inflight = undefined;
19
+ ctx.ui.setStatus(STATUS_KEY, undefined);
20
+ return;
21
+ }
22
+
23
+ const controller = new AbortController();
24
+ this.inflight = controller;
25
+
26
+ await this.fetch(ctx, provider, controller.signal).finally(() => {
27
+ if (this.inflight === controller) this.inflight = undefined;
28
+ });
29
+ }
30
+
31
+ private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
32
+ try {
33
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.provider);
34
+ if (!apiKey) {
35
+ ctx.ui.setStatus(STATUS_KEY, undefined);
36
+ return;
37
+ }
38
+
39
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal];
40
+ if (ctx.signal) signals.push(ctx.signal);
41
+
42
+ const credits = await provider.fetch(ctx, apiKey, AbortSignal.any(signals));
43
+
44
+ // The active model may have changed while the request was in flight.
45
+ if (ctx.model?.provider !== provider.provider) return;
46
+
47
+ ctx.ui.setStatus(STATUS_KEY, renderCredits(ctx.ui.theme, provider.label, credits));
48
+ } catch (error) {
49
+ if (signal.aborted || ctx.signal?.aborted) return;
50
+ if (ctx.model?.provider !== provider.provider) return;
51
+
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ ctx.ui.setStatus(STATUS_KEY, renderError(ctx.ui.theme, provider.label, message));
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,15 @@
1
+ import { openaiCodexProvider } from "./openai-codex";
2
+ import { openrouterProvider } from "./openrouter";
3
+ import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
4
+
5
+ import type { CreditsProvider } from "../types";
6
+
7
+ const PROVIDERS: CreditsProvider[] = [
8
+ openaiCodexProvider,
9
+ openrouterProvider,
10
+ vercelAiGatewayProvider,
11
+ ];
12
+
13
+ export function findProvider(provider?: string): CreditsProvider | undefined {
14
+ return PROVIDERS.find((entry) => entry.provider === provider);
15
+ }
@@ -0,0 +1,62 @@
1
+ import { toNumber } from "../utils";
2
+
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Credits, CreditsProvider } from "../types";
5
+
6
+ const PROVIDER = "openai-codex";
7
+ const URL = "https://chatgpt.com/backend-api/wham/usage";
8
+
9
+ interface CodexUsageResponse {
10
+ rate_limit?: {
11
+ primary_window?: CodexRateWindow | null;
12
+ secondary_window?: CodexRateWindow | null;
13
+ } | null;
14
+ credits?: {
15
+ unlimited?: boolean;
16
+ } | null;
17
+ }
18
+
19
+ interface CodexRateWindow {
20
+ used_percent?: number | string;
21
+ }
22
+
23
+ export function getAccountId(ctx: ExtensionContext): string | undefined {
24
+ const credential = ctx.modelRegistry.authStorage.get(PROVIDER) as { accountId?: string } | undefined;
25
+ const accountId = credential?.accountId;
26
+
27
+ return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
28
+ }
29
+
30
+ function parseUsedPercent(window?: CodexRateWindow | null): number | undefined {
31
+ const value = toNumber(window?.used_percent);
32
+ return typeof value === "number" ? Math.min(100, Math.max(0, value)) : undefined;
33
+ }
34
+
35
+ export const openaiCodexProvider: CreditsProvider = {
36
+ provider: PROVIDER,
37
+ label: "Codex",
38
+
39
+ async fetch(ctx, apiKey, signal): Promise<Credits> {
40
+ const headers: Record<string, string> = {
41
+ Accept: "application/json",
42
+ Authorization: `Bearer ${apiKey}`,
43
+ };
44
+
45
+ const accountId = getAccountId(ctx);
46
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
47
+
48
+ const response = await fetch(URL, { headers, signal });
49
+ if (!response.ok) throw new Error("request failed");
50
+
51
+ const payload = (await response.json()) as CodexUsageResponse;
52
+
53
+ return {
54
+ type: "windows",
55
+ unlimited: payload.credits?.unlimited === true,
56
+ lanes: [
57
+ { label: "5h", percent: parseUsedPercent(payload.rate_limit?.primary_window) },
58
+ { label: "7d", percent: parseUsedPercent(payload.rate_limit?.secondary_window) },
59
+ ],
60
+ };
61
+ },
62
+ };
@@ -0,0 +1,35 @@
1
+ import { toNumber } from "../utils";
2
+
3
+ import type { Credits, CreditsProvider } from "../types";
4
+
5
+ const PROVIDER = "openrouter";
6
+ const URL = "https://openrouter.ai/api/v1/credits";
7
+
8
+ interface OpenRouterCreditsResponse {
9
+ data?: {
10
+ total_credits?: number | null;
11
+ total_usage?: number | null;
12
+ } | null;
13
+ }
14
+
15
+ export const openrouterProvider: CreditsProvider = {
16
+ provider: PROVIDER,
17
+ label: "OpenRouter",
18
+
19
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
20
+ const headers: Record<string, string> = {
21
+ Accept: "application/json",
22
+ Authorization: `Bearer ${apiKey}`,
23
+ };
24
+
25
+ const response = await fetch(URL, { headers, signal });
26
+ if (!response.ok) throw new Error("request failed");
27
+
28
+ const payload = (await response.json()) as OpenRouterCreditsResponse;
29
+ const totalCredits = toNumber(payload.data?.total_credits);
30
+ const totalUsage = toNumber(payload.data?.total_usage);
31
+ const remaining = typeof totalCredits === "number" && typeof totalUsage === "number" ? totalCredits - totalUsage : undefined;
32
+
33
+ return { type: "balance", remaining };
34
+ },
35
+ };
@@ -0,0 +1,30 @@
1
+ import { toNumber } from "../utils";
2
+
3
+ import type { Credits, CreditsProvider } from "../types";
4
+
5
+ const PROVIDER = "vercel-ai-gateway";
6
+ const URL = "https://ai-gateway.vercel.sh/v1/credits";
7
+
8
+ interface VercelCreditsResponse {
9
+ balance?: string | number;
10
+ }
11
+
12
+ export const vercelAiGatewayProvider: CreditsProvider = {
13
+ provider: PROVIDER,
14
+ label: "Vercel",
15
+
16
+ async fetch(_ctx, apiKey, signal): Promise<Credits> {
17
+ const headers: Record<string, string> = {
18
+ Accept: "application/json",
19
+ Authorization: `Bearer ${apiKey}`,
20
+ };
21
+
22
+ const response = await fetch(URL, { headers, signal });
23
+ if (!response.ok) throw new Error("request failed");
24
+
25
+ const payload = (await response.json()) as VercelCreditsResponse;
26
+ const remaining = toNumber(payload.balance);
27
+
28
+ return { type: "balance", remaining };
29
+ },
30
+ };
package/src/status.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Credits, CreditsLane } from "./types";
3
+
4
+ const WINDOWS_WARNING = 70;
5
+ const WINDOWS_ERROR = 90;
6
+ const BALANCE_WARNING = 10;
7
+ const BALANCE_ERROR = 5;
8
+
9
+ export function renderCredits(theme: Theme, label: string, credits: Credits): string {
10
+ const styledLabel = theme.fg("dim", label);
11
+
12
+ if (credits.type === "windows") return `${styledLabel} ${renderWindows(theme, credits)}`;
13
+ return `${styledLabel} ${renderBalance(theme, credits)}`;
14
+ }
15
+
16
+ export function renderError(theme: Theme, label: string, message: string): string {
17
+ return theme.fg("error", `${label} credits unavailable: ${message}`);
18
+ }
19
+
20
+ function renderWindows(theme: Theme, credits: Extract<Credits, { type: "windows" }>): string {
21
+ const unlimited = credits.unlimited || credits.lanes.every((lane) => lane.percent === undefined);
22
+ if (unlimited) return theme.fg("success", "unlimited");
23
+
24
+ return credits.lanes.map((lane) => renderLane(theme, lane)).join(" ");
25
+ }
26
+
27
+ function renderLane(theme: Theme, lane: CreditsLane): string {
28
+ const text = `${lane.label} ${lane.percent === undefined ? "?" : lane.percent.toFixed(0)}%`;
29
+
30
+ if (lane.percent && lane.percent > WINDOWS_ERROR) return theme.fg("error", text);
31
+ if (lane.percent && lane.percent > WINDOWS_WARNING) return theme.fg("warning", text);
32
+ return theme.fg("success", text);
33
+ }
34
+
35
+ function renderBalance(theme: Theme, credits: Extract<Credits, { type: "balance" }>): string {
36
+ if (credits.remaining === undefined) return theme.fg("dim", "$?");
37
+
38
+ const text = `$${credits.remaining.toFixed(2)}`;
39
+
40
+ if (credits.remaining < BALANCE_ERROR) return theme.fg("error", text);
41
+ if (credits.remaining < BALANCE_WARNING) return theme.fg("warning", text);
42
+ return theme.fg("success", text);
43
+ }
package/src/types.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { Provider } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /**
5
+ * Normalized credits/usage for a provider.
6
+ *
7
+ * - `balance` providers (e.g., OpenRouter, Vercel AI Gateway) report a remaining dollar balance.
8
+ * - `windows` providers (e.g., OpenAI Codex) report rate-limit windows as used percentages.
9
+ */
10
+ export type Credits =
11
+ | { type: "balance"; remaining?: number | undefined }
12
+ | { type: "windows"; lanes: CreditsLane[]; unlimited?: boolean | undefined };
13
+
14
+ export interface CreditsLane {
15
+ label: string;
16
+ percent: number | undefined;
17
+ }
18
+
19
+ /** A credits source for a pi provider, shown in the status line while that provider is active. */
20
+ export interface CreditsProvider {
21
+ readonly provider: Provider;
22
+ readonly label: string;
23
+ fetch(ctx: ExtensionContext, apiKey: string, signal: AbortSignal): Promise<Credits>;
24
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+
3
+ /** Structural type guard for the pi `Usage` shape. */
4
+ export function isUsage(value: unknown): value is Usage {
5
+ if (typeof value !== "object" || value === null) return false;
6
+
7
+ const usage = value as Record<string, unknown>;
8
+ const hasTokenFields =
9
+ typeof usage.input === "number" &&
10
+ typeof usage.output === "number" &&
11
+ typeof usage.cacheRead === "number" &&
12
+ typeof usage.cacheWrite === "number" &&
13
+ typeof usage.totalTokens === "number";
14
+ if (!hasTokenFields) return false;
15
+
16
+ if (typeof usage.cost !== "object" || usage.cost === null) return false;
17
+ const cost = usage.cost as Record<string, unknown>;
18
+ return (
19
+ typeof cost.input === "number" &&
20
+ typeof cost.output === "number" &&
21
+ typeof cost.cacheRead === "number" &&
22
+ typeof cost.cacheWrite === "number" &&
23
+ typeof cost.total === "number"
24
+ );
25
+ }
26
+
27
+ export function toNumber(value?: string | number | null): number | undefined {
28
+ if (value === undefined || value === null) return undefined;
29
+ const parsed = typeof value === "number" ? value : Number(value);
30
+ return Number.isFinite(parsed) ? parsed : undefined;
31
+ }