@aaroncarry/pi-usage 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 +151 -0
- package/README.zh-CN.md +152 -0
- package/package.json +48 -0
- package/src/config.ts +156 -0
- package/src/credentials.ts +217 -0
- package/src/format.ts +35 -0
- package/src/index.ts +168 -0
- package/src/parse.ts +21 -0
- package/src/providers/anthropic.ts +76 -0
- package/src/providers/auto-detect.ts +343 -0
- package/src/providers/codex.ts +81 -0
- package/src/providers/custom.ts +100 -0
- package/src/providers/deepseek.ts +68 -0
- package/src/providers/github-copilot.ts +101 -0
- package/src/providers/index.ts +11 -0
- package/src/providers/openrouter.ts +52 -0
- package/src/providers/zai.ts +200 -0
- package/src/service.ts +275 -0
- package/src/session-usage.ts +57 -0
- package/src/types.ts +83 -0
- package/src/ui/card.ts +82 -0
- package/src/ui/statusline.ts +77 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-detecting adapter for providers without a built-in one.
|
|
3
|
+
*
|
|
4
|
+
* Approach borrowed from pi-provider-hub: given the provider's base URL,
|
|
5
|
+
* probe well-known relay billing protocols — New API (`/dashboard/billing/*`,
|
|
6
|
+
* the most common self-hosted gateway), Sub2API (`/usage`) — plus hostname
|
|
7
|
+
* pinned endpoints for DeepSeek, MiniMax, and Zhipu (for custom ids pointing
|
|
8
|
+
* at those hosts).
|
|
9
|
+
*
|
|
10
|
+
* The first successful probe pins the winning candidate for the session; an
|
|
11
|
+
* exhausted probe (every candidate rejected terminally, e.g. HTTP 404/401)
|
|
12
|
+
* pins a "not supported" error so probing does not repeat on every refresh.
|
|
13
|
+
* Transient network errors stay unpinned and are retried next time.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { toNumber } from "../parse.ts";
|
|
17
|
+
import type { AccountBalance, ProviderAdapter, ProviderFetchArgs } from "../types.ts";
|
|
18
|
+
|
|
19
|
+
/** Same-origin URL join: "https://x.com/v1" + "usage" → "https://x.com/v1/usage". */
|
|
20
|
+
function resolveSameOriginEndpoint(baseUrl: string, endpoint: string): string {
|
|
21
|
+
return new URL(endpoint.trim(), `${baseUrl.replace(/\/+$/, "")}/`).toString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Base URLs to probe: a base with a path is already specific; a bare origin also tries /v1. */
|
|
25
|
+
function baseUrlCandidates(baseUrl: string): string[] {
|
|
26
|
+
const parsed = new URL(baseUrl);
|
|
27
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
28
|
+
if (parsed.pathname !== "" && parsed.pathname !== "/") return [trimmed];
|
|
29
|
+
return [trimmed, `${trimmed}/v1`];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CandidateContext {
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
token: string;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
fetchImpl: ProviderFetchArgs["fetchImpl"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Candidate = {
|
|
40
|
+
name: string;
|
|
41
|
+
run(ctx: CandidateContext): Promise<Omit<AccountBalance, "providerId" | "label" | "fetchedAt">>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function requestJson(
|
|
45
|
+
url: string,
|
|
46
|
+
token: string,
|
|
47
|
+
fetchImpl: ProviderFetchArgs["fetchImpl"],
|
|
48
|
+
signal: AbortSignal | undefined,
|
|
49
|
+
authorization: "bearer" | "raw" = "bearer",
|
|
50
|
+
): Promise<unknown> {
|
|
51
|
+
const response = await fetchImpl(url, {
|
|
52
|
+
headers: {
|
|
53
|
+
Accept: "application/json",
|
|
54
|
+
Authorization: authorization === "bearer" ? `Bearer ${token}` : token,
|
|
55
|
+
},
|
|
56
|
+
signal,
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
59
|
+
return response.json();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function numberAt(payload: unknown, ...paths: string[][]): number | undefined {
|
|
63
|
+
for (const path of paths) {
|
|
64
|
+
let current: unknown = payload;
|
|
65
|
+
for (const segment of path) {
|
|
66
|
+
if (typeof current !== "object" || current === null) {
|
|
67
|
+
current = undefined;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
current = (current as Record<string, unknown>)[segment];
|
|
71
|
+
}
|
|
72
|
+
const value = toNumber(current);
|
|
73
|
+
if (value !== undefined) return value;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stringAt(payload: unknown, ...paths: string[][]): string | undefined {
|
|
79
|
+
for (const path of paths) {
|
|
80
|
+
let current: unknown = payload;
|
|
81
|
+
for (const segment of path) {
|
|
82
|
+
if (typeof current !== "object" || current === null) {
|
|
83
|
+
current = undefined;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
current = (current as Record<string, unknown>)[segment];
|
|
87
|
+
}
|
|
88
|
+
if (typeof current === "string" && current.trim() !== "") return current.trim();
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
94
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const deepseekCandidate: Candidate = {
|
|
98
|
+
name: "deepseek",
|
|
99
|
+
async run({ baseUrl, token, signal, fetchImpl }) {
|
|
100
|
+
const payload = (await requestJson(resolveSameOriginEndpoint(baseUrl, "user/balance"), token, fetchImpl, signal)) as unknown;
|
|
101
|
+
if (!isRecord(payload) || !Array.isArray(payload.balance_infos)) {
|
|
102
|
+
throw new Error("response has no balance_infos");
|
|
103
|
+
}
|
|
104
|
+
const entries = payload.balance_infos.filter(isRecord);
|
|
105
|
+
const entry =
|
|
106
|
+
entries.find((item) => stringAt(item, ["currency"])?.toUpperCase() === "CNY") ?? entries[0] ?? payload;
|
|
107
|
+
const amount = numberAt(entry, ["total_balance"]);
|
|
108
|
+
if (amount === undefined) throw new Error("response has no total_balance");
|
|
109
|
+
return { balance: { amount, currency: stringAt(entry, ["currency"]) ?? "CNY" }, windows: [], notes: [] };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const minimaxCandidate: Candidate = {
|
|
114
|
+
name: "minimax",
|
|
115
|
+
async run({ baseUrl, token, signal, fetchImpl }) {
|
|
116
|
+
let lastError: Error | undefined;
|
|
117
|
+
for (const endpoint of ["v1/token_plan/remains", "v1/api/openplatform/coding_plan/remains"]) {
|
|
118
|
+
try {
|
|
119
|
+
const payload = (await requestJson(resolveSameOriginEndpoint(baseUrl, endpoint), token, fetchImpl, signal)) as unknown;
|
|
120
|
+
const data = isRecord(payload) && isRecord(payload.data) ? payload.data : isRecord(payload) ? payload : {};
|
|
121
|
+
const models = Array.isArray(data.model_remains)
|
|
122
|
+
? data.model_remains.filter(isRecord)
|
|
123
|
+
: [];
|
|
124
|
+
const model =
|
|
125
|
+
models.find((item) => stringAt(item, ["model_name"], ["modelName"])?.toLowerCase().startsWith("minimax-m")) ??
|
|
126
|
+
models[0] ??
|
|
127
|
+
data;
|
|
128
|
+
const total = numberAt(model, ["current_interval_total_count"], ["currentIntervalTotalCount"]);
|
|
129
|
+
const remainingPercent = numberAt(
|
|
130
|
+
model,
|
|
131
|
+
["current_interval_remaining_percent"],
|
|
132
|
+
["currentIntervalRemainingPercent"],
|
|
133
|
+
["remaining_percent"],
|
|
134
|
+
["remainingPercent"],
|
|
135
|
+
);
|
|
136
|
+
const remainingCount = numberAt(
|
|
137
|
+
model,
|
|
138
|
+
["current_interval_remaining"],
|
|
139
|
+
["currentIntervalRemaining"],
|
|
140
|
+
["current_interval_usage_count"],
|
|
141
|
+
["currentIntervalUsageCount"],
|
|
142
|
+
);
|
|
143
|
+
let usedPercent: number | undefined;
|
|
144
|
+
if (remainingPercent !== undefined) usedPercent = 100 - remainingPercent;
|
|
145
|
+
else if (total !== undefined && total > 0 && remainingCount !== undefined) {
|
|
146
|
+
usedPercent = (remainingCount / total) * 100;
|
|
147
|
+
}
|
|
148
|
+
if (usedPercent === undefined) throw new Error("response has no quota fields");
|
|
149
|
+
return {
|
|
150
|
+
windows: [{ label: "plan", usedPercent: Math.max(0, Math.min(100, usedPercent)) }],
|
|
151
|
+
notes: [],
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throw lastError ?? new Error("endpoints not found");
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const zhipuCandidate: Candidate = {
|
|
162
|
+
name: "zhipu",
|
|
163
|
+
async run({ baseUrl, token, signal, fetchImpl }) {
|
|
164
|
+
const payload = (await requestJson(
|
|
165
|
+
resolveSameOriginEndpoint(baseUrl, "/api/monitor/usage/quota/limit"),
|
|
166
|
+
token,
|
|
167
|
+
fetchImpl,
|
|
168
|
+
signal,
|
|
169
|
+
"raw",
|
|
170
|
+
)) as unknown;
|
|
171
|
+
const data = isRecord(payload) && isRecord(payload.data) ? payload.data : isRecord(payload) ? payload : {};
|
|
172
|
+
const limits = Array.isArray(data.limits) ? data.limits.filter(isRecord) : [];
|
|
173
|
+
const limit = limits.find((item) => stringAt(item, ["type"])?.toUpperCase() === "TOKENS_LIMIT");
|
|
174
|
+
const usedPercent = numberAt(limit, ["percentage"], ["usedPercentage"]);
|
|
175
|
+
if (usedPercent === undefined) throw new Error("response has no usage percentage");
|
|
176
|
+
return {
|
|
177
|
+
windows: [{ label: "tokens", usedPercent: Math.max(0, Math.min(100, usedPercent)) }],
|
|
178
|
+
notes: [],
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const newApiCandidate: Candidate = {
|
|
184
|
+
name: "new-api",
|
|
185
|
+
async run({ baseUrl, token, signal, fetchImpl }) {
|
|
186
|
+
let lastError: Error | undefined;
|
|
187
|
+
for (const base of baseUrlCandidates(baseUrl)) {
|
|
188
|
+
try {
|
|
189
|
+
const subscription = (await requestJson(
|
|
190
|
+
resolveSameOriginEndpoint(base, "dashboard/billing/subscription"),
|
|
191
|
+
token,
|
|
192
|
+
fetchImpl,
|
|
193
|
+
signal,
|
|
194
|
+
)) as unknown;
|
|
195
|
+
const usage = (await requestJson(
|
|
196
|
+
resolveSameOriginEndpoint(base, "dashboard/billing/usage"),
|
|
197
|
+
token,
|
|
198
|
+
fetchImpl,
|
|
199
|
+
signal,
|
|
200
|
+
)) as unknown;
|
|
201
|
+
const parsedBase = new URL(base);
|
|
202
|
+
if (parsedBase.pathname.replace(/\/+$/, "") === "/v1") parsedBase.pathname = "/";
|
|
203
|
+
let tokenData: Record<string, unknown> | undefined;
|
|
204
|
+
try {
|
|
205
|
+
const tokenUsage = (await requestJson(
|
|
206
|
+
resolveSameOriginEndpoint(parsedBase.toString(), "/api/usage/token/"),
|
|
207
|
+
token,
|
|
208
|
+
fetchImpl,
|
|
209
|
+
signal,
|
|
210
|
+
)) as unknown;
|
|
211
|
+
tokenData = isRecord(tokenUsage) && isRecord(tokenUsage.data) ? tokenUsage.data : undefined;
|
|
212
|
+
} catch {
|
|
213
|
+
// Optional endpoint; the dashboard pair alone often suffices.
|
|
214
|
+
}
|
|
215
|
+
const rawTotal = numberAt(subscription, ["hard_limit_usd"], ["soft_limit_usd"], ["system_hard_limit_usd"]);
|
|
216
|
+
const dashboardUsed = numberAt(usage, ["total_usage"]);
|
|
217
|
+
const usedFromDashboard = dashboardUsed === undefined ? undefined : dashboardUsed / 100;
|
|
218
|
+
const unlimited = tokenData?.unlimited_quota === true || rawTotal === 100_000_000;
|
|
219
|
+
const tokenTotal = toNumber(tokenData?.total_granted);
|
|
220
|
+
const tokenRemaining = toNumber(tokenData?.total_available);
|
|
221
|
+
const total = tokenTotal ?? (unlimited ? undefined : rawTotal);
|
|
222
|
+
const used =
|
|
223
|
+
tokenTotal !== undefined && tokenRemaining !== undefined
|
|
224
|
+
? Math.max(0, tokenTotal - tokenRemaining)
|
|
225
|
+
: usedFromDashboard;
|
|
226
|
+
const remaining =
|
|
227
|
+
tokenRemaining ?? (total !== undefined && used !== undefined ? Math.max(0, total - used) : undefined);
|
|
228
|
+
if (remaining === undefined && !unlimited) {
|
|
229
|
+
throw new Error("response has no quota values");
|
|
230
|
+
}
|
|
231
|
+
const notes: string[] = [];
|
|
232
|
+
if (unlimited) notes.push("unlimited quota");
|
|
233
|
+
if (used !== undefined && total !== undefined && !unlimited) {
|
|
234
|
+
notes.push(`used $${used.toFixed(2)} of $${total.toFixed(2)}`);
|
|
235
|
+
}
|
|
236
|
+
return { balance: { amount: remaining ?? 0, currency: "USD" }, windows: [], notes };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
throw lastError ?? new Error("endpoints not found");
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const sub2apiCandidate: Candidate = {
|
|
246
|
+
name: "sub2api",
|
|
247
|
+
async run({ baseUrl, token, signal, fetchImpl }) {
|
|
248
|
+
let lastError: Error | undefined;
|
|
249
|
+
for (const base of baseUrlCandidates(baseUrl)) {
|
|
250
|
+
try {
|
|
251
|
+
const payload = (await requestJson(resolveSameOriginEndpoint(base, "usage"), token, fetchImpl, signal)) as unknown;
|
|
252
|
+
if (!isRecord(payload)) throw new Error("invalid response");
|
|
253
|
+
const total = numberAt(payload, ["quota", "limit"], ["total"]);
|
|
254
|
+
const used = numberAt(payload, ["quota", "used"], ["used"]);
|
|
255
|
+
const remaining =
|
|
256
|
+
numberAt(payload, ["remaining"], ["balance"], ["quota", "remaining"]) ??
|
|
257
|
+
(total !== undefined && used !== undefined ? Math.max(0, total - used) : undefined);
|
|
258
|
+
if (remaining === undefined) throw new Error("response has no balance");
|
|
259
|
+
const currency = stringAt(payload, ["unit"], ["quota", "unit"]) ?? "USD";
|
|
260
|
+
const notes: string[] = [];
|
|
261
|
+
if (used !== undefined && total !== undefined) notes.push(`used ${used} of ${total}`);
|
|
262
|
+
return { balance: { amount: remaining, currency }, windows: [], notes };
|
|
263
|
+
} catch (error) {
|
|
264
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
throw lastError ?? new Error("endpoint not found");
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function hostnameCandidates(baseUrl: string): Candidate[] {
|
|
272
|
+
let hostname = "";
|
|
273
|
+
try {
|
|
274
|
+
hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
275
|
+
} catch {
|
|
276
|
+
return [newApiCandidate, sub2apiCandidate];
|
|
277
|
+
}
|
|
278
|
+
const candidates: Candidate[] = [];
|
|
279
|
+
if (hostname === "api.deepseek.com") candidates.push(deepseekCandidate);
|
|
280
|
+
if (["api.minimaxi.com", "www.minimaxi.com", "api.minimax.io", "www.minimax.io"].includes(hostname)) {
|
|
281
|
+
candidates.push(minimaxCandidate);
|
|
282
|
+
}
|
|
283
|
+
if (["open.bigmodel.cn", "bigmodel.cn", "api.z.ai", "z.ai"].includes(hostname)) candidates.push(zhipuCandidate);
|
|
284
|
+
candidates.push(newApiCandidate, sub2apiCandidate);
|
|
285
|
+
return candidates;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isTransientError(message: string): boolean {
|
|
289
|
+
return /fetch failed|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket|network|aborted|timed? ?out/i.test(message);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build the auto-detecting adapter for one provider id. Detection runs on the
|
|
294
|
+
* first fetch; the winning candidate is pinned for the session, as is an
|
|
295
|
+
* exhausted "not supported" outcome. Transient network errors are not pinned.
|
|
296
|
+
*/
|
|
297
|
+
export function createAutoDetectAdapter(id: string): ProviderAdapter {
|
|
298
|
+
let winner: Candidate | undefined;
|
|
299
|
+
let pinnedError: string | undefined;
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
id,
|
|
303
|
+
label: id,
|
|
304
|
+
async fetch(args: ProviderFetchArgs): Promise<AccountBalance> {
|
|
305
|
+
if (pinnedError) throw new Error(pinnedError);
|
|
306
|
+
if (!args.baseUrl) {
|
|
307
|
+
throw new Error(`Base URL for "${id}" is unknown; cannot auto-detect a balance endpoint`);
|
|
308
|
+
}
|
|
309
|
+
const context: CandidateContext = {
|
|
310
|
+
baseUrl: args.baseUrl,
|
|
311
|
+
token: args.token,
|
|
312
|
+
signal: args.signal,
|
|
313
|
+
fetchImpl: args.fetchImpl,
|
|
314
|
+
};
|
|
315
|
+
const finalize = (balance: Omit<AccountBalance, "providerId" | "label" | "fetchedAt">): AccountBalance => ({
|
|
316
|
+
...balance,
|
|
317
|
+
providerId: id,
|
|
318
|
+
label: id,
|
|
319
|
+
fetchedAt: Date.now(),
|
|
320
|
+
});
|
|
321
|
+
if (winner) {
|
|
322
|
+
return finalize(await winner.run(context));
|
|
323
|
+
}
|
|
324
|
+
const errors: string[] = [];
|
|
325
|
+
let sawTransient = false;
|
|
326
|
+
for (const candidate of hostnameCandidates(args.baseUrl)) {
|
|
327
|
+
try {
|
|
328
|
+
const balance = await candidate.run(context);
|
|
329
|
+
winner = candidate;
|
|
330
|
+
return finalize(balance);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (args.signal?.aborted) throw error;
|
|
333
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
334
|
+
if (isTransientError(message)) sawTransient = true;
|
|
335
|
+
errors.push(`${candidate.name}: ${message}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const summary = `no balance endpoint detected (${errors.join("; ")})`;
|
|
339
|
+
if (!sawTransient) pinnedError = summary;
|
|
340
|
+
throw new Error(summary);
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Codex (ChatGPT subscription) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Query method borrowed from CodexBar: the ChatGPT OAuth access token stored
|
|
5
|
+
* by pi in auth.json is accepted by the backend usage endpoint.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AccountBalance, ProviderAdapter, ProviderFetchArgs, UsageWindow } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
const ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
11
|
+
|
|
12
|
+
interface CodexWindow {
|
|
13
|
+
used_percent?: unknown;
|
|
14
|
+
reset_at?: unknown;
|
|
15
|
+
limit_window_seconds?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CodexUsageResponse {
|
|
19
|
+
plan_type?: unknown;
|
|
20
|
+
rate_limit?: { primary_window?: unknown; secondary_window?: unknown } | null;
|
|
21
|
+
credits?: { balance?: unknown } | null;
|
|
22
|
+
spend_control?: { individual_limit?: unknown } | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function windowLabel(limitWindowSeconds: number): string {
|
|
26
|
+
if (limitWindowSeconds === 18_000) return "5h";
|
|
27
|
+
if (limitWindowSeconds === 604_800) return "weekly";
|
|
28
|
+
const hours = Math.round(limitWindowSeconds / 3_600);
|
|
29
|
+
return hours > 0 ? `${hours}h` : `${limitWindowSeconds}s`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseWindow(fallbackLabel: string, raw: unknown): UsageWindow | undefined {
|
|
33
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
34
|
+
const window = raw as CodexWindow;
|
|
35
|
+
if (typeof window.used_percent !== "number") return undefined;
|
|
36
|
+
const label = typeof window.limit_window_seconds === "number" ? windowLabel(window.limit_window_seconds) : fallbackLabel;
|
|
37
|
+
const resetsAt = typeof window.reset_at === "number" ? window.reset_at * 1000 : undefined;
|
|
38
|
+
return { label, usedPercent: window.used_percent, resetsAt };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Accept numbers or numeric strings ("0" from credits.balance). */
|
|
42
|
+
function money(value: unknown): number | undefined {
|
|
43
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
44
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const codexAdapter: ProviderAdapter = {
|
|
49
|
+
id: "openai-codex",
|
|
50
|
+
label: "Codex",
|
|
51
|
+
async fetch({ token, signal, fetchImpl }: ProviderFetchArgs): Promise<AccountBalance> {
|
|
52
|
+
const response = await fetchImpl(ENDPOINT, {
|
|
53
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
54
|
+
signal,
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`Codex usage API returned HTTP ${response.status}`);
|
|
58
|
+
}
|
|
59
|
+
const body: unknown = await response.json();
|
|
60
|
+
if (typeof body !== "object" || body === null) {
|
|
61
|
+
throw new Error("Codex usage API returned an unexpected response");
|
|
62
|
+
}
|
|
63
|
+
const usage = body as CodexUsageResponse;
|
|
64
|
+
const windows: UsageWindow[] = [];
|
|
65
|
+
const primary = parseWindow("5h", usage.rate_limit?.primary_window);
|
|
66
|
+
if (primary) windows.push(primary);
|
|
67
|
+
const secondary = parseWindow("weekly", usage.rate_limit?.secondary_window);
|
|
68
|
+
if (secondary) windows.push(secondary);
|
|
69
|
+
const notes: string[] = [];
|
|
70
|
+
const credits = money(usage.credits?.balance);
|
|
71
|
+
if (credits !== undefined && credits > 0) notes.push(`$${credits.toFixed(2)} credits`);
|
|
72
|
+
const spendCap = money(usage.spend_control?.individual_limit);
|
|
73
|
+
if (spendCap !== undefined && spendCap > 0) notes.push(`monthly spend cap $${spendCap.toFixed(2)}`);
|
|
74
|
+
const planType = typeof usage.plan_type === "string" ? usage.plan_type : undefined;
|
|
75
|
+
const plan = planType ? planType.charAt(0).toUpperCase() + planType.slice(1) : undefined;
|
|
76
|
+
if (windows.length === 0) {
|
|
77
|
+
throw new Error("Codex usage API returned no rate limit windows");
|
|
78
|
+
}
|
|
79
|
+
return { providerId: "openai-codex", label: "Codex", plan, windows, notes, fetchedAt: Date.now() };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-driven generic adapter: reads an arbitrary JSON endpoint and extracts
|
|
3
|
+
* a monetary balance and/or usage windows via dot paths. Used for providers
|
|
4
|
+
* without a built-in adapter (e.g. self-hosted relays).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CustomProviderConfig } from "../config.ts";
|
|
8
|
+
import { resolveConfigValue } from "../credentials.ts";
|
|
9
|
+
import type { AccountBalance, MoneyBalance, ProviderAdapter, ProviderFetchArgs, UsageWindow } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve "data.list[0].percent"-style paths. Bracket suffixes index into
|
|
13
|
+
* arrays; missing segments yield undefined.
|
|
14
|
+
*/
|
|
15
|
+
export function dotPath(root: unknown, path: string): unknown {
|
|
16
|
+
let current: unknown = root;
|
|
17
|
+
for (const segment of path.split(".")) {
|
|
18
|
+
if (typeof current !== "object" || current === null) return undefined;
|
|
19
|
+
const bracket = /^([^\[\]]+)\[(\d+)\]$/u.exec(segment);
|
|
20
|
+
const container = current as Record<string, unknown>;
|
|
21
|
+
if (bracket) {
|
|
22
|
+
const key = bracket[1];
|
|
23
|
+
const index = bracket[2];
|
|
24
|
+
if (!key || index === undefined) return undefined;
|
|
25
|
+
const array = container[key];
|
|
26
|
+
if (!Array.isArray(array)) return undefined;
|
|
27
|
+
current = array[Number(index)];
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
current = container[segment];
|
|
31
|
+
}
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toNumber(value: unknown): number | undefined {
|
|
36
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
37
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toEpoch(value: unknown): number | undefined {
|
|
42
|
+
const number = toNumber(value);
|
|
43
|
+
if (number === undefined) return undefined;
|
|
44
|
+
return number < 1e12 ? number * 1000 : number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildHeaders(custom: CustomProviderConfig, token: string): Record<string, string> {
|
|
48
|
+
const headers: Record<string, string> = {};
|
|
49
|
+
for (const [name, value] of Object.entries(custom.headers ?? {})) {
|
|
50
|
+
// "{token}" pulls in the provider's own auth.json credential, if any.
|
|
51
|
+
const resolved = resolveConfigValue(value.replaceAll("{token}", token));
|
|
52
|
+
if (resolved !== undefined) headers[name] = resolved;
|
|
53
|
+
}
|
|
54
|
+
return headers;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createCustomAdapter(id: string, custom: CustomProviderConfig, fallbackLabel: string): ProviderAdapter {
|
|
58
|
+
return {
|
|
59
|
+
id,
|
|
60
|
+
label: fallbackLabel,
|
|
61
|
+
async fetch({ token, signal, fetchImpl }: ProviderFetchArgs): Promise<AccountBalance> {
|
|
62
|
+
const response = await fetchImpl(custom.url, {
|
|
63
|
+
method: custom.method ?? "GET",
|
|
64
|
+
headers: { Accept: "application/json", ...buildHeaders(custom, token) },
|
|
65
|
+
signal,
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`${id} returned HTTP ${response.status}`);
|
|
69
|
+
}
|
|
70
|
+
const body: unknown = await response.json();
|
|
71
|
+
let balance: MoneyBalance | undefined;
|
|
72
|
+
if (custom.balancePath) {
|
|
73
|
+
const amount = toNumber(dotPath(body, custom.balancePath));
|
|
74
|
+
if (amount !== undefined) balance = { amount, currency: custom.currency ?? "USD" };
|
|
75
|
+
}
|
|
76
|
+
const windows: UsageWindow[] = [];
|
|
77
|
+
if (custom.windowsPath) {
|
|
78
|
+
const rawWindows = dotPath(body, custom.windowsPath);
|
|
79
|
+
if (Array.isArray(rawWindows)) {
|
|
80
|
+
const fields = { label: "label", percent: "percent", resetsAt: "resetsAt", ...custom.windowFields };
|
|
81
|
+
rawWindows.forEach((entry, index) => {
|
|
82
|
+
const percent = toNumber(dotPath(entry, fields.percent ?? "percent"));
|
|
83
|
+
if (percent === undefined) return;
|
|
84
|
+
const label = dotPath(entry, fields.label ?? "label");
|
|
85
|
+
const resetsAt = toEpoch(dotPath(entry, fields.resetsAt ?? "resetsAt"));
|
|
86
|
+
windows.push({
|
|
87
|
+
label: typeof label === "string" && label ? label : `w${index + 1}`,
|
|
88
|
+
usedPercent: percent,
|
|
89
|
+
resetsAt,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!balance && windows.length === 0) {
|
|
95
|
+
throw new Error(`${id}: no balance or windows extracted (check balancePath/windowsPath)`);
|
|
96
|
+
}
|
|
97
|
+
return { providerId: id, label: fallbackLabel, windows, balance, notes: [], fetchedAt: Date.now() };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek adapter: prepaid account balance via the public balance endpoint.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AccountBalance, ProviderAdapter, ProviderFetchArgs } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
const ENDPOINT = "https://api.deepseek.com/user/balance";
|
|
8
|
+
|
|
9
|
+
interface DeepseekBalanceInfo {
|
|
10
|
+
currency?: unknown;
|
|
11
|
+
total_balance?: unknown;
|
|
12
|
+
granted_balance?: unknown;
|
|
13
|
+
topped_up_balance?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DeepseekBalanceResponse {
|
|
17
|
+
is_available?: unknown;
|
|
18
|
+
balance_infos?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseAmount(value: unknown): number | undefined {
|
|
22
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
23
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const deepseekAdapter: ProviderAdapter = {
|
|
28
|
+
id: "deepseek",
|
|
29
|
+
label: "DeepSeek",
|
|
30
|
+
async fetch({ token, signal, fetchImpl }: ProviderFetchArgs): Promise<AccountBalance> {
|
|
31
|
+
const response = await fetchImpl(ENDPOINT, {
|
|
32
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
33
|
+
signal,
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`DeepSeek balance API returned HTTP ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
const body: unknown = await response.json();
|
|
39
|
+
if (typeof body !== "object" || body === null) {
|
|
40
|
+
throw new Error("DeepSeek balance API returned an unexpected response");
|
|
41
|
+
}
|
|
42
|
+
const parsed = body as DeepseekBalanceResponse;
|
|
43
|
+
if (!Array.isArray(parsed.balance_infos)) {
|
|
44
|
+
throw new Error("DeepSeek balance API returned no balance_infos");
|
|
45
|
+
}
|
|
46
|
+
const first = parsed.balance_infos.find((entry): entry is DeepseekBalanceInfo => typeof entry === "object" && entry !== null);
|
|
47
|
+
if (!first) {
|
|
48
|
+
throw new Error("DeepSeek balance API returned an empty balance_infos list");
|
|
49
|
+
}
|
|
50
|
+
const amount = parseAmount(first.total_balance);
|
|
51
|
+
if (amount === undefined) {
|
|
52
|
+
throw new Error("DeepSeek balance API returned no usable total_balance");
|
|
53
|
+
}
|
|
54
|
+
const currency = typeof first.currency === "string" && first.currency ? first.currency : "CNY";
|
|
55
|
+
const notes: string[] = [];
|
|
56
|
+
const granted = parseAmount(first.granted_balance);
|
|
57
|
+
if (granted !== undefined && granted > 0) notes.push(`granted ${currency} ${granted.toFixed(2)}`);
|
|
58
|
+
if (parsed.is_available === false) notes.push("account unavailable");
|
|
59
|
+
return {
|
|
60
|
+
providerId: "deepseek",
|
|
61
|
+
label: "DeepSeek",
|
|
62
|
+
balance: { amount, currency },
|
|
63
|
+
windows: [],
|
|
64
|
+
notes,
|
|
65
|
+
fetchedAt: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Copilot adapter.
|
|
3
|
+
*
|
|
4
|
+
* Query method borrowed from CodexBar: the GitHub OAuth token stored by pi
|
|
5
|
+
* (k Copilot's device-flow login) is accepted directly by the internal user
|
|
6
|
+
* endpoint, no Copilot token exchange needed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseTimestamp, toNumber } from "../parse.ts";
|
|
10
|
+
import type { AccountBalance, ProviderAdapter, ProviderFetchArgs, UsageWindow } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
const ENDPOINT = "https://github.com/copilot_internal/user";
|
|
13
|
+
|
|
14
|
+
const REQUEST_HEADERS = {
|
|
15
|
+
Accept: "application/json",
|
|
16
|
+
"Editor-Version": "vscode/1.96.2",
|
|
17
|
+
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
|
18
|
+
"X-Github-Api-Version": "2025-04-01",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
interface CopilotSnapshot {
|
|
22
|
+
entitlement?: unknown;
|
|
23
|
+
remaining?: unknown;
|
|
24
|
+
percent_remaining?: unknown;
|
|
25
|
+
unlimited?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CopilotUsageResponse {
|
|
29
|
+
quota_snapshots?: unknown;
|
|
30
|
+
copilot_plan?: unknown;
|
|
31
|
+
quota_reset_date?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Short display labels for the known snapshot keys. */
|
|
35
|
+
const SNAPSHOT_LABELS: Record<string, string> = {
|
|
36
|
+
premium_interactions: "premium",
|
|
37
|
+
chat: "chat",
|
|
38
|
+
embeddings: "embeddings",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function snapshotLabel(key: string): string {
|
|
42
|
+
return SNAPSHOT_LABELS[key] ?? key.replaceAll("_", " ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseSnapshot(key: string, raw: unknown): UsageWindow | undefined {
|
|
46
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
47
|
+
const snapshot = raw as CopilotSnapshot;
|
|
48
|
+
const label = snapshotLabel(key);
|
|
49
|
+
if (snapshot.unlimited === true) {
|
|
50
|
+
return { label, usedPercent: 0, detail: "unlimited" };
|
|
51
|
+
}
|
|
52
|
+
const percentRemaining = toNumber(snapshot.percent_remaining);
|
|
53
|
+
if (percentRemaining === undefined) return undefined;
|
|
54
|
+
const entitlement = toNumber(snapshot.entitlement);
|
|
55
|
+
const remaining = toNumber(snapshot.remaining);
|
|
56
|
+
// Placeholder shape: GitHub reports all-zero rows for token-based billing
|
|
57
|
+
// and some business seats; they carry no usable quota signal.
|
|
58
|
+
if (entitlement === 0 && remaining === 0) return undefined;
|
|
59
|
+
return { label, usedPercent: Math.max(0, 100 - percentRemaining) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const githubCopilotAdapter: ProviderAdapter = {
|
|
63
|
+
id: "github-copilot",
|
|
64
|
+
label: "Copilot",
|
|
65
|
+
async fetch({ token, signal, fetchImpl }: ProviderFetchArgs): Promise<AccountBalance> {
|
|
66
|
+
const response = await fetchImpl(ENDPOINT, {
|
|
67
|
+
headers: { Authorization: `token ${token}`, ...REQUEST_HEADERS },
|
|
68
|
+
signal,
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error(`Copilot usage API returned HTTP ${response.status}`);
|
|
72
|
+
}
|
|
73
|
+
const body: unknown = await response.json();
|
|
74
|
+
if (typeof body !== "object" || body === null) {
|
|
75
|
+
throw new Error("Copilot usage API returned an unexpected response");
|
|
76
|
+
}
|
|
77
|
+
const usage = body as CopilotUsageResponse;
|
|
78
|
+
if (typeof usage.quota_snapshots !== "object" || usage.quota_snapshots === null) {
|
|
79
|
+
throw new Error("Copilot usage API returned no quota snapshots");
|
|
80
|
+
}
|
|
81
|
+
const snapshots = usage.quota_snapshots as Record<string, unknown>;
|
|
82
|
+
const windows = Object.entries(snapshots)
|
|
83
|
+
.map(([key, value]) => parseSnapshot(key, value))
|
|
84
|
+
.filter((window): window is UsageWindow => window !== undefined);
|
|
85
|
+
if (windows.length === 0) {
|
|
86
|
+
throw new Error("Copilot usage API returned no usable quota snapshots");
|
|
87
|
+
}
|
|
88
|
+
const planRaw = typeof usage.copilot_plan === "string" ? usage.copilot_plan : undefined;
|
|
89
|
+
const plan = planRaw
|
|
90
|
+
? planRaw
|
|
91
|
+
.split("_")
|
|
92
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
93
|
+
.join(" ")
|
|
94
|
+
: undefined;
|
|
95
|
+
const quotaResetDate = parseTimestamp(usage.quota_reset_date);
|
|
96
|
+
for (const window of windows) {
|
|
97
|
+
window.resetsAt ??= quotaResetDate;
|
|
98
|
+
}
|
|
99
|
+
return { providerId: "github-copilot", label: "Copilot", plan, windows, notes: [], fetchedAt: Date.now() };
|
|
100
|
+
},
|
|
101
|
+
};
|