@narumitw/pi-usage 0.52.3 → 0.54.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 +111 -10
- package/dist/index.ts +1038 -43
- package/dist/index.ts.map +4 -4
- package/package.json +12 -4
- package/src/format.ts +117 -1
- package/src/index.ts +8 -0
- package/src/providers/kimi-coding.ts +276 -0
- package/src/providers/xai.ts +186 -0
- package/src/providers/zai.ts +150 -0
- package/src/query.ts +251 -3
- package/src/settings.ts +7 -0
- package/src/types.ts +28 -2
- package/src/usage-helpers.ts +3 -3
- package/src/usage-settings-ui.ts +128 -0
- package/src/usage.ts +127 -12
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { sanitizeDisplayText } from "../core.js";
|
|
2
|
+
import type { UsageBucket, UsageMetric, UsageReport, ZaiQuotaPayload } from "../types.js";
|
|
3
|
+
|
|
4
|
+
const FIVE_HOUR_WINDOW_MINUTES = 300;
|
|
5
|
+
const WEEKLY_WINDOW_MINUTES = 10_080;
|
|
6
|
+
|
|
7
|
+
export function normalizeZaiQuotaPayload(
|
|
8
|
+
providerId: string,
|
|
9
|
+
providerName: string,
|
|
10
|
+
payload: ZaiQuotaPayload,
|
|
11
|
+
capturedAt: number,
|
|
12
|
+
): UsageReport {
|
|
13
|
+
const data = asObject(payload.data);
|
|
14
|
+
if (!data) throw new Error("Z.AI quota response data was not an object.");
|
|
15
|
+
const limits = Array.isArray(data.limits) ? (data.limits as unknown[]) : [];
|
|
16
|
+
|
|
17
|
+
const buckets: UsageBucket[] = [];
|
|
18
|
+
const metrics: UsageMetric[] = [];
|
|
19
|
+
for (const raw of limits) {
|
|
20
|
+
const limit = asObject(raw);
|
|
21
|
+
if (!limit) continue;
|
|
22
|
+
const type = asString(limit.type);
|
|
23
|
+
const unit = asNonnegativeNumber(limit.unit);
|
|
24
|
+
const isPlanUsage = type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT";
|
|
25
|
+
if (type === "TIME_LIMIT") {
|
|
26
|
+
addCountBucket(buckets, limit, "mcp-monthly", "MCP monthly allowance");
|
|
27
|
+
addUsageDetailMetrics(metrics, limit.usageDetails);
|
|
28
|
+
} else if (isPlanUsage && unit === 3) {
|
|
29
|
+
addPercentBucket(buckets, limit, "five-hour", "5h window", FIVE_HOUR_WINDOW_MINUTES);
|
|
30
|
+
} else if (isPlanUsage && unit === 6) {
|
|
31
|
+
const used = asNonnegativeNumber(limit.currentValue);
|
|
32
|
+
const quota = asNonnegativeNumber(limit.usage);
|
|
33
|
+
if (used !== undefined && quota !== undefined) {
|
|
34
|
+
addCountBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
|
|
35
|
+
} else {
|
|
36
|
+
addPercentBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (buckets.length === 0) {
|
|
41
|
+
throw new Error("Z.AI quota endpoint returned no displayable usage data.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const notes: string[] = [];
|
|
45
|
+
const level = asString(data.level);
|
|
46
|
+
if (level) notes.push(`Plan: ${level}`);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
providerId,
|
|
50
|
+
providerName,
|
|
51
|
+
capturedAt,
|
|
52
|
+
source: "zai-quota",
|
|
53
|
+
semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
|
|
54
|
+
buckets,
|
|
55
|
+
metrics,
|
|
56
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addPercentBucket(
|
|
61
|
+
buckets: UsageBucket[],
|
|
62
|
+
limit: Record<string, unknown>,
|
|
63
|
+
id: string,
|
|
64
|
+
label: string,
|
|
65
|
+
windowMinutes: number,
|
|
66
|
+
): void {
|
|
67
|
+
const used = asNonnegativeNumber(limit.percentage);
|
|
68
|
+
if (used === undefined) return;
|
|
69
|
+
const percent = clampPercent(used);
|
|
70
|
+
const resetsAt = asEpochSeconds(limit.nextResetTime);
|
|
71
|
+
buckets.push({
|
|
72
|
+
id,
|
|
73
|
+
label,
|
|
74
|
+
used: percent,
|
|
75
|
+
remaining: 100 - percent,
|
|
76
|
+
limit: 100,
|
|
77
|
+
unit: "percent",
|
|
78
|
+
windowMinutes,
|
|
79
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function addCountBucket(
|
|
84
|
+
buckets: UsageBucket[],
|
|
85
|
+
limit: Record<string, unknown>,
|
|
86
|
+
id: string,
|
|
87
|
+
label: string,
|
|
88
|
+
windowMinutes?: number,
|
|
89
|
+
): void {
|
|
90
|
+
const used = asNonnegativeNumber(limit.currentValue);
|
|
91
|
+
const quota = asNonnegativeNumber(limit.usage);
|
|
92
|
+
if (used === undefined || quota === undefined) return;
|
|
93
|
+
const resetsAt = asEpochSeconds(limit.nextResetTime);
|
|
94
|
+
buckets.push({
|
|
95
|
+
id,
|
|
96
|
+
label,
|
|
97
|
+
used,
|
|
98
|
+
remaining: Math.max(0, quota - used),
|
|
99
|
+
limit: quota,
|
|
100
|
+
unit: "count",
|
|
101
|
+
...(windowMinutes !== undefined ? { windowMinutes } : {}),
|
|
102
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function addUsageDetailMetrics(metrics: UsageMetric[], value: unknown): void {
|
|
107
|
+
if (!Array.isArray(value)) return;
|
|
108
|
+
for (const raw of value) {
|
|
109
|
+
const detail = asObject(raw);
|
|
110
|
+
if (!detail) continue;
|
|
111
|
+
const label = asString(detail.modelCode);
|
|
112
|
+
const usage = asNonnegativeNumber(detail.usage);
|
|
113
|
+
if (!label || usage === undefined) continue;
|
|
114
|
+
metrics.push({ id: `mcp-${kebabCase(label)}`, label, value: usage, unit: "count" });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
119
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
120
|
+
return value as Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function asString(value: unknown): string | undefined {
|
|
124
|
+
if (typeof value !== "string") return undefined;
|
|
125
|
+
return sanitizeDisplayText(value, 80) || undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function asNonnegativeNumber(value: unknown): number | undefined {
|
|
129
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function asEpochSeconds(value: unknown): number | undefined {
|
|
134
|
+
const millis = asNonnegativeNumber(value);
|
|
135
|
+
if (millis === undefined) return undefined;
|
|
136
|
+
return Math.floor(millis / 1000);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function kebabCase(label: string): string {
|
|
140
|
+
return (
|
|
141
|
+
label
|
|
142
|
+
.toLowerCase()
|
|
143
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
144
|
+
.replace(/^-+|-+$/gu, "") || "tool"
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clampPercent(value: number): number {
|
|
149
|
+
return Math.min(100, Math.max(0, value));
|
|
150
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -7,28 +7,45 @@ import {
|
|
|
7
7
|
} from "./oauth-credential-source.js";
|
|
8
8
|
import { normalizeCodexBackendPayload } from "./providers/codex.js";
|
|
9
9
|
import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
|
|
10
|
+
import { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
|
|
10
11
|
import { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
|
|
11
12
|
import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
|
|
13
|
+
import { normalizeXaiBillingPayload } from "./providers/xai.js";
|
|
14
|
+
import { normalizeZaiQuotaPayload } from "./providers/zai.js";
|
|
12
15
|
import type {
|
|
13
16
|
CodexBackendPayload,
|
|
14
17
|
GitHubCopilotUsagePayload,
|
|
18
|
+
KimiCodingUsagePayload,
|
|
15
19
|
OpenCodeZenPayload,
|
|
16
20
|
OpenRouterKeyPayload,
|
|
17
21
|
PiModel,
|
|
18
22
|
ResolvedUsageAuth,
|
|
19
23
|
UsageProviderAdapter,
|
|
20
24
|
UsageReport,
|
|
25
|
+
XaiBillingPayload,
|
|
26
|
+
XaiUserPayload,
|
|
27
|
+
ZaiQuotaPayload,
|
|
21
28
|
} from "./types.js";
|
|
22
29
|
|
|
23
30
|
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
24
31
|
const GITHUB_COPILOT_USAGE_URL = "https://api.github.com/copilot_internal/user";
|
|
25
32
|
const OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key";
|
|
26
33
|
const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
34
|
+
const KIMI_CODING_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
|
35
|
+
const XAI_USER_URL = "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
|
|
36
|
+
const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
|
37
|
+
const XAI_CLIENT_HEADERS = Object.freeze({
|
|
38
|
+
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
39
|
+
"x-grok-client-version": "1.0.10",
|
|
40
|
+
"x-grok-client-mode": "interactive",
|
|
41
|
+
});
|
|
27
42
|
const MAX_SUCCESS_BODY_BYTES = 64 * 1024;
|
|
28
43
|
const MAX_ERROR_BODY_BYTES = 4 * 1024;
|
|
29
44
|
|
|
30
45
|
export const AUTH_FINGERPRINT_SALT = randomBytes(32);
|
|
31
46
|
|
|
47
|
+
export type UsageRequestGuard = () => Promise<void>;
|
|
48
|
+
|
|
32
49
|
export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
|
|
33
50
|
{
|
|
34
51
|
id: "openai-codex",
|
|
@@ -96,12 +113,125 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
|
|
|
96
113
|
return normalizeOpenCodeZenPayload(payload as OpenCodeZenPayload, Date.now());
|
|
97
114
|
},
|
|
98
115
|
},
|
|
116
|
+
{
|
|
117
|
+
id: "kimi-coding",
|
|
118
|
+
displayName: "Kimi For Coding",
|
|
119
|
+
semantics: { kind: "consumer-subscription", label: "Kimi Coding Plan usage" },
|
|
120
|
+
async query(auth, signal, timeoutMs) {
|
|
121
|
+
const payload = await fetchProviderJson(
|
|
122
|
+
KIMI_CODING_USAGE_URL,
|
|
123
|
+
auth,
|
|
124
|
+
signal,
|
|
125
|
+
timeoutMs,
|
|
126
|
+
"Kimi Coding usage endpoint",
|
|
127
|
+
{ redirect: "error" },
|
|
128
|
+
);
|
|
129
|
+
return normalizeKimiCodingUsagePayload(payload as KimiCodingUsagePayload, Date.now());
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "zai",
|
|
134
|
+
displayName: "Z.AI",
|
|
135
|
+
semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
|
|
136
|
+
publishesStatusline: false,
|
|
137
|
+
async query(auth, signal, timeoutMs) {
|
|
138
|
+
const payload = await fetchProviderJson(
|
|
139
|
+
zaiMonitorUrl(auth.model.baseUrl),
|
|
140
|
+
zaiMonitorAuth(auth),
|
|
141
|
+
signal,
|
|
142
|
+
timeoutMs,
|
|
143
|
+
"Z.AI quota endpoint",
|
|
144
|
+
);
|
|
145
|
+
return normalizeZaiQuotaPayload("zai", "Z.AI", payload as ZaiQuotaPayload, Date.now());
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "zai-coding-cn",
|
|
150
|
+
displayName: "Z.AI Coding CN",
|
|
151
|
+
semantics: { kind: "consumer-subscription", label: "GLM Coding Plan usage" },
|
|
152
|
+
publishesStatusline: false,
|
|
153
|
+
async query(auth, signal, timeoutMs) {
|
|
154
|
+
const payload = await fetchProviderJson(
|
|
155
|
+
zaiMonitorUrl(auth.model.baseUrl),
|
|
156
|
+
zaiMonitorAuth(auth),
|
|
157
|
+
signal,
|
|
158
|
+
timeoutMs,
|
|
159
|
+
"Z.AI Coding CN quota endpoint",
|
|
160
|
+
);
|
|
161
|
+
return normalizeZaiQuotaPayload(
|
|
162
|
+
"zai-coding-cn",
|
|
163
|
+
"Z.AI Coding CN",
|
|
164
|
+
payload as ZaiQuotaPayload,
|
|
165
|
+
Date.now(),
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
99
169
|
];
|
|
100
170
|
|
|
171
|
+
// Reviewed contract pins:
|
|
172
|
+
// - Pi xAI provider at https://api.x.ai and OAuth scope
|
|
173
|
+
// "openid profile email offline_access grok-cli:access api:access" at
|
|
174
|
+
// e86823096c5bad39e1ca282ec24bc5eb9bec745b, unchanged at
|
|
175
|
+
// ccfe79ed238674f760c986e3a61493aab794000a.
|
|
176
|
+
// - Grok Build identity, credits routes/structs, required token-auth and version headers, and
|
|
177
|
+
// client-mode telemetry at 9684fa3cdbf2995e30ea8b9b637f1db008f144fc (client version 1.0.10).
|
|
178
|
+
// - xAI Management API's separate team billing boundary at
|
|
179
|
+
// 723dd2aa22d17be35617463837dc47cda008d90e.
|
|
180
|
+
// x-userid remains attached only to billing to bind the proxy-canonical identity as Grok Build does.
|
|
181
|
+
export const XAI_ADAPTER: UsageProviderAdapter = {
|
|
182
|
+
id: "xai",
|
|
183
|
+
displayName: "xAI",
|
|
184
|
+
semantics: {
|
|
185
|
+
kind: "consumer-subscription",
|
|
186
|
+
label: "xAI consumer subscription usage",
|
|
187
|
+
},
|
|
188
|
+
publishesStatusline: false,
|
|
189
|
+
async query(auth, signal, timeoutMs, guard) {
|
|
190
|
+
if (!guard) throw new Error("xAI usage requires request-boundary revalidation.");
|
|
191
|
+
const startedAt = Date.now();
|
|
192
|
+
const clientAuth = {
|
|
193
|
+
...auth,
|
|
194
|
+
headers: { ...auth.headers, ...XAI_CLIENT_HEADERS },
|
|
195
|
+
};
|
|
196
|
+
await guard();
|
|
197
|
+
const userPayload = (await fetchProviderJson(
|
|
198
|
+
XAI_USER_URL,
|
|
199
|
+
clientAuth,
|
|
200
|
+
signal,
|
|
201
|
+
remainingTimeout(timeoutMs, startedAt),
|
|
202
|
+
"xAI consumer identity endpoint",
|
|
203
|
+
{ redirect: "error", userAgent: false },
|
|
204
|
+
)) as XaiUserPayload;
|
|
205
|
+
await guard();
|
|
206
|
+
const userId = validatedXaiUserId(userPayload.userId);
|
|
207
|
+
const billingAuth = {
|
|
208
|
+
...clientAuth,
|
|
209
|
+
headers: { ...clientAuth.headers, "x-userid": userId },
|
|
210
|
+
secrets: [...clientAuth.secrets, userId],
|
|
211
|
+
};
|
|
212
|
+
await guard();
|
|
213
|
+
const billingPayload = (await fetchProviderJson(
|
|
214
|
+
XAI_BILLING_URL,
|
|
215
|
+
billingAuth,
|
|
216
|
+
signal,
|
|
217
|
+
remainingTimeout(timeoutMs, startedAt),
|
|
218
|
+
"xAI consumer billing endpoint",
|
|
219
|
+
{ redirect: "error", userAgent: false },
|
|
220
|
+
)) as XaiBillingPayload;
|
|
221
|
+
await guard();
|
|
222
|
+
return normalizeXaiBillingPayload(billingPayload, userPayload.subscriptionTier, Date.now());
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export function usageAdapters(xaiUsage = true): readonly UsageProviderAdapter[] {
|
|
227
|
+
return xaiUsage ? [...SUPPORTED_ADAPTERS, XAI_ADAPTER] : SUPPORTED_ADAPTERS;
|
|
228
|
+
}
|
|
229
|
+
|
|
101
230
|
export function adapterForProvider(
|
|
102
231
|
providerId: string | undefined,
|
|
232
|
+
xaiUsage = true,
|
|
103
233
|
): UsageProviderAdapter | undefined {
|
|
104
|
-
return
|
|
234
|
+
return usageAdapters(xaiUsage).find((adapter) => adapter.id === providerId);
|
|
105
235
|
}
|
|
106
236
|
|
|
107
237
|
export function isStaleExtensionContextError(error: unknown): boolean {
|
|
@@ -165,6 +295,13 @@ export async function resolveUsageAuth(
|
|
|
165
295
|
offered.offeredCount === 0,
|
|
166
296
|
);
|
|
167
297
|
}
|
|
298
|
+
if (adapter.id === "xai") {
|
|
299
|
+
const offered = candidateReader
|
|
300
|
+
? candidateReader(ctx, adapter.id)
|
|
301
|
+
: fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
|
|
302
|
+
if (!offered.ok) throw new Error("xAI OAuth credential discovery failed closed.");
|
|
303
|
+
return resolveXaiUsageAuth(auth, model, salt, offered.candidates);
|
|
304
|
+
}
|
|
168
305
|
const authorization = authorizationFrom(auth);
|
|
169
306
|
if (!authorization) return undefined;
|
|
170
307
|
const headers = { Authorization: authorization };
|
|
@@ -185,9 +322,10 @@ export async function queryProviderUsage(
|
|
|
185
322
|
auth: ResolvedUsageAuth,
|
|
186
323
|
signal: AbortSignal,
|
|
187
324
|
timeoutMs: number,
|
|
325
|
+
guard?: UsageRequestGuard,
|
|
188
326
|
): Promise<UsageReport> {
|
|
189
327
|
try {
|
|
190
|
-
return await adapter.query(auth, signal, timeoutMs);
|
|
328
|
+
return await adapter.query(auth, signal, timeoutMs, guard);
|
|
191
329
|
} catch (error) {
|
|
192
330
|
if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
|
|
193
331
|
throw new Error(redactUsageError(errorMessage(error), auth.secrets));
|
|
@@ -227,6 +365,8 @@ export async function fetchProviderJson(
|
|
|
227
365
|
request: {
|
|
228
366
|
method?: "GET" | "POST";
|
|
229
367
|
body?: Record<string, unknown>;
|
|
368
|
+
redirect?: RequestRedirect;
|
|
369
|
+
userAgent?: boolean;
|
|
230
370
|
} = {},
|
|
231
371
|
): Promise<Record<string, unknown>> {
|
|
232
372
|
const controller = new AbortController();
|
|
@@ -240,7 +380,9 @@ export async function fetchProviderJson(
|
|
|
240
380
|
}, timeoutMs);
|
|
241
381
|
try {
|
|
242
382
|
const headers = { ...auth.headers };
|
|
243
|
-
if (!hasHeader(headers, "User-Agent"))
|
|
383
|
+
if (request.userAgent !== false && !hasHeader(headers, "User-Agent")) {
|
|
384
|
+
headers["User-Agent"] = "pi-usage";
|
|
385
|
+
}
|
|
244
386
|
if (request.body && !hasHeader(headers, "Content-Type")) {
|
|
245
387
|
headers["Content-Type"] = "application/json";
|
|
246
388
|
}
|
|
@@ -248,8 +390,10 @@ export async function fetchProviderJson(
|
|
|
248
390
|
method: request.method ?? "GET",
|
|
249
391
|
headers,
|
|
250
392
|
...(request.body ? { body: JSON.stringify(request.body) } : {}),
|
|
393
|
+
...(request.redirect ? { redirect: request.redirect } : {}),
|
|
251
394
|
signal: controller.signal,
|
|
252
395
|
});
|
|
396
|
+
if (response.redirected) throw new Error(`${description} refused a redirected response.`);
|
|
253
397
|
if (controller.signal.aborted)
|
|
254
398
|
throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
255
399
|
const text = await readBoundedResponse(
|
|
@@ -257,6 +401,7 @@ export async function fetchProviderJson(
|
|
|
257
401
|
response.ok ? MAX_SUCCESS_BODY_BYTES : MAX_ERROR_BODY_BYTES,
|
|
258
402
|
!response.ok,
|
|
259
403
|
description,
|
|
404
|
+
controller.signal,
|
|
260
405
|
);
|
|
261
406
|
if (controller.signal.aborted)
|
|
262
407
|
throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
@@ -293,12 +438,16 @@ async function readBoundedResponse(
|
|
|
293
438
|
maxBytes: number,
|
|
294
439
|
truncateOverflow: boolean,
|
|
295
440
|
description: string,
|
|
441
|
+
signal: AbortSignal,
|
|
296
442
|
): Promise<string> {
|
|
297
443
|
if (!response.body) return "";
|
|
298
444
|
const reader = response.body.getReader();
|
|
299
445
|
const chunks: Uint8Array[] = [];
|
|
300
446
|
let total = 0;
|
|
301
447
|
let truncated = false;
|
|
448
|
+
const abort = () => void reader.cancel().catch(() => undefined);
|
|
449
|
+
if (signal.aborted) abort();
|
|
450
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
302
451
|
try {
|
|
303
452
|
while (true) {
|
|
304
453
|
const { done, value } = await reader.read();
|
|
@@ -315,6 +464,7 @@ async function readBoundedResponse(
|
|
|
315
464
|
total += value.byteLength;
|
|
316
465
|
}
|
|
317
466
|
} finally {
|
|
467
|
+
signal.removeEventListener("abort", abort);
|
|
318
468
|
reader.releaseLock();
|
|
319
469
|
}
|
|
320
470
|
if (truncated && !truncateOverflow) {
|
|
@@ -349,6 +499,73 @@ type UsageAuthRegistry = {
|
|
|
349
499
|
>;
|
|
350
500
|
};
|
|
351
501
|
|
|
502
|
+
function resolveXaiUsageAuth(
|
|
503
|
+
auth: RequestAuth,
|
|
504
|
+
model: PiModel,
|
|
505
|
+
salt: Uint8Array,
|
|
506
|
+
candidates: readonly unknown[],
|
|
507
|
+
): ResolvedUsageAuth {
|
|
508
|
+
const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
|
|
509
|
+
if (!resolvedAccess) throw new Error("xAI runtime authentication was incomplete.");
|
|
510
|
+
let sawOAuth = false;
|
|
511
|
+
let sawMatchingAccess = false;
|
|
512
|
+
let sawIncompleteMatch = false;
|
|
513
|
+
const matches: Array<{ access: string; refresh: string }> = [];
|
|
514
|
+
for (const candidate of candidates) {
|
|
515
|
+
try {
|
|
516
|
+
const credential = asObject(candidate);
|
|
517
|
+
if (credential?.type !== "oauth") continue;
|
|
518
|
+
sawOAuth = true;
|
|
519
|
+
if (credential.access !== resolvedAccess) continue;
|
|
520
|
+
sawMatchingAccess = true;
|
|
521
|
+
if (
|
|
522
|
+
typeof credential.access !== "string" ||
|
|
523
|
+
!credential.access ||
|
|
524
|
+
typeof credential.refresh !== "string" ||
|
|
525
|
+
!credential.refresh ||
|
|
526
|
+
typeof credential.expires !== "number" ||
|
|
527
|
+
!Number.isFinite(credential.expires)
|
|
528
|
+
) {
|
|
529
|
+
sawIncompleteMatch = true;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
matches.push({ access: credential.access, refresh: credential.refresh });
|
|
533
|
+
} catch {
|
|
534
|
+
// Malformed candidates never authorize a consumer-proxy request.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (sawIncompleteMatch) throw new Error("The matching xAI OAuth credential was incomplete.");
|
|
538
|
+
if (matches.length > 1) {
|
|
539
|
+
throw new Error("Multiple OAuth credentials match the active xAI runtime account.");
|
|
540
|
+
}
|
|
541
|
+
const match = matches[0];
|
|
542
|
+
if (!match) {
|
|
543
|
+
if (!sawOAuth) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
"xAI consumer usage requires the OAuth subscription account configured through Pi /login; XAI_API_KEY users can review API spend at console.x.ai.",
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
if (sawMatchingAccess) throw new Error("The matching xAI OAuth credential was incomplete.");
|
|
549
|
+
throw new Error("The active xAI runtime account does not match Pi's stored OAuth account.");
|
|
550
|
+
}
|
|
551
|
+
const authorization = `Bearer ${match.access}`;
|
|
552
|
+
const headers = { Authorization: authorization };
|
|
553
|
+
return {
|
|
554
|
+
apiKey: match.access,
|
|
555
|
+
headers,
|
|
556
|
+
fingerprint: fingerprintResolvedAuth({ headers }, salt),
|
|
557
|
+
secrets: [
|
|
558
|
+
match.access,
|
|
559
|
+
match.refresh,
|
|
560
|
+
resolvedAccess,
|
|
561
|
+
auth.apiKey,
|
|
562
|
+
headerValue(auth.headers, "Authorization"),
|
|
563
|
+
authorization,
|
|
564
|
+
].filter((value): value is string => Boolean(value)),
|
|
565
|
+
model,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
352
569
|
function resolveGitHubCopilotUsageAuth(
|
|
353
570
|
auth: RequestAuth,
|
|
354
571
|
model: PiModel,
|
|
@@ -470,6 +687,10 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
|
|
|
470
687
|
if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
|
|
471
688
|
if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
|
|
472
689
|
if (providerId === "opencode-go") return url.origin === "https://opencode.ai";
|
|
690
|
+
if (providerId === "kimi-coding") return url.origin === "https://api.kimi.com";
|
|
691
|
+
if (providerId === "xai") return url.origin === "https://api.x.ai";
|
|
692
|
+
if (providerId === "zai") return url.origin === "https://api.z.ai";
|
|
693
|
+
if (providerId === "zai-coding-cn") return url.origin === "https://open.bigmodel.cn";
|
|
473
694
|
if (providerId === "github-copilot") {
|
|
474
695
|
return (
|
|
475
696
|
url.protocol === "https:" && /^api\.[a-z0-9-]+\.githubcopilot\.com$/u.test(url.hostname)
|
|
@@ -495,6 +716,33 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
|
495
716
|
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
496
717
|
}
|
|
497
718
|
|
|
719
|
+
function validatedXaiUserId(value: unknown): string {
|
|
720
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/u.test(value)) {
|
|
721
|
+
throw new Error("xAI consumer identity returned an unsafe canonical user ID.");
|
|
722
|
+
}
|
|
723
|
+
return value;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function remainingTimeout(timeoutMs: number, startedAt: number): number {
|
|
727
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
728
|
+
if (remaining <= 0) throw new Error("Timed out while fetching xAI consumer usage.");
|
|
729
|
+
return remaining;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function zaiMonitorUrl(baseUrl: string | undefined): string {
|
|
733
|
+
const base = baseUrl?.trim();
|
|
734
|
+
if (!base) throw new Error("Z.AI model base URL is unavailable.");
|
|
735
|
+
return `${new URL(base).origin}/api/monitor/usage/quota/limit`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function zaiMonitorAuth(auth: ResolvedUsageAuth): ResolvedUsageAuth {
|
|
739
|
+
const authorization = headerValue(auth.headers, "Authorization");
|
|
740
|
+
const token =
|
|
741
|
+
authorization === undefined ? undefined : (bearerToken(authorization) ?? authorization);
|
|
742
|
+
if (token === undefined || token === authorization) return auth;
|
|
743
|
+
return { ...auth, headers: { ...auth.headers, Authorization: token } };
|
|
744
|
+
}
|
|
745
|
+
|
|
498
746
|
function isAbortError(error: unknown): boolean {
|
|
499
747
|
return error instanceof Error && error.name === "AbortError";
|
|
500
748
|
}
|
package/src/settings.ts
CHANGED
|
@@ -9,10 +9,12 @@ export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
|
|
|
9
9
|
|
|
10
10
|
export interface UsageSettings {
|
|
11
11
|
codexFastMode: boolean;
|
|
12
|
+
xaiUsage: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
|
|
15
16
|
codexFastMode: false,
|
|
17
|
+
xaiUsage: true,
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
export interface UsageSettingsState {
|
|
@@ -52,11 +54,16 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
|
|
|
52
54
|
if (Object.hasOwn(value, "codexFastMode") && typeof value.codexFastMode !== "boolean") {
|
|
53
55
|
return undefined;
|
|
54
56
|
}
|
|
57
|
+
if (Object.hasOwn(value, "xaiUsage") && typeof value.xaiUsage !== "boolean") {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
55
60
|
return {
|
|
56
61
|
codexFastMode:
|
|
57
62
|
typeof value.codexFastMode === "boolean"
|
|
58
63
|
? value.codexFastMode
|
|
59
64
|
: DEFAULT_USAGE_SETTINGS.codexFastMode,
|
|
65
|
+
xaiUsage:
|
|
66
|
+
typeof value.xaiUsage === "boolean" ? value.xaiUsage : DEFAULT_USAGE_SETTINGS.xaiUsage,
|
|
60
67
|
};
|
|
61
68
|
}
|
|
62
69
|
|
package/src/types.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
|
4
4
|
export type UsageModel = Pick<PiModel, "id" | "name" | "provider">;
|
|
5
5
|
|
|
6
6
|
export type UsageSemanticsKind = "consumer-subscription" | "api-key" | "project";
|
|
7
|
-
export type UsageUnit = "percent" | "usd" | "count";
|
|
7
|
+
export type UsageUnit = "percent" | "usd" | "currency" | "count";
|
|
8
8
|
export type UsageDisplayState = "current" | "configured";
|
|
9
9
|
|
|
10
10
|
export interface UsageSemantics {
|
|
@@ -32,6 +32,7 @@ export interface UsageMetric {
|
|
|
32
32
|
label: string;
|
|
33
33
|
value: number | string;
|
|
34
34
|
unit?: UsageUnit;
|
|
35
|
+
currency?: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export interface UsageReport {
|
|
@@ -58,7 +59,13 @@ export interface UsageProviderAdapter {
|
|
|
58
59
|
id: string;
|
|
59
60
|
displayName: string;
|
|
60
61
|
semantics: UsageSemantics;
|
|
61
|
-
|
|
62
|
+
publishesStatusline?: boolean;
|
|
63
|
+
query(
|
|
64
|
+
auth: ResolvedUsageAuth,
|
|
65
|
+
signal: AbortSignal,
|
|
66
|
+
timeoutMs: number,
|
|
67
|
+
guard?: () => Promise<void>,
|
|
68
|
+
): Promise<UsageReport>;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export type ProviderUsageState =
|
|
@@ -97,6 +104,25 @@ export type OpenCodeZenPayload = {
|
|
|
97
104
|
usage?: unknown;
|
|
98
105
|
};
|
|
99
106
|
|
|
107
|
+
export type ZaiQuotaPayload = {
|
|
108
|
+
data?: unknown;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type KimiCodingUsagePayload = {
|
|
112
|
+
usage?: unknown;
|
|
113
|
+
limits?: unknown;
|
|
114
|
+
boosterWallet?: unknown;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type XaiUserPayload = {
|
|
118
|
+
userId?: unknown;
|
|
119
|
+
subscriptionTier?: unknown;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type XaiBillingPayload = {
|
|
123
|
+
config?: unknown;
|
|
124
|
+
};
|
|
125
|
+
|
|
100
126
|
export type CodexBackendPayload = {
|
|
101
127
|
plan_type?: unknown;
|
|
102
128
|
rate_limit?: unknown;
|
package/src/usage-helpers.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { sanitizeDisplayText } from "./core.js";
|
|
3
|
-
import { providerIsConfigured,
|
|
3
|
+
import { providerIsConfigured, usageAdapters } from "./query.js";
|
|
4
4
|
import type { PiModel, UsageProviderAdapter } from "./types.js";
|
|
5
5
|
|
|
6
|
-
export function configuredAdapters(ctx: ExtensionContext): UsageProviderAdapter[] {
|
|
7
|
-
return
|
|
6
|
+
export function configuredAdapters(ctx: ExtensionContext, xaiUsage = true): UsageProviderAdapter[] {
|
|
7
|
+
return usageAdapters(xaiUsage).filter(
|
|
8
8
|
(adapter) => adapter.id === ctx.model?.provider || providerIsConfigured(ctx, adapter.id),
|
|
9
9
|
);
|
|
10
10
|
}
|