@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.
@@ -0,0 +1,11 @@
1
+ import type { ProviderAdapter } from "../types.ts";
2
+ import { anthropicAdapter } from "./anthropic.ts";
3
+ import { codexAdapter } from "./codex.ts";
4
+ import { deepseekAdapter } from "./deepseek.ts";
5
+ import { githubCopilotAdapter } from "./github-copilot.ts";
6
+ import { openrouterAdapter } from "./openrouter.ts";
7
+ import { zaiAdapter } from "./zai.ts";
8
+
9
+ export function getBuiltinAdapters(): ProviderAdapter[] {
10
+ return [codexAdapter, anthropicAdapter, githubCopilotAdapter, openrouterAdapter, zaiAdapter, deepseekAdapter];
11
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * OpenRouter adapter: prepaid credits via the public credits endpoint. Works
3
+ * with both API keys and OAuth access tokens (plain Bearer either way).
4
+ */
5
+
6
+ import { toNumber } from "../parse.ts";
7
+ import type { AccountBalance, ProviderAdapter, ProviderFetchArgs } from "../types.ts";
8
+
9
+ const ENDPOINT = "https://openrouter.ai/api/v1/credits";
10
+
11
+ interface OpenrouterCreditsResponse {
12
+ data?: {
13
+ total_credits?: unknown;
14
+ total_usage?: unknown;
15
+ } | null;
16
+ }
17
+
18
+ export const openrouterAdapter: ProviderAdapter = {
19
+ id: "openrouter",
20
+ label: "OpenRouter",
21
+ async fetch({ token, signal, fetchImpl }: ProviderFetchArgs): Promise<AccountBalance> {
22
+ const response = await fetchImpl(ENDPOINT, {
23
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
24
+ signal,
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`OpenRouter credits API returned HTTP ${response.status}`);
28
+ }
29
+ const body: unknown = await response.json();
30
+ if (typeof body !== "object" || body === null) {
31
+ throw new Error("OpenRouter credits API returned an unexpected response");
32
+ }
33
+ const parsed = body as OpenrouterCreditsResponse;
34
+ const totalCredits = toNumber(parsed.data?.total_credits);
35
+ const totalUsage = toNumber(parsed.data?.total_usage);
36
+ if (totalCredits === undefined || totalUsage === undefined) {
37
+ throw new Error("OpenRouter credits API returned no credit totals");
38
+ }
39
+ return {
40
+ providerId: "openrouter",
41
+ label: "OpenRouter",
42
+ balance: {
43
+ amount: totalCredits - totalUsage,
44
+ currency: "USD",
45
+ note: `used $${totalUsage.toFixed(2)} of $${totalCredits.toFixed(2)}`,
46
+ },
47
+ windows: [],
48
+ notes: [],
49
+ fetchedAt: Date.now(),
50
+ };
51
+ },
52
+ };
@@ -0,0 +1,200 @@
1
+ /**
2
+ * z.ai / GLM adapter.
3
+ *
4
+ * Two disjoint surfaces, both borrowed from CodexBar's zai.js plugin:
5
+ * 1. Coding plan quota windows (`/api/monitor/usage/quota/limit`), available
6
+ * only to coding-plan keys.
7
+ * 2. BigModel CN pay-as-you-go balance (console endpoint). Empirically this
8
+ * also accepts z.ai global keys, so it is attempted as a best-effort
9
+ * fallback regardless of region; a failure never breaks quota display.
10
+ */
11
+
12
+ import type { AccountBalance, MoneyBalance, ProviderAdapter, ProviderFetchArgs, UsageWindow } from "../types.ts";
13
+
14
+ const QUOTA_PATH = "/api/monitor/usage/quota/limit";
15
+ const CN_BALANCE_URL = "https://www.bigmodel.cn/api/biz/account/query-customer-account-report";
16
+ const BASES = { global: "https://api.z.ai", cn: "https://open.bigmodel.cn" } as const;
17
+
18
+ /** unit → minutes multiplier (1=day, 3=hour, 5=minute, 6=week). */
19
+ const UNIT_MINUTES: Record<number, number> = { 1: 1_440, 3: 60, 5: 1, 6: 10_080 };
20
+
21
+ interface ZaiLimit {
22
+ type?: unknown;
23
+ unit?: unknown;
24
+ number?: unknown;
25
+ percentage?: unknown;
26
+ usage?: unknown;
27
+ remaining?: unknown;
28
+ nextResetTime?: unknown;
29
+ }
30
+
31
+ interface ZaiQuotaResponse {
32
+ success?: unknown;
33
+ msg?: unknown;
34
+ data?: { planName?: unknown; plan?: unknown; limits?: unknown } | null;
35
+ }
36
+
37
+ interface BigmodelBalanceResponse {
38
+ success?: unknown;
39
+ data?: {
40
+ availableBalance?: unknown;
41
+ balance?: unknown;
42
+ rechargeAmount?: unknown;
43
+ giveAmount?: unknown;
44
+ totalSpendAmount?: unknown;
45
+ } | null;
46
+ }
47
+
48
+ interface ParsedLimit {
49
+ type: "TIME_LIMIT" | "TOKENS_LIMIT" | "CREDIT_LIMIT";
50
+ percent: number;
51
+ windowMinutes?: number;
52
+ resetsAt?: number;
53
+ detail?: string;
54
+ }
55
+
56
+ function optionalInt(value: unknown): number | undefined {
57
+ return typeof value === "number" && Number.isInteger(value) ? value : undefined;
58
+ }
59
+
60
+ /** z.ai mixes seconds and milliseconds epochs; disambiguate by magnitude. */
61
+ function normalizeEpoch(value: number): number {
62
+ return value < 1e12 ? value * 1000 : value;
63
+ }
64
+
65
+ function parseLimit(raw: unknown): ParsedLimit | undefined {
66
+ if (typeof raw !== "object" || raw === null) return undefined;
67
+ const limit = raw as ZaiLimit;
68
+ if (typeof limit.percentage !== "number") return undefined;
69
+ if (limit.type !== "TIME_LIMIT" && limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT") return undefined;
70
+ const unit = optionalInt(limit.unit);
71
+ const number = optionalInt(limit.number);
72
+ const multiplier = unit !== undefined ? UNIT_MINUTES[unit] : undefined;
73
+ const windowMinutes =
74
+ multiplier !== undefined && number !== undefined && number > 0 ? number * multiplier : undefined;
75
+ const reset = optionalInt(limit.nextResetTime);
76
+ const usage = optionalInt(limit.usage);
77
+ const remaining = optionalInt(limit.remaining);
78
+ const detail = usage !== undefined && remaining !== undefined ? `${remaining} of ${usage} left` : undefined;
79
+ return {
80
+ type: limit.type,
81
+ percent: limit.percentage,
82
+ windowMinutes,
83
+ resetsAt: reset !== undefined ? normalizeEpoch(reset) : undefined,
84
+ detail,
85
+ };
86
+ }
87
+
88
+ function windowTitle(windowMinutes: number | undefined): string {
89
+ if (windowMinutes === 300) return "5h";
90
+ if (windowMinutes === 10_080) return "weekly";
91
+ if (windowMinutes === 43_200) return "monthly";
92
+ if (windowMinutes !== undefined && windowMinutes % 60 === 0) return `${windowMinutes / 60}h`;
93
+ if (windowMinutes !== undefined) return `${windowMinutes}m`;
94
+ return "window";
95
+ }
96
+
97
+ function windowsFromLimits(limits: ParsedLimit[]): UsageWindow[] {
98
+ const windows: UsageWindow[] = [];
99
+ const creditLimits = limits
100
+ .filter((limit) => limit.type !== "TIME_LIMIT")
101
+ .sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER));
102
+ const timeLimit = limits.filter((limit) => limit.type === "TIME_LIMIT").at(-1);
103
+ const sessionLimit = creditLimits.length >= 2 ? creditLimits[0] : undefined;
104
+ const tokenLimit = creditLimits.at(-1);
105
+ for (const entry of [sessionLimit, tokenLimit, timeLimit]) {
106
+ if (!entry) continue;
107
+ windows.push({
108
+ label: entry.type === "TIME_LIMIT" ? "MCP" : windowTitle(entry.windowMinutes),
109
+ usedPercent: entry.percent,
110
+ resetsAt: entry.resetsAt,
111
+ detail: entry.detail,
112
+ });
113
+ }
114
+ return windows;
115
+ }
116
+
117
+ /** Server messages arrive in Chinese; map the known one to English. */
118
+ const QUOTA_FAILURE_TRANSLATIONS: Record<string, string> = {
119
+ "当前用户不存在coding plan": "no coding plan on this account",
120
+ };
121
+
122
+ async function fetchQuota(
123
+ url: string,
124
+ token: string,
125
+ fetchImpl: ProviderFetchArgs["fetchImpl"],
126
+ signal: AbortSignal | undefined,
127
+ ): Promise<{ windows: UsageWindow[]; plan?: string; failure?: string }> {
128
+ const response = await fetchImpl(url, {
129
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
130
+ signal,
131
+ });
132
+ if (!response.ok) return { windows: [], failure: `HTTP ${response.status}` };
133
+ const body: unknown = await response.json();
134
+ if (typeof body !== "object" || body === null) return { windows: [], failure: "invalid response" };
135
+ const quota = body as ZaiQuotaResponse;
136
+ if (quota.success !== true) {
137
+ const message = typeof quota.msg === "string" ? quota.msg : "invalid response";
138
+ return { windows: [], failure: QUOTA_FAILURE_TRANSLATIONS[message] ?? message };
139
+ }
140
+ if (!Array.isArray(quota.data?.limits)) return { windows: [], failure: "no limits in response" };
141
+ const limits = quota.data.limits.map(parseLimit).filter((limit): limit is ParsedLimit => limit !== undefined);
142
+ const windows = windowsFromLimits(limits);
143
+ const planCandidate = [quota.data?.planName, quota.data?.plan].find((value) => typeof value === "string" && value.trim() !== "");
144
+ const plan = typeof planCandidate === "string" ? planCandidate.trim() : undefined;
145
+ return { windows, plan };
146
+ }
147
+
148
+ async function fetchCnBalance(
149
+ token: string,
150
+ fetchImpl: ProviderFetchArgs["fetchImpl"],
151
+ signal: AbortSignal | undefined,
152
+ ): Promise<MoneyBalance | undefined> {
153
+ const response = await fetchImpl(CN_BALANCE_URL, {
154
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
155
+ signal,
156
+ });
157
+ if (!response.ok) return undefined;
158
+ const body: unknown = await response.json();
159
+ if (typeof body !== "object" || body === null) return undefined;
160
+ const parsed = body as BigmodelBalanceResponse;
161
+ if (parsed.success !== true) return undefined;
162
+ const data = parsed.data ?? {};
163
+ const available = typeof data.availableBalance === "number" ? data.availableBalance : typeof data.balance === "number" ? data.balance : undefined;
164
+ if (available === undefined || !Number.isFinite(available)) return undefined;
165
+ const notes: string[] = [];
166
+ if (typeof data.rechargeAmount === "number") notes.push(`recharged ¥${data.rechargeAmount.toFixed(2)}`);
167
+ if (typeof data.totalSpendAmount === "number") notes.push(`spent ¥${data.totalSpendAmount.toFixed(2)}`);
168
+ return { amount: available, currency: "CNY", note: notes.join(" · ") || undefined };
169
+ }
170
+
171
+ export const zaiAdapter: ProviderAdapter = {
172
+ id: "zai",
173
+ label: "GLM",
174
+ async fetch({ token, signal, fetchImpl, options }: ProviderFetchArgs): Promise<AccountBalance> {
175
+ const region = options.region === "global" ? "global" : options.region === "cn" ? "cn" : "auto";
176
+ const bases = region === "auto" ? [BASES.global, BASES.cn] : [BASES[region]];
177
+ let quotaFailure: string | undefined;
178
+ let windows: UsageWindow[] = [];
179
+ let plan: string | undefined;
180
+ for (const base of bases) {
181
+ try {
182
+ const result = await fetchQuota(`${base}${QUOTA_PATH}`, token, fetchImpl, signal);
183
+ if (result.windows.length > 0) {
184
+ windows = result.windows;
185
+ plan = result.plan;
186
+ break;
187
+ }
188
+ quotaFailure ??= result.failure;
189
+ } catch (error) {
190
+ quotaFailure ??= error instanceof Error ? error.message : String(error);
191
+ }
192
+ }
193
+ // Best-effort CN balance; a failure here must never break quota display.
194
+ const balance = await fetchCnBalance(token, fetchImpl, signal).catch(() => undefined);
195
+ if (windows.length === 0 && !balance) {
196
+ throw new Error(quotaFailure ? `z.ai: ${quotaFailure}` : "z.ai returned no usage data");
197
+ }
198
+ return { providerId: "zai", label: "GLM", plan, windows, balance, notes: [], fetchedAt: Date.now() };
199
+ },
200
+ };
package/src/service.ts ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * BalanceService: resolves credentials, runs provider adapters, caches
3
+ * snapshots with a TTL, dedupes concurrent fetches, and runs the background
4
+ * refresh timer for one session.
5
+ */
6
+
7
+ import { intervalMs, type BalanceConfig, type ProviderConfig, loadBalanceConfig } from "./config.ts";
8
+ import {
9
+ readStoredCredential,
10
+ readStoredCredentialIds,
11
+ resolveProviderToken,
12
+ } from "./credentials.ts";
13
+ import { getBuiltinAdapters } from "./providers/index.ts";
14
+ import { createAutoDetectAdapter } from "./providers/auto-detect.ts";
15
+ import { createCustomAdapter } from "./providers/custom.ts";
16
+ import type { AccountBalance, CredentialResolver, FetchLike, ProviderAdapter, ResolvedCredential } from "./types.ts";
17
+
18
+ export interface BalanceServiceOptions {
19
+ agentDir: string;
20
+ config?: BalanceConfig;
21
+ fetchImpl?: FetchLike;
22
+ /** Extra/override adapters, mainly for tests. */
23
+ adapters?: ProviderAdapter[];
24
+ }
25
+
26
+ function toErrorMessage(error: unknown): string {
27
+ if (!(error instanceof Error)) return String(error);
28
+ let message = error.message || error.name;
29
+ let cause: unknown = error.cause;
30
+ for (let depth = 0; depth < 3 && cause !== undefined && cause !== null; depth++) {
31
+ const causeText = describeCause(cause);
32
+ if (!causeText || message.includes(causeText)) break;
33
+ message = `${message} (${causeText})`;
34
+ cause = cause instanceof Error ? cause.cause : undefined;
35
+ }
36
+ return message;
37
+ }
38
+
39
+ function describeCause(cause: unknown): string {
40
+ if (cause instanceof Error) {
41
+ if (cause.message) return cause.message;
42
+ const code = (cause as Error & { code?: unknown }).code;
43
+ if (typeof code === "string") return code;
44
+ const errors = (cause as Error & { errors?: unknown }).errors;
45
+ if (Array.isArray(errors)) {
46
+ const nested = errors.map(describeCause).filter(Boolean).join(", ");
47
+ if (nested) return nested;
48
+ }
49
+ return cause.name;
50
+ }
51
+ return typeof cause === "string" ? cause : "";
52
+ }
53
+
54
+ export class BalanceService {
55
+ readonly config: BalanceConfig;
56
+ private readonly agentDir: string;
57
+ private readonly fetchImpl: FetchLike;
58
+ private readonly adapters: Map<string, ProviderAdapter>;
59
+ private readonly autoAdapters = new Map<string, ProviderAdapter>();
60
+ private credentialResolver: CredentialResolver | undefined;
61
+ private readonly balances = new Map<string, AccountBalance>();
62
+ private readonly lastFetched = new Map<string, number>();
63
+ private readonly inflight = new Map<string, Promise<AccountBalance>>();
64
+ private readonly listeners = new Set<() => void>();
65
+ private timer: ReturnType<typeof setInterval> | undefined;
66
+
67
+ constructor(options: BalanceServiceOptions) {
68
+ this.agentDir = options.agentDir;
69
+ this.config = options.config ?? loadBalanceConfig(options.agentDir);
70
+ this.fetchImpl = options.fetchImpl ?? ((url, init) => fetch(url, init));
71
+ this.adapters = new Map(getBuiltinAdapters().map((adapter) => [adapter.id, adapter]));
72
+ for (const adapter of options.adapters ?? []) {
73
+ this.adapters.set(adapter.id, adapter);
74
+ }
75
+ }
76
+
77
+ /** Wire the live credential resolver (pi's modelRegistry-backed lookup). */
78
+ setCredentialResolver(resolver: CredentialResolver | undefined): void {
79
+ this.credentialResolver = resolver;
80
+ }
81
+
82
+ /** Subscribe to cache changes (any fetch finishing). Returns unsubscribe. */
83
+ onChange(listener: () => void): () => void {
84
+ this.listeners.add(listener);
85
+ return () => {
86
+ this.listeners.delete(listener);
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Provider ids that should be displayed: built-in adapters with a stored
92
+ * credential, every remaining auth.json credential when auto-detection is
93
+ * on, and explicitly configured custom adapters.
94
+ */
95
+ listConfiguredProviderIds(): string[] {
96
+ const ids: string[] = [];
97
+ const configured = this.config.providers ?? {};
98
+ const autoDetect = this.config.autoDetect !== false;
99
+ for (const id of this.adapters.keys()) {
100
+ if (configured[id]?.enabled === false) continue;
101
+ if (readStoredCredential(this.agentDir, id)) ids.push(id);
102
+ }
103
+ if (autoDetect) {
104
+ for (const id of readStoredCredentialIds(this.agentDir)) {
105
+ if (this.adapters.has(id) || ids.includes(id)) continue;
106
+ if (configured[id]?.enabled === false) continue;
107
+ ids.push(id);
108
+ }
109
+ }
110
+ for (const [id, entry] of Object.entries(configured)) {
111
+ if (entry.enabled === false) continue;
112
+ if (this.adapters.has(id) || ids.includes(id) || !entry.custom?.url) continue;
113
+ ids.push(id);
114
+ }
115
+ return ids;
116
+ }
117
+
118
+ get(providerId: string): AccountBalance | undefined {
119
+ return this.balances.get(providerId);
120
+ }
121
+
122
+ /** Best display name before the first fetch lands: config label → adapter label → id. */
123
+ labelFor(providerId: string): string {
124
+ return this.config.providers?.[providerId]?.label ?? this.adapterFor(providerId)?.label ?? providerId;
125
+ }
126
+
127
+ /** Cached snapshots for all configured providers (missing ones omitted). */
128
+ getAll(): AccountBalance[] {
129
+ return this.listConfiguredProviderIds()
130
+ .map((id) => this.balances.get(id))
131
+ .filter((balance): balance is AccountBalance => balance !== undefined);
132
+ }
133
+
134
+ /** Fetch (or return cached) balance for one provider. Never rejects. */
135
+ async refresh(providerId: string, options?: { force?: boolean; signal?: AbortSignal }): Promise<AccountBalance> {
136
+ const cached = this.balances.get(providerId);
137
+ const last = this.lastFetched.get(providerId);
138
+ // Failed snapshots never satisfy the TTL: transient network errors must
139
+ // be retried on the next refresh instead of being served for 5 minutes.
140
+ if (!options?.force && cached && !cached.error && last !== undefined && Date.now() - last < intervalMs(this.config)) {
141
+ return cached;
142
+ }
143
+ const pending = this.inflight.get(providerId);
144
+ if (pending) return pending;
145
+ const task = this.fetchBalance(providerId, options?.signal).finally(() => {
146
+ this.inflight.delete(providerId);
147
+ });
148
+ this.inflight.set(providerId, task);
149
+ return task;
150
+ }
151
+
152
+ /** Refresh every configured provider in parallel. Never rejects. */
153
+ async refreshAll(options?: { force?: boolean; signal?: AbortSignal }): Promise<AccountBalance[]> {
154
+ return Promise.all(this.listConfiguredProviderIds().map((id) => this.refresh(id, options)));
155
+ }
156
+
157
+ /** Start the background refresh timer and kick off an initial fetch. */
158
+ start(): void {
159
+ if (this.timer) return;
160
+ void this.refreshAll();
161
+ this.timer = setInterval(() => {
162
+ void this.refreshAll();
163
+ }, intervalMs(this.config));
164
+ }
165
+
166
+ stop(): void {
167
+ if (!this.timer) return;
168
+ clearInterval(this.timer);
169
+ this.timer = undefined;
170
+ }
171
+
172
+ private adapterFor(providerId: string): ProviderAdapter | undefined {
173
+ const known = this.adapters.get(providerId);
174
+ if (known) return known;
175
+ const entry = this.config.providers?.[providerId];
176
+ if (entry?.custom?.url) {
177
+ return createCustomAdapter(providerId, entry.custom, entry.label ?? providerId);
178
+ }
179
+ if (this.config.autoDetect !== false) {
180
+ let adapter = this.autoAdapters.get(providerId);
181
+ if (!adapter) {
182
+ adapter = createAutoDetectAdapter(providerId);
183
+ this.autoAdapters.set(providerId, adapter);
184
+ }
185
+ return adapter;
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ private async fetchBalance(providerId: string, signal?: AbortSignal): Promise<AccountBalance> {
191
+ const adapter = this.adapterFor(providerId);
192
+ let balance: AccountBalance;
193
+ if (!adapter) {
194
+ balance = {
195
+ providerId,
196
+ label: providerId,
197
+ windows: [],
198
+ notes: [],
199
+ error: `Unknown provider "${providerId}"`,
200
+ fetchedAt: Date.now(),
201
+ };
202
+ } else {
203
+ balance = await this.runAdapter(providerId, adapter, signal);
204
+ }
205
+ const labelOverride = this.config.providers?.[providerId]?.label;
206
+ if (labelOverride) balance = { ...balance, label: labelOverride };
207
+ this.balances.set(providerId, balance);
208
+ this.lastFetched.set(providerId, Date.now());
209
+ this.notifyListeners();
210
+ return balance;
211
+ }
212
+
213
+ private async runAdapter(
214
+ providerId: string,
215
+ adapter: ProviderAdapter,
216
+ signal?: AbortSignal,
217
+ ): Promise<AccountBalance> {
218
+ try {
219
+ const resolved = await this.resolveRunCredential(providerId);
220
+ const options: ProviderConfig = this.config.providers?.[providerId] ?? {};
221
+ return await adapter.fetch({
222
+ token: resolved.token,
223
+ baseUrl: resolved.baseUrl,
224
+ signal,
225
+ fetchImpl: this.fetchImpl,
226
+ options,
227
+ });
228
+ } catch (error) {
229
+ return {
230
+ providerId,
231
+ label: adapter.label,
232
+ windows: [],
233
+ notes: [],
234
+ error: toErrorMessage(error),
235
+ fetchedAt: Date.now(),
236
+ };
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Token plus base URL for one fetch. Registry-backed resolution runs first
242
+ * (it refreshes OAuth tokens); auth.json is the fallback. Custom providers
243
+ * without a stored credential get an empty token — they authenticate via
244
+ * their own configured headers.
245
+ */
246
+ private async resolveRunCredential(providerId: string): Promise<ResolvedCredential> {
247
+ const stored = readStoredCredential(this.agentDir, providerId);
248
+ if (!stored) {
249
+ if (this.credentialResolver) {
250
+ return this.credentialResolver(providerId).catch(() => ({ token: "" }));
251
+ }
252
+ return { token: "" };
253
+ }
254
+ if (this.credentialResolver) {
255
+ try {
256
+ const resolved = await this.credentialResolver(providerId);
257
+ if (resolved.token) return resolved;
258
+ } catch {
259
+ // Registry lookup failed (unknown provider, refresh error); fall back.
260
+ }
261
+ }
262
+ const token = await resolveProviderToken(providerId, undefined, this.agentDir);
263
+ return { token };
264
+ }
265
+
266
+ private notifyListeners(): void {
267
+ for (const listener of this.listeners) {
268
+ try {
269
+ listener();
270
+ } catch {
271
+ // Listener errors must not break refresh bookkeeping.
272
+ }
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Session consumption aggregation, mirroring pi's footer accounting: sum of
3
+ * usage across assistant messages, tool results, branch summaries, and
4
+ * compaction entries of the current session.
5
+ */
6
+
7
+ export interface UsageLike {
8
+ totalTokens?: number;
9
+ input?: number;
10
+ output?: number;
11
+ cacheRead?: number;
12
+ cacheWrite?: number;
13
+ cost?: { total?: number };
14
+ }
15
+
16
+ export interface SessionEntryLike {
17
+ type: string;
18
+ message?: { role?: string; usage?: UsageLike };
19
+ usage?: UsageLike;
20
+ }
21
+
22
+ export interface SessionUsageTotals {
23
+ tokens: number;
24
+ cost: number;
25
+ }
26
+
27
+ export function sumSessionUsage(entries: readonly SessionEntryLike[]): SessionUsageTotals {
28
+ const totals: SessionUsageTotals = { tokens: 0, cost: 0 };
29
+ for (const entry of entries) {
30
+ let usage: UsageLike | undefined;
31
+ if (entry.type === "message" && entry.message?.role === "assistant") {
32
+ usage = entry.message.usage;
33
+ } else if (entry.type === "message" && entry.message?.role === "toolResult") {
34
+ usage = entry.message.usage;
35
+ } else if (entry.type === "branch_summary" || entry.type === "compaction") {
36
+ usage = entry.usage;
37
+ }
38
+ if (!usage) continue;
39
+ totals.tokens += usageTotalTokens(usage);
40
+ totals.cost += usage.cost?.total ?? 0;
41
+ }
42
+ return totals;
43
+ }
44
+
45
+ function usageTotalTokens(usage: UsageLike): number {
46
+ if (typeof usage.totalTokens === "number") return usage.totalTokens;
47
+ return (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
48
+ }
49
+
50
+ /** Same tiers as pi's footer formatTokens, so numbers match across rows. */
51
+ export function formatTokens(count: number): string {
52
+ if (count < 1000) return count.toString();
53
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
54
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
55
+ if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
56
+ return `${Math.round(count / 1_000_000)}M`;
57
+ }
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared data model for pi-usage.
3
+ *
4
+ * Every provider adapter normalizes its API responses into `AccountBalance`;
5
+ * the panel and status line render that shape without provider-specific logic.
6
+ */
7
+
8
+ import type { ProviderConfig } from "./config.ts";
9
+
10
+ /** One usage window, e.g. a 5-hour or weekly subscription quota window. */
11
+ export interface UsageWindow {
12
+ /** Short window label, e.g. "5h", "weekly", "MCP". */
13
+ label: string;
14
+ /** Used fraction in percent (0-100+, values above 100 are clamped when drawn). */
15
+ usedPercent: number;
16
+ /** Epoch milliseconds when the window resets, if the API reports one. */
17
+ resetsAt?: number;
18
+ /** Optional detail such as "880 of 1000 left". */
19
+ detail?: string;
20
+ }
21
+
22
+ /** Monetary balance (prepaid credits, account balance). */
23
+ export interface MoneyBalance {
24
+ amount: number;
25
+ /** ISO currency code, e.g. "CNY", "USD". */
26
+ currency: string;
27
+ /** Optional breakdown text, e.g. "recharged ¥118.00 · spent ¥96.54". */
28
+ note?: string;
29
+ }
30
+
31
+ /** Normalized snapshot for one account. */
32
+ export interface AccountBalance {
33
+ /** pi provider id, matching the key in auth.json. */
34
+ providerId: string;
35
+ /** Display name, e.g. "Codex". */
36
+ label: string;
37
+ /** Plan name if the API reports one, e.g. "Plus". */
38
+ plan?: string;
39
+ windows: UsageWindow[];
40
+ balance?: MoneyBalance;
41
+ /** Extra single-line facts rendered in the panel. */
42
+ notes: string[];
43
+ /** Fetch/parse failure; when set, windows /usage may be empty. */
44
+ error?: string;
45
+ /** Epoch milliseconds of the fetch attempt. */
46
+ fetchedAt: number;
47
+ }
48
+
49
+ /** Minimal HTTP client interface so adapters can be tested without network. */
50
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
51
+
52
+ /** Arguments handed to a provider adapter on each fetch. */
53
+ export interface ProviderFetchArgs {
54
+ /**
55
+ * Resolved bearer token or API key for the provider. Empty for custom
56
+ * providers without a stored credential; those authenticate via their own
57
+ * configured headers.
58
+ */
59
+ token: string;
60
+ /** Provider base URL from pi's model registry, when known. */
61
+ baseUrl?: string;
62
+ signal?: AbortSignal;
63
+ fetchImpl: FetchLike;
64
+ /** Per-provider options from usage.json (`providers` section). */
65
+ options: ProviderConfig;
66
+ }
67
+
68
+ /** Normalizes one provider's usage /usage API into an AccountBalance. */
69
+ export interface ProviderAdapter {
70
+ /** pi provider id, matching auth.json key / model registry provider id. */
71
+ id: string;
72
+ label: string;
73
+ fetch(args: ProviderFetchArgs): Promise<AccountBalance>;
74
+ }
75
+
76
+ /** Live credential plus the provider base URL (for auto-detection). */
77
+ export interface ResolvedCredential {
78
+ token: string;
79
+ baseUrl?: string;
80
+ }
81
+
82
+ /** Resolves the live bearer token/API key (and base URL) for a provider id. */
83
+ export type CredentialResolver = (providerId: string) => Promise<ResolvedCredential>;