@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.
- package/README.md +8 -0
- package/package.json +5 -5
- package/src/core/anthropic/models.ts +5 -2
- package/src/core/anthropic/quota.ts +6 -2
- package/src/core/gemini/client.ts +39 -0
- package/src/core/gemini/convert.ts +239 -0
- package/src/core/gemini/models.ts +2 -2
- package/src/core/gemini/quota.ts +58 -0
- package/src/core/gemini/request.ts +37 -53
- package/src/core/gemini/stream.ts +82 -51
- 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/domains/subscriptions/providers/builtin.ts +19 -0
- package/src/domains/subscriptions/providers/codex.ts +2 -2
- package/src/domains/subscriptions/providers/hosted.ts +3 -4
- package/src/services/usage-service.ts +21 -6
- package/src/ui/usage-bars.ts +113 -25
|
@@ -1,40 +1,39 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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`.
|
|
36
|
-
*
|
|
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
|
|
174
|
-
*
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
:
|
|
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
|
|
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 =
|
|
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
|
-
...
|
|
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
|
});
|
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
|
}
|