@jameslovespancakes/pi-plus 1.0.17 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/core/anthropic/quota.ts +6 -2
- package/src/core/gemini/client.ts +39 -0
- package/src/core/gemini/quota.ts +58 -0
- package/src/core/quota/pool.ts +23 -1
- package/src/core/quota/usage-source.ts +272 -49
- package/src/domains/models/catalog-tool.ts +21 -9
- package/src/domains/subscriptions/footer.ts +31 -9
- package/src/services/usage-service.ts +21 -6
- package/src/ui/usage-bars.ts +113 -25
package/README.md
CHANGED
|
@@ -73,6 +73,14 @@ Your live quota, always in the footer:
|
|
|
73
73
|
With more than two accounts only the two most recently used are listed, so the
|
|
74
74
|
footer stays a fixed height however many you pool.
|
|
75
75
|
|
|
76
|
+
The right-hand column follows the model in use. It shows Codex by default and
|
|
77
|
+
swaps to Gemini while a `gemini/*` model is selected: one bar per quota family
|
|
78
|
+
(Flash, Pro, and Claude or GPT-OSS), pooled across your Gemini accounts, with
|
|
79
|
+
the active family highlighted. Whether a family resets weekly or every five
|
|
80
|
+
hours depends on the account's plan; the reset time shows which. Kimi and Grok
|
|
81
|
+
publish no usage endpoint, so their column shows only the last rate-limit
|
|
82
|
+
reading, if any.
|
|
83
|
+
|
|
76
84
|
Account and routing commands are provider-agnostic. Sequential routing uses
|
|
77
85
|
account order; quota-aware routing uses reported capacity and fairly probes
|
|
78
86
|
accounts whose provider does not publish quota headers.
|
package/package.json
CHANGED
|
@@ -61,12 +61,16 @@ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
|
|
|
61
61
|
};
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
// `limits` also restates the session and weekly windows (`kind: session`,
|
|
65
|
+
// `weekly_all`) with no scope. Only model-scoped entries are extra limits;
|
|
66
|
+
// keeping the others filed the 5h and 7d windows a second time as "scoped".
|
|
64
67
|
const scoped = (Array.isArray(body?.limits) ? body.limits : [])
|
|
65
68
|
.map((limit: any) => {
|
|
69
|
+
const name = limit?.scope?.model?.display_name;
|
|
66
70
|
const used = pct(limit?.percent);
|
|
67
|
-
if (used === undefined) return undefined;
|
|
71
|
+
if (typeof name !== "string" || !name || used === undefined) return undefined;
|
|
68
72
|
return {
|
|
69
|
-
id:
|
|
73
|
+
id: name.toLowerCase(),
|
|
70
74
|
usedPercent: used,
|
|
71
75
|
remainingPercent: 100 - used,
|
|
72
76
|
resetsAt: typeof limit?.resets_at === "string" ? limit.resets_at : undefined,
|
|
@@ -154,6 +154,45 @@ export async function fetchUserEmail(token: string, signal?: AbortSignal): Promi
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/** One `retrieveUserQuota` bucket: a runtime model's remaining share of its window. */
|
|
158
|
+
export interface QuotaBucket {
|
|
159
|
+
modelId: string;
|
|
160
|
+
/** 0..1 of the window left. */
|
|
161
|
+
remainingFraction: number;
|
|
162
|
+
/** ISO time the window resets; absent for buckets with no window. */
|
|
163
|
+
resetTime?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The account's quota, one bucket per runtime model id. Endpoints are tried in
|
|
168
|
+
* order and the first answer wins: quota is per account, not per endpoint.
|
|
169
|
+
* Throws only when no endpoint answered at all.
|
|
170
|
+
*/
|
|
171
|
+
export async function fetchUserQuota(
|
|
172
|
+
token: string,
|
|
173
|
+
projectId: string,
|
|
174
|
+
signal?: AbortSignal,
|
|
175
|
+
): Promise<QuotaBucket[]> {
|
|
176
|
+
for (const endpoint of GEMINI_ENDPOINTS) {
|
|
177
|
+
const answer = await postJson(endpoint, "retrieveUserQuota", token, { project: projectId }, signal);
|
|
178
|
+
if (!isRecord(answer)) continue;
|
|
179
|
+
const buckets = Array.isArray(answer.buckets) ? answer.buckets : [];
|
|
180
|
+
return buckets.flatMap((bucket): QuotaBucket[] => {
|
|
181
|
+
if (!isRecord(bucket) || typeof bucket.modelId !== "string") return [];
|
|
182
|
+
// proto3 JSON omits zero values, so an exhausted bucket arrives with no
|
|
183
|
+
// `remainingFraction` at all. Absent means empty, not unknown.
|
|
184
|
+
const fraction = bucket.remainingFraction === undefined ? 0 : Number(bucket.remainingFraction);
|
|
185
|
+
if (!Number.isFinite(fraction)) return [];
|
|
186
|
+
return [{
|
|
187
|
+
modelId: bucket.modelId,
|
|
188
|
+
remainingFraction: Math.min(1, Math.max(0, fraction)),
|
|
189
|
+
...(typeof bucket.resetTime === "string" && bucket.resetTime && { resetTime: bucket.resetTime }),
|
|
190
|
+
}];
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
throw new Error("Gemini did not return quota from any endpoint.");
|
|
194
|
+
}
|
|
195
|
+
|
|
157
196
|
/** One entry of `fetchAvailableModels`, keyed by its runtime model id. */
|
|
158
197
|
export interface RuntimeModelInfo {
|
|
159
198
|
isInternal?: boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { QuotaBucket } from "./client.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemini quota, grouped the way the backend pools it.
|
|
5
|
+
*
|
|
6
|
+
* `retrieveUserQuota` answers per runtime model id (`gemini-3.8-flash-high`,
|
|
7
|
+
* `gemini-pro-agent`, `claude-opus-4-6-thinking`, …), but the ids of one
|
|
8
|
+
* family draw on one shared allowance: every Flash variant reports the same
|
|
9
|
+
* fraction and reset, as does every Pro variant. The window differs by plan —
|
|
10
|
+
* a paid account resets weekly, a free one every five hours — so the bars are
|
|
11
|
+
* labelled by family and the reset time says which window it is.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const GEMINI_QUOTA_FAMILIES = ["Flash", "Pro", "Claude", "GPT"] as const;
|
|
15
|
+
export type GeminiQuotaFamily = typeof GEMINI_QUOTA_FAMILIES[number];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The family a public or runtime model id draws quota from. Undefined for ids
|
|
19
|
+
* that are not agent models (tab completion, image, the `-lite` helpers the
|
|
20
|
+
* client uses for commit messages and search), so they never skew a family.
|
|
21
|
+
*/
|
|
22
|
+
export function geminiQuotaFamily(modelId: string | undefined): GeminiQuotaFamily | undefined {
|
|
23
|
+
const id = (modelId ?? "").toLowerCase();
|
|
24
|
+
if (id.startsWith("claude-")) return "Claude";
|
|
25
|
+
if (id.startsWith("gpt-oss")) return "GPT";
|
|
26
|
+
if (!id.startsWith("gemini-") || /image|lite/.test(id)) return undefined;
|
|
27
|
+
if (/(^|-)pro(-|$)/.test(id)) return "Pro";
|
|
28
|
+
if (/(^|-)flash(-|$)/.test(id)) return "Flash";
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GeminiFamilyQuota {
|
|
33
|
+
family: GeminiQuotaFamily;
|
|
34
|
+
/** Percent left, 0..100. */
|
|
35
|
+
remaining: number;
|
|
36
|
+
resetAt?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One figure per family: the most depleted bucket in it, so a variant that
|
|
41
|
+
* has run dry is never hidden behind a sibling that has not.
|
|
42
|
+
*/
|
|
43
|
+
export function summarizeGeminiQuota(buckets: readonly QuotaBucket[]): GeminiFamilyQuota[] {
|
|
44
|
+
const byFamily = new Map<GeminiQuotaFamily, GeminiFamilyQuota>();
|
|
45
|
+
for (const bucket of buckets) {
|
|
46
|
+
const family = geminiQuotaFamily(bucket.modelId);
|
|
47
|
+
if (!family) continue;
|
|
48
|
+
const remaining = bucket.remainingFraction * 100;
|
|
49
|
+
const parsed = bucket.resetTime ? Date.parse(bucket.resetTime) : Number.NaN;
|
|
50
|
+
const resetAt = Number.isFinite(parsed) ? parsed : undefined;
|
|
51
|
+
const current = byFamily.get(family);
|
|
52
|
+
const lower = !current || remaining < current.remaining;
|
|
53
|
+
const sooner = current && remaining === current.remaining
|
|
54
|
+
&& resetAt !== undefined && (current.resetAt === undefined || resetAt < current.resetAt);
|
|
55
|
+
if (lower || sooner) byFamily.set(family, { family, remaining, ...(resetAt !== undefined && { resetAt }) });
|
|
56
|
+
}
|
|
57
|
+
return GEMINI_QUOTA_FAMILIES.flatMap((family) => byFamily.get(family) ?? []);
|
|
58
|
+
}
|
package/src/core/quota/pool.ts
CHANGED
|
@@ -19,6 +19,16 @@ export type UsageRow = {
|
|
|
19
19
|
*/
|
|
20
20
|
export const CLAUDE_FRESH_MS = 12 * 60_000;
|
|
21
21
|
export const isClaudeAccount = (row: UsageRow) => row.group.startsWith("Claude ") && !row.group.startsWith("Claude pool ×");
|
|
22
|
+
export const isGeminiAccount = (row: UsageRow) => row.group.startsWith("Gemini ");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pooled providers with no usage endpoint, and the group their header-observed
|
|
26
|
+
* rate-limit reading is filed under. Their bars can only show what responses said.
|
|
27
|
+
*/
|
|
28
|
+
export const OBSERVED_PROVIDERS: ReadonlyArray<readonly [providerId: string, group: string]> = [
|
|
29
|
+
["kimi-coding", "Kimi"],
|
|
30
|
+
["xai", "Grok"],
|
|
31
|
+
];
|
|
22
32
|
export const isFresh = (row: UsageRow, now = Date.now()) => !row.stale && !!row.checkedAt
|
|
23
33
|
&& now - row.checkedAt < CLAUDE_FRESH_MS && (!row.resetAt || row.resetAt > now);
|
|
24
34
|
|
|
@@ -26,7 +36,19 @@ export const isFresh = (row: UsageRow, now = Date.now()) => !row.stale && !!row.
|
|
|
26
36
|
* Without published capacities this is explicitly an equal-account estimate.
|
|
27
37
|
*/
|
|
28
38
|
export function combinedWindow(rows: UsageRow[], label: string, expected: number, now = Date.now(), allowPartial = false) {
|
|
29
|
-
|
|
39
|
+
return pooledWindow(rows, label, expected, isClaudeAccount, now, allowPartial);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** {@link combinedWindow} for any pooled provider's account rows. */
|
|
43
|
+
export function pooledWindow(
|
|
44
|
+
rows: UsageRow[],
|
|
45
|
+
label: string,
|
|
46
|
+
expected: number,
|
|
47
|
+
isMember: (row: UsageRow) => boolean,
|
|
48
|
+
now = Date.now(),
|
|
49
|
+
allowPartial = false,
|
|
50
|
+
) {
|
|
51
|
+
const matching = rows.filter((r) => isMember(r) && r.label === label && isFresh(r, now));
|
|
30
52
|
const partial = matching.length !== expected;
|
|
31
53
|
if (!expected || !matching.length || (partial && !allowPartial)) return undefined;
|
|
32
54
|
const weighted = matching.every((r) => typeof r.capacity === "number" && r.capacity > 0);
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { loadOAuthPool, saveOAuthAccount, sharedOAuthPoolStore, type PooledOAuthAccount } from "../accounts/oauth-pool.ts";
|
|
2
|
+
import { refreshAbortSignal, type AccountQuotaState } from "../accounts/routing.ts";
|
|
3
|
+
import { anthropicAccountIdentity, cachedAnthropicAccountIdentity } from "../anthropic/identity.ts";
|
|
4
4
|
import { loadAccounts, saveAccount, type Account as AnthropicAccount } from "../anthropic/store.ts";
|
|
5
5
|
import { refreshToken } from "../anthropic/oauth.ts";
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
6
|
+
import { claimsOf } from "../codex/store.ts";
|
|
7
|
+
import { fetchUserQuota } from "../gemini/client.ts";
|
|
8
|
+
import { credentialEmail } from "../gemini/credentials.ts";
|
|
9
|
+
import { geminiOAuth, requestProjectId } from "../gemini/oauth.ts";
|
|
10
|
+
import { summarizeGeminiQuota } from "../gemini/quota.ts";
|
|
11
|
+
import { agentPath, readJson } from "../store.ts";
|
|
12
|
+
import { CLAUDE_FRESH_MS, OBSERVED_PROVIDERS, type UsageRow } from "./pool.ts";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
|
-
* Fetches subscription quota from the Claude and
|
|
15
|
+
* Fetches subscription quota from the Claude, Codex and Gemini endpoints, and
|
|
16
|
+
* reports what response headers showed for providers with no usage endpoint.
|
|
11
17
|
*
|
|
12
18
|
* Pure data access: no pi imports, no module-level mutable state, no rendering.
|
|
13
19
|
* Everything here returns values so it can be tested without a live agent.
|
|
@@ -19,10 +25,67 @@ const TIMEOUT_MS = 10_000;
|
|
|
19
25
|
export interface SourceResult {
|
|
20
26
|
rows: UsageRow[];
|
|
21
27
|
errors: string[];
|
|
28
|
+
/** Claude account groups expected to report. */
|
|
22
29
|
groups: string[];
|
|
30
|
+
/** Gemini account groups expected to report. */
|
|
31
|
+
geminiGroups: string[];
|
|
23
32
|
codexPlan?: string;
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
/** Reads a provider's credential as pi stores it (pi's `readStoredCredential`). */
|
|
36
|
+
export type CredentialReader = (providerId: string) => unknown;
|
|
37
|
+
|
|
38
|
+
export interface SourceOptions {
|
|
39
|
+
/**
|
|
40
|
+
* How the primary (pi-owned) credential is read. It must be read as stored:
|
|
41
|
+
* `modelRegistry.getProviderAuth()` returns whatever account *routing*
|
|
42
|
+
* picked, which can be a pooled one, so figures labelled as the primary
|
|
43
|
+
* account could silently belong to another.
|
|
44
|
+
*/
|
|
45
|
+
readCredential?: CredentialReader;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Fallback when pi's reader was not supplied: the same file pi reads. */
|
|
49
|
+
function readAuthFile(providerId: string): unknown {
|
|
50
|
+
return readJson<Record<string, unknown>>(agentPath("auth.json"), {})[providerId];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface StoredOAuth {
|
|
54
|
+
type: "oauth";
|
|
55
|
+
access: string;
|
|
56
|
+
refresh: string;
|
|
57
|
+
expires: number;
|
|
58
|
+
[key: string]: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function usableOAuth(value: unknown, now = Date.now()): value is StoredOAuth {
|
|
62
|
+
const credential = value as Partial<StoredOAuth> | undefined;
|
|
63
|
+
return credential?.type === "oauth"
|
|
64
|
+
&& typeof credential.access === "string" && credential.access.length > 0
|
|
65
|
+
&& (typeof credential.expires !== "number" || credential.expires > now + 60_000);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The provider's primary OAuth credential, or undefined when pi holds none
|
|
70
|
+
* (not logged in, or an API key, which has no subscription quota).
|
|
71
|
+
*
|
|
72
|
+
* An expiring credential is refreshed by pi itself (`getProviderAuth`
|
|
73
|
+
* refreshes and persists before it routes) and then read back, so rotating
|
|
74
|
+
* refresh tokens are only ever spent by pi.
|
|
75
|
+
*/
|
|
76
|
+
async function primaryOAuth(ctx: any, providerId: string, read: CredentialReader): Promise<StoredOAuth | undefined> {
|
|
77
|
+
const stored = read(providerId) as { type?: unknown } | undefined;
|
|
78
|
+
if (stored?.type !== "oauth") return undefined;
|
|
79
|
+
if (usableOAuth(stored)) return stored;
|
|
80
|
+
|
|
81
|
+
await ctx?.modelRegistry?.getProviderAuth?.(providerId);
|
|
82
|
+
const refreshed = read(providerId);
|
|
83
|
+
if (usableOAuth(refreshed)) return refreshed;
|
|
84
|
+
throw new Error(`login expired, run /login ${providerId}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
88
|
+
|
|
26
89
|
function pct(value: unknown): number | undefined {
|
|
27
90
|
if (value == null || typeof value === "boolean" || (typeof value === "string" && !value.trim())) return undefined;
|
|
28
91
|
const n = typeof value === "number" ? value : Number(value);
|
|
@@ -60,14 +123,16 @@ async function claudeUsage(group: string, token: string): Promise<{ rows: UsageR
|
|
|
60
123
|
};
|
|
61
124
|
push("5h", body.five_hour);
|
|
62
125
|
push("7d", body.seven_day);
|
|
63
|
-
|
|
64
|
-
|
|
126
|
+
// Scoped labels are lower case in every source (the stored snapshot keeps
|
|
127
|
+
// lower-cased ids), or one limit pools as two half-reported windows.
|
|
128
|
+
push("7d opus", body.seven_day_opus ?? body.seven_day_omelette);
|
|
129
|
+
push("7d sonnet", body.seven_day_sonnet);
|
|
65
130
|
|
|
66
131
|
for (const limit of Array.isArray(body.limits) ? body.limits : []) {
|
|
67
132
|
const scoped = limit?.scope?.model?.display_name;
|
|
68
133
|
const used = pct(limit?.percent);
|
|
69
|
-
if (!scoped || used === undefined) continue;
|
|
70
|
-
const label = `7d ${scoped}`;
|
|
134
|
+
if (typeof scoped !== "string" || !scoped || used === undefined) continue;
|
|
135
|
+
const label = `7d ${scoped.toLowerCase()}`;
|
|
71
136
|
if (rows.some((row) => row.label === label)) continue;
|
|
72
137
|
rows.push({ group, label, remaining: 100 - used, resetAt: resetToMs(limit?.resets_at) });
|
|
73
138
|
}
|
|
@@ -131,33 +196,59 @@ function rowsFromSnapshot(group: string, quota: any): UsageRow[] | undefined {
|
|
|
131
196
|
push("5h", quota.five_hour);
|
|
132
197
|
push("7d", quota.seven_day);
|
|
133
198
|
for (const scoped of Array.isArray(quota.scoped) ? quota.scoped : []) {
|
|
134
|
-
if (typeof scoped?.remainingPercent !== "number" ||
|
|
135
|
-
|
|
199
|
+
if (typeof scoped?.remainingPercent !== "number" || typeof scoped?.id !== "string" || !scoped.id) continue;
|
|
200
|
+
// Snapshots written before the fix restate the 5h/7d windows as "scoped".
|
|
201
|
+
if (scoped.id === "scoped") continue;
|
|
202
|
+
push(`7d ${scoped.id.toLowerCase()}`, scoped);
|
|
136
203
|
}
|
|
137
204
|
return rows.length ? rows : undefined;
|
|
138
205
|
}
|
|
139
206
|
|
|
140
|
-
|
|
207
|
+
const claudeGroup = (account: AnthropicAccount) => `Claude ${account.label ?? account.id.slice(0, 8)}`;
|
|
208
|
+
|
|
209
|
+
export async function fetchClaudeRows(
|
|
210
|
+
ctx: any,
|
|
211
|
+
options: SourceOptions = {},
|
|
212
|
+
): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
|
|
213
|
+
const read = options.readCredential ?? readAuthFile;
|
|
141
214
|
const rows: UsageRow[] = [];
|
|
142
215
|
const errors: string[] = [];
|
|
143
216
|
const accounts: Array<{ group: string; token?: string; account?: AnthropicAccount }> = [];
|
|
217
|
+
const groups = new Set<string>();
|
|
144
218
|
|
|
219
|
+
let sidecars: AnthropicAccount[] = [];
|
|
145
220
|
try {
|
|
146
|
-
|
|
147
|
-
if (token) accounts.push({ group: "Claude Personal", token });
|
|
148
|
-
else errors.push("Claude Personal: not logged in");
|
|
221
|
+
sidecars = (loadAccounts()?.accounts ?? []).filter((account) => account.type === "oauth" && account.enabled !== false);
|
|
149
222
|
} catch (error) {
|
|
150
|
-
errors.push(`Claude
|
|
223
|
+
errors.push(`Claude accounts: ${errorText(error)}`);
|
|
151
224
|
}
|
|
225
|
+
const taken = new Set(sidecars.map(claudeGroup));
|
|
226
|
+
const primaryGroup = taken.has("Claude Personal") ? "Claude Primary" : "Claude Personal";
|
|
152
227
|
|
|
153
228
|
try {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
229
|
+
const primary = await primaryOAuth(ctx, "anthropic", read);
|
|
230
|
+
if (!primary) {
|
|
231
|
+
errors.push(`${primaryGroup}: not logged in`);
|
|
232
|
+
} else {
|
|
233
|
+
// pi's own login is often the same Claude account as a pooled one.
|
|
234
|
+
// Counting it twice filed every window twice and marked the pool partial.
|
|
235
|
+
const identity = cachedAnthropicAccountIdentity(primary.access)
|
|
236
|
+
?? await anthropicAccountIdentity(primary.access).catch(() => undefined);
|
|
237
|
+
const twin = identity ? sidecars.find((account) => account.identity === identity) : undefined;
|
|
238
|
+
if (!twin) {
|
|
239
|
+
accounts.push({ group: primaryGroup, token: primary.access });
|
|
240
|
+
groups.add(primaryGroup);
|
|
241
|
+
}
|
|
158
242
|
}
|
|
159
243
|
} catch (error) {
|
|
160
|
-
|
|
244
|
+
// Logged in but unreadable: still an account the pool expects.
|
|
245
|
+
groups.add(primaryGroup);
|
|
246
|
+
errors.push(`${primaryGroup}: ${errorText(error)}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const account of sidecars) {
|
|
250
|
+
accounts.push({ group: claudeGroup(account), account });
|
|
251
|
+
groups.add(claudeGroup(account));
|
|
161
252
|
}
|
|
162
253
|
|
|
163
254
|
for (const entry of accounts) {
|
|
@@ -185,35 +276,30 @@ export async function fetchClaudeRows(ctx: any): Promise<{ rows: UsageRow[]; err
|
|
|
185
276
|
}
|
|
186
277
|
}
|
|
187
278
|
|
|
188
|
-
return { rows, errors, groups: [...
|
|
279
|
+
return { rows, errors, groups: [...groups] };
|
|
189
280
|
}
|
|
190
281
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const auth = JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8"));
|
|
200
|
-
const credential = auth["openai-codex"];
|
|
201
|
-
if (credential?.accountId) return credential.accountId;
|
|
202
|
-
if (credential?.account_id) return credential.account_id;
|
|
203
|
-
} catch { /* fall through */ }
|
|
204
|
-
try {
|
|
205
|
-
const codex = JSON.parse(readFileSync(join(homedir(), ".codex", "auth.json"), "utf8"));
|
|
206
|
-
return codex?.tokens?.account_id ?? codex?.tokens?.accountId;
|
|
207
|
-
} catch {
|
|
208
|
-
return undefined;
|
|
282
|
+
/**
|
|
283
|
+
* The ChatGPT account the token belongs to. It must come from the same
|
|
284
|
+
* credential: an id taken from a pooled account while the token is pi's
|
|
285
|
+
* primary asks the endpoint about one account on behalf of another.
|
|
286
|
+
*/
|
|
287
|
+
function codexAccountId(credential: StoredOAuth): string | undefined {
|
|
288
|
+
for (const value of [credential.accountId, credential.account_id, claimsOf(credential.access).accountId]) {
|
|
289
|
+
if (typeof value === "string" && value) return value;
|
|
209
290
|
}
|
|
291
|
+
return undefined;
|
|
210
292
|
}
|
|
211
293
|
|
|
212
|
-
export async function fetchCodexRows(
|
|
294
|
+
export async function fetchCodexRows(
|
|
295
|
+
ctx: any,
|
|
296
|
+
options: SourceOptions = {},
|
|
297
|
+
): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
|
|
213
298
|
try {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
299
|
+
const primary = await primaryOAuth(ctx, "openai-codex", options.readCredential ?? readAuthFile);
|
|
300
|
+
if (!primary) return { rows: [], error: "Codex: not logged in" };
|
|
301
|
+
const token = primary.access;
|
|
302
|
+
const accountId = codexAccountId(primary);
|
|
217
303
|
if (!accountId) return { rows: [], error: "Codex: no ChatGPT account id" };
|
|
218
304
|
|
|
219
305
|
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
@@ -277,13 +363,150 @@ export async function fetchCodexRows(ctx: any): Promise<{ rows: UsageRow[]; erro
|
|
|
277
363
|
}
|
|
278
364
|
}
|
|
279
365
|
|
|
366
|
+
const GEMINI = "gemini";
|
|
367
|
+
const GEMINI_PRIMARY_GROUP = "Gemini Primary";
|
|
368
|
+
|
|
369
|
+
/** A pooled Gemini account with a live token, refreshed and saved if it had lapsed. */
|
|
370
|
+
async function freshGeminiAccount(account: PooledOAuthAccount): Promise<PooledOAuthAccount> {
|
|
371
|
+
if (account.expires > Date.now() + 60_000) return account;
|
|
372
|
+
// Google does not rotate refresh tokens on use, so a refresh racing the
|
|
373
|
+
// serving path's own is harmless: both tokens stay valid.
|
|
374
|
+
const credential = await geminiOAuth.refresh(account, refreshAbortSignal());
|
|
375
|
+
const updated = { ...account, ...credential };
|
|
376
|
+
saveOAuthAccount(GEMINI, updated);
|
|
377
|
+
return updated;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Gemini quota per account from `retrieveUserQuota`, one row per model
|
|
382
|
+
* family (Flash, Pro, Claude, GPT). Silent when no Gemini account exists.
|
|
383
|
+
*/
|
|
384
|
+
export async function fetchGeminiRows(
|
|
385
|
+
ctx: any,
|
|
386
|
+
options: SourceOptions = {},
|
|
387
|
+
): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
|
|
388
|
+
const errors: string[] = [];
|
|
389
|
+
const accounts: Array<{ group: string; credential?: StoredOAuth; account?: PooledOAuthAccount }> = [];
|
|
390
|
+
const groups: string[] = [];
|
|
391
|
+
const emails = new Set<string>();
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const primary = await primaryOAuth(ctx, GEMINI, options.readCredential ?? readAuthFile);
|
|
395
|
+
if (primary) {
|
|
396
|
+
accounts.push({ group: GEMINI_PRIMARY_GROUP, credential: primary });
|
|
397
|
+
const email = credentialEmail(primary as any);
|
|
398
|
+
if (email) emails.add(email.toLowerCase());
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
groups.push(GEMINI_PRIMARY_GROUP);
|
|
402
|
+
errors.push(`${GEMINI_PRIMARY_GROUP}: ${errorText(error)}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
for (const account of loadOAuthPool(GEMINI).accounts) {
|
|
407
|
+
if (account.enabled === false || !account.access) continue;
|
|
408
|
+
// The same Google account signed in twice has one allowance, not two.
|
|
409
|
+
const email = credentialEmail(account)?.toLowerCase();
|
|
410
|
+
if (email && emails.has(email)) continue;
|
|
411
|
+
if (email) emails.add(email);
|
|
412
|
+
let group = `Gemini ${account.label || email || account.id.slice(0, 8)}`;
|
|
413
|
+
if (accounts.some((entry) => entry.group === group)) group = `${group} ${account.id.slice(0, 4)}`;
|
|
414
|
+
accounts.push({ group, account });
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
errors.push(`Gemini accounts: ${errorText(error)}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const results = await Promise.all(accounts.map(async (entry): Promise<{ rows: UsageRow[]; error?: string }> => {
|
|
421
|
+
try {
|
|
422
|
+
const credential = entry.account ? await freshGeminiAccount(entry.account) : entry.credential!;
|
|
423
|
+
const buckets = await fetchUserQuota(
|
|
424
|
+
credential.access,
|
|
425
|
+
requestProjectId(credential as any),
|
|
426
|
+
AbortSignal.timeout(TIMEOUT_MS),
|
|
427
|
+
);
|
|
428
|
+
const checkedAt = Date.now();
|
|
429
|
+
const rows = summarizeGeminiQuota(buckets).map((family): UsageRow => ({
|
|
430
|
+
group: entry.group,
|
|
431
|
+
label: family.family,
|
|
432
|
+
remaining: family.remaining,
|
|
433
|
+
resetAt: family.resetAt,
|
|
434
|
+
checkedAt,
|
|
435
|
+
}));
|
|
436
|
+
return rows.length ? { rows } : { rows, error: `${entry.group}: quota unavailable` };
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const message = errorText(error);
|
|
439
|
+
return {
|
|
440
|
+
rows: [],
|
|
441
|
+
error: /invalid_grant/i.test(message)
|
|
442
|
+
? `${entry.group}: login expired, run ${entry.account
|
|
443
|
+
? `/accounts reauth gemini ${entry.account.label || entry.account.id}`
|
|
444
|
+
: "/login gemini"}`
|
|
445
|
+
: `${entry.group}: ${message}`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}));
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
rows: results.flatMap((result) => result.rows),
|
|
452
|
+
errors: [...errors, ...results.flatMap((result) => result.error ?? [])],
|
|
453
|
+
groups: [...groups, ...accounts.map((entry) => entry.group)],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function observedRemaining(quota: AccountQuotaState, now: number): number | undefined {
|
|
458
|
+
// A 429 reading means nothing once its block has cleared.
|
|
459
|
+
if (quota.blockedUntil !== undefined) return quota.blockedUntil > now ? 0 : undefined;
|
|
460
|
+
return quota.remainingPercent;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* What response headers last said for providers without a usage endpoint:
|
|
465
|
+
* the account routing can still use most. A rate-limit reading, not a
|
|
466
|
+
* subscription allowance, so it is labelled "rate" and dropped once old.
|
|
467
|
+
*/
|
|
468
|
+
export function observedRows(now = Date.now()): UsageRow[] {
|
|
469
|
+
return OBSERVED_PROVIDERS.flatMap(([providerId, group]): UsageRow[] => {
|
|
470
|
+
let quotas: AccountQuotaState[] = [];
|
|
471
|
+
try {
|
|
472
|
+
const store = sharedOAuthPoolStore(providerId);
|
|
473
|
+
quotas = [
|
|
474
|
+
store.primaryQuota(),
|
|
475
|
+
...store.load().accounts.filter((account) => account.enabled !== false).map((account) => account.quota),
|
|
476
|
+
].filter((quota): quota is AccountQuotaState => !!quota);
|
|
477
|
+
} catch {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const current = quotas.filter((quota) => observedRemaining(quota, now) !== undefined
|
|
482
|
+
&& ((quota.blockedUntil ?? 0) > now || now - quota.checkedAt < CLAUDE_FRESH_MS));
|
|
483
|
+
if (current.length === 0) return [];
|
|
484
|
+
const best = current.reduce((left, right) =>
|
|
485
|
+
observedRemaining(right, now)! > observedRemaining(left, now)! ? right : left);
|
|
486
|
+
const blocked = (best.blockedUntil ?? 0) > now;
|
|
487
|
+
return [{
|
|
488
|
+
group,
|
|
489
|
+
label: "rate",
|
|
490
|
+
remaining: observedRemaining(best, now)!,
|
|
491
|
+
resetAt: blocked ? best.blockedUntil : best.resetAt,
|
|
492
|
+
// A live block stays current until it clears, however old the reading.
|
|
493
|
+
checkedAt: blocked ? now : best.checkedAt,
|
|
494
|
+
}];
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
280
498
|
/** One full poll of every configured subscription source. */
|
|
281
|
-
export async function fetchAll(ctx: any): Promise<SourceResult> {
|
|
282
|
-
const [claude, codex] = await Promise.all([
|
|
499
|
+
export async function fetchAll(ctx: any, options: SourceOptions = {}): Promise<SourceResult> {
|
|
500
|
+
const [claude, codex, gemini] = await Promise.all([
|
|
501
|
+
fetchClaudeRows(ctx, options),
|
|
502
|
+
fetchCodexRows(ctx, options),
|
|
503
|
+
fetchGeminiRows(ctx, options),
|
|
504
|
+
]);
|
|
283
505
|
return {
|
|
284
|
-
rows: [...claude.rows, ...codex.rows],
|
|
285
|
-
errors: [...claude.errors, codex.error].filter((error): error is string => !!error),
|
|
506
|
+
rows: [...claude.rows, ...codex.rows, ...gemini.rows, ...observedRows()],
|
|
507
|
+
errors: [...claude.errors, codex.error, ...gemini.errors].filter((error): error is string => !!error),
|
|
286
508
|
groups: claude.groups,
|
|
509
|
+
geminiGroups: gemini.groups,
|
|
287
510
|
codexPlan: codex.plan,
|
|
288
511
|
};
|
|
289
512
|
}
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
refreshQuality,
|
|
14
14
|
} from "../../core/catalog/quality.ts";
|
|
15
15
|
import { ensureFresh, usageState } from "../../services/usage-service.ts";
|
|
16
|
+
import { geminiQuotaFamily } from "../../core/gemini/quota.ts";
|
|
17
|
+
import { combinedWindow, isGeminiAccount, pooledWindow } from "../../core/quota/pool.ts";
|
|
16
18
|
import { env, isFromProcessEnv, maskSecret, setEnv } from "../../core/env.ts";
|
|
17
19
|
import { fitId } from "../../ui/format.ts";
|
|
18
20
|
|
|
@@ -53,14 +55,24 @@ interface Entry {
|
|
|
53
55
|
efficiency: { intelligencePerDollar?: number; codingPerDollar?: number; agenticPerDollar?: number };
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
function quotaFor(provider: string): number | undefined {
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
function quotaFor(provider: string, modelId: string): number | undefined {
|
|
59
|
+
const state = usageState();
|
|
60
|
+
const rows = state.rows;
|
|
61
|
+
if (provider === "anthropic") {
|
|
62
|
+
// The pool's figure, not whichever account happens to be listed first.
|
|
63
|
+
const pool = combinedWindow(rows, "5h", state.accounts, Date.now(), true);
|
|
64
|
+
return pool ? Math.round(pool.remaining) : undefined;
|
|
65
|
+
}
|
|
66
|
+
if (provider === "gemini") {
|
|
67
|
+
// Gemini pools per model family, so the figure depends on the model.
|
|
68
|
+
const family = geminiQuotaFamily(modelId);
|
|
69
|
+
const expected = state.geminiAccounts || new Set(rows.filter(isGeminiAccount).map((row) => row.group)).size;
|
|
70
|
+
const pool = family ? pooledWindow(rows, family, expected, isGeminiAccount, Date.now(), true) : undefined;
|
|
71
|
+
return pool ? Math.round(pool.remaining) : undefined;
|
|
72
|
+
}
|
|
73
|
+
const match = provider === "openai-codex"
|
|
74
|
+
? rows.find((row) => row.group === "Codex" && row.label === "weekly")
|
|
75
|
+
: undefined;
|
|
64
76
|
return match ? Math.round(match.remaining) : undefined;
|
|
65
77
|
}
|
|
66
78
|
|
|
@@ -76,7 +88,7 @@ function buildEntry(model: any): Entry {
|
|
|
76
88
|
id: `${model.provider}/${model.id}`,
|
|
77
89
|
provider: model.provider,
|
|
78
90
|
billing: SUBSCRIPTION_PROVIDERS.has(model.provider) ? "subscription" : "metered",
|
|
79
|
-
quotaLeftPercent: quotaFor(model.provider),
|
|
91
|
+
quotaLeftPercent: quotaFor(model.provider, model.id),
|
|
80
92
|
contextWindow: model.contextWindow,
|
|
81
93
|
maxTokens: model.maxTokens,
|
|
82
94
|
reasoning: !!model.reasoning,
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { readStoredCredential, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
3
|
+
import { isGeminiAccount } from "../../core/quota/pool.ts";
|
|
4
|
+
import {
|
|
5
|
+
configureUsageSources,
|
|
6
|
+
refreshUsage,
|
|
7
|
+
startPolling,
|
|
8
|
+
stopPolling,
|
|
9
|
+
subscribe,
|
|
10
|
+
usageState,
|
|
11
|
+
} from "../../services/usage-service.ts";
|
|
4
12
|
import { renderUsageLines, usageSummaryText } from "../../ui/usage-bars.ts";
|
|
5
13
|
import { formatTokens, sanitize } from "../../ui/format.ts";
|
|
6
14
|
|
|
@@ -48,7 +56,23 @@ function usageSignature(entries: any[]): string {
|
|
|
48
56
|
return [entries.length, last?.id, usage?.input, usage?.output, usage?.cacheRead, usage?.cacheWrite, usage?.cost?.total].join(":");
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
/**
|
|
60
|
+
* True when the column the footer shows for `provider` has nothing to draw
|
|
61
|
+
* yet, e.g. right after a first `/login gemini`. Waiting for the next poll
|
|
62
|
+
* would leave the swapped-in column empty for minutes.
|
|
63
|
+
*/
|
|
64
|
+
function columnIsEmpty(provider: string | undefined): boolean {
|
|
65
|
+
const rows = usageState().rows;
|
|
66
|
+
if (provider === "gemini") return !rows.some(isGeminiAccount);
|
|
67
|
+
if (provider === "openai-codex") return !rows.some((row) => row.group === "Codex");
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
export function registerFooter(pi: ExtensionAPI): void {
|
|
72
|
+
// The primary account's quota must be read from pi's store as-is; the
|
|
73
|
+
// registry would hand back whichever pooled account routing picked.
|
|
74
|
+
configureUsageSources({ readCredential: (providerId) => readStoredCredential(providerId) });
|
|
75
|
+
|
|
52
76
|
let showUsage = true;
|
|
53
77
|
let requestRender: (() => void) | undefined;
|
|
54
78
|
let unsubscribe: (() => void) | undefined;
|
|
@@ -137,12 +161,8 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
137
161
|
}
|
|
138
162
|
|
|
139
163
|
if (showUsage) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
theme,
|
|
143
|
-
width,
|
|
144
|
-
model?.provider === "anthropic" ? model.id : undefined,
|
|
145
|
-
));
|
|
164
|
+
// The right-hand column follows the provider in use.
|
|
165
|
+
lines.push(...renderUsageLines(usageState(), theme, width, { provider: model?.provider, modelId: model?.id }));
|
|
146
166
|
}
|
|
147
167
|
|
|
148
168
|
// Final safety net: never emit a line wider than the terminal.
|
|
@@ -159,9 +179,11 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
159
179
|
startPolling(ctx);
|
|
160
180
|
});
|
|
161
181
|
|
|
162
|
-
pi.on("model_select", async (
|
|
182
|
+
pi.on("model_select", async (event, ctx) => {
|
|
163
183
|
apply(ctx);
|
|
164
184
|
requestRender?.();
|
|
185
|
+
// The swap itself is immediate; fetch only if the new column has no figures.
|
|
186
|
+
if (ctx.hasUI && showUsage && columnIsEmpty(event.model?.provider)) void refreshUsage(ctx, true);
|
|
165
187
|
});
|
|
166
188
|
|
|
167
189
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { agentPath, readJson, writeJson } from "../core/store.ts";
|
|
2
|
-
import { isClaudeAccount, type UsageRow } from "../core/quota/pool.ts";
|
|
3
|
-
import { fetchAll } from "../core/quota/usage-source.ts";
|
|
2
|
+
import { isClaudeAccount, isGeminiAccount, type UsageRow } from "../core/quota/pool.ts";
|
|
3
|
+
import { fetchAll, type SourceOptions } from "../core/quota/usage-source.ts";
|
|
4
4
|
|
|
5
5
|
/** Shared subscription-usage cache and poller. */
|
|
6
6
|
|
|
@@ -14,14 +14,23 @@ export interface UsageState {
|
|
|
14
14
|
errors: string[];
|
|
15
15
|
updatedAt?: number;
|
|
16
16
|
loading: boolean;
|
|
17
|
+
/** Claude accounts expected to report. */
|
|
17
18
|
accounts: number;
|
|
19
|
+
/** Gemini accounts expected to report. */
|
|
20
|
+
geminiAccounts?: number;
|
|
18
21
|
codexPlan?: string;
|
|
19
22
|
/** Last observed quota drop for each account group. */
|
|
20
23
|
lastUsedAt?: Record<string, number>;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, lastUsedAt: {} };
|
|
26
|
+
const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, geminiAccounts: 0, lastUsedAt: {} };
|
|
24
27
|
const listeners = new Set<() => void>();
|
|
28
|
+
let sourceOptions: SourceOptions = {};
|
|
29
|
+
|
|
30
|
+
/** Supplies host services the sources need, such as pi's credential reader. */
|
|
31
|
+
export function configureUsageSources(options: SourceOptions): void {
|
|
32
|
+
sourceOptions = { ...sourceOptions, ...options };
|
|
33
|
+
}
|
|
25
34
|
|
|
26
35
|
let nextAllowedFetch = 0;
|
|
27
36
|
let inFlight: Promise<void> | undefined;
|
|
@@ -39,6 +48,7 @@ function loadCache(): void {
|
|
|
39
48
|
if (Date.now() - cached.updatedAt > CACHE_MAX_AGE_MS) return;
|
|
40
49
|
state.rows = cached.rows.filter((row) => !row.group.startsWith("Claude pool ×"));
|
|
41
50
|
state.accounts = cached.accounts ?? 0;
|
|
51
|
+
state.geminiAccounts = cached.geminiAccounts ?? 0;
|
|
42
52
|
state.codexPlan = cached.codexPlan;
|
|
43
53
|
state.updatedAt = cached.updatedAt;
|
|
44
54
|
state.lastUsedAt = cached.lastUsedAt ?? {};
|
|
@@ -49,6 +59,7 @@ function saveCache(): void {
|
|
|
49
59
|
writeJson(cachePath(), {
|
|
50
60
|
rows: state.rows,
|
|
51
61
|
accounts: state.accounts,
|
|
62
|
+
geminiAccounts: state.geminiAccounts,
|
|
52
63
|
codexPlan: state.codexPlan,
|
|
53
64
|
updatedAt: state.updatedAt,
|
|
54
65
|
lastUsedAt: state.lastUsedAt,
|
|
@@ -107,20 +118,24 @@ export async function refreshUsage(ctx: any, force = false): Promise<void> {
|
|
|
107
118
|
|
|
108
119
|
inFlight = (async () => {
|
|
109
120
|
try {
|
|
110
|
-
const result = await fetchAll(ctx);
|
|
121
|
+
const result = await fetchAll(ctx, sourceOptions);
|
|
111
122
|
const rateLimited = result.errors.some((error) => error.includes("429"));
|
|
112
123
|
recordUsageDrops(result.rows);
|
|
113
124
|
|
|
114
|
-
// Per-account merge:
|
|
125
|
+
// Per-account merge: accounts that failed this cycle keep their last
|
|
126
|
+
// figures, marked stale. Removed accounts and header-observed readings
|
|
127
|
+
// (which lapse by design) are not kept.
|
|
115
128
|
const freshGroups = new Set(result.rows.map((row) => row.group));
|
|
129
|
+
const expected = new Set([...result.groups, ...result.geminiGroups]);
|
|
116
130
|
const retained = state.rows
|
|
117
131
|
.filter((row) => !row.group.startsWith("Claude pool ×")
|
|
118
132
|
&& !freshGroups.has(row.group)
|
|
119
|
-
&& (
|
|
133
|
+
&& (isClaudeAccount(row) || isGeminiAccount(row) ? expected.has(row.group) : row.group === "Codex"))
|
|
120
134
|
.map((row) => ({ ...row, stale: true }));
|
|
121
135
|
|
|
122
136
|
state.rows = [...result.rows, ...retained];
|
|
123
137
|
state.accounts = result.groups.length;
|
|
138
|
+
state.geminiAccounts = result.geminiGroups.length;
|
|
124
139
|
state.updatedAt = Date.now(); // poll time only; rows retain their own checkedAt
|
|
125
140
|
state.errors = result.errors;
|
|
126
141
|
if (result.codexPlan !== undefined) state.codexPlan = result.codexPlan;
|
package/src/ui/usage-bars.ts
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import {
|
|
2
|
+
import { geminiQuotaFamily, GEMINI_QUOTA_FAMILIES } from "../core/gemini/quota.ts";
|
|
3
|
+
import {
|
|
4
|
+
combinedWindow,
|
|
5
|
+
isClaudeAccount,
|
|
6
|
+
isFresh,
|
|
7
|
+
isGeminiAccount,
|
|
8
|
+
OBSERVED_PROVIDERS,
|
|
9
|
+
poolAvailability,
|
|
10
|
+
pooledWindow,
|
|
11
|
+
scopedLabels,
|
|
12
|
+
type UsageRow,
|
|
13
|
+
} from "../core/quota/pool.ts";
|
|
3
14
|
import type { UsageState } from "../services/usage-service.ts";
|
|
4
15
|
import { formatReset, formatShortReset, hasTruecolor, levelColor, themeLevel } from "./format.ts";
|
|
5
16
|
|
|
6
17
|
/**
|
|
7
|
-
* Renders the
|
|
8
|
-
*
|
|
18
|
+
* Renders the quota bars: Claude on the left, and on the right the provider in
|
|
19
|
+
* use: Gemini, Kimi or Grok while one of their models is selected, Codex
|
|
20
|
+
* otherwise. Takes state as an argument rather than importing the service, so
|
|
21
|
+
* the renderer stays a pure function of its input.
|
|
9
22
|
*/
|
|
10
23
|
|
|
11
|
-
type Cell = { label: string; remaining?: number; resetAt?: number; partial?: boolean };
|
|
24
|
+
type Cell = { label: string; remaining?: number; resetAt?: number; partial?: boolean; active?: boolean };
|
|
25
|
+
type Column = { title: string; cells: Cell[] };
|
|
26
|
+
|
|
27
|
+
/** The session's current model; its provider picks the right-hand column. */
|
|
28
|
+
export interface ActiveModel {
|
|
29
|
+
provider?: string;
|
|
30
|
+
modelId?: string;
|
|
31
|
+
}
|
|
12
32
|
|
|
13
33
|
function pooled(state: UsageState, label: string): Cell | undefined {
|
|
14
34
|
return combinedWindow(state.rows, label, state.accounts, Date.now(), true);
|
|
@@ -19,6 +39,11 @@ function codexCell(state: UsageState, display: string, match: (label: string) =>
|
|
|
19
39
|
return row ? { label: display, remaining: row.remaining, resetAt: row.resetAt } : { label: display };
|
|
20
40
|
}
|
|
21
41
|
|
|
42
|
+
/** Gemini accounts the pool expects, falling back to those that reported. */
|
|
43
|
+
function geminiExpected(state: UsageState): number {
|
|
44
|
+
return state.geminiAccounts || new Set(state.rows.filter(isGeminiAccount).map((row) => row.group)).size;
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
/**
|
|
23
48
|
* Turns a scoped limit id into something that fits the label column.
|
|
24
49
|
*
|
|
@@ -33,7 +58,7 @@ function scopedDisplayName(label: string): string {
|
|
|
33
58
|
return family.charAt(0).toUpperCase() + family.slice(1);
|
|
34
59
|
}
|
|
35
60
|
|
|
36
|
-
function
|
|
61
|
+
function claudeColumn(state: UsageState, modelId?: string): Column {
|
|
37
62
|
// Three fixed tiers, so the block keeps its shape whether or not a scoped
|
|
38
63
|
// limit is currently reported.
|
|
39
64
|
const scoped = scopedLabels(state.rows, modelId)[0];
|
|
@@ -41,18 +66,76 @@ function buildColumns(state: UsageState, modelId?: string): { claude: Cell[]; co
|
|
|
41
66
|
? { ...(pooled(state, scoped) ?? { label: scoped }), label: scopedDisplayName(scoped) }
|
|
42
67
|
: { label: "Fable" };
|
|
43
68
|
|
|
44
|
-
const
|
|
69
|
+
const cells: Cell[] = [
|
|
45
70
|
{ ...(pooled(state, "5h") ?? { label: "5h" }), label: "5h" },
|
|
46
71
|
{ ...(pooled(state, "7d") ?? { label: "7d" }), label: "weekly" },
|
|
47
72
|
scopedCell,
|
|
48
73
|
];
|
|
49
74
|
|
|
75
|
+
const availability = poolAvailability(state.rows, state.accounts, modelId);
|
|
76
|
+
const status = availability.ready
|
|
77
|
+
? `${availability.ready}/${availability.total} ready`
|
|
78
|
+
: availability.unknown ? "unknown/stale" : "exhausted";
|
|
79
|
+
return { title: `Claude Σ${state.accounts} · ${status}`, cells };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function codexColumn(state: UsageState): Column {
|
|
50
83
|
// Codex reports only the two windows; it has no scoped equivalent.
|
|
51
|
-
const
|
|
84
|
+
const cells: Cell[] = [
|
|
52
85
|
codexCell(state, "5h", (label) => label === "5h") ?? { label: "5h" },
|
|
53
86
|
codexCell(state, "weekly", (label) => label === "weekly") ?? { label: "weekly" },
|
|
54
87
|
];
|
|
55
|
-
return {
|
|
88
|
+
return { title: state.codexPlan ? `Codex · ${state.codexPlan}` : "Codex", cells };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gemini pools quota per model family, so each bar is a family. The third is
|
|
93
|
+
* whichever third-party family is in use (Claude unless GPT-OSS is), and the
|
|
94
|
+
* active family's label is highlighted.
|
|
95
|
+
*/
|
|
96
|
+
function geminiColumn(state: UsageState, modelId?: string): Column {
|
|
97
|
+
const expected = geminiExpected(state);
|
|
98
|
+
const active = geminiQuotaFamily(modelId);
|
|
99
|
+
const labels = ["Flash", "Pro", active === "GPT" ? "GPT" : "Claude"];
|
|
100
|
+
const cells: Cell[] = labels.map((label) => ({
|
|
101
|
+
...(pooledWindow(state.rows, label, expected, isGeminiAccount, Date.now(), true) ?? {}),
|
|
102
|
+
label,
|
|
103
|
+
active: label === active,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
let title = expected > 1 ? `Gemini Σ${expected}` : "Gemini";
|
|
107
|
+
if (active && expected > 0) {
|
|
108
|
+
// Ready = can serve the active model now: its family has quota left.
|
|
109
|
+
const groups = [...new Set(state.rows.filter(isGeminiAccount).map((row) => row.group))];
|
|
110
|
+
let ready = 0;
|
|
111
|
+
let unknown = Math.max(0, expected - groups.length);
|
|
112
|
+
for (const group of groups) {
|
|
113
|
+
const row = state.rows.find((candidate) => candidate.group === group && candidate.label === active);
|
|
114
|
+
if (!row || !isFresh(row)) unknown++;
|
|
115
|
+
else if (row.remaining > 0) ready++;
|
|
116
|
+
}
|
|
117
|
+
title += ` · ${ready ? `${ready}/${expected} ready` : unknown ? "unknown/stale" : "exhausted"}`;
|
|
118
|
+
}
|
|
119
|
+
return { title, cells };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Providers with no usage endpoint: the last rate-limit reading, if any. */
|
|
123
|
+
function observedColumn(state: UsageState, group: string): Column {
|
|
124
|
+
const row = state.rows.find((candidate) => candidate.group === group && candidate.label === "rate");
|
|
125
|
+
return row
|
|
126
|
+
? { title: group, cells: [{ label: "rate", remaining: row.remaining, resetAt: row.resetAt }] }
|
|
127
|
+
: { title: `${group} · usage not reported`, cells: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The right-hand column: the active provider's, else Codex. */
|
|
131
|
+
function sideColumn(state: UsageState, active?: ActiveModel): Column {
|
|
132
|
+
if (active?.provider === "gemini") return geminiColumn(state, active.modelId);
|
|
133
|
+
if (active?.provider === "openai-codex") return codexColumn(state);
|
|
134
|
+
const observed = OBSERVED_PROVIDERS.find(([providerId]) => providerId === active?.provider);
|
|
135
|
+
if (observed) return observedColumn(state, observed[1]);
|
|
136
|
+
// Nothing more specific in use: Codex, unless only Gemini has figures.
|
|
137
|
+
const codex = state.rows.some((row) => row.group === "Codex");
|
|
138
|
+
return !codex && state.rows.some(isGeminiAccount) ? geminiColumn(state) : codexColumn(state);
|
|
56
139
|
}
|
|
57
140
|
|
|
58
141
|
/** `Work 61% · Personal 88%`. Empty when there is nothing extra to say. */
|
|
@@ -77,13 +160,13 @@ function renderAccountSummary(state: UsageState, cellWidth: number): string | un
|
|
|
77
160
|
}
|
|
78
161
|
|
|
79
162
|
function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: number): string {
|
|
80
|
-
const label = cell.label.slice(0, labelWidth).padEnd(labelWidth);
|
|
163
|
+
const label = theme.fg(cell.active ? "accent" : "muted", cell.label.slice(0, labelWidth).padEnd(labelWidth));
|
|
81
164
|
const reset = formatShortReset(cell.resetAt);
|
|
82
165
|
const resetWidth = 4;
|
|
83
166
|
const barWidth = Math.max(4, cellWidth - labelWidth - 6 - resetWidth - 3);
|
|
84
167
|
|
|
85
168
|
if (cell.remaining === undefined) {
|
|
86
|
-
return `${
|
|
169
|
+
return `${label} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
|
|
87
170
|
}
|
|
88
171
|
|
|
89
172
|
const filled = Math.round((cell.remaining / 100) * barWidth);
|
|
@@ -102,10 +185,10 @@ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: numbe
|
|
|
102
185
|
percent = theme.fg(color, percentText);
|
|
103
186
|
}
|
|
104
187
|
|
|
105
|
-
return `${
|
|
188
|
+
return `${label} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
|
|
106
189
|
}
|
|
107
190
|
|
|
108
|
-
export function renderUsageLines(state: UsageState, theme: any, width: number,
|
|
191
|
+
export function renderUsageLines(state: UsageState, theme: any, width: number, active?: ActiveModel): string[] {
|
|
109
192
|
if (state.loading) return [theme.fg("dim", " usage: loading…")];
|
|
110
193
|
if (state.rows.length === 0) {
|
|
111
194
|
if (state.errors.length > 0) return state.errors.map((error) => theme.fg("warning", ` ${error}`));
|
|
@@ -115,18 +198,15 @@ export function renderUsageLines(state: UsageState, theme: any, width: number, m
|
|
|
115
198
|
const gap = 3;
|
|
116
199
|
const cellWidth = Math.max(22, Math.floor((width - 2 - gap) / 2));
|
|
117
200
|
const labelWidth = 6;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
for (let index = 0; index < Math.max(claude.length, codex.length); index += 1) {
|
|
128
|
-
const left = claude[index] ? renderCell(theme, claude[index], labelWidth, cellWidth) : " ".repeat(cellWidth);
|
|
129
|
-
const right = codex[index] ? renderCell(theme, codex[index], labelWidth, cellWidth) : "";
|
|
201
|
+
// Claude's scoped limit follows the model only while Anthropic serves it;
|
|
202
|
+
// the same Claude id through Gemini draws on Gemini's quota instead.
|
|
203
|
+
const claude = claudeColumn(state, active?.provider === "anthropic" ? active.modelId : undefined);
|
|
204
|
+
const side = sideColumn(state, active);
|
|
205
|
+
const lines = [` ${theme.fg("accent", claude.title.padEnd(cellWidth))}${" ".repeat(gap)}${theme.fg("accent", side.title)}`];
|
|
206
|
+
|
|
207
|
+
for (let index = 0; index < Math.max(claude.cells.length, side.cells.length); index += 1) {
|
|
208
|
+
const left = claude.cells[index] ? renderCell(theme, claude.cells[index], labelWidth, cellWidth) : " ".repeat(cellWidth);
|
|
209
|
+
const right = side.cells[index] ? renderCell(theme, side.cells[index], labelWidth, cellWidth) : "";
|
|
130
210
|
lines.push(` ${left}${" ".repeat(gap)}${right}`);
|
|
131
211
|
}
|
|
132
212
|
|
|
@@ -146,7 +226,15 @@ export function usageSummaryText(state: UsageState): string {
|
|
|
146
226
|
? `${pool.partial ? "~" : ""}${Math.round(pool.remaining)}% left${pool.partial ? " (partial: reporting accounts only)" : ""} ${formatReset(pool.resetAt)}`
|
|
147
227
|
: "unknown/stale"}`;
|
|
148
228
|
});
|
|
149
|
-
|
|
229
|
+
// Gemini's combined figures only add information with more than one account.
|
|
230
|
+
const geminiAccounts = geminiExpected(state);
|
|
231
|
+
const gemini = geminiAccounts > 1 ? GEMINI_QUOTA_FAMILIES.flatMap((label) => {
|
|
232
|
+
const pool = pooledWindow(state.rows, label, geminiAccounts, isGeminiAccount, Date.now(), true);
|
|
233
|
+
return pool
|
|
234
|
+
? [`Gemini combined ${label}: ${pool.partial ? "~" : ""}${Math.round(pool.remaining)}% left ${formatReset(pool.resetAt)}`.trim()]
|
|
235
|
+
: [];
|
|
236
|
+
}) : [];
|
|
237
|
+
const summary = [...combined, ...gemini, ...state.rows.map(
|
|
150
238
|
(row: UsageRow) => `${row.group} ${row.label}: ${Math.round(row.remaining)}% left ${formatReset(row.resetAt)}${row.stale ? " (stale)" : ""}`.trim(),
|
|
151
239
|
)];
|
|
152
240
|
return [...summary, ...state.errors].join("\n") || "No usage data";
|