@jameslovespancakes/pi-plus 1.0.16 → 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.
@@ -1,40 +1,39 @@
1
- import type {
2
- Api,
3
- AssistantMessage,
4
- AssistantMessageEventStream,
5
- Model,
6
- ModelThinkingLevel,
7
- ProviderStreams,
8
- SimpleStreamOptions,
9
- StopReason,
10
- StreamOptions,
11
- TextContent,
12
- ThinkingContent,
13
- ToolCall,
14
- ToolChoice,
15
- TranscriptContext,
1
+ // Only pi-ai's package root: it is one of the entry points pi supplies to
2
+ // extensions from its own copy. Deep imports have nothing to resolve against
3
+ // on a clean install (see convert.ts).
4
+ import {
5
+ calculateCost,
6
+ clampThinkingLevel,
7
+ createAssistantMessageEventStream,
8
+ formatThrownValue,
9
+ type Api,
10
+ type AssistantMessage,
11
+ type AssistantMessageEventStream,
12
+ type Model,
13
+ type ModelThinkingLevel,
14
+ type ProviderStreams,
15
+ type SimpleStreamOptions,
16
+ type StopReason,
17
+ type StreamOptions,
18
+ type TextContent,
19
+ type ThinkingContent,
20
+ type ToolCall,
21
+ type ToolChoice,
22
+ type TranscriptContext,
16
23
  } from "@earendil-works/pi-ai";
17
- import { calculateCost, clampThinkingLevel } from "@earendil-works/pi-ai";
18
- import { buildBaseOptions } from "@earendil-works/pi-ai/api/simple-options";
19
- import { formatProviderError, normalizeProviderError } from "@earendil-works/pi-ai/utils/error-body";
20
- // pi exports the concrete stream class from this subpath; the bare name on the
21
- // package root is the interface, which cannot be constructed.
22
- import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
23
- import { headersToRecord, providerHeadersToRecord } from "@earendil-works/pi-ai/utils/headers";
24
- import { retryProviderRequest } from "@earendil-works/pi-ai/utils/provider-retry";
25
24
  import { geminiHeaders, endpointsFor } from "./client.ts";
25
+ import type { Part } from "./convert.ts";
26
26
  import { decodeApiKey } from "./credentials.ts";
27
27
  import { GEMINI_API, runtimeModelId } from "./models.ts";
28
- import { buildRequest, googleShared, type Part } from "./request.ts";
28
+ import { buildRequest } from "./request.ts";
29
29
 
30
30
  /**
31
31
  * Gemini streaming transport.
32
32
  *
33
33
  * The request is Gemini inside an agent envelope, posted to
34
34
  * `v1internal:streamGenerateContent`, and every SSE frame comes back wrapped
35
- * in `.response`. Retry policy, error formatting, stop-reason mapping and
36
- * thought-signature retention are pi's; what is here is what this backend
37
- * needs beyond them — endpoint fallback, reading quota walls out of the
35
+ * in `.response`. Retrying is pi's: its session retries on the wording
36
+ * below. What is here is what this backend needs beyond that — endpoint fallback, reading quota walls out of the
38
37
  * response body, and a watchdog for streams that go silent.
39
38
  */
40
39
 
@@ -170,19 +169,39 @@ export function describeFailure(status: number, body: string, runtimeId: string)
170
169
  }
171
170
 
172
171
  /**
173
- * Response headers plus what the body said about retrying, in the form pi's
174
- * `retryProviderRequest` and the account pool both read.
172
+ * Response headers plus the retry delay the body stated, in the form the
173
+ * account pool reads when it decides how long to hold an account back.
175
174
  */
176
175
  function failureHeaders(headers: Headers, failure: Failure): Headers {
177
176
  const next = new Headers(headers);
178
- // A quota wall will not clear within any retry backoff; spend nothing on it.
179
- if (failure.quotaWall) next.set("x-should-retry", "false");
180
177
  if (failure.retryAfterSeconds !== undefined && !next.has("retry-after")) {
181
178
  next.set("retry-after", String(failure.retryAfterSeconds));
182
179
  }
183
180
  return next;
184
181
  }
185
182
 
183
+ function toRecord(headers: Headers): Record<string, string> {
184
+ const record: Record<string, string> = {};
185
+ headers.forEach((value, name) => { record[name] = value; });
186
+ return record;
187
+ }
188
+
189
+ /** pi's header rule: later layers win, and a null value removes the header. */
190
+ function mergeHeaders(...layers: Array<Record<string, string | null | undefined> | undefined>): Record<string, string> {
191
+ const merged: Record<string, string | null | undefined> = {};
192
+ for (const layer of layers) Object.assign(merged, layer);
193
+ return Object.fromEntries(Object.entries(merged).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
194
+ }
195
+
196
+ /** "STOP" and "MAX_TOKENS" are successes; every other finish reason is a failure, as in pi. */
197
+ function stopReasonOf(finishReason: string): StopReason {
198
+ return finishReason === "STOP" ? "stop" : finishReason === "MAX_TOKENS" ? "length" : "error";
199
+ }
200
+
201
+ /** Some backends send a signature only on a block's first delta; keep it. */
202
+ const retainSignature = (existing: string | undefined, incoming: string | undefined) =>
203
+ typeof incoming === "string" && incoming.length > 0 ? incoming : existing;
204
+
186
205
  // --- Transport -------------------------------------------------------------
187
206
 
188
207
  function pause(ms: number, signal?: AbortSignal): Promise<void> {
@@ -274,17 +293,12 @@ export const stream = (
274
293
  try {
275
294
  const { token, projectId } = decodeApiKey(options?.apiKey);
276
295
  const runtimeId = runtimeModelId(model, options?.reasoning);
277
- const google = await googleShared();
278
296
 
279
- let body: unknown = await buildRequest(model, context, projectId, options);
297
+ let body: unknown = buildRequest(model, context, projectId, options);
280
298
  body = (await options?.onPayload?.(body, model)) ?? body;
281
299
  const payload = JSON.stringify(body);
282
300
 
283
- const headers: Record<string, string> = {
284
- ...geminiHeaders(token),
285
- // Same precedence and null-suppression rules as pi's own adapters.
286
- ...providerHeadersToRecord({ ...model.headers, ...options?.headers }),
287
- };
301
+ const headers = mergeHeaders(geminiHeaders(token), model.headers, options?.headers);
288
302
  const fetchImpl = options?.fetch ?? globalThis.fetch;
289
303
  const headerTimeout = options?.timeoutMs ?? HEADER_TIMEOUT_MS;
290
304
  const idleTimeout = options?.timeoutMs ?? STALL_TIMEOUT_MS;
@@ -307,7 +321,7 @@ export const stream = (
307
321
  headerTimeout,
308
322
  );
309
323
  if (response.ok) {
310
- await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
324
+ await options?.onResponse?.({ status: response.status, headers: toRecord(response.headers) }, model);
311
325
  return response;
312
326
  }
313
327
  const failure = describeFailure(response.status, await response.text(), runtimeId);
@@ -318,7 +332,7 @@ export const stream = (
318
332
  const { status, headers: reported, failure } = failed!;
319
333
  // Reported with the body's reset time, so the account pool can hold a
320
334
  // quota-walled account out of routing until it actually resets.
321
- await options?.onResponse?.({ status, headers: headersToRecord(reported) }, model);
335
+ await options?.onResponse?.({ status, headers: toRecord(reported) }, model);
322
336
  // The shape pi's retry policy reads: `status` plus `Headers`.
323
337
  throw Object.assign(new Error(failure.message), { status, headers: reported });
324
338
  };
@@ -360,7 +374,7 @@ export const stream = (
360
374
  for (const part of candidate?.content?.parts ?? []) {
361
375
  if (part.text !== undefined) {
362
376
  received = true;
363
- const thinking = google.isThinkingPart(part);
377
+ const thinking = part.thought === true;
364
378
  if (!block || (thinking ? block.type !== "thinking" : block.type !== "text")) {
365
379
  closeBlock(block);
366
380
  block = thinking
@@ -373,11 +387,11 @@ export const stream = (
373
387
 
374
388
  if (block.type === "thinking") {
375
389
  block.thinking += part.text;
376
- block.thinkingSignature = google.retainThoughtSignature(block.thinkingSignature, part.thoughtSignature);
390
+ block.thinkingSignature = retainSignature(block.thinkingSignature, part.thoughtSignature);
377
391
  events.push({ type: "thinking_delta", contentIndex: index(), delta: part.text, partial: output });
378
392
  } else {
379
393
  block.text += part.text;
380
- block.textSignature = google.retainThoughtSignature(block.textSignature, part.thoughtSignature);
394
+ block.textSignature = retainSignature(block.textSignature, part.thoughtSignature);
381
395
  events.push({ type: "text_delta", contentIndex: index(), delta: part.text, partial: output });
382
396
  }
383
397
  }
@@ -409,7 +423,7 @@ export const stream = (
409
423
  output.rawStopReason = candidate.finishReason;
410
424
  output.stopReason = output.content.some((item) => item.type === "toolCall")
411
425
  ? "toolUse"
412
- : google.mapStopReasonString(candidate.finishReason);
426
+ : stopReasonOf(candidate.finishReason);
413
427
  }
414
428
 
415
429
  const usage = data.usageMetadata;
@@ -475,11 +489,7 @@ export const stream = (
475
489
  output.rawStopReason = undefined;
476
490
  started = false;
477
491
  }
478
- received = await consume(await retryProviderRequest(send, {
479
- maxRetries: options?.maxRetries,
480
- maxRetryDelayMs: options?.maxRetryDelayMs,
481
- signal: options?.signal,
482
- }));
492
+ received = await consume(await send());
483
493
  }
484
494
 
485
495
  if (!received) throw new Error("Gemini returned an empty response.");
@@ -499,7 +509,7 @@ export const stream = (
499
509
  events.end();
500
510
  } catch (error) {
501
511
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
502
- output.errorMessage = formatProviderError(normalizeProviderError(error), "Gemini");
512
+ output.errorMessage = formatThrownValue(error);
503
513
  events.push({ type: "error", reason: output.stopReason, error: output });
504
514
  events.end();
505
515
  }
@@ -508,14 +518,35 @@ export const stream = (
508
518
  return events;
509
519
  };
510
520
 
521
+ /** Headroom pi leaves between the estimated context and the window. */
522
+ const CONTEXT_SAFETY_TOKENS = 4096;
523
+
524
+ /**
525
+ * The context already used: the last response's reported usage, plus about
526
+ * four characters per token for anything after it. The same estimate pi uses
527
+ * to keep an output ceiling from pushing a request past the context window.
528
+ */
529
+ function estimateContextTokens(context: TranscriptContext): number {
530
+ const messages = context.messages;
531
+ let index = messages.length - 1;
532
+ while (index >= 0 && !(messages[index].role === "assistant" && (messages[index] as AssistantMessage).usage?.totalTokens)) index--;
533
+ const usage = index >= 0 ? (messages[index] as AssistantMessage).usage : undefined;
534
+ const counted = usage ? usage.input + usage.output + usage.cacheRead + usage.cacheWrite : 0;
535
+ const trailing = messages.slice(index + 1).reduce((sum, message) => sum + JSON.stringify(message).length, 0);
536
+ return counted + Math.ceil(trailing / 4);
537
+ }
538
+
511
539
  export const streamSimple = (
512
540
  model: Model<Api>,
513
541
  context: TranscriptContext,
514
542
  options?: SimpleStreamOptions,
515
543
  ): AssistantMessageEventStream => {
516
544
  const level = options?.reasoning && model.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
545
+ const requested = options?.maxTokens ?? model.maxTokens;
546
+ const room = model.contextWindow - estimateContextTokens(context) - CONTEXT_SAFETY_TOKENS;
517
547
  return stream(model, context, {
518
- ...buildBaseOptions(model, context, options, options?.apiKey),
548
+ ...options,
549
+ maxTokens: model.contextWindow > 0 ? Math.min(requested, Math.max(1, room)) : requested,
519
550
  toolChoice: options?.toolChoice,
520
551
  reasoning: level === "off" ? undefined : level,
521
552
  });
@@ -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
- const matching = rows.filter((r) => isClaudeAccount(r) && r.label === label && isFresh(r, now));
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 { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
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 { loadCodexAccounts } from "../codex/store.ts";
7
- import type { UsageRow } from "./pool.ts";
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 Codex endpoints.
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
- push("7d Opus", body.seven_day_opus ?? body.seven_day_omelette);
64
- push("7d Sonnet", body.seven_day_sonnet);
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" || !scoped?.id) continue;
135
- push(`7d ${scoped.id}`, scoped);
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
- export async function fetchClaudeRows(ctx: any): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
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
- const token = (await ctx.modelRegistry.getProviderAuth("anthropic"))?.auth?.apiKey;
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 Personal: ${error instanceof Error ? error.message : String(error)}`);
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 storage = loadAccounts();
155
- for (const account of storage?.accounts ?? []) {
156
- if (account.type !== "oauth" || account.enabled === false) continue;
157
- accounts.push({ group: `Claude ${account.label ?? account.id.slice(0, 8)}`, account });
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
- errors.push(`Claude accounts: ${error instanceof Error ? error.message : String(error)}`);
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: [...new Set(["Claude Personal", ...accounts.map((entry) => entry.group)])] };
279
+ return { rows, errors, groups: [...groups] };
189
280
  }
190
281
 
191
- function codexAccountId(): string | undefined {
192
- // Prefer an enabled account from our own pool, so /usage reflects the
193
- // accounts /accounts manages rather than only pi's single credential.
194
- try {
195
- const pooled = loadCodexAccounts().accounts.find((a) => a.enabled !== false && a.accountId);
196
- if (pooled?.accountId) return pooled.accountId;
197
- } catch { /* fall through */ }
198
- try {
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(ctx: any): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
294
+ export async function fetchCodexRows(
295
+ ctx: any,
296
+ options: SourceOptions = {},
297
+ ): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
213
298
  try {
214
- const token = (await ctx.modelRegistry.getProviderAuth("openai-codex"))?.auth?.apiKey;
215
- const accountId = codexAccountId();
216
- if (!token) return { rows: [], error: "Codex: not logged in" };
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([fetchClaudeRows(ctx), fetchCodexRows(ctx)]);
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
  }