@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
|
@@ -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) => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Api, Provider } from "@earendil-works/pi-ai";
|
|
2
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One of pi's own providers, from the host's copy of pi-ai.
|
|
6
|
+
*
|
|
7
|
+
* pi hands extensions its pi-ai through a fixed set of entry points (the
|
|
8
|
+
* package root, `compat`, `oauth` and `providers/all`) and installs packages
|
|
9
|
+
* without their peers. A deep import such as `pi-ai/providers/openai-codex`
|
|
10
|
+
* therefore has no copy to resolve against on a clean install, and wherever a
|
|
11
|
+
* stray copy does exist it is a different version from the host — the exact
|
|
12
|
+
* way stale provider definitions have dropped tools before.
|
|
13
|
+
* `providers/all` is always the host's, so providers come from here.
|
|
14
|
+
*/
|
|
15
|
+
export function builtinProvider<TApi extends Api>(id: string): Provider<TApi> {
|
|
16
|
+
const provider = builtinProviders().find((candidate) => candidate.id === id);
|
|
17
|
+
if (!provider) throw new Error(`This version of pi has no built-in "${id}" provider.`);
|
|
18
|
+
return provider as unknown as Provider<TApi>;
|
|
19
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
-
import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
|
|
3
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import type { RoutingMode } from "../../../core/accounts/registry.ts";
|
|
5
4
|
import { normalizeRoutingMode, type AccountQuotaState } from "../../../core/accounts/routing.ts";
|
|
@@ -13,6 +12,7 @@ import {
|
|
|
13
12
|
saveCodexAccounts,
|
|
14
13
|
type CodexAccount,
|
|
15
14
|
} from "../../../core/codex/store.ts";
|
|
15
|
+
import { builtinProvider } from "./builtin.ts";
|
|
16
16
|
import {
|
|
17
17
|
chooseCredential,
|
|
18
18
|
createPooledOAuthAdapter,
|
|
@@ -146,7 +146,7 @@ function markCodexRateLimited(accountId: string, headers: Record<string, string>
|
|
|
146
146
|
export const CODEX_SPEC: PooledOAuthProviderSpec<"openai-codex-responses"> = {
|
|
147
147
|
id: "openai-codex",
|
|
148
148
|
label: "Codex",
|
|
149
|
-
createProvider:
|
|
149
|
+
createProvider: () => builtinProvider("openai-codex"),
|
|
150
150
|
store: CODEX_STORE,
|
|
151
151
|
addPrompt: "Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
|
|
152
152
|
describeAccount: (account) => describePlan(account as PooledOAuthAccount & CodexAccount),
|
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
|
1
|
+
import { builtinProvider } from "./builtin.ts";
|
|
3
2
|
import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
|
|
4
3
|
|
|
5
4
|
export const KIMI_SPEC: PooledOAuthProviderSpec<"anthropic-messages"> = {
|
|
6
5
|
id: "kimi-coding",
|
|
7
6
|
label: "Kimi",
|
|
8
|
-
createProvider:
|
|
7
|
+
createProvider: () => builtinProvider("kimi-coding"),
|
|
9
8
|
};
|
|
10
9
|
|
|
11
10
|
export const XAI_SPEC: PooledOAuthProviderSpec<"openai-responses"> = {
|
|
12
11
|
id: "xai",
|
|
13
12
|
label: "Grok",
|
|
14
|
-
createProvider:
|
|
13
|
+
createProvider: () => builtinProvider("xai"),
|
|
15
14
|
};
|
|
16
15
|
|
|
17
16
|
export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);
|
|
@@ -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";
|