@narumitw/pi-usage 0.24.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 narumiruna
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,150 @@
1
+ # 📊 pi-usage — Provider Usage for Pi
2
+
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-usage)](https://www.npmjs.com/package/@narumitw/pi-usage) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
+
5
+ `@narumitw/pi-usage` is a native [Pi coding agent](https://pi.dev) extension that adds one interactive `/usage` command for reading usage from the account Pi is actually using. It supports OpenAI Codex ChatGPT subscription windows and OpenRouter API-key spend limits without pretending those limits have the same semantics.
6
+
7
+ ## ✨ Features
8
+
9
+ - Opens one interactive `/usage` menu with current state and next actions.
10
+ - Automatically queries the selected model provider and active runtime account.
11
+ - Supports OpenAI Codex subscription windows, resets, credits, and model-specific buckets.
12
+ - Supports OpenRouter per-key credit limits plus daily, weekly, monthly, and all-time spend.
13
+ - Provides explicit refresh, another-provider, and all-configured-provider actions.
14
+ - Runs manually requested all-provider queries with concurrency limited to two and preserves partial results.
15
+ - Labels only the selected model provider as `Current`; other results are `Configured`.
16
+ - Keeps the compact statusline scoped to the current provider and runtime account.
17
+ - Isolates its five-minute in-memory cache by provider and a process-salted credential fingerprint.
18
+ - Resolves credentials through Pi and never reads Pi, account-extension, Codex CLI, or provider auth files.
19
+ - Retains `/codex-status` as a temporary argument-free compatibility alias.
20
+
21
+ ## 📦 Install
22
+
23
+ Requires Pi 0.81.0 or newer so the extension can validate the effective base URL attached to resolved provider auth before sending credentials to an official usage endpoint.
24
+
25
+ ```bash
26
+ pi install npm:@narumitw/pi-usage
27
+ ```
28
+
29
+ Try without installing permanently:
30
+
31
+ ```bash
32
+ pi -e npm:@narumitw/pi-usage
33
+ ```
34
+
35
+ Try this package locally from the repository root:
36
+
37
+ ```bash
38
+ pi -e ./extensions/pi-usage
39
+ ```
40
+
41
+ ## 🚀 Usage
42
+
43
+ Run:
44
+
45
+ ```text
46
+ /usage
47
+ ```
48
+
49
+ The menu first queries the current model provider and presents its state with these actions:
50
+
51
+ ```text
52
+ Refresh current usage
53
+ View another configured provider…
54
+ View all configured providers…
55
+ Close
56
+ ```
57
+
58
+ There are intentionally no `/usage --refresh`, `/usage <provider>`, or `/usage --all` argument paths. Cross-provider traffic requires an explicit interactive choice.
59
+
60
+ `/codex-status` opens the same menu during the migration period. Its former flags are not supported by `pi-usage`.
61
+
62
+ ## 📋 Provider semantics
63
+
64
+ ### OpenAI Codex
65
+
66
+ - Provider ID: `openai-codex`
67
+ - Semantics: ChatGPT consumer subscription limits
68
+ - Source: the Codex usage endpoint using Pi's resolved runtime authorization
69
+ - Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
70
+ - Statusline examples: `codex 59% 5h 61% wk` or `codex spark 100% 5h`
71
+
72
+ The statusline selects a returned bucket that matches the current Codex model when one is available. Unlike `pi-codex-usage`, this successor intentionally has no Codex CLI fallback because the CLI may be logged into a different account than Pi's active runtime account.
73
+
74
+ ### OpenRouter
75
+
76
+ - Provider ID: `openrouter`
77
+ - Semantics: API-key spend and per-key credit limits—not consumer subscription quota
78
+ - Source: OpenRouter's documented [`GET /api/v1/key`](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-api-key) endpoint using Pi's resolved inference API key
79
+ - Displayed data: key label when safely returned, optional per-key limit and remaining amount, reset period, and daily/weekly/monthly/all-time spend
80
+ - Statusline examples: `openrouter $74.50 left` or `openrouter $25.50 used`
81
+
82
+ The extension does not call OpenRouter's account-level `/credits` endpoint because that operation requires a separate management key. OpenRouter documents the distinction between credit and rate limits in its [API limits guide](https://openrouter.ai/docs/api_reference/limits).
83
+
84
+ ## 🧭 Current and configured accounts
85
+
86
+ `Current` means the provider and credential used by Pi's selected model. `Configured` means Pi reports runtime auth for another supported provider; it does not mean that provider is active.
87
+
88
+ The extension does not enumerate multiple accounts inside one provider and does not switch accounts. Account selection remains owned by Pi or an account-management extension. After the active runtime credential changes, the next command, turn, or scheduled refresh resolves auth again and cannot reuse another account's cached report.
89
+
90
+ ## 📊 Statusline behavior
91
+
92
+ The `usage` status item is active only for the selected model provider. It refreshes every five minutes while the session remains on a supported provider and is cleared when the model changes to an unsupported provider.
93
+
94
+ Manual another-provider and all-provider queries never publish to the statusline. `@narumitw/pi-statusline` supplies the default `📊` icon; `pi-usage` publishes text-only values.
95
+
96
+ ## 🔄 Migrating from pi-codex-usage
97
+
98
+ `pi-codex-usage` remains available while this successor soaks. To migrate one installation:
99
+
100
+ ```bash
101
+ pi remove npm:@narumitw/pi-codex-usage
102
+ pi install npm:@narumitw/pi-usage
103
+ ```
104
+
105
+ Do not load both packages together: both register `/codex-status`, so Pi must suffix duplicate commands and the two packages can publish overlapping usage status.
106
+
107
+ Behavior changes:
108
+
109
+ - Use `/usage` as the primary entry point.
110
+ - `/codex-status` is an argument-free compatibility alias.
111
+ - Refresh and cross-provider operations are menu actions rather than flags.
112
+ - Codex CLI fallback is removed to preserve active-runtime-account correctness.
113
+ - The status key changes from `codex-usage` to `usage`.
114
+
115
+ ## 🚧 Limitations
116
+
117
+ - Only providers with a stable, meaningful usage source and Pi-resolvable runtime auth are supported.
118
+ - Credentials resolved for custom provider base URLs are never forwarded to the providers' official usage endpoints; effective auth origin validation requires Pi 0.81.0 or newer.
119
+ - Provider reports are snapshots and may themselves be delayed by the provider.
120
+ - OpenRouter successful inference responses do not expose proactive request-rate counters; `/usage` reports the documented per-key credit/spend fields instead.
121
+ - A provider may not return a safe human-readable account identity. In that case the provider and runtime credential state remain visible without exposing secrets.
122
+ - Immediate account-change events are not available from Pi; auth is re-resolved before commands, turns, and scheduled refreshes.
123
+
124
+ ## 🗂️ Package layout
125
+
126
+ ```txt
127
+ extensions/pi-usage/
128
+ ├── src/
129
+ │ ├── usage.ts # Pi entrypoint, menu, cache, and lifecycle orchestration
130
+ │ ├── query.ts # Runtime auth resolution and provider queries
131
+ │ ├── format.ts # Provider-aware notifications and statusline text
132
+ │ ├── core.ts # Cache, concurrency, fingerprint, and redaction helpers
133
+ │ ├── providers/ # Codex and OpenRouter normalization adapters
134
+ │ └── types.ts # Common presentation and adapter contracts
135
+ ├── test/
136
+ ├── README.md
137
+ ├── LICENSE
138
+ ├── tsconfig.json
139
+ └── package.json
140
+ ```
141
+
142
+ Only `usage.ts` is a Pi entrypoint; other source modules are internal.
143
+
144
+ ## 🔎 Keywords
145
+
146
+ Pi extension, Pi coding agent, usage, quota, OpenAI Codex usage, ChatGPT subscription limits, OpenRouter credits, API-key spend limits, TypeScript Pi package, npm Pi extension.
147
+
148
+ ## 📄 License
149
+
150
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@narumitw/pi-usage",
3
+ "version": "0.24.0",
4
+ "description": "Pi extension that shows current-account usage across supported model providers.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "usage",
13
+ "quota",
14
+ "codex",
15
+ "openrouter"
16
+ ],
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "./src/usage.ts"
25
+ ]
26
+ },
27
+ "scripts": {
28
+ "check": "biome check --vcs-use-ignore-file=false src test package.json tsconfig.json README.md && npm run typecheck",
29
+ "format": "biome check --write --vcs-use-ignore-file=false src test package.json tsconfig.json README.md",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": ">=0.81.0"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "2.5.3",
37
+ "@earendil-works/pi-coding-agent": "0.81.0",
38
+ "@types/node": "26.1.1",
39
+ "typescript": "7.0.2"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/narumiruna/pi-extensions",
44
+ "directory": "extensions/pi-usage"
45
+ }
46
+ }
package/src/core.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { createHmac } from "node:crypto";
2
+ import type { UsageReport } from "./types.js";
3
+
4
+ export class UsageCache {
5
+ private readonly entries = new Map<string, { createdAt: number; report: UsageReport }>();
6
+ private readonly ttlMs: number;
7
+ private readonly maxEntries: number;
8
+
9
+ constructor(ttlMs: number, maxEntries = 32) {
10
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error("Cache TTL must be positive.");
11
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
12
+ throw new Error("Cache entry limit must be a positive integer.");
13
+ }
14
+ this.ttlMs = ttlMs;
15
+ this.maxEntries = maxEntries;
16
+ }
17
+
18
+ get size(): number {
19
+ return this.entries.size;
20
+ }
21
+
22
+ get(providerId: string, fingerprint: string, now = Date.now()): UsageReport | undefined {
23
+ this.sweepExpired(now);
24
+ return this.entries.get(cacheKey(providerId, fingerprint))?.report;
25
+ }
26
+
27
+ set(providerId: string, fingerprint: string, report: UsageReport, now = Date.now()): void {
28
+ this.sweepExpired(now);
29
+ const key = cacheKey(providerId, fingerprint);
30
+ this.entries.delete(key);
31
+ while (this.entries.size >= this.maxEntries) {
32
+ const oldest = this.entries.keys().next().value;
33
+ if (oldest === undefined) break;
34
+ this.entries.delete(oldest);
35
+ }
36
+ this.entries.set(key, { createdAt: now, report });
37
+ }
38
+
39
+ clearProvider(providerId: string): void {
40
+ for (const key of this.entries.keys()) {
41
+ if (key.startsWith(`${providerId}:`)) this.entries.delete(key);
42
+ }
43
+ }
44
+
45
+ clear(): void {
46
+ this.entries.clear();
47
+ }
48
+
49
+ private sweepExpired(now: number): void {
50
+ for (const [key, entry] of this.entries) {
51
+ if (now - entry.createdAt >= this.ttlMs) this.entries.delete(key);
52
+ }
53
+ }
54
+ }
55
+
56
+ export function fingerprintResolvedAuth(
57
+ auth: { apiKey?: string; headers?: Record<string, string> },
58
+ salt: Uint8Array,
59
+ ): string {
60
+ const headers = Object.entries(auth.headers ?? {})
61
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
62
+ .sort(([left], [right]) => left.localeCompare(right));
63
+ const canonical = JSON.stringify({ apiKey: auth.apiKey ?? "", headers });
64
+ return createHmac("sha256", salt).update(canonical).digest("hex");
65
+ }
66
+
67
+ export async function runWithConcurrency<T, R>(
68
+ items: readonly T[],
69
+ limit: number,
70
+ worker: (item: T, index: number, signal: AbortSignal) => Promise<R>,
71
+ signal: AbortSignal,
72
+ ): Promise<PromiseSettledResult<R>[]> {
73
+ if (signal.aborted) throw abortError();
74
+ if (!Number.isSafeInteger(limit) || limit < 1)
75
+ throw new Error("Concurrency limit must be positive.");
76
+
77
+ const results = new Array<PromiseSettledResult<R>>(items.length);
78
+ let nextIndex = 0;
79
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
80
+ while (nextIndex < items.length) {
81
+ if (signal.aborted) throw abortError();
82
+ const index = nextIndex;
83
+ nextIndex += 1;
84
+ try {
85
+ results[index] = {
86
+ status: "fulfilled",
87
+ value: await worker(items[index] as T, index, signal),
88
+ };
89
+ } catch (reason) {
90
+ results[index] = { status: "rejected", reason };
91
+ }
92
+ }
93
+ });
94
+
95
+ await Promise.all(runners);
96
+ if (signal.aborted) throw abortError();
97
+ return results;
98
+ }
99
+
100
+ export async function awaitWithDeadline<T>(
101
+ operation: Promise<T>,
102
+ signal: AbortSignal,
103
+ timeoutMs: number,
104
+ description: string,
105
+ ): Promise<T> {
106
+ if (signal.aborted) throw abortError();
107
+ const controller = new AbortController();
108
+ const abortFromCaller = () => controller.abort();
109
+ signal.addEventListener("abort", abortFromCaller, { once: true });
110
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
111
+ try {
112
+ return await Promise.race([
113
+ operation,
114
+ new Promise<never>((_resolve, reject) => {
115
+ controller.signal.addEventListener(
116
+ "abort",
117
+ () => {
118
+ reject(
119
+ signal.aborted
120
+ ? abortError()
121
+ : Object.assign(
122
+ new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s ${description}.`),
123
+ { name: "TimeoutError" },
124
+ ),
125
+ );
126
+ },
127
+ { once: true },
128
+ );
129
+ }),
130
+ ]);
131
+ } finally {
132
+ clearTimeout(timeout);
133
+ signal.removeEventListener("abort", abortFromCaller);
134
+ }
135
+ }
136
+
137
+ export function sanitizeDisplayText(value: string, maxChars = 160): string {
138
+ let result = "";
139
+ for (let index = 0; index < value.length; ) {
140
+ const codePoint = value.codePointAt(index) ?? 0;
141
+ const character = String.fromCodePoint(codePoint);
142
+ if (codePoint === 0x1b || codePoint === 0x9b || codePoint === 0x9d) {
143
+ index = skipTerminalEscape(value, index, codePoint);
144
+ continue;
145
+ }
146
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
147
+ if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d) result += " ";
148
+ index += character.length;
149
+ continue;
150
+ }
151
+ result += character;
152
+ index += character.length;
153
+ }
154
+ return truncate(result.replace(/\s+/gu, " ").trim(), maxChars);
155
+ }
156
+
157
+ export function redactUsageError(value: string, secrets: readonly string[] = []): string {
158
+ let redacted = value;
159
+ for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length)) {
160
+ redacted = redacted.replace(new RegExp(escapeRegExp(secret), "g"), "<redacted>");
161
+ }
162
+ redacted = redacted
163
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
164
+ .replace(/"(?:access_token|refresh_token|api_key)"\s*:\s*"[^"]+"/gi, (match) => {
165
+ const separator = match.indexOf(":");
166
+ return `${match.slice(0, separator + 1)}"<redacted>"`;
167
+ });
168
+ return sanitizeDisplayText(redacted, 600);
169
+ }
170
+
171
+ export function errorMessage(error: unknown): string {
172
+ return sanitizeDisplayText(error instanceof Error ? error.message : String(error), 600);
173
+ }
174
+
175
+ export function abortError(): Error {
176
+ return Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
177
+ }
178
+
179
+ function skipTerminalEscape(value: string, start: number, codePoint: number): number {
180
+ let index = start + 1;
181
+ const next = value.charCodeAt(index);
182
+ const isOsc = codePoint === 0x9d || (codePoint === 0x1b && next === 0x5d);
183
+ if (isOsc) {
184
+ if (codePoint === 0x1b) index += 1;
185
+ while (index < value.length) {
186
+ const current = value.charCodeAt(index);
187
+ if (current === 0x07) return index + 1;
188
+ if (current === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
189
+ index += 1;
190
+ }
191
+ return index;
192
+ }
193
+ const isCsi = codePoint === 0x9b || (codePoint === 0x1b && next === 0x5b);
194
+ if (isCsi) {
195
+ if (codePoint === 0x1b) index += 1;
196
+ while (index < value.length) {
197
+ const current = value.charCodeAt(index);
198
+ index += 1;
199
+ if (current >= 0x40 && current <= 0x7e) break;
200
+ }
201
+ return index;
202
+ }
203
+ return Math.min(value.length, start + (codePoint === 0x1b ? 2 : 1));
204
+ }
205
+
206
+ function cacheKey(providerId: string, fingerprint: string): string {
207
+ return `${providerId}:${fingerprint}`;
208
+ }
209
+
210
+ function escapeRegExp(value: string): string {
211
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
212
+ }
213
+
214
+ function truncate(value: string, maxChars: number): string {
215
+ if (value.length <= maxChars) return value;
216
+ return `${value.slice(0, maxChars - 1)}…`;
217
+ }
package/src/format.ts ADDED
@@ -0,0 +1,249 @@
1
+ import type {
2
+ ProviderUsageState,
3
+ UsageBucket,
4
+ UsageDisplayState,
5
+ UsageModel,
6
+ UsageReport,
7
+ } from "./types.js";
8
+
9
+ const BAR_SEGMENTS = 20;
10
+ const VALUE_COLUMN = 29;
11
+
12
+ export function formatUsageReport(report: UsageReport, displayState: UsageDisplayState): string {
13
+ const stateLabel = displayState === "current" ? "Current" : "Configured";
14
+ const lines = [`${report.providerName} Usage · ${stateLabel}`];
15
+ if (report.accountLabel) lines.push(`Account: ${report.accountLabel}`);
16
+ lines.push(`Semantics: ${report.semantics.label}`, "");
17
+
18
+ if (report.providerId === "openai-codex") formatCodexReport(lines, report);
19
+ else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
20
+ else formatGenericReport(lines, report);
21
+
22
+ if (report.notes) {
23
+ for (const note of report.notes) lines.push(note);
24
+ }
25
+ return lines.join("\n").trimEnd();
26
+ }
27
+
28
+ export function formatUsageStatusline(report: UsageReport, model?: UsageModel): string | undefined {
29
+ if (report.providerId === "openai-codex") return formatCodexStatusline(report, model);
30
+ if (report.providerId === "openrouter") {
31
+ const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
32
+ if (limit?.remaining !== undefined) return `openrouter ${formatUsd(limit.remaining)} left`;
33
+ const total = report.metrics.find((metric) => metric.id === "usage-total");
34
+ if (typeof total?.value === "number") return `openrouter ${formatUsd(total.value)} used`;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function formatProviderStates(states: readonly ProviderUsageState[]): string {
40
+ return states
41
+ .map((state) => {
42
+ if (state.status === "ready") return formatUsageReport(state.report, state.displayState);
43
+ const label = state.displayState === "current" ? "Current" : "Configured";
44
+ const status =
45
+ state.status === "auth-unavailable"
46
+ ? "Authentication unavailable"
47
+ : state.status === "unsupported"
48
+ ? "Unsupported"
49
+ : "Query failed";
50
+ return `${state.providerName} · ${label}\n${status}: ${state.message}`;
51
+ })
52
+ .join("\n\n");
53
+ }
54
+
55
+ function formatCodexReport(lines: string[], report: UsageReport): void {
56
+ let previousGroup: string | undefined;
57
+ for (const bucket of report.buckets) {
58
+ const group = bucket.groupId ?? bucket.id;
59
+ if (group !== previousGroup && group !== "codex") {
60
+ lines.push(`${bucket.groupLabel ?? group} limit:`);
61
+ }
62
+ previousGroup = group;
63
+ const fallback = bucket.id.endsWith(":secondary") ? "weekly" : "5h";
64
+ const label = `${formatWindowLabel(bucket.windowMinutes, fallback, false)} limit:`;
65
+ lines.push(`${label.padEnd(VALUE_COLUMN)}${formatPercentBucket(bucket)}`);
66
+ }
67
+ for (const metric of report.metrics) {
68
+ if (metric.id === "reset-credits") {
69
+ lines.push(`${"Usage limit resets:".padEnd(VALUE_COLUMN)}${metric.value} available`);
70
+ } else if (metric.id === "credits") {
71
+ lines.push(
72
+ `${"Credits:".padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`,
73
+ );
74
+ }
75
+ }
76
+ }
77
+
78
+ function formatOpenRouterReport(lines: string[], report: UsageReport): void {
79
+ const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
80
+ if (limit) {
81
+ const period = limit.period ? ` (${limit.period})` : "";
82
+ const value =
83
+ limit.remaining === undefined
84
+ ? `${formatUsd(limit.limit ?? 0)} cap; remaining unavailable`
85
+ : `${formatUsd(limit.remaining)} of ${formatUsd(limit.limit ?? 0)} left`;
86
+ lines.push(`${`Key limit${period}:`.padEnd(VALUE_COLUMN)}${value}`);
87
+ }
88
+ for (const metric of report.metrics) {
89
+ lines.push(
90
+ `${`${metric.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`,
91
+ );
92
+ }
93
+ }
94
+
95
+ function formatGenericReport(lines: string[], report: UsageReport): void {
96
+ for (const bucket of report.buckets) {
97
+ lines.push(
98
+ `${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(bucket.remaining ?? bucket.used ?? "unavailable", bucket.unit)}`,
99
+ );
100
+ }
101
+ for (const metric of report.metrics) {
102
+ lines.push(
103
+ `${`${metric.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ function formatCodexStatusline(report: UsageReport, model?: UsageModel): string | undefined {
109
+ const group = selectCodexGroup(report, model);
110
+ if (!group) return formatCodexCreditsStatus(report);
111
+ const buckets = report.buckets.filter((bucket) => (bucket.groupId ?? bucket.id) === group);
112
+ const labelBucket = buckets[0];
113
+ const parts = [
114
+ group === "codex" ? "codex" : `codex ${compactLimitLabel(labelBucket?.groupLabel ?? group)}`,
115
+ ];
116
+ for (const bucket of buckets) {
117
+ if (bucket.remaining === undefined) continue;
118
+ const fallback = bucket.id.endsWith(":secondary") ? "weekly" : "5h";
119
+ parts.push(
120
+ `${clampPercent(bucket.remaining).toFixed(0)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`,
121
+ );
122
+ }
123
+ return parts.length > 1 ? parts.join(" ") : formatCodexCreditsStatus(report);
124
+ }
125
+
126
+ function formatCodexCreditsStatus(report: UsageReport): string {
127
+ const credits = report.metrics.find((metric) => metric.id === "credits");
128
+ if (!credits) return "codex usage unavailable";
129
+ if (credits.value === "none") return "codex no credits";
130
+ if (credits.value === "available") return "codex credits available";
131
+ if (credits.value === "unlimited") return "codex credits unlimited";
132
+ return `codex ${formatMetricValue(credits.value, "count")} credits`;
133
+ }
134
+
135
+ function selectCodexGroup(report: UsageReport, model?: UsageModel): string | undefined {
136
+ const groups = [...new Set(report.buckets.map((bucket) => bucket.groupId ?? bucket.id))];
137
+ if (model?.provider !== "openai-codex") {
138
+ return groups.includes("codex") ? "codex" : groups[0];
139
+ }
140
+ const modelKeys = normalizedModelKeys(model);
141
+ for (const group of groups) {
142
+ const bucket = report.buckets.find(
143
+ (candidate) => (candidate.groupId ?? candidate.id) === group,
144
+ );
145
+ const keys = [group, bucket?.groupLabel, ...(bucket?.modelKeys ?? [])]
146
+ .map(normalizeKey)
147
+ .filter((key): key is string => key !== undefined);
148
+ if (keys.some((key) => modelKeys.has(key))) return group;
149
+ }
150
+ const variants = [...modelKeys]
151
+ .map((key) => key.match(/(?:^|-)codex-(.+)$/)?.[1])
152
+ .filter((value): value is string => Boolean(value));
153
+ for (const variant of variants) {
154
+ const matches = groups.filter((group) => {
155
+ if (group === "codex") return false;
156
+ const key = normalizeKey(group);
157
+ return key ? normalizedKeyHasToken(key, variant) : false;
158
+ });
159
+ if (matches.length === 1) return matches[0];
160
+ }
161
+ return groups.includes("codex") ? "codex" : groups[0];
162
+ }
163
+
164
+ function normalizedModelKeys(model: UsageModel): Set<string> {
165
+ const keys = new Set<string>();
166
+ for (const value of [model.id, model.name]) {
167
+ const key = normalizeKey(value);
168
+ if (!key) continue;
169
+ keys.add(key);
170
+ const index = key.indexOf("codex");
171
+ if (index >= 0) keys.add(key.slice(index));
172
+ }
173
+ return keys;
174
+ }
175
+
176
+ function normalizeKey(value: string | undefined): string | undefined {
177
+ const normalized = value
178
+ ?.toLowerCase()
179
+ .replace(/[^a-z0-9]+/g, "-")
180
+ .replace(/^-+|-+$/g, "");
181
+ return normalized || undefined;
182
+ }
183
+
184
+ function normalizedKeyHasToken(key: string, token: string): boolean {
185
+ return (
186
+ key === token ||
187
+ key.startsWith(`${token}-`) ||
188
+ key.endsWith(`-${token}`) ||
189
+ key.includes(`-${token}-`)
190
+ );
191
+ }
192
+
193
+ function compactLimitLabel(label: string): string {
194
+ const normalized = label.replace(/[_-]+/g, " ").trim();
195
+ return (normalized.match(/\bcodex\s+(.+)$/i)?.[1]?.trim() || normalized)
196
+ .toLowerCase()
197
+ .replace(/\s+/g, " ");
198
+ }
199
+
200
+ function formatPercentBucket(bucket: UsageBucket): string {
201
+ const remaining = clampPercent(bucket.remaining ?? 0);
202
+ const filled = Math.round((remaining / 100) * BAR_SEGMENTS);
203
+ const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
204
+ return `[${"█".repeat(filled)}${"░".repeat(BAR_SEGMENTS - filled)}] ${remaining.toFixed(0)}% left${reset}`;
205
+ }
206
+
207
+ function formatWindowLabel(
208
+ minutes: number | undefined,
209
+ fallback: "5h" | "weekly",
210
+ compact: boolean,
211
+ ): string {
212
+ if (!minutes || !Number.isFinite(minutes) || minutes <= 0) {
213
+ return compact && fallback === "weekly" ? "wk" : capitalize(fallback);
214
+ }
215
+ if (minutes === 10_080) return compact ? "wk" : "Weekly";
216
+ if (minutes % 10_080 === 0) return `${minutes / 10_080}w`;
217
+ if (minutes % 1_440 === 0) return `${minutes / 1_440}d`;
218
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
219
+ return `${minutes}m`;
220
+ }
221
+
222
+ function formatMetricValue(value: number | string, unit: UsageBucket["unit"] | undefined): string {
223
+ if (unit === "usd" && typeof value === "number") return formatUsd(value);
224
+ return String(value);
225
+ }
226
+
227
+ function formatUsd(value: number): string {
228
+ return `$${value.toFixed(2)}`;
229
+ }
230
+
231
+ function formatReset(epochSeconds: number): string {
232
+ const reset = new Date(epochSeconds * 1000);
233
+ if (Number.isNaN(reset.getTime())) return "at an unknown time";
234
+ const time = `${reset.getHours().toString().padStart(2, "0")}:${reset
235
+ .getMinutes()
236
+ .toString()
237
+ .padStart(2, "0")}`;
238
+ const now = new Date();
239
+ if (reset.toDateString() === now.toDateString()) return time;
240
+ return `${time} on ${reset.getDate()} ${reset.toLocaleDateString(undefined, { month: "short" })}`;
241
+ }
242
+
243
+ function capitalize(value: string): string {
244
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
245
+ }
246
+
247
+ function clampPercent(value: number): number {
248
+ return Math.min(100, Math.max(0, value));
249
+ }