@narumitw/pi-usage 0.31.0 β†’ 0.38.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/README.md CHANGED
@@ -2,21 +2,21 @@
2
2
 
3
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
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.
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, GitHub Copilot allowances, and OpenRouter API-key spend limits without pretending those limits have the same semantics.
6
6
 
7
7
  ## ✨ Features
8
8
 
9
9
  - Opens one interactive `/usage` menu with current state and next actions.
10
10
  - Automatically queries the selected model provider and active runtime account.
11
11
  - Supports OpenAI Codex subscription windows, resets, credits, and model-specific buckets.
12
+ - Supports GitHub Copilot AI Credits, legacy premium requests, Free chat quota, additional usage, percentage, and reset time.
12
13
  - Supports OpenRouter per-key credit limits plus daily, weekly, monthly, and all-time spend.
13
14
  - Provides explicit refresh, another-provider, and all-configured-provider actions.
14
15
  - Runs manually requested all-provider queries with concurrency limited to two and preserves partial results.
15
16
  - Labels only the selected model provider as `Current`; other results are `Configured`.
16
17
  - Keeps the compact statusline scoped to the current provider and runtime account.
17
18
  - 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.
19
+ - Resolves runtime credentials through Pi; for Copilot only, reads Pi's stored OAuth credential through Pi's public credential API and verifies that it matches the active runtime account.
20
20
 
21
21
  ## πŸ“¦ Install
22
22
 
@@ -46,7 +46,8 @@ Run:
46
46
  /usage
47
47
  ```
48
48
 
49
- The menu first queries the current model provider and presents its state with these actions:
49
+ In TUI or RPC mode, the standard menu first queries the current model provider and presents its state
50
+ with these actions:
50
51
 
51
52
  ```text
52
53
  Refresh current usage
@@ -55,9 +56,11 @@ View all configured providers…
55
56
  Close
56
57
  ```
57
58
 
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`.
59
+ There are intentionally no `/usage --refresh`, `/usage <provider>`, or `/usage --all` argument paths.
60
+ Cross-provider traffic requires an explicit interactive choice. Escape returns from provider selection
61
+ and closes the root menu. Print and JSON modes reject `/usage` observably because they cannot host the
62
+ interactive flow. The cancellable live-query progress view remains extension-owned because it streams
63
+ provider work and supports in-flight abort rather than presenting a standard menu screen.
61
64
 
62
65
  ## πŸ“‹ Provider semantics
63
66
 
@@ -71,6 +74,16 @@ There are intentionally no `/usage --refresh`, `/usage <provider>`, or `/usage -
71
74
 
72
75
  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
76
 
77
+ ### GitHub Copilot
78
+
79
+ - Provider ID: `github-copilot`
80
+ - Semantics: the allowance reported for the active Copilot planβ€”AI credits for current usage-based billing, premium requests for legacy annual billing, or chat requests for Copilot Free's limited response shape
81
+ - Source: GitHub's undocumented `GET /copilot_internal/user` endpoint
82
+ - Displayed data: entitlement, remaining allowance, percentage, reset time, plan, and any additional usage beyond the included allowance
83
+ - Statusline examples: `copilot credits 1200/1500 80%`, `copilot 245/300 82%`, or `copilot chat 40/50 80%`
84
+
85
+ GitHub's quota endpoint requires the original GitHub OAuth token rather than the short-lived Copilot inference token exposed by runtime auth. `pi-usage` therefore supports Copilot accounts created through Pi's `/login` flow, reads that stored credential through Pi's public API, and uses it only when its short-lived access token matches the active runtime credential. API-key credentials, account mismatches, GitHub Enterprise accounts, and proxy/custom provider origins fail closed. The detailed report follows the endpoint's `token_based_billing` marker so AI credits are not mislabeled as legacy premium requests, and it reports overage without treating a negative included balance as a malformed response.
86
+
74
87
  ### OpenRouter
75
88
 
76
89
  - Provider ID: `openrouter`
@@ -102,19 +115,19 @@ pi remove npm:@narumitw/pi-codex-usage
102
115
  pi install npm:@narumitw/pi-usage
103
116
  ```
104
117
 
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.
118
+ Remove the deprecated package rather than loading both usage extensions together.
106
119
 
107
120
  Behavior changes:
108
121
 
109
- - Use `/usage` as the primary entry point.
110
- - `/codex-status` is an argument-free compatibility alias.
122
+ - Use `/usage` as the only entry point; `/codex-status` is no longer registered.
111
123
  - Refresh and cross-provider operations are menu actions rather than flags.
112
124
  - Codex CLI fallback is removed to preserve active-runtime-account correctness.
113
125
  - The status key changes from `codex-usage` to `usage`.
114
126
 
115
127
  ## 🚧 Limitations
116
128
 
117
- - Only providers with a stable, meaningful usage source and Pi-resolvable runtime auth are supported.
129
+ - Only providers with a meaningful usage source and verifiable Pi runtime auth are supported.
130
+ - GitHub Copilot quota uses an undocumented GitHub endpoint that may change without notice.
118
131
  - 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
132
  - Provider reports are snapshots and may themselves be delayed by the provider.
120
133
  - OpenRouter successful inference responses do not expose proactive request-rate counters; `/usage` reports the documented per-key credit/spend fields instead.
@@ -131,7 +144,7 @@ extensions/pi-usage/
131
144
  β”‚ β”œβ”€β”€ query.ts # Runtime auth resolution and provider queries
132
145
  β”‚ β”œβ”€β”€ format.ts # Provider-aware notifications and statusline text
133
146
  β”‚ β”œβ”€β”€ core.ts # Cache, concurrency, fingerprint, and redaction helpers
134
- β”‚ β”œβ”€β”€ providers/ # Codex and OpenRouter normalization adapters
147
+ β”‚ β”œβ”€β”€ providers/ # Codex, GitHub Copilot, and OpenRouter normalization adapters
135
148
  β”‚ └── types.ts # Common presentation and adapter contracts
136
149
  β”œβ”€β”€ test/
137
150
  β”œβ”€β”€ README.md
@@ -144,7 +157,7 @@ extensions/pi-usage/
144
157
 
145
158
  ## πŸ”Ž Keywords
146
159
 
147
- 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.
160
+ Pi extension, Pi coding agent, usage, quota, OpenAI Codex usage, ChatGPT subscription limits, GitHub Copilot AI credits, GitHub Copilot premium requests, OpenRouter credits, API-key spend limits, TypeScript Pi package, npm Pi extension.
148
161
 
149
162
  ## πŸ“„ License
150
163
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.31.0",
4
- "description": "Pi extension that shows current-account usage across supported model providers.",
3
+ "version": "0.38.0",
4
+ "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, and OpenRouter.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -12,6 +12,7 @@
12
12
  "usage",
13
13
  "quota",
14
14
  "codex",
15
+ "copilot",
15
16
  "openrouter"
16
17
  ],
17
18
  "files": [
@@ -42,5 +43,8 @@
42
43
  "type": "git",
43
44
  "url": "https://github.com/narumiruna/pi-extensions",
44
45
  "directory": "extensions/pi-usage"
46
+ },
47
+ "dependencies": {
48
+ "@narumitw/pi-tui-kit": "<1"
45
49
  }
46
50
  }
package/src/format.ts CHANGED
@@ -16,6 +16,7 @@ export function formatUsageReport(report: UsageReport, displayState: UsageDispla
16
16
  lines.push(`Semantics: ${report.semantics.label}`, "");
17
17
 
18
18
  if (report.providerId === "openai-codex") formatCodexReport(lines, report);
19
+ else if (report.providerId === "github-copilot") formatGitHubCopilotReport(lines, report);
19
20
  else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
20
21
  else formatGenericReport(lines, report);
21
22
 
@@ -27,6 +28,7 @@ export function formatUsageReport(report: UsageReport, displayState: UsageDispla
27
28
 
28
29
  export function formatUsageStatusline(report: UsageReport, model?: UsageModel): string | undefined {
29
30
  if (report.providerId === "openai-codex") return formatCodexStatusline(report, model);
31
+ if (report.providerId === "github-copilot") return formatGitHubCopilotStatusline(report);
30
32
  if (report.providerId === "openrouter") {
31
33
  const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
32
34
  if (limit?.remaining !== undefined) return `openrouter ${formatUsd(limit.remaining)} left`;
@@ -75,6 +77,52 @@ function formatCodexReport(lines: string[], report: UsageReport): void {
75
77
  }
76
78
  }
77
79
 
80
+ function formatGitHubCopilotReport(lines: string[], report: UsageReport): void {
81
+ const quota = findGitHubCopilotQuota(report);
82
+ if (!quota || quota.limit === undefined || quota.remaining === undefined) {
83
+ lines.push(`${`${quota?.label ?? "Copilot quota"}:`.padEnd(VALUE_COLUMN)}unlimited`);
84
+ return;
85
+ }
86
+ const percent = percentRemaining(quota);
87
+ const reset = quota.resetsAt ? ` (resets ${formatReset(quota.resetsAt)})` : "";
88
+ lines.push(
89
+ `${`${quota.label}:`.padEnd(VALUE_COLUMN)}${quota.remaining} of ${quota.limit} left Β· ${percent}%${reset}`,
90
+ );
91
+ const overage = report.metrics.find((metric) => metric.id === "overage-used");
92
+ if (typeof overage?.value === "number" && overage.value > 0) {
93
+ lines.push(`${"Additional usage:".padEnd(VALUE_COLUMN)}${overage.value} ${quota.label}`);
94
+ }
95
+ }
96
+
97
+ function formatGitHubCopilotStatusline(report: UsageReport): string {
98
+ const quota = findGitHubCopilotQuota(report);
99
+ const kind = compactGitHubCopilotQuotaKind(quota);
100
+ if (!quota || quota.limit === undefined || quota.remaining === undefined) {
101
+ return `copilot ${kind} unlimited`;
102
+ }
103
+ const overage = report.metrics.find((metric) => metric.id === "overage-used");
104
+ const overageSuffix =
105
+ typeof overage?.value === "number" && overage.value > 0 ? ` +${overage.value} over` : "";
106
+ return `copilot ${kind === "premium" ? "" : `${kind} `}${quota.remaining}/${quota.limit} ${percentRemaining(quota)}%${overageSuffix}`;
107
+ }
108
+
109
+ function findGitHubCopilotQuota(report: UsageReport): UsageBucket | undefined {
110
+ return report.buckets.find((bucket) =>
111
+ ["ai-credits", "premium-requests", "chat-requests"].includes(bucket.id),
112
+ );
113
+ }
114
+
115
+ function compactGitHubCopilotQuotaKind(bucket: UsageBucket | undefined): string {
116
+ if (bucket?.id === "ai-credits") return "credits";
117
+ if (bucket?.id === "chat-requests") return "chat";
118
+ return "premium";
119
+ }
120
+
121
+ function percentRemaining(bucket: UsageBucket): number {
122
+ if (!bucket.limit || bucket.remaining === undefined) return 0;
123
+ return Math.round(clampPercent((bucket.remaining / bucket.limit) * 100));
124
+ }
125
+
78
126
  function formatOpenRouterReport(lines: string[], report: UsageReport): void {
79
127
  const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
80
128
  if (limit) {
@@ -174,11 +222,13 @@ function normalizedModelKeys(model: UsageModel): Set<string> {
174
222
  }
175
223
 
176
224
  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;
225
+ const separated = value?.toLowerCase().replace(/[^a-z0-9]+/g, "-");
226
+ if (!separated) return undefined;
227
+ let start = 0;
228
+ let end = separated.length;
229
+ while (separated[start] === "-") start += 1;
230
+ while (end > start && separated[end - 1] === "-") end -= 1;
231
+ return separated.slice(start, end) || undefined;
182
232
  }
183
233
 
184
234
  function normalizedKeyHasToken(key: string, token: string): boolean {
@@ -192,9 +242,9 @@ function normalizedKeyHasToken(key: string, token: string): boolean {
192
242
 
193
243
  function compactLimitLabel(label: string): string {
194
244
  const normalized = label.replace(/[_-]+/g, " ").trim();
195
- return (normalized.match(/\bcodex\s+(.+)$/i)?.[1]?.trim() || normalized)
196
- .toLowerCase()
197
- .replace(/\s+/g, " ");
245
+ const codex = /\bcodex\s/iu.exec(normalized);
246
+ const suffix = codex ? normalized.slice(codex.index + codex[0].length).trim() : "";
247
+ return (suffix || normalized).toLowerCase().replace(/\s+/g, " ");
198
248
  }
199
249
 
200
250
  function formatPercentBucket(bucket: UsageBucket): string {
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  } from "./core.js";
11
11
  export { formatProviderStates, formatUsageReport, formatUsageStatusline } from "./format.js";
12
12
  export { normalizeCodexBackendPayload } from "./providers/codex.js";
13
+ export { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
13
14
  export { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
14
15
  export {
15
16
  adapterForProvider,
@@ -0,0 +1,120 @@
1
+ import { sanitizeDisplayText } from "../core.js";
2
+ import type { GitHubCopilotUsagePayload, UsageBucket, UsageReport } from "../types.js";
3
+
4
+ export function normalizeGitHubCopilotUsagePayload(
5
+ payload: GitHubCopilotUsagePayload,
6
+ capturedAt: number,
7
+ ): UsageReport {
8
+ const snapshots = asObject(payload.quota_snapshots);
9
+ const premium = asObject(snapshots?.premium_interactions);
10
+ const metrics: UsageReport["metrics"] = [];
11
+ let semanticsLabel: string;
12
+ let bucket: UsageBucket;
13
+
14
+ if (premium) {
15
+ const tokenBasedBilling = premium.token_based_billing === true;
16
+ const id = tokenBasedBilling ? "ai-credits" : "premium-requests";
17
+ const label = tokenBasedBilling ? "AI credits" : "Premium requests";
18
+ semanticsLabel = tokenBasedBilling
19
+ ? "GitHub Copilot AI Credits allowance"
20
+ : "GitHub Copilot premium request quota";
21
+
22
+ if (premium.unlimited === true) {
23
+ bucket = { id, label, unit: "count" };
24
+ } else {
25
+ const entitlement = asNonnegativeNumber(premium.entitlement);
26
+ const rawRemaining =
27
+ asFiniteNumber(premium.remaining) ?? asFiniteNumber(premium.quota_remaining);
28
+ if (entitlement === undefined || rawRemaining === undefined) {
29
+ throw new Error(`GitHub Copilot ${label.toLowerCase()} quota was incomplete.`);
30
+ }
31
+ const overageUsed = Math.max(
32
+ asNonnegativeNumber(premium.overage_count) ?? 0,
33
+ Math.max(0, -rawRemaining),
34
+ );
35
+ if (overageUsed > 0) {
36
+ metrics.push({
37
+ id: "overage-used",
38
+ label: "Additional usage",
39
+ value: overageUsed,
40
+ unit: "count",
41
+ });
42
+ }
43
+ bucket = {
44
+ id,
45
+ label,
46
+ used: asNonnegativeNumber(premium.credits_used) ?? Math.max(0, entitlement - rawRemaining),
47
+ remaining: Math.max(0, rawRemaining),
48
+ limit: entitlement,
49
+ unit: "count",
50
+ period: "monthly",
51
+ ...resetTimestamp(payload),
52
+ };
53
+ }
54
+ } else {
55
+ const limited = asObject(payload.limited_user_quotas);
56
+ const monthly = asObject(payload.monthly_quotas);
57
+ const remaining = asNonnegativeNumber(limited?.chat);
58
+ const entitlement = asNonnegativeNumber(monthly?.chat);
59
+ if (remaining === undefined || entitlement === undefined) {
60
+ throw new Error("GitHub Copilot usage response contained no supported quota.");
61
+ }
62
+ semanticsLabel = "GitHub Copilot Free chat quota";
63
+ bucket = {
64
+ id: "chat-requests",
65
+ label: "Chat requests",
66
+ used: Math.max(0, entitlement - remaining),
67
+ remaining,
68
+ limit: entitlement,
69
+ unit: "count",
70
+ period: "monthly",
71
+ ...resetTimestamp(payload),
72
+ };
73
+ }
74
+
75
+ const notes: string[] = [];
76
+ const plan = asString(payload.copilot_plan) ?? asString(payload.access_type_sku);
77
+ if (plan) notes.push(`Plan: ${plan}`);
78
+
79
+ return {
80
+ providerId: "github-copilot",
81
+ providerName: "GitHub Copilot",
82
+ capturedAt,
83
+ source: "github-copilot-user",
84
+ semantics: { kind: "consumer-subscription", label: semanticsLabel },
85
+ accountLabel: asString(payload.login),
86
+ buckets: [bucket],
87
+ metrics,
88
+ ...(notes.length > 0 ? { notes } : {}),
89
+ };
90
+ }
91
+
92
+ function resetTimestamp(payload: GitHubCopilotUsagePayload): { resetsAt?: number } {
93
+ const raw =
94
+ asString(payload.quota_reset_date_utc) ??
95
+ asString(payload.quota_reset_date) ??
96
+ asString(payload.limited_user_reset_date);
97
+ if (!raw) return {};
98
+ const milliseconds = Date.parse(raw);
99
+ return Number.isNaN(milliseconds) ? {} : { resetsAt: Math.floor(milliseconds / 1000) };
100
+ }
101
+
102
+ function asObject(value: unknown): Record<string, unknown> | undefined {
103
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
104
+ return value as Record<string, unknown>;
105
+ }
106
+
107
+ function asString(value: unknown): string | undefined {
108
+ if (typeof value !== "string") return undefined;
109
+ return sanitizeDisplayText(value, 80) || undefined;
110
+ }
111
+
112
+ function asFiniteNumber(value: unknown): number | undefined {
113
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
114
+ return value;
115
+ }
116
+
117
+ function asNonnegativeNumber(value: unknown): number | undefined {
118
+ const number = asFiniteNumber(value);
119
+ return number === undefined || number < 0 ? undefined : number;
120
+ }
package/src/query.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type ExtensionContext, readStoredCredential } from "@earendil-works/pi-coding-agent";
3
3
  import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
4
4
  import { normalizeCodexBackendPayload } from "./providers/codex.js";
5
+ import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
5
6
  import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
6
7
  import type {
7
8
  CodexBackendPayload,
9
+ GitHubCopilotUsagePayload,
8
10
  OpenRouterKeyPayload,
9
11
  PiModel,
10
12
  ResolvedUsageAuth,
@@ -13,6 +15,7 @@ import type {
13
15
  } from "./types.js";
14
16
 
15
17
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
18
+ const GITHUB_COPILOT_USAGE_URL = "https://api.github.com/copilot_internal/user";
16
19
  const OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key";
17
20
  const MAX_SUCCESS_BODY_BYTES = 64 * 1024;
18
21
  const MAX_ERROR_BODY_BYTES = 4 * 1024;
@@ -38,6 +41,24 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
38
41
  return normalizeCodexBackendPayload(payload as CodexBackendPayload, Date.now());
39
42
  },
40
43
  },
44
+ {
45
+ id: "github-copilot",
46
+ displayName: "GitHub Copilot",
47
+ semantics: {
48
+ kind: "consumer-subscription",
49
+ label: "GitHub Copilot account allowance",
50
+ },
51
+ async query(auth, signal, timeoutMs) {
52
+ const payload = await fetchProviderJson(
53
+ GITHUB_COPILOT_USAGE_URL,
54
+ auth,
55
+ signal,
56
+ timeoutMs,
57
+ "GitHub Copilot usage endpoint",
58
+ );
59
+ return normalizeGitHubCopilotUsagePayload(payload as GitHubCopilotUsagePayload, Date.now());
60
+ },
61
+ },
41
62
  {
42
63
  id: "openrouter",
43
64
  displayName: "OpenRouter",
@@ -72,6 +93,7 @@ export async function resolveUsageAuth(
72
93
  ctx: ExtensionContext,
73
94
  adapter: UsageProviderAdapter,
74
95
  salt: Uint8Array = AUTH_FINGERPRINT_SALT,
96
+ credentialReader: StoredCredentialReader = readStoredCredential,
75
97
  ): Promise<ResolvedUsageAuth | undefined> {
76
98
  if (ctx.model?.provider === adapter.id && !hasOfficialOrigin(ctx.model, adapter.id)) {
77
99
  throw new Error(
@@ -104,6 +126,9 @@ export async function resolveUsageAuth(
104
126
  }
105
127
  const auth = modelAuth ?? providerResult?.auth;
106
128
  if (!auth) return undefined;
129
+ if (adapter.id === "github-copilot") {
130
+ return resolveGitHubCopilotUsageAuth(auth, model, salt, credentialReader);
131
+ }
107
132
  const authorization = authorizationFrom(auth);
108
133
  if (!authorization) return undefined;
109
134
  const headers = { Authorization: authorization };
@@ -262,6 +287,8 @@ type RequestAuth = {
262
287
  headers?: Record<string, string | null>;
263
288
  };
264
289
 
290
+ type StoredCredentialReader = (providerId: string) => unknown;
291
+
265
292
  type UsageAuthRegistry = {
266
293
  getApiKeyAndHeaders?(
267
294
  model: PiModel,
@@ -274,6 +301,51 @@ type UsageAuthRegistry = {
274
301
  >;
275
302
  };
276
303
 
304
+ function resolveGitHubCopilotUsageAuth(
305
+ auth: RequestAuth,
306
+ model: PiModel,
307
+ salt: Uint8Array,
308
+ credentialReader: StoredCredentialReader,
309
+ ): ResolvedUsageAuth {
310
+ const credential = asObject(credentialReader("github-copilot"));
311
+ if (credential?.type !== "oauth") {
312
+ throw new Error(
313
+ "GitHub Copilot usage requires the OAuth account configured through Pi /login.",
314
+ );
315
+ }
316
+ if (
317
+ typeof credential.enterpriseUrl === "string" &&
318
+ credential.enterpriseUrl &&
319
+ !isPublicGitHubDomain(credential.enterpriseUrl)
320
+ ) {
321
+ throw new Error("GitHub Copilot usage does not yet support GitHub Enterprise accounts.");
322
+ }
323
+ const refresh = typeof credential.refresh === "string" ? credential.refresh : undefined;
324
+ const storedAccess = typeof credential.access === "string" ? credential.access : undefined;
325
+ const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
326
+ if (!refresh || !storedAccess || !resolvedAccess) {
327
+ throw new Error("GitHub Copilot OAuth credentials were incomplete.");
328
+ }
329
+ if (storedAccess !== resolvedAccess) {
330
+ throw new Error(
331
+ "The active GitHub Copilot runtime account does not match Pi's stored OAuth account.",
332
+ );
333
+ }
334
+
335
+ const authorization = `Bearer ${refresh}`;
336
+ const headers = {
337
+ Authorization: authorization,
338
+ "X-GitHub-Api-Version": "2025-05-01",
339
+ };
340
+ return {
341
+ apiKey: refresh,
342
+ headers,
343
+ fingerprint: fingerprintResolvedAuth({ headers }, salt),
344
+ secrets: [refresh, storedAccess, resolvedAccess, authorization],
345
+ model,
346
+ };
347
+ }
348
+
277
349
  function authorizationFrom(auth: RequestAuth): string | undefined {
278
350
  return (
279
351
  headerValue(auth.headers, "Authorization") ??
@@ -281,14 +353,40 @@ function authorizationFrom(auth: RequestAuth): string | undefined {
281
353
  );
282
354
  }
283
355
 
356
+ function bearerToken(authorization: string | undefined): string | undefined {
357
+ const match = /^Bearer\s+(.+)$/iu.exec(authorization ?? "");
358
+ return match?.[1];
359
+ }
360
+
361
+ function asObject(value: unknown): Record<string, unknown> | undefined {
362
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
363
+ return value as Record<string, unknown>;
364
+ }
365
+
366
+ function isPublicGitHubDomain(value: string): boolean {
367
+ try {
368
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
369
+ return url.hostname.toLowerCase() === "github.com";
370
+ } catch {
371
+ return false;
372
+ }
373
+ }
374
+
284
375
  function hasOfficialOrigin(model: PiModel, providerId: string): boolean {
285
376
  return hasOfficialUrlOrigin(model.baseUrl, providerId);
286
377
  }
287
378
 
288
379
  function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
289
- const expected = providerId === "openai-codex" ? "https://chatgpt.com" : "https://openrouter.ai";
290
380
  try {
291
- return new URL(value).origin === expected;
381
+ const url = new URL(value);
382
+ if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
383
+ if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
384
+ if (providerId === "github-copilot") {
385
+ return (
386
+ url.protocol === "https:" && /^api\.[a-z0-9-]+\.githubcopilot\.com$/u.test(url.hostname)
387
+ );
388
+ }
389
+ return false;
292
390
  } catch {
293
391
  return false;
294
392
  }
package/src/types.ts CHANGED
@@ -77,6 +77,18 @@ export type ProviderUsageState =
77
77
  message: string;
78
78
  };
79
79
 
80
+ export type GitHubCopilotUsagePayload = {
81
+ login?: unknown;
82
+ copilot_plan?: unknown;
83
+ access_type_sku?: unknown;
84
+ limited_user_quotas?: unknown;
85
+ limited_user_reset_date?: unknown;
86
+ monthly_quotas?: unknown;
87
+ quota_reset_date?: unknown;
88
+ quota_reset_date_utc?: unknown;
89
+ quota_snapshots?: unknown;
90
+ };
91
+
80
92
  export type OpenRouterKeyPayload = {
81
93
  data?: unknown;
82
94
  };
package/src/usage.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type ExtensionCommandContext,
5
5
  type ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
7
8
  import {
8
9
  awaitWithDeadline,
9
10
  errorMessage,
@@ -39,7 +40,6 @@ const REFRESH_CURRENT = "Refresh current usage";
39
40
  const VIEW_ANOTHER = "View another configured provider…";
40
41
  const VIEW_ALL = "View all configured providers…";
41
42
  const CLOSE = "Close";
42
- const MENU_ACTIONS = [REFRESH_CURRENT, VIEW_ANOTHER, VIEW_ALL, CLOSE];
43
43
 
44
44
  type QueryOutcome = {
45
45
  state: ProviderUsageState;
@@ -455,11 +455,9 @@ export default function usageExtension(pi: ExtensionAPI) {
455
455
  };
456
456
 
457
457
  const showMenu = async (ctx: ExtensionCommandContext): Promise<void> => {
458
- if (!ctx.hasUI) {
459
- ctx.ui.notify("/usage requires an interactive Pi mode.", "warning");
460
- return;
461
- }
458
+ if (!ctx.hasUI) throw new Error("/usage requires TUI or RPC mode.");
462
459
  statusGeneration += 1;
460
+ const menuGeneration = statusGeneration;
463
461
  statusController?.abort();
464
462
  statusController = undefined;
465
463
  clearStatusTimer();
@@ -476,115 +474,149 @@ export default function usageExtension(pi: ExtensionAPI) {
476
474
  publishStableCurrent(ctx, stableCurrent);
477
475
  let current = stableCurrent.outcome;
478
476
  let visibleStates: ProviderUsageState[] = [current.state];
479
-
480
- while (!controller.signal.aborted) {
481
- const action = await ctx.ui.select(formatProviderStates(visibleStates), [...MENU_ACTIONS], {
482
- signal: controller.signal,
483
- });
484
- if (!action || action === CLOSE) return;
485
- if (action === REFRESH_CURRENT) {
486
- stableCurrent = await queryStableCurrent(
487
- ctx,
488
- true,
489
- controller,
490
- "Refreshing current usage…",
491
- );
492
- if (!stableCurrent) continue;
493
- publishStableCurrent(ctx, stableCurrent);
494
- current = stableCurrent.outcome;
495
- visibleStates = [current.state];
496
- continue;
497
- }
498
- if (action === VIEW_ANOTHER) {
499
- const others = configuredAdapters(ctx).filter(
500
- (adapter) => adapter.id !== ctx.model?.provider,
501
- );
502
- if (others.length === 0) {
503
- ctx.ui.notify("No other supported provider has configured runtime auth.", "info");
504
- continue;
505
- }
506
- const choice = await ctx.ui.select(
507
- "Select a configured provider",
508
- others.map((adapter) => adapter.displayName),
509
- { signal: controller.signal },
510
- );
511
- const adapter = others.find((candidate) => candidate.displayName === choice);
512
- if (!adapter) continue;
513
- const outcome = await runMenuOperation(
514
- ctx,
515
- `Checking ${adapter.displayName} usage…`,
516
- controller.signal,
517
- (signal) => queryAdapterState(ctx, adapter, "configured", false, signal),
518
- );
519
- if (!outcome) continue;
520
- const revalidated = await queryStableCurrent(
521
- ctx,
522
- false,
523
- controller,
524
- "Revalidating current usage…",
525
- );
526
- if (!revalidated) continue;
527
- stableCurrent = revalidated;
528
- current = revalidated.outcome;
529
- visibleStates =
530
- outcome.state.providerId === current.state.providerId
531
- ? [current.state]
532
- : [current.state, { ...outcome.state, displayState: "configured" }];
533
- continue;
534
- }
535
- if (action === VIEW_ALL) {
536
- const adapters = configuredAdapters(ctx);
537
- const currentProviderId = ctx.model?.provider;
538
- const settled = await runMenuOperation(
539
- ctx,
540
- "Checking configured provider usage…",
541
- controller.signal,
542
- (signal) =>
543
- runWithConcurrency(
544
- adapters,
545
- ALL_PROVIDER_CONCURRENCY,
546
- (adapter, _index, workerSignal) =>
547
- queryAdapterState(
548
- ctx,
549
- adapter,
550
- adapter.id === currentProviderId ? "current" : "configured",
551
- true,
552
- workerSignal,
553
- ),
554
- signal,
555
- ),
556
- );
557
- if (!settled) continue;
558
- const queriedStates: ProviderUsageState[] = settled.map((result, index) => {
559
- if (result.status === "fulfilled") {
560
- return { ...result.value.state, displayState: "configured" };
477
+ type Screen = "main" | "providers";
478
+ type Action = "refresh" | "another" | "all" | "provider";
479
+ const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
480
+ start: "main",
481
+ screens: {
482
+ main: () => ({
483
+ kind: "actions",
484
+ title: "Provider usage",
485
+ lines: formatProviderStates(visibleStates).split("\n"),
486
+ items: [
487
+ { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
488
+ { id: "another", label: VIEW_ANOTHER, action: "another" },
489
+ { id: "all", label: VIEW_ALL, action: "all" },
490
+ { id: "close", label: CLOSE, close: true },
491
+ ],
492
+ hint: "close",
493
+ }),
494
+ providers: () => ({
495
+ kind: "actions",
496
+ title: "Select a configured provider",
497
+ items: configuredAdapters(ctx)
498
+ .filter((adapter) => adapter.id !== ctx.model?.provider)
499
+ .map((adapter) => ({
500
+ id: adapter.id,
501
+ label: adapter.displayName,
502
+ action: "provider" as const,
503
+ })),
504
+ hint: "back",
505
+ }),
506
+ },
507
+ actions: {
508
+ refresh: async () => {
509
+ const refreshed = await queryStableCurrent(
510
+ ctx,
511
+ true,
512
+ controller,
513
+ "Refreshing current usage…",
514
+ );
515
+ if (!refreshed) return { kind: "stay" };
516
+ stableCurrent = refreshed;
517
+ publishStableCurrent(ctx, refreshed);
518
+ current = refreshed.outcome;
519
+ visibleStates = [current.state];
520
+ return { kind: "stay" };
521
+ },
522
+ another: async () => {
523
+ const others = configuredAdapters(ctx).filter(
524
+ (adapter) => adapter.id !== ctx.model?.provider,
525
+ );
526
+ if (others.length === 0) {
527
+ ctx.ui.notify("No other supported provider has configured runtime auth.", "info");
528
+ return { kind: "stay" };
561
529
  }
562
- const adapter = adapters[index] as UsageProviderAdapter;
563
- return {
564
- providerId: adapter.id,
565
- providerName: adapter.displayName,
566
- displayState: "configured",
567
- status: "query-failed",
568
- message: errorMessage(result.reason),
569
- };
570
- });
571
- const revalidated = await queryStableCurrent(
572
- ctx,
573
- false,
574
- controller,
575
- "Revalidating current usage…",
576
- );
577
- if (!revalidated) continue;
578
- stableCurrent = revalidated;
579
- current = revalidated.outcome;
580
- visibleStates = [
581
- current.state,
582
- ...queriedStates.filter((state) => state.providerId !== current.state.providerId),
583
- ];
584
- }
585
- }
530
+ return { kind: "to", screen: "providers" };
531
+ },
532
+ provider: async ({ itemId }) => {
533
+ const adapter = configuredAdapters(ctx).find(
534
+ (candidate) => candidate.id === itemId && candidate.id !== ctx.model?.provider,
535
+ );
536
+ if (!adapter) return { kind: "back" };
537
+ const outcome = await runMenuOperation(
538
+ ctx,
539
+ `Checking ${adapter.displayName} usage…`,
540
+ controller.signal,
541
+ (signal) => queryAdapterState(ctx, adapter, "configured", false, signal),
542
+ );
543
+ if (!outcome) return { kind: "back" };
544
+ const revalidated = await queryStableCurrent(
545
+ ctx,
546
+ false,
547
+ controller,
548
+ "Revalidating current usage…",
549
+ );
550
+ if (!revalidated) return { kind: "back" };
551
+ stableCurrent = revalidated;
552
+ current = revalidated.outcome;
553
+ visibleStates = [
554
+ outcome.state.providerId === current.state.providerId
555
+ ? current.state
556
+ : { ...outcome.state, displayState: "configured" },
557
+ ];
558
+ return { kind: "back" };
559
+ },
560
+ all: async () => {
561
+ const adapters = configuredAdapters(ctx);
562
+ const currentProviderId = ctx.model?.provider;
563
+ const settled = await runMenuOperation(
564
+ ctx,
565
+ "Checking configured provider usage…",
566
+ controller.signal,
567
+ (signal) =>
568
+ runWithConcurrency(
569
+ adapters,
570
+ ALL_PROVIDER_CONCURRENCY,
571
+ (adapter, _index, workerSignal) =>
572
+ queryAdapterState(
573
+ ctx,
574
+ adapter,
575
+ adapter.id === currentProviderId ? "current" : "configured",
576
+ true,
577
+ workerSignal,
578
+ ),
579
+ signal,
580
+ ),
581
+ );
582
+ if (!settled) return { kind: "stay" };
583
+ const queriedStates: ProviderUsageState[] = settled.map((result, index) => {
584
+ if (result.status === "fulfilled") {
585
+ return { ...result.value.state, displayState: "configured" };
586
+ }
587
+ const adapter = adapters[index] as UsageProviderAdapter;
588
+ return {
589
+ providerId: adapter.id,
590
+ providerName: adapter.displayName,
591
+ displayState: "configured",
592
+ status: "query-failed",
593
+ message: errorMessage(result.reason),
594
+ };
595
+ });
596
+ const revalidated = await queryStableCurrent(
597
+ ctx,
598
+ false,
599
+ controller,
600
+ "Revalidating current usage…",
601
+ );
602
+ if (!revalidated) return { kind: "stay" };
603
+ stableCurrent = revalidated;
604
+ current = revalidated.outcome;
605
+ visibleStates = [
606
+ current.state,
607
+ ...queriedStates.filter((state) => state.providerId !== current.state.providerId),
608
+ ];
609
+ return { kind: "stay" };
610
+ },
611
+ },
612
+ });
613
+ await runMenu(ctx, menu, {
614
+ getState: () => undefined,
615
+ signal: controller.signal,
616
+ isCurrent: () => statusGeneration === menuGeneration && !controller.signal.aborted,
617
+ });
586
618
  } finally {
587
- controller.abort();
619
+ controller.abort(new DOMException("Usage menu closed", "AbortError"));
588
620
  activeControllers.delete(controller);
589
621
  }
590
622
  };
@@ -606,10 +638,6 @@ export default function usageExtension(pi: ExtensionAPI) {
606
638
  description: "Show usage for the current runtime account",
607
639
  handler: commandHandler,
608
640
  });
609
- pi.registerCommand("codex-status", {
610
- description: "Open /usage (temporary compatibility alias)",
611
- handler: commandHandler,
612
- });
613
641
 
614
642
  pi.on("session_start", (_event, ctx) => {
615
643
  sessionActive = true;