@diousk/pi-usage-fast 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +259 -0
- package/dist/index.ts +5478 -0
- package/dist/index.ts.map +7 -0
- package/docs/operations.md +31 -0
- package/docs/providers.md +273 -0
- package/package.json +82 -0
- package/src/codex-fast-runtime.ts +184 -0
- package/src/codex-fast.ts +118 -0
- package/src/codex-resets.ts +347 -0
- package/src/core.ts +245 -0
- package/src/format.ts +678 -0
- package/src/index.ts +116 -0
- package/src/oauth-credential-source.ts +117 -0
- package/src/providers/baseten.ts +51 -0
- package/src/providers/codex.ts +139 -0
- package/src/providers/deepseek.ts +80 -0
- package/src/providers/fireworks.ts +291 -0
- package/src/providers/github-copilot.ts +114 -0
- package/src/providers/kimi-coding.ts +265 -0
- package/src/providers/minimax.ts +280 -0
- package/src/providers/moonshot.ts +59 -0
- package/src/providers/opencode-zen.ts +80 -0
- package/src/providers/openrouter.ts +70 -0
- package/src/providers/vercel-ai-gateway.ts +43 -0
- package/src/providers/xai.ts +179 -0
- package/src/providers/zai-errors.ts +94 -0
- package/src/providers/zai.ts +239 -0
- package/src/query.ts +1073 -0
- package/src/settings.ts +366 -0
- package/src/types.ts +235 -0
- package/src/usage-helpers.ts +40 -0
- package/src/usage-settings-ui.ts +152 -0
- package/src/usage-targets.ts +127 -0
- package/src/usage.ts +1335 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { sanitizeDisplayText } from "../core.js";
|
|
2
|
+
import type {
|
|
3
|
+
FireworksAccountsPayload,
|
|
4
|
+
FireworksBillingSummaryPayload,
|
|
5
|
+
ResolvedUsageAuth,
|
|
6
|
+
UsageMetric,
|
|
7
|
+
UsageProviderAdapter,
|
|
8
|
+
UsageReport,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
|
|
11
|
+
const NANOS_PER_UNIT = 1_000_000_000n;
|
|
12
|
+
const CURRENCY_PATTERN = /^[A-Z]{3}$/u;
|
|
13
|
+
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/u;
|
|
14
|
+
const INTEGER_PATTERN = /^-?\d+$/u;
|
|
15
|
+
const INT64_MIN = -(2n ** 63n);
|
|
16
|
+
const INT64_MAX = 2n ** 63n - 1n;
|
|
17
|
+
const MAX_UNITS_CHARS = 20;
|
|
18
|
+
const MAX_NANOS_CHARS = 11;
|
|
19
|
+
const FIREWORKS_BILLING_SUMMARY_ORIGIN = "https://api.fireworks.ai";
|
|
20
|
+
const FIREWORKS_SPEND_WINDOW_DAYS = 30;
|
|
21
|
+
const FIREWORKS_MAX_ACCOUNT_PAGES = 5;
|
|
22
|
+
|
|
23
|
+
const SERIES_KEYS = ["serverless", "dedicated", "training", "other"] as const;
|
|
24
|
+
type SeriesKey = (typeof SERIES_KEYS)[number];
|
|
25
|
+
|
|
26
|
+
const SERIES_LABELS: Readonly<Record<SeriesKey, string>> = {
|
|
27
|
+
serverless: "Serverless",
|
|
28
|
+
dedicated: "Dedicated deployments",
|
|
29
|
+
training: "Training",
|
|
30
|
+
other: "Other",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function isFireworksAccountId(value: unknown): value is string {
|
|
34
|
+
return typeof value === "string" && ACCOUNT_ID_PATTERN.test(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeFireworksAccountsPayload(payload: FireworksAccountsPayload): string[] {
|
|
38
|
+
if (!Array.isArray(payload.accounts)) {
|
|
39
|
+
throw new Error("Fireworks accounts response did not contain an accounts array.");
|
|
40
|
+
}
|
|
41
|
+
const accounts: string[] = [];
|
|
42
|
+
for (const raw of payload.accounts) {
|
|
43
|
+
const account = asObject(raw);
|
|
44
|
+
if (!account) throw new Error("Fireworks accounts response row was not an object.");
|
|
45
|
+
if (typeof account.name !== "string") {
|
|
46
|
+
throw new Error("Fireworks accounts response omitted the account resource name.");
|
|
47
|
+
}
|
|
48
|
+
const match = /^accounts\/([^/]+)$/u.exec(account.name);
|
|
49
|
+
if (!match || !isFireworksAccountId(match[1])) {
|
|
50
|
+
throw new Error("Fireworks accounts response returned an unsafe account resource name.");
|
|
51
|
+
}
|
|
52
|
+
const accountId = match[1];
|
|
53
|
+
if (accounts.includes(accountId)) {
|
|
54
|
+
throw new Error(`Fireworks accounts response repeated ${accountId}.`);
|
|
55
|
+
}
|
|
56
|
+
accounts.push(accountId);
|
|
57
|
+
}
|
|
58
|
+
return accounts;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type FetchProviderJson = (
|
|
62
|
+
url: string,
|
|
63
|
+
auth: ResolvedUsageAuth,
|
|
64
|
+
signal: AbortSignal,
|
|
65
|
+
timeoutMs: number,
|
|
66
|
+
description: string,
|
|
67
|
+
request?: { redirect?: RequestRedirect },
|
|
68
|
+
) => Promise<Record<string, unknown>>;
|
|
69
|
+
|
|
70
|
+
export function createFireworksAdapter(fetchProviderJson: FetchProviderJson): UsageProviderAdapter {
|
|
71
|
+
return {
|
|
72
|
+
id: "fireworks",
|
|
73
|
+
displayName: "Fireworks",
|
|
74
|
+
semantics: { kind: "api-key", label: "Fireworks API spend" },
|
|
75
|
+
targets: {
|
|
76
|
+
singularLabel: "account",
|
|
77
|
+
pluralLabel: "accounts",
|
|
78
|
+
async list(auth, signal, timeoutMs, guard) {
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
const accounts: string[] = [];
|
|
81
|
+
let pageToken: string | undefined;
|
|
82
|
+
for (let page = 0; page < FIREWORKS_MAX_ACCOUNT_PAGES; page += 1) {
|
|
83
|
+
await guard();
|
|
84
|
+
const payload = (await fetchProviderJson(
|
|
85
|
+
fireworksAccountsUrl(pageToken),
|
|
86
|
+
auth,
|
|
87
|
+
signal,
|
|
88
|
+
remainingTimeout(timeoutMs, startedAt, "fetching Fireworks accounts"),
|
|
89
|
+
"Fireworks accounts endpoint",
|
|
90
|
+
{ redirect: "error" },
|
|
91
|
+
)) as FireworksAccountsPayload;
|
|
92
|
+
await guard();
|
|
93
|
+
for (const accountId of normalizeFireworksAccountsPayload(payload)) {
|
|
94
|
+
if (accounts.includes(accountId)) {
|
|
95
|
+
throw new Error(`Fireworks accounts listing repeated ${accountId}.`);
|
|
96
|
+
}
|
|
97
|
+
accounts.push(accountId);
|
|
98
|
+
}
|
|
99
|
+
pageToken = fireworksNextPageToken(payload.nextPageToken);
|
|
100
|
+
if (!pageToken) break;
|
|
101
|
+
}
|
|
102
|
+
if (pageToken) {
|
|
103
|
+
throw new Error(`Fireworks account listing exceeded ${FIREWORKS_MAX_ACCOUNT_PAGES} pages.`);
|
|
104
|
+
}
|
|
105
|
+
return accounts.map((id) => ({ id, label: id }));
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
async query(auth, signal, timeoutMs, guard, targetId) {
|
|
109
|
+
if (!guard) throw new Error("Fireworks API spend requires request-boundary revalidation.");
|
|
110
|
+
if (!isFireworksAccountId(targetId)) {
|
|
111
|
+
throw new Error("Fireworks billing requires a safe selected account slug.");
|
|
112
|
+
}
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
await guard();
|
|
115
|
+
const billingWindowAt = Date.now();
|
|
116
|
+
const payload = (await fetchProviderJson(
|
|
117
|
+
fireworksBillingSummaryUrl(targetId, billingWindowAt),
|
|
118
|
+
auth,
|
|
119
|
+
signal,
|
|
120
|
+
remainingTimeout(timeoutMs, startedAt, "fetching Fireworks rated spend"),
|
|
121
|
+
"Fireworks billing summary endpoint",
|
|
122
|
+
{ redirect: "error" },
|
|
123
|
+
)) as FireworksBillingSummaryPayload;
|
|
124
|
+
await guard();
|
|
125
|
+
return normalizeFireworksBillingSummaryPayload(payload, targetId, Date.now());
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function normalizeFireworksBillingSummaryPayload(
|
|
131
|
+
payload: FireworksBillingSummaryPayload,
|
|
132
|
+
accountId: string,
|
|
133
|
+
capturedAt: number,
|
|
134
|
+
): UsageReport {
|
|
135
|
+
if (!isFireworksAccountId(accountId)) {
|
|
136
|
+
throw new Error("Fireworks billing summary received an unsafe account identifier.");
|
|
137
|
+
}
|
|
138
|
+
if (payload.lineItems !== undefined && !Array.isArray(payload.lineItems)) {
|
|
139
|
+
throw new Error("Fireworks billing summary lineItems was not an array.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const totals = new Map<string, Map<SeriesKey, bigint>>();
|
|
143
|
+
for (const raw of payload.lineItems ?? []) {
|
|
144
|
+
const lineItem = asObject(raw);
|
|
145
|
+
if (!lineItem) throw new Error("Fireworks billing line item was not an object.");
|
|
146
|
+
const cost = moneyAmount(lineItem.totalCost, "line item total cost");
|
|
147
|
+
const series = seriesKey(lineItem.series);
|
|
148
|
+
let amounts = totals.get(cost.currency);
|
|
149
|
+
if (!amounts) {
|
|
150
|
+
amounts = new Map<SeriesKey, bigint>();
|
|
151
|
+
totals.set(cost.currency, amounts);
|
|
152
|
+
}
|
|
153
|
+
amounts.set(series, (amounts.get(series) ?? 0n) + cost.amount);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const metrics: UsageMetric[] = [];
|
|
157
|
+
for (const [currency, amounts] of totals) {
|
|
158
|
+
metrics.push({
|
|
159
|
+
id: `${currency.toLowerCase()}-total`,
|
|
160
|
+
label: "Total spend",
|
|
161
|
+
value: formatMoneyAmount(sumSeries(amounts)),
|
|
162
|
+
unit: "currency",
|
|
163
|
+
currency,
|
|
164
|
+
});
|
|
165
|
+
for (const series of SERIES_KEYS) {
|
|
166
|
+
const amount = amounts.get(series);
|
|
167
|
+
if (amount === undefined) continue;
|
|
168
|
+
metrics.push({
|
|
169
|
+
id: `${currency.toLowerCase()}-${series}`,
|
|
170
|
+
label: SERIES_LABELS[series],
|
|
171
|
+
value: formatMoneyAmount(amount),
|
|
172
|
+
unit: "currency",
|
|
173
|
+
currency,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const notes = ["Rated line items may differ from the final invoice once credits or adjustments are applied."];
|
|
179
|
+
if (metrics.length === 0) {
|
|
180
|
+
notes.push("Fireworks returned no rated line items for the last 30 days.");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
providerId: "fireworks",
|
|
185
|
+
providerName: "Fireworks",
|
|
186
|
+
capturedAt,
|
|
187
|
+
source: "fireworks-billing-summary",
|
|
188
|
+
semantics: { kind: "api-key", label: "Fireworks API spend" },
|
|
189
|
+
accountLabel: sanitizeDisplayText(accountId, 80),
|
|
190
|
+
buckets: [],
|
|
191
|
+
metrics,
|
|
192
|
+
notes,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type RatedMoney = { currency: string; amount: bigint };
|
|
197
|
+
|
|
198
|
+
function moneyAmount(value: unknown, description: string): RatedMoney {
|
|
199
|
+
const money = asObject(value);
|
|
200
|
+
if (!money) throw new Error(`Fireworks billing ${description} was not a money object.`);
|
|
201
|
+
const currency = typeof money.currencyCode === "string" ? money.currencyCode : undefined;
|
|
202
|
+
if (!currency || !CURRENCY_PATTERN.test(currency)) {
|
|
203
|
+
throw new Error(`Fireworks billing ${description} currency was not an ISO 4217 code.`);
|
|
204
|
+
}
|
|
205
|
+
// proto3 JSON omits zero-valued money fields, so absent components default to zero.
|
|
206
|
+
const units =
|
|
207
|
+
money.units === undefined ? 0n : integerComponent(money.units, description, "whole units", MAX_UNITS_CHARS);
|
|
208
|
+
if (units < INT64_MIN || units > INT64_MAX) {
|
|
209
|
+
throw new Error(`Fireworks billing ${description} whole units exceeded the int64 range.`);
|
|
210
|
+
}
|
|
211
|
+
const nanos =
|
|
212
|
+
money.nanos === undefined ? 0n : integerComponent(money.nanos, description, "nano units", MAX_NANOS_CHARS);
|
|
213
|
+
if (nanos <= -NANOS_PER_UNIT || nanos >= NANOS_PER_UNIT) {
|
|
214
|
+
throw new Error(`Fireworks billing ${description} nano units exceeded the Money range.`);
|
|
215
|
+
}
|
|
216
|
+
if ((units > 0n && nanos < 0n) || (units < 0n && nanos > 0n)) {
|
|
217
|
+
throw new Error(`Fireworks billing ${description} mixed unit and nano signs.`);
|
|
218
|
+
}
|
|
219
|
+
return { currency, amount: units * NANOS_PER_UNIT + nanos };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function integerComponent(value: unknown, description: string, component: string, maxChars: number): bigint {
|
|
223
|
+
const text =
|
|
224
|
+
typeof value === "number" && Number.isSafeInteger(value)
|
|
225
|
+
? String(value)
|
|
226
|
+
: typeof value === "string" && INTEGER_PATTERN.test(value)
|
|
227
|
+
? value
|
|
228
|
+
: undefined;
|
|
229
|
+
if (text === undefined || text.length > maxChars) {
|
|
230
|
+
throw new Error(`Fireworks billing ${description} ${component} was not a bounded integer.`);
|
|
231
|
+
}
|
|
232
|
+
return BigInt(text);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function seriesKey(value: unknown): SeriesKey {
|
|
236
|
+
if (value === undefined || value === null) return "other";
|
|
237
|
+
if (typeof value !== "string") throw new Error("Fireworks billing line item series was invalid.");
|
|
238
|
+
if (value === "SERVERLESS") return "serverless";
|
|
239
|
+
if (value === "DEDICATED_DEPLOYMENT") return "dedicated";
|
|
240
|
+
if (value === "TRAINING") return "training";
|
|
241
|
+
return "other";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sumSeries(amounts: ReadonlyMap<SeriesKey, bigint>): bigint {
|
|
245
|
+
let total = 0n;
|
|
246
|
+
for (const amount of amounts.values()) total += amount;
|
|
247
|
+
return total;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatMoneyAmount(amount: bigint): string {
|
|
251
|
+
const negative = amount < 0n;
|
|
252
|
+
const magnitude = negative ? -amount : amount;
|
|
253
|
+
const units = magnitude / NANOS_PER_UNIT;
|
|
254
|
+
const nanos = (magnitude % NANOS_PER_UNIT).toString().padStart(9, "0").replace(/0+$/u, "");
|
|
255
|
+
return `${negative ? "-" : ""}${units.toString()}${nanos ? `.${nanos}` : ""}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function fireworksAccountsUrl(pageToken: string | undefined): string {
|
|
259
|
+
const url = new URL("/v1/accounts", FIREWORKS_BILLING_SUMMARY_ORIGIN);
|
|
260
|
+
url.searchParams.set("pageSize", "200");
|
|
261
|
+
if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
|
|
262
|
+
return url.toString();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function fireworksNextPageToken(value: unknown): string | undefined {
|
|
266
|
+
if (value === undefined || value === null) return undefined;
|
|
267
|
+
if (typeof value !== "string" || !value || value.length > 512) {
|
|
268
|
+
throw new Error("Fireworks accounts listing returned an invalid page token.");
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function fireworksBillingSummaryUrl(accountId: string, startedAt: number): string {
|
|
274
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
275
|
+
const dayFloor = (time: number) => `${new Date(time).toISOString().slice(0, 10)}T00:00:00Z`;
|
|
276
|
+
const url = new URL(`/v1/accounts/${accountId}/billing/summary`, FIREWORKS_BILLING_SUMMARY_ORIGIN);
|
|
277
|
+
url.searchParams.set("startTime", dayFloor(startedAt - (FIREWORKS_SPEND_WINDOW_DAYS - 1) * dayMs));
|
|
278
|
+
url.searchParams.set("endTime", dayFloor(startedAt + dayMs));
|
|
279
|
+
return url.toString();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function remainingTimeout(timeoutMs: number, startedAt: number, description: string): number {
|
|
283
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
284
|
+
if (remaining <= 0) throw new Error(`Timed out while ${description}.`);
|
|
285
|
+
return remaining;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
289
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
290
|
+
return value as Record<string, unknown>;
|
|
291
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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 ? "GitHub Copilot AI Credits allowance" : "GitHub Copilot premium request quota";
|
|
19
|
+
|
|
20
|
+
if (premium.unlimited === true) {
|
|
21
|
+
bucket = { id, label, unit: "count" };
|
|
22
|
+
} else {
|
|
23
|
+
const entitlement = asNonnegativeNumber(premium.entitlement);
|
|
24
|
+
const rawRemaining = asFiniteNumber(premium.remaining) ?? asFiniteNumber(premium.quota_remaining);
|
|
25
|
+
if (entitlement === undefined || rawRemaining === undefined) {
|
|
26
|
+
throw new Error(`GitHub Copilot ${label.toLowerCase()} quota was incomplete.`);
|
|
27
|
+
}
|
|
28
|
+
const overageUsed = Math.max(asNonnegativeNumber(premium.overage_count) ?? 0, Math.max(0, -rawRemaining));
|
|
29
|
+
if (overageUsed > 0) {
|
|
30
|
+
metrics.push({
|
|
31
|
+
id: "overage-used",
|
|
32
|
+
label: "Additional usage",
|
|
33
|
+
value: overageUsed,
|
|
34
|
+
unit: "count",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
bucket = {
|
|
38
|
+
id,
|
|
39
|
+
label,
|
|
40
|
+
used: asNonnegativeNumber(premium.credits_used) ?? Math.max(0, entitlement - rawRemaining),
|
|
41
|
+
remaining: Math.max(0, rawRemaining),
|
|
42
|
+
limit: entitlement,
|
|
43
|
+
unit: "count",
|
|
44
|
+
period: "monthly",
|
|
45
|
+
...resetTimestamp(payload),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
const limited = asObject(payload.limited_user_quotas);
|
|
50
|
+
const monthly = asObject(payload.monthly_quotas);
|
|
51
|
+
const remaining = asNonnegativeNumber(limited?.chat);
|
|
52
|
+
const entitlement = asNonnegativeNumber(monthly?.chat);
|
|
53
|
+
if (remaining === undefined || entitlement === undefined) {
|
|
54
|
+
throw new Error("GitHub Copilot usage response contained no supported quota.");
|
|
55
|
+
}
|
|
56
|
+
semanticsLabel = "GitHub Copilot Free chat quota";
|
|
57
|
+
bucket = {
|
|
58
|
+
id: "chat-requests",
|
|
59
|
+
label: "Chat requests",
|
|
60
|
+
used: Math.max(0, entitlement - remaining),
|
|
61
|
+
remaining,
|
|
62
|
+
limit: entitlement,
|
|
63
|
+
unit: "count",
|
|
64
|
+
period: "monthly",
|
|
65
|
+
...resetTimestamp(payload),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const notes: string[] = [];
|
|
70
|
+
const plan = asString(payload.copilot_plan) ?? asString(payload.access_type_sku);
|
|
71
|
+
if (plan) notes.push(`Plan: ${plan}`);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
providerId: "github-copilot",
|
|
75
|
+
providerName: "GitHub Copilot",
|
|
76
|
+
capturedAt,
|
|
77
|
+
source: "github-copilot-user",
|
|
78
|
+
semantics: { kind: "consumer-subscription", label: semanticsLabel },
|
|
79
|
+
accountLabel: asString(payload.login),
|
|
80
|
+
buckets: [bucket],
|
|
81
|
+
metrics,
|
|
82
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resetTimestamp(payload: GitHubCopilotUsagePayload): { resetsAt?: number } {
|
|
87
|
+
const raw =
|
|
88
|
+
asString(payload.quota_reset_date_utc) ??
|
|
89
|
+
asString(payload.quota_reset_date) ??
|
|
90
|
+
asString(payload.limited_user_reset_date);
|
|
91
|
+
if (!raw) return {};
|
|
92
|
+
const milliseconds = Date.parse(raw);
|
|
93
|
+
return Number.isNaN(milliseconds) ? {} : { resetsAt: Math.floor(milliseconds / 1000) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
97
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
98
|
+
return value as Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function asString(value: unknown): string | undefined {
|
|
102
|
+
if (typeof value !== "string") return undefined;
|
|
103
|
+
return sanitizeDisplayText(value, 80) || undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function asFiniteNumber(value: unknown): number | undefined {
|
|
107
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function asNonnegativeNumber(value: unknown): number | undefined {
|
|
112
|
+
const number = asFiniteNumber(value);
|
|
113
|
+
return number === undefined || number < 0 ? undefined : number;
|
|
114
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { sanitizeDisplayText } from "../core.js";
|
|
2
|
+
import type { KimiCodingUsagePayload, UsageBucket, UsageMetric, UsageReport } from "../types.js";
|
|
3
|
+
|
|
4
|
+
const FIVE_HOUR_WINDOW_MINUTES = 300;
|
|
5
|
+
const DAILY_WINDOW_MINUTES = 1_440;
|
|
6
|
+
const WEEKLY_WINDOW_MINUTES = 10_080;
|
|
7
|
+
const FIXED_POINT_UNITS_PER_CENT = 1_000_000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Source contract revalidated on 2026-08-27.
|
|
11
|
+
* Pi c49906ec77788625aacbdc53ebca6fbe65bd20f5 defines provider `kimi-coding`,
|
|
12
|
+
* `https://api.kimi.com/coding`, API-key auth, and OAuth Bearer auth:
|
|
13
|
+
* https://github.com/earendil-works/pi/blob/c49906ec77788625aacbdc53ebca6fbe65bd20f5/packages/ai/src/providers/kimi-coding.ts
|
|
14
|
+
* https://github.com/earendil-works/pi/blob/c49906ec77788625aacbdc53ebca6fbe65bd20f5/packages/ai/src/auth/oauth/kimi-coding.ts
|
|
15
|
+
* Kimi Code 676e4d82240855044fe809fea89ce1dbe8e512cf defines `GET /coding/v1/usages`,
|
|
16
|
+
* numeric-string plan rows, proto-style windows, and 1,000,000 fixed-point units per cent:
|
|
17
|
+
* https://github.com/MoonshotAI/kimi-code/blob/676e4d82240855044fe809fea89ce1dbe8e512cf/packages/oauth/src/managed-usage.ts
|
|
18
|
+
* Live responses observed on 2026-09-12 omit both `used` and `remaining` on untouched rows and may report only
|
|
19
|
+
* `remaining` once usage starts, so a row with a positive `limit` and no counters means zero usage, and when only
|
|
20
|
+
* one counter is present the other is derived from `limit`. Counters that are present but malformed stay rejected.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeKimiCodingUsagePayload(payload: KimiCodingUsagePayload, capturedAt: number): UsageReport {
|
|
23
|
+
const root = asObject(payload);
|
|
24
|
+
if (!root) throw new Error("Kimi Coding usage response was not an object.");
|
|
25
|
+
|
|
26
|
+
const candidates: UsageBucket[] = [];
|
|
27
|
+
let omittedWindow = false;
|
|
28
|
+
const summary = parseUsageRow(root.usage, WEEKLY_WINDOW_MINUTES, "Weekly window");
|
|
29
|
+
if (summary) candidates.push(summary);
|
|
30
|
+
else if (root.usage !== undefined) omittedWindow = true;
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(root.limits)) {
|
|
33
|
+
for (const raw of root.limits) {
|
|
34
|
+
const item = asObject(raw);
|
|
35
|
+
const windowMinutes = parseWindowMinutes(item?.window);
|
|
36
|
+
const label = sanitizedLabel(item?.name);
|
|
37
|
+
const bucket =
|
|
38
|
+
windowMinutes === undefined
|
|
39
|
+
? undefined
|
|
40
|
+
: parseUsageRow(item?.detail, windowMinutes, label ?? defaultWindowLabel(windowMinutes));
|
|
41
|
+
if (bucket) candidates.push(bucket);
|
|
42
|
+
else omittedWindow = true;
|
|
43
|
+
}
|
|
44
|
+
} else if (root.limits !== undefined) {
|
|
45
|
+
omittedWindow = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const buckets: UsageBucket[] = [];
|
|
49
|
+
const byWindow = new Map<number, UsageBucket[]>();
|
|
50
|
+
for (const bucket of candidates) {
|
|
51
|
+
const windowMinutes = bucket.windowMinutes as number;
|
|
52
|
+
byWindow.set(windowMinutes, [...(byWindow.get(windowMinutes) ?? []), bucket]);
|
|
53
|
+
}
|
|
54
|
+
for (const rows of byWindow.values()) {
|
|
55
|
+
if (rows.length === 1) buckets.push(rows[0] as UsageBucket);
|
|
56
|
+
else omittedWindow = true;
|
|
57
|
+
}
|
|
58
|
+
buckets.sort((left, right) => (left.windowMinutes ?? 0) - (right.windowMinutes ?? 0));
|
|
59
|
+
|
|
60
|
+
const metrics = parseBoosterWallet(root.boosterWallet);
|
|
61
|
+
if (buckets.length === 0 && metrics.length === 0) {
|
|
62
|
+
throw new Error("Kimi Coding usage endpoint returned no displayable usage data.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
providerId: "kimi-coding",
|
|
67
|
+
providerName: "Kimi For Coding",
|
|
68
|
+
capturedAt,
|
|
69
|
+
source: "kimi-managed-usage",
|
|
70
|
+
semantics: { kind: "consumer-subscription", label: "Kimi Coding Plan usage" },
|
|
71
|
+
buckets,
|
|
72
|
+
metrics,
|
|
73
|
+
...(omittedWindow ? { notes: ["Unsupported, malformed, or duplicate plan windows were unavailable."] } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseUsageRow(value: unknown, windowMinutes: number, label: string): UsageBucket | undefined {
|
|
78
|
+
const row = asObject(value);
|
|
79
|
+
if (!row) return undefined;
|
|
80
|
+
const limit = asNonnegativeInteger(row.limit);
|
|
81
|
+
if (limit === undefined || limit === 0) return undefined;
|
|
82
|
+
const usedRaw = asNonnegativeInteger(row.used);
|
|
83
|
+
const remainingRaw = asNonnegativeInteger(row.remaining);
|
|
84
|
+
// Counters that are present but malformed reject the row instead of silently inventing a count.
|
|
85
|
+
if (
|
|
86
|
+
(row.used !== undefined && usedRaw === undefined) ||
|
|
87
|
+
(row.remaining !== undefined && remainingRaw === undefined)
|
|
88
|
+
) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
// Live responses omit both counters on untouched rows, so a bare `limit` means zero usage.
|
|
92
|
+
const used = usedRaw ?? (remainingRaw === undefined ? 0 : Math.max(0, limit - remainingRaw));
|
|
93
|
+
const remaining = remainingRaw ?? Math.max(0, limit - used);
|
|
94
|
+
const resetsAt = asIsoEpochSeconds(row.resetTime);
|
|
95
|
+
return {
|
|
96
|
+
id: windowId(windowMinutes),
|
|
97
|
+
label,
|
|
98
|
+
used,
|
|
99
|
+
remaining,
|
|
100
|
+
limit,
|
|
101
|
+
unit: "count",
|
|
102
|
+
windowMinutes,
|
|
103
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseWindowMinutes(value: unknown): number | undefined {
|
|
108
|
+
const window = asObject(value);
|
|
109
|
+
if (!window) return undefined;
|
|
110
|
+
const duration = asPositiveInteger(window.duration);
|
|
111
|
+
if (duration === undefined) return undefined;
|
|
112
|
+
const multiplier =
|
|
113
|
+
window.timeUnit === "TIME_UNIT_MINUTE"
|
|
114
|
+
? 1
|
|
115
|
+
: window.timeUnit === "TIME_UNIT_HOUR"
|
|
116
|
+
? 60
|
|
117
|
+
: window.timeUnit === "TIME_UNIT_DAY"
|
|
118
|
+
? 1_440
|
|
119
|
+
: window.timeUnit === "TIME_UNIT_WEEK"
|
|
120
|
+
? 10_080
|
|
121
|
+
: undefined;
|
|
122
|
+
if (multiplier === undefined) return undefined;
|
|
123
|
+
const minutes = duration * multiplier;
|
|
124
|
+
return Number.isSafeInteger(minutes) ? minutes : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseBoosterWallet(value: unknown): UsageMetric[] {
|
|
128
|
+
const wallet = asObject(value);
|
|
129
|
+
const balance = asObject(wallet?.balance);
|
|
130
|
+
if (!wallet || !balance || balance.type !== "BOOSTER") return [];
|
|
131
|
+
const totalRaw = asPositiveInteger(balance.amount);
|
|
132
|
+
if (totalRaw === undefined) return [];
|
|
133
|
+
const leftRaw = asNonnegativeInteger(balance.amountLeft) ?? 0;
|
|
134
|
+
const monthlyLimit = parseMoney(wallet.monthlyChargeLimit);
|
|
135
|
+
const monthlyUsed = parseMoney(wallet.monthlyUsed);
|
|
136
|
+
const currencies = new Set(
|
|
137
|
+
[monthlyLimit?.currency, monthlyUsed?.currency].filter((currency): currency is string => currency !== undefined),
|
|
138
|
+
);
|
|
139
|
+
if (currencies.size !== 1) return [];
|
|
140
|
+
const currency = currencies.values().next().value;
|
|
141
|
+
if (!currency) return [];
|
|
142
|
+
const total = fixedPointToMajor(totalRaw);
|
|
143
|
+
const left = fixedPointToMajor(leftRaw);
|
|
144
|
+
if (total === undefined || left === undefined) return [];
|
|
145
|
+
|
|
146
|
+
const metrics: UsageMetric[] = [
|
|
147
|
+
{ id: "booster-balance", label: "Balance", value: left, unit: "currency", currency },
|
|
148
|
+
{ id: "booster-total", label: "Total balance", value: total, unit: "currency", currency },
|
|
149
|
+
];
|
|
150
|
+
if (monthlyUsed) {
|
|
151
|
+
metrics.push({
|
|
152
|
+
id: "booster-monthly-used",
|
|
153
|
+
label: "Used this month",
|
|
154
|
+
value: monthlyUsed.cents / 100,
|
|
155
|
+
unit: "currency",
|
|
156
|
+
currency,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (wallet.monthlyChargeLimitEnabled === false) {
|
|
160
|
+
metrics.push({
|
|
161
|
+
id: "booster-monthly-limit",
|
|
162
|
+
label: "Monthly limit",
|
|
163
|
+
value: "unlimited",
|
|
164
|
+
unit: "currency",
|
|
165
|
+
currency,
|
|
166
|
+
});
|
|
167
|
+
} else if (wallet.monthlyChargeLimitEnabled === true && monthlyLimit) {
|
|
168
|
+
metrics.push({
|
|
169
|
+
id: "booster-monthly-limit",
|
|
170
|
+
label: "Monthly limit",
|
|
171
|
+
value: monthlyLimit.cents / 100,
|
|
172
|
+
unit: "currency",
|
|
173
|
+
currency,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return metrics;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseMoney(value: unknown): { cents: number; currency: string } | undefined {
|
|
180
|
+
const money = asObject(value);
|
|
181
|
+
if (!money) return undefined;
|
|
182
|
+
const cents = asNonnegativeInteger(money.priceInCents);
|
|
183
|
+
if (cents === undefined) return undefined;
|
|
184
|
+
const currency = asCurrency(money.currency);
|
|
185
|
+
if (!currency) return undefined;
|
|
186
|
+
return { cents, currency };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fixedPointToMajor(value: number): number | undefined {
|
|
190
|
+
const cents = value / FIXED_POINT_UNITS_PER_CENT;
|
|
191
|
+
const roundedCents = cents > 0 && cents < 1 ? 1 : Math.round(cents);
|
|
192
|
+
const major = roundedCents / 100;
|
|
193
|
+
return Number.isSafeInteger(roundedCents) && Number.isFinite(major) ? major : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
197
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
198
|
+
return value as Record<string, unknown>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function asNonnegativeInteger(value: unknown): number | undefined {
|
|
202
|
+
if (typeof value === "string" && !/^\d+$/u.test(value)) return undefined;
|
|
203
|
+
const number = typeof value === "string" ? Number(value) : value;
|
|
204
|
+
if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) return undefined;
|
|
205
|
+
return number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function asPositiveInteger(value: unknown): number | undefined {
|
|
209
|
+
const number = asNonnegativeInteger(value);
|
|
210
|
+
return number !== undefined && number > 0 ? number : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function asIsoEpochSeconds(value: unknown): number | undefined {
|
|
214
|
+
if (typeof value !== "string") return undefined;
|
|
215
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/u.exec(value);
|
|
216
|
+
if (!match) return undefined;
|
|
217
|
+
const [, yearText, monthText, dayText, hourText, minuteText, secondText, , offsetHour, offsetMinute] = match;
|
|
218
|
+
const year = Number(yearText);
|
|
219
|
+
const month = Number(monthText);
|
|
220
|
+
const day = Number(dayText);
|
|
221
|
+
const hour = Number(hourText);
|
|
222
|
+
const minute = Number(minuteText);
|
|
223
|
+
const second = Number(secondText);
|
|
224
|
+
if (
|
|
225
|
+
month < 1 ||
|
|
226
|
+
month > 12 ||
|
|
227
|
+
day < 1 ||
|
|
228
|
+
day > new Date(Date.UTC(year, month, 0)).getUTCDate() ||
|
|
229
|
+
hour > 23 ||
|
|
230
|
+
minute > 59 ||
|
|
231
|
+
second > 59 ||
|
|
232
|
+
(offsetHour !== undefined && Number(offsetHour) > 23) ||
|
|
233
|
+
(offsetMinute !== undefined && Number(offsetMinute) > 59)
|
|
234
|
+
) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
const millis = Date.parse(value);
|
|
238
|
+
return Number.isFinite(millis) && millis >= 0 ? Math.floor(millis / 1000) : undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sanitizedLabel(value: unknown): string | undefined {
|
|
242
|
+
if (typeof value !== "string") return undefined;
|
|
243
|
+
return sanitizeDisplayText(value, 80) || undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function asCurrency(value: unknown): string | undefined {
|
|
247
|
+
if (typeof value !== "string") return undefined;
|
|
248
|
+
const currency = sanitizeDisplayText(value, 3).toUpperCase();
|
|
249
|
+
return /^[A-Z]{3}$/u.test(currency) ? currency : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function windowId(minutes: number): string {
|
|
253
|
+
if (minutes === FIVE_HOUR_WINDOW_MINUTES) return "five-hour";
|
|
254
|
+
if (minutes === DAILY_WINDOW_MINUTES) return "daily";
|
|
255
|
+
if (minutes === WEEKLY_WINDOW_MINUTES) return "weekly";
|
|
256
|
+
return `window-${minutes}-minutes`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function defaultWindowLabel(minutes: number): string {
|
|
260
|
+
if (minutes === WEEKLY_WINDOW_MINUTES) return "Weekly window";
|
|
261
|
+
if (minutes % 10_080 === 0) return `${minutes / 10_080}w window`;
|
|
262
|
+
if (minutes % 1_440 === 0) return `${minutes / 1_440}d window`;
|
|
263
|
+
if (minutes % 60 === 0) return `${minutes / 60}h window`;
|
|
264
|
+
return `${minutes}m window`;
|
|
265
|
+
}
|