@narumitw/pi-usage 0.34.0 β 0.39.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 +24 -7
- package/package.json +9 -5
- package/src/format.ts +48 -0
- package/src/index.ts +1 -0
- package/src/providers/github-copilot.ts +120 -0
- package/src/query.ts +101 -3
- package/src/types.ts +12 -0
- package/src/usage.ts +144 -112
package/README.md
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@narumitw/pi-usage) [](https://pi.dev) [](./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
|
|
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.
|
|
19
20
|
|
|
20
21
|
## π¦ Install
|
|
21
22
|
|
|
@@ -45,7 +46,8 @@ Run:
|
|
|
45
46
|
/usage
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
In TUI or RPC mode, the standard menu first queries the current model provider and presents its state
|
|
50
|
+
with these actions:
|
|
49
51
|
|
|
50
52
|
```text
|
|
51
53
|
Refresh current usage
|
|
@@ -54,7 +56,11 @@ View all configured providersβ¦
|
|
|
54
56
|
Close
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
There are intentionally no `/usage --refresh`, `/usage <provider>`, or `/usage --all` argument paths.
|
|
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.
|
|
58
64
|
|
|
59
65
|
## π Provider semantics
|
|
60
66
|
|
|
@@ -68,6 +74,16 @@ There are intentionally no `/usage --refresh`, `/usage <provider>`, or `/usage -
|
|
|
68
74
|
|
|
69
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.
|
|
70
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
|
+
|
|
71
87
|
### OpenRouter
|
|
72
88
|
|
|
73
89
|
- Provider ID: `openrouter`
|
|
@@ -110,7 +126,8 @@ Behavior changes:
|
|
|
110
126
|
|
|
111
127
|
## π§ Limitations
|
|
112
128
|
|
|
113
|
-
- Only providers with a
|
|
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.
|
|
114
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.
|
|
115
132
|
- Provider reports are snapshots and may themselves be delayed by the provider.
|
|
116
133
|
- OpenRouter successful inference responses do not expose proactive request-rate counters; `/usage` reports the documented per-key credit/spend fields instead.
|
|
@@ -127,7 +144,7 @@ extensions/pi-usage/
|
|
|
127
144
|
β βββ query.ts # Runtime auth resolution and provider queries
|
|
128
145
|
β βββ format.ts # Provider-aware notifications and statusline text
|
|
129
146
|
β βββ core.ts # Cache, concurrency, fingerprint, and redaction helpers
|
|
130
|
-
β βββ providers/ # Codex and OpenRouter normalization adapters
|
|
147
|
+
β βββ providers/ # Codex, GitHub Copilot, and OpenRouter normalization adapters
|
|
131
148
|
β βββ types.ts # Common presentation and adapter contracts
|
|
132
149
|
βββ test/
|
|
133
150
|
βββ README.md
|
|
@@ -140,7 +157,7 @@ extensions/pi-usage/
|
|
|
140
157
|
|
|
141
158
|
## π Keywords
|
|
142
159
|
|
|
143
|
-
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.
|
|
144
161
|
|
|
145
162
|
## π License
|
|
146
163
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension that shows current-account usage
|
|
3
|
+
"version": "0.39.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": [
|
|
@@ -33,14 +34,17 @@
|
|
|
33
34
|
"@earendil-works/pi-coding-agent": ">=0.81.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
|
-
"@biomejs/biome": "2.5.
|
|
37
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
38
|
-
"@types/node": "26.1.
|
|
37
|
+
"@biomejs/biome": "2.5.6",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
39
|
+
"@types/node": "26.1.2",
|
|
39
40
|
"typescript": "7.0.2"
|
|
40
41
|
},
|
|
41
42
|
"repository": {
|
|
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) {
|
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
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
};
|