@herbertgao/pi-extensions 2026.9.9 → 2026.9.11
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 +1 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/node_modules/@herbertgao/pi-bark/package.json +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -2
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +12 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +427 -120
- package/node_modules/@herbertgao/pi-subagents/docs/rpc.md +184 -0
- package/node_modules/@herbertgao/pi-subagents/docs/workflows.md +466 -0
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +6 -6
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/compose.js +52 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/fan-out-audit.js +56 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/gated-fix.js +60 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/lib/count-child.js +30 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/review-panel.js +68 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/structured-findings.js +81 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +12 -10
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +52 -12
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +837 -146
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +213 -39
- package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +73 -14
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +101 -47
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +2249 -914
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +13 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +215 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention.ts +147 -0
- package/node_modules/@herbertgao/pi-subagents/src/model-resolver.ts +9 -1
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +40 -26
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +18 -8
- package/node_modules/@herbertgao/pi-subagents/src/prompts.ts +46 -9
- package/node_modules/@herbertgao/pi-subagents/src/schedule.ts +21 -16
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +137 -7
- package/node_modules/@herbertgao/pi-subagents/src/structured-output.ts +136 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +126 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-mention.ts +274 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +20 -5
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +10 -4
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +167 -22
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-card.ts +555 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-dialog.ts +1304 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-menu.ts +226 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/collisions.ts +122 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/entry.ts +47 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/host.ts +463 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/journal.ts +164 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/json-schema.ts +142 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/meta.ts +401 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/progress.ts +622 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/runtime.ts +1399 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/saved.ts +230 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/task.ts +333 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/tool-description.ts +200 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/worker-source.ts +781 -0
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +97 -95
- package/node_modules/@herbertgao/pi-subagents/src/xml.ts +13 -0
- package/node_modules/@herbertgao/resume-from/package.json +1 -1
- package/node_modules/@herbertgao/sol-pi/README.md +3 -3
- package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
- package/node_modules/@herbertgao/sol-pi/agents-install.md +4 -4
- package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +6 -6
- package/node_modules/@herbertgao/sol-pi/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/dist/index.ts +39 -89
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/btw.ts +28 -87
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +8 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +40 -37
- package/node_modules/@narumitw/pi-caffeinate/README.md +21 -66
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +10 -41
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +2 -2
- package/node_modules/@narumitw/pi-caffeinate/package.json +50 -51
- package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +637 -663
- package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +114 -120
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +29 -29
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +108 -126
- package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +124 -128
- package/node_modules/pi-multi-account/CHANGELOG.md +1209 -0
- package/node_modules/pi-multi-account/CONTRIBUTING.md +61 -0
- package/node_modules/pi-multi-account/LICENSE +21 -0
- package/node_modules/pi-multi-account/README.md +197 -0
- package/node_modules/pi-multi-account/SECURITY.md +27 -0
- package/node_modules/pi-multi-account/auth-file-transaction.ts +56 -0
- package/node_modules/pi-multi-account/child-usability.ts +233 -0
- package/node_modules/pi-multi-account/compaction-summary.ts +32 -0
- package/node_modules/pi-multi-account/completion-route-planner.ts +224 -0
- package/node_modules/pi-multi-account/context-guard.ts +420 -0
- package/node_modules/pi-multi-account/cursor/LICENSE +21 -0
- package/node_modules/pi-multi-account/cursor/NOTICE +2 -0
- package/node_modules/pi-multi-account/cursor/auth.ts +165 -0
- package/node_modules/pi-multi-account/cursor/bridge-handle.ts +155 -0
- package/node_modules/pi-multi-account/cursor/conversation-registry.ts +104 -0
- package/node_modules/pi-multi-account/cursor/cursor-models-raw.json +611 -0
- package/node_modules/pi-multi-account/cursor/cursor-shared.ts +192 -0
- package/node_modules/pi-multi-account/cursor/h2-bridge.mjs +175 -0
- package/node_modules/pi-multi-account/cursor/index.ts +572 -0
- package/node_modules/pi-multi-account/cursor/message-parsing.ts +323 -0
- package/node_modules/pi-multi-account/cursor/prompt-usage.ts +53 -0
- package/node_modules/pi-multi-account/cursor/proto/agent_pb.ts +15294 -0
- package/node_modules/pi-multi-account/cursor/proxy.ts +2510 -0
- package/node_modules/pi-multi-account/cursor/session-lifecycle.ts +40 -0
- package/node_modules/pi-multi-account/cursor/sse-keepalive.ts +24 -0
- package/node_modules/pi-multi-account/cursor/stream-lifecycle.ts +193 -0
- package/node_modules/pi-multi-account/cursor/upstream-watchdog.ts +88 -0
- package/node_modules/pi-multi-account/cursor-bridge.ts +240 -0
- package/node_modules/pi-multi-account/cursor-model-name.ts +12 -0
- package/node_modules/pi-multi-account/index.ts +11825 -0
- package/node_modules/pi-multi-account/model-catalog.ts +354 -0
- package/node_modules/pi-multi-account/package.json +101 -0
- package/node_modules/pi-multi-account/pi-contract.ts +281 -0
- package/node_modules/pi-multi-account/provider-payload-stream.ts +44 -0
- package/node_modules/pi-multi-account/provider-priority.ts +189 -0
- package/node_modules/pi-multi-account/slot-proxy-auth.ts +167 -0
- package/node_modules/pi-multi-account/slot-proxy.ts +344 -0
- package/node_modules/pi-multi-account/state-file-transaction.ts +67 -0
- package/node_modules/pi-multi-account/usage.ts +1099 -0
- package/node_modules/pi-typesafe/README.md +6 -2
- package/node_modules/pi-typesafe/dist/client.d.ts +11 -0
- package/node_modules/pi-typesafe/dist/client.js +45 -10
- package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
- package/node_modules/pi-typesafe/dist/index.js +1 -1
- package/node_modules/pi-typesafe/package.json +2 -2
- package/node_modules/pi-web-access/CHANGELOG.md +36 -0
- package/node_modules/pi-web-access/README.md +75 -18
- package/node_modules/pi-web-access/anysearch.ts +4 -15
- package/node_modules/pi-web-access/bocha.ts +3 -22
- package/node_modules/pi-web-access/brave.ts +3 -21
- package/node_modules/pi-web-access/brightdata.ts +5 -32
- package/node_modules/pi-web-access/content-find.ts +168 -53
- package/node_modules/pi-web-access/curator-page.ts +4 -1
- package/node_modules/pi-web-access/curator-run.ts +2 -1
- package/node_modules/pi-web-access/curator-server.ts +1 -0
- package/node_modules/pi-web-access/dist/index.js +24620 -0
- package/node_modules/pi-web-access/domain-filter-normalization.ts +14 -0
- package/node_modules/pi-web-access/duckduckgo.ts +3 -21
- package/node_modules/pi-web-access/extract.ts +3 -1
- package/node_modules/pi-web-access/firecrawl.ts +5 -29
- package/node_modules/pi-web-access/gemini-search.ts +81 -32
- package/node_modules/pi-web-access/index.ts +149 -148
- package/node_modules/pi-web-access/jina-search.ts +4 -15
- package/node_modules/pi-web-access/kagi.ts +4 -13
- package/node_modules/pi-web-access/kimi-search.ts +5 -30
- package/node_modules/pi-web-access/mistral-search.ts +1 -15
- package/node_modules/pi-web-access/ollama.ts +2 -7
- package/node_modules/pi-web-access/openai-search.ts +174 -36
- package/node_modules/pi-web-access/opencode-session-headers.ts +24 -0
- package/node_modules/pi-web-access/package.json +10 -4
- package/node_modules/pi-web-access/page-query.ts +10 -2
- package/node_modules/pi-web-access/parallel.ts +1 -15
- package/node_modules/pi-web-access/pdf-extract.ts +3 -0
- package/node_modules/pi-web-access/querit.ts +5 -29
- package/node_modules/pi-web-access/search-answer-formatting.ts +11 -0
- package/node_modules/pi-web-access/search-result-count-normalization.ts +4 -0
- package/node_modules/pi-web-access/search1api.ts +5 -29
- package/node_modules/pi-web-access/searchinfinity.ts +5 -29
- package/node_modules/pi-web-access/searxng.ts +3 -21
- package/node_modules/pi-web-access/serpapi.ts +5 -28
- package/node_modules/pi-web-access/serpbase.ts +3 -22
- package/node_modules/pi-web-access/serpdive.ts +3 -21
- package/node_modules/pi-web-access/serper.ts +5 -28
- package/node_modules/pi-web-access/serply.ts +197 -0
- package/node_modules/pi-web-access/source-check.ts +11 -47
- package/node_modules/pi-web-access/summary-review.ts +7 -3
- package/node_modules/pi-web-access/tavily.ts +3 -21
- package/node_modules/pi-web-access/tinyfish.ts +5 -29
- package/node_modules/pi-web-access/utils.ts +9 -1
- package/node_modules/pi-web-access/valyu.ts +5 -28
- package/node_modules/pi-web-access/xai-search.ts +1 -15
- package/node_modules/pi-web-access/xcrawl.ts +5 -32
- package/package.json +17 -11
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type UsageFamily = "codex" | "anthropic" | "ollama" | "cursor" | "qwen" | "kimi-coding" | "xai" | "zai-coding-cn";
|
|
6
|
+
|
|
7
|
+
export type UsageWindow = {
|
|
8
|
+
usedPercent: number;
|
|
9
|
+
resetAt: number;
|
|
10
|
+
windowSeconds?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type UsageSnapshot = {
|
|
14
|
+
provider: string;
|
|
15
|
+
family: UsageFamily;
|
|
16
|
+
fetchedAt: number;
|
|
17
|
+
credentialHash?: string;
|
|
18
|
+
plan?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Which real account this is — the email the provider reports, when it reports one.
|
|
21
|
+
*
|
|
22
|
+
* Slot ids (`openai-codex-account-5`) are positions in a config file, not identities. With
|
|
23
|
+
* several slots the position says nothing about whose quota is being spent, which is the fact
|
|
24
|
+
* a person actually needs when deciding where to switch.
|
|
25
|
+
*/
|
|
26
|
+
account?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The provider's OWN verdict on whether this account can be used right now.
|
|
29
|
+
*
|
|
30
|
+
* Everything else in this snapshot is arithmetic we do on quota windows — a forecast about
|
|
31
|
+
* one window, which cannot see session limits, plan limits or an early reset. This field is
|
|
32
|
+
* not that: it is the account answering the question directly. `undefined` means the response
|
|
33
|
+
* stated no verdict, and only then is the forecast the best information available.
|
|
34
|
+
*/
|
|
35
|
+
serviceable?: boolean;
|
|
36
|
+
primary?: UsageWindow;
|
|
37
|
+
secondary?: UsageWindow;
|
|
38
|
+
/**
|
|
39
|
+
* A third, separately-metered bucket a provider exposes BESIDE the two rotation windows.
|
|
40
|
+
*
|
|
41
|
+
* Cursor is the reason it exists: one Pro+ subscription bills three independent pools —
|
|
42
|
+
* included models (`primary`), the third-party "other models" pool (`secondary`) and the
|
|
43
|
+
* Grok Bot product, which meters on its own weekly reset. Unlike primary/secondary it does
|
|
44
|
+
* not gate the account: a maxed extra bucket still leaves the other pools able to serve
|
|
45
|
+
* work, so it is displayed but never treated as a rotation cooldown.
|
|
46
|
+
*/
|
|
47
|
+
tertiary?: UsageWindow;
|
|
48
|
+
credits?: {
|
|
49
|
+
hasCredits?: boolean;
|
|
50
|
+
unlimited?: boolean;
|
|
51
|
+
balance?: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type UsageCredential = {
|
|
56
|
+
type?: string;
|
|
57
|
+
access?: string;
|
|
58
|
+
accountId?: string;
|
|
59
|
+
key?: string;
|
|
60
|
+
expires?: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export class UsageFetchError extends Error {
|
|
64
|
+
readonly status?: number;
|
|
65
|
+
|
|
66
|
+
constructor(message: string, status?: number) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "UsageFetchError";
|
|
69
|
+
this.status = status;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function record(value: unknown): Record<string, any> {
|
|
74
|
+
return value && typeof value === "object" ? (value as Record<string, any>) : {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
78
|
+
if (typeof value !== "number" && (typeof value !== "string" || !value.trim())) return undefined;
|
|
79
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
80
|
+
return Number.isFinite(number) ? number : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function percent(value: unknown): number | undefined {
|
|
84
|
+
const number = finiteNumber(value);
|
|
85
|
+
return number === undefined ? undefined : Math.min(100, Math.max(0, number));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function epochMs(value: unknown): number | undefined {
|
|
89
|
+
if (typeof value === "string" && value.trim() && !Number.isFinite(Number(value))) {
|
|
90
|
+
const parsed = Date.parse(value);
|
|
91
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
92
|
+
}
|
|
93
|
+
const number = finiteNumber(value);
|
|
94
|
+
if (number === undefined || number <= 0) return undefined;
|
|
95
|
+
return number < 10_000_000_000 ? number * 1000 : number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function usageWindow(value: unknown, fallbackWindowSeconds?: number): UsageWindow | undefined {
|
|
99
|
+
const source = record(value);
|
|
100
|
+
const usedPercent = percent(source.used_percent ?? source.utilization);
|
|
101
|
+
const resetAt = epochMs(source.reset_at ?? source.resets_at);
|
|
102
|
+
if (usedPercent === undefined || resetAt === undefined) return undefined;
|
|
103
|
+
const windowSeconds = finiteNumber(source.limit_window_seconds) ?? fallbackWindowSeconds;
|
|
104
|
+
return {
|
|
105
|
+
usedPercent,
|
|
106
|
+
resetAt,
|
|
107
|
+
...(windowSeconds !== undefined ? { windowSeconds } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function usageFamily(provider: string): UsageFamily | undefined {
|
|
112
|
+
if (provider === "openai-codex" || /^openai-codex-account-\d+$/.test(provider)) return "codex";
|
|
113
|
+
if (provider === "anthropic" || /^anthropic-account-\d+$/.test(provider)) return "anthropic";
|
|
114
|
+
if (provider === "ollama" || /^ollama-account-\d+$/.test(provider)) return "ollama";
|
|
115
|
+
if (provider === "cursor" || /^cursor-account-\d+$/.test(provider)) return "cursor";
|
|
116
|
+
if (provider === "alibaba" || /^alibaba-account-\d+$/.test(provider) || /^qwen/i.test(provider)) return "qwen";
|
|
117
|
+
if (provider === "kimi-coding" || /^kimi-coding-account-\d+$/.test(provider)) return "kimi-coding";
|
|
118
|
+
if (provider === "xai" || /^xai-account-\d+$/.test(provider)) return "xai";
|
|
119
|
+
if (provider === "zai-coding-cn" || /^zai-coding-cn-account-\d+$/.test(provider)) return "zai-coding-cn";
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseCodexUsageBody(
|
|
124
|
+
provider: string,
|
|
125
|
+
body: unknown,
|
|
126
|
+
fetchedAt = Date.now(),
|
|
127
|
+
credentialHash?: string,
|
|
128
|
+
): UsageSnapshot | undefined {
|
|
129
|
+
const source = record(body);
|
|
130
|
+
const rateLimit = record(source.rate_limit);
|
|
131
|
+
const primary = usageWindow(rateLimit.primary_window, 5 * 60 * 60);
|
|
132
|
+
const secondary = usageWindow(rateLimit.secondary_window, 7 * 24 * 60 * 60);
|
|
133
|
+
if (!primary && !secondary) return undefined;
|
|
134
|
+
const credits = record(source.credits);
|
|
135
|
+
return {
|
|
136
|
+
provider,
|
|
137
|
+
family: "codex",
|
|
138
|
+
fetchedAt,
|
|
139
|
+
credentialHash,
|
|
140
|
+
plan: typeof source.plan_type === "string" ? source.plan_type : undefined,
|
|
141
|
+
account: typeof source.email === "string" && source.email.trim() ? source.email : undefined,
|
|
142
|
+
// `limit_reached` is the negative statement and `allowed` the positive one; either alone
|
|
143
|
+
// is enough. Read both so a response that carries only one of them still answers.
|
|
144
|
+
serviceable:
|
|
145
|
+
typeof rateLimit.limit_reached === "boolean"
|
|
146
|
+
? !rateLimit.limit_reached
|
|
147
|
+
: typeof rateLimit.allowed === "boolean"
|
|
148
|
+
? rateLimit.allowed
|
|
149
|
+
: undefined,
|
|
150
|
+
primary,
|
|
151
|
+
secondary,
|
|
152
|
+
credits: {
|
|
153
|
+
hasCredits: typeof credits.has_credits === "boolean" ? credits.has_credits : undefined,
|
|
154
|
+
unlimited: typeof credits.unlimited === "boolean" ? credits.unlimited : undefined,
|
|
155
|
+
balance:
|
|
156
|
+
typeof credits.balance === "string" || typeof credits.balance === "number"
|
|
157
|
+
? String(credits.balance)
|
|
158
|
+
: undefined,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function parseAnthropicUsageBody(
|
|
164
|
+
provider: string,
|
|
165
|
+
body: unknown,
|
|
166
|
+
fetchedAt = Date.now(),
|
|
167
|
+
credentialHash?: string,
|
|
168
|
+
): UsageSnapshot | undefined {
|
|
169
|
+
const source = record(body);
|
|
170
|
+
const primary = usageWindow(source.five_hour, 5 * 60 * 60);
|
|
171
|
+
const secondary = usageWindow(source.seven_day, 7 * 24 * 60 * 60);
|
|
172
|
+
if (!primary && !secondary) return undefined;
|
|
173
|
+
return {
|
|
174
|
+
provider,
|
|
175
|
+
family: "anthropic",
|
|
176
|
+
fetchedAt,
|
|
177
|
+
credentialHash,
|
|
178
|
+
primary,
|
|
179
|
+
secondary,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function headerValue(headers: unknown, name: string): string | undefined {
|
|
184
|
+
const getter = (headers as any)?.get;
|
|
185
|
+
if (typeof getter === "function") {
|
|
186
|
+
const value = getter.call(headers, name);
|
|
187
|
+
return typeof value === "string" ? value : undefined;
|
|
188
|
+
}
|
|
189
|
+
for (const [key, value] of Object.entries(record(headers))) {
|
|
190
|
+
if (key.toLowerCase() === name.toLowerCase() && value !== undefined) return String(value);
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function headerWindow(headers: unknown, prefix: "primary" | "secondary"): UsageWindow | undefined {
|
|
196
|
+
const usedPercent = percent(headerValue(headers, `x-codex-${prefix}-used-percent`));
|
|
197
|
+
const resetAt = epochMs(headerValue(headers, `x-codex-${prefix}-reset-at`));
|
|
198
|
+
const windowMinutes = finiteNumber(headerValue(headers, `x-codex-${prefix}-window-minutes`));
|
|
199
|
+
if (usedPercent === undefined || resetAt === undefined) return undefined;
|
|
200
|
+
return {
|
|
201
|
+
usedPercent,
|
|
202
|
+
resetAt,
|
|
203
|
+
...(windowMinutes !== undefined ? { windowSeconds: windowMinutes * 60 } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function parseCodexUsageHeaders(
|
|
208
|
+
provider: string,
|
|
209
|
+
headers: unknown,
|
|
210
|
+
fetchedAt = Date.now(),
|
|
211
|
+
credentialHash?: string,
|
|
212
|
+
): UsageSnapshot | undefined {
|
|
213
|
+
const primary = headerWindow(headers, "primary");
|
|
214
|
+
const secondary = headerWindow(headers, "secondary");
|
|
215
|
+
if (!primary && !secondary) return undefined;
|
|
216
|
+
return {
|
|
217
|
+
provider,
|
|
218
|
+
family: "codex",
|
|
219
|
+
fetchedAt,
|
|
220
|
+
credentialHash,
|
|
221
|
+
plan: headerValue(headers, "x-codex-plan-type"),
|
|
222
|
+
primary,
|
|
223
|
+
secondary,
|
|
224
|
+
credits: {
|
|
225
|
+
hasCredits: headerValue(headers, "x-codex-credits-has-credits")?.toLowerCase() === "true",
|
|
226
|
+
unlimited: headerValue(headers, "x-codex-credits-unlimited")?.toLowerCase() === "true",
|
|
227
|
+
balance: headerValue(headers, "x-codex-credits-balance"),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function parseOllamaMeBody(
|
|
233
|
+
provider: string,
|
|
234
|
+
body: unknown,
|
|
235
|
+
fetchedAt = Date.now(),
|
|
236
|
+
credentialHash?: string,
|
|
237
|
+
): UsageSnapshot {
|
|
238
|
+
const source = record(body);
|
|
239
|
+
const planName =
|
|
240
|
+
typeof source.Plan === "string"
|
|
241
|
+
? source.Plan
|
|
242
|
+
: typeof source.plan === "string"
|
|
243
|
+
? source.plan
|
|
244
|
+
: undefined;
|
|
245
|
+
// Ollama's /api/me carries the plan tier, billing-period end and suspended flag. Current cloud
|
|
246
|
+
// quota windows come from /api/usage, but retain support for windows here in case Ollama folds
|
|
247
|
+
// them into the documented account response later.
|
|
248
|
+
const nullableTime = (value: unknown): string | undefined => {
|
|
249
|
+
if (!value || typeof value !== "object") return undefined;
|
|
250
|
+
const v = value as { Time?: unknown; Valid?: unknown };
|
|
251
|
+
return v.Valid === true && typeof v.Time === "string" ? v.Time : undefined;
|
|
252
|
+
};
|
|
253
|
+
const planParts: string[] = [];
|
|
254
|
+
if (planName) planParts.push(planName);
|
|
255
|
+
if (nullableTime(source.SuspendedAt)) planParts.push("SUSPENDED");
|
|
256
|
+
const periodEnd = nullableTime(source.SubscriptionPeriodEnd);
|
|
257
|
+
if (periodEnd) {
|
|
258
|
+
const d = new Date(periodEnd);
|
|
259
|
+
if (!Number.isNaN(d.getTime()))
|
|
260
|
+
planParts.push(`renews ${d.toISOString().slice(0, 10)}`);
|
|
261
|
+
}
|
|
262
|
+
const plan = planParts.length > 0 ? planParts.join(" · ") : planName;
|
|
263
|
+
const sessionSource =
|
|
264
|
+
source.session ??
|
|
265
|
+
source.Session ??
|
|
266
|
+
source.session_usage ??
|
|
267
|
+
source.SessionUsage;
|
|
268
|
+
const weeklySource =
|
|
269
|
+
source.weekly ??
|
|
270
|
+
source.Weekly ??
|
|
271
|
+
source.weekly_usage ??
|
|
272
|
+
source.WeeklyUsage;
|
|
273
|
+
const primary = usageWindow(sessionSource, 5 * 60 * 60);
|
|
274
|
+
const secondary = usageWindow(weeklySource, 7 * 24 * 60 * 60);
|
|
275
|
+
return {
|
|
276
|
+
provider,
|
|
277
|
+
family: "ollama",
|
|
278
|
+
fetchedAt,
|
|
279
|
+
credentialHash,
|
|
280
|
+
plan,
|
|
281
|
+
primary,
|
|
282
|
+
secondary,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const OLLAMA_SESSION_SECONDS = 5 * 60 * 60;
|
|
287
|
+
const OLLAMA_WEEK_SECONDS = 7 * 24 * 60 * 60;
|
|
288
|
+
const OLLAMA_WEEK_ANCHOR_MS = 4 * 24 * 60 * 60_000; // Monday 00:00 UTC after Unix epoch.
|
|
289
|
+
|
|
290
|
+
function nextBoundary(now: number, windowMs: number, anchorMs = 0): number {
|
|
291
|
+
return anchorMs + (Math.floor((now - anchorMs) / windowMs) + 1) * windowMs;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function ollamaFractionWindow(
|
|
295
|
+
value: unknown,
|
|
296
|
+
fetchedAt: number,
|
|
297
|
+
windowSeconds: number,
|
|
298
|
+
anchorMs = 0,
|
|
299
|
+
): UsageWindow | undefined {
|
|
300
|
+
const rawUsage = record(value).usage;
|
|
301
|
+
if (rawUsage === null || rawUsage === undefined || rawUsage === "") return undefined;
|
|
302
|
+
const usage = finiteNumber(rawUsage);
|
|
303
|
+
// Ollama documents this only through its live response today. Be strict about the observed
|
|
304
|
+
// fractional shape so a future percentage-valued response cannot silently turn 50% into 100%.
|
|
305
|
+
if (usage === undefined || usage < 0 || usage > 1) return undefined;
|
|
306
|
+
return {
|
|
307
|
+
usedPercent: usage * 100,
|
|
308
|
+
resetAt: nextBoundary(fetchedAt, windowSeconds * 1000, anchorMs),
|
|
309
|
+
windowSeconds,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Parse Ollama Cloud's best-effort /api/usage session and weekly quota fractions. */
|
|
314
|
+
export function parseOllamaUsageBody(
|
|
315
|
+
provider: string,
|
|
316
|
+
body: unknown,
|
|
317
|
+
fetchedAt = Date.now(),
|
|
318
|
+
credentialHash?: string,
|
|
319
|
+
): UsageSnapshot | undefined {
|
|
320
|
+
const limits = record(record(body).limits);
|
|
321
|
+
const primary = ollamaFractionWindow(
|
|
322
|
+
limits.session,
|
|
323
|
+
fetchedAt,
|
|
324
|
+
OLLAMA_SESSION_SECONDS,
|
|
325
|
+
);
|
|
326
|
+
const secondary = ollamaFractionWindow(
|
|
327
|
+
limits.weekly,
|
|
328
|
+
fetchedAt,
|
|
329
|
+
OLLAMA_WEEK_SECONDS,
|
|
330
|
+
OLLAMA_WEEK_ANCHOR_MS,
|
|
331
|
+
);
|
|
332
|
+
if (!primary && !secondary) return undefined;
|
|
333
|
+
return {
|
|
334
|
+
provider,
|
|
335
|
+
family: "ollama",
|
|
336
|
+
fetchedAt,
|
|
337
|
+
credentialHash,
|
|
338
|
+
primary,
|
|
339
|
+
secondary,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function fetchOllamaUsageSnapshot(
|
|
344
|
+
provider: string,
|
|
345
|
+
credential: UsageCredential,
|
|
346
|
+
options: {
|
|
347
|
+
fetchImpl?: typeof fetch;
|
|
348
|
+
timeoutMs?: number;
|
|
349
|
+
credentialHash?: string;
|
|
350
|
+
} = {},
|
|
351
|
+
): Promise<UsageSnapshot> {
|
|
352
|
+
if (credential.type !== "api_key" || !credential.key) {
|
|
353
|
+
throw new UsageFetchError(`${provider} has no API key`);
|
|
354
|
+
}
|
|
355
|
+
const controller = new AbortController();
|
|
356
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
|
|
357
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
358
|
+
const headers = {
|
|
359
|
+
Authorization: `Bearer ${credential.key}`,
|
|
360
|
+
Accept: "application/json",
|
|
361
|
+
"Content-Type": "application/json",
|
|
362
|
+
};
|
|
363
|
+
try {
|
|
364
|
+
let response = await fetchImpl("https://ollama.com/api/me", {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers,
|
|
367
|
+
body: "{}",
|
|
368
|
+
signal: controller.signal,
|
|
369
|
+
});
|
|
370
|
+
if (!response.ok) {
|
|
371
|
+
response = await fetchImpl("http://127.0.0.1:11434/api/me", {
|
|
372
|
+
method: "POST",
|
|
373
|
+
headers,
|
|
374
|
+
body: "{}",
|
|
375
|
+
signal: controller.signal,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
throw new UsageFetchError(
|
|
380
|
+
`${provider} Ollama account check returned HTTP ${response.status}`,
|
|
381
|
+
response.status,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const body = await response.json();
|
|
385
|
+
const account = parseOllamaMeBody(
|
|
386
|
+
provider,
|
|
387
|
+
body,
|
|
388
|
+
Date.now(),
|
|
389
|
+
options.credentialHash,
|
|
390
|
+
);
|
|
391
|
+
// /api/usage is not yet a stable documented contract. Its failure must never erase the
|
|
392
|
+
// useful /api/me account status or make a healthy Ollama key look invalid.
|
|
393
|
+
try {
|
|
394
|
+
const usageResponse = await fetchImpl("https://ollama.com/api/usage", {
|
|
395
|
+
method: "GET",
|
|
396
|
+
headers,
|
|
397
|
+
signal: controller.signal,
|
|
398
|
+
});
|
|
399
|
+
if (usageResponse.ok) {
|
|
400
|
+
const usage = parseOllamaUsageBody(
|
|
401
|
+
provider,
|
|
402
|
+
await usageResponse.json(),
|
|
403
|
+
Date.now(),
|
|
404
|
+
options.credentialHash,
|
|
405
|
+
);
|
|
406
|
+
if (usage) {
|
|
407
|
+
return {
|
|
408
|
+
...account,
|
|
409
|
+
fetchedAt: usage.fetchedAt,
|
|
410
|
+
primary: usage.primary ?? account.primary,
|
|
411
|
+
secondary: usage.secondary ?? account.secondary,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// Best-effort endpoint: preserve the plan-only account snapshot.
|
|
417
|
+
}
|
|
418
|
+
return account;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error instanceof UsageFetchError) throw error;
|
|
421
|
+
if ((error as any)?.name === "AbortError") {
|
|
422
|
+
throw new UsageFetchError(`${provider} Ollama usage request timed out`);
|
|
423
|
+
}
|
|
424
|
+
throw new UsageFetchError(
|
|
425
|
+
`${provider} Ollama usage request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
426
|
+
);
|
|
427
|
+
} finally {
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Cursor's own dashboard usage lives in the aiserver.v1.DashboardService Connect-RPC service.
|
|
433
|
+
// The methods are undocumented; they were read out of Cursor's own clients (the editor and the
|
|
434
|
+
// Grok Bot app) and verified live on 2026-09-12 — the same OAuth session token the bridge already
|
|
435
|
+
// holds answers both, and the numbers match the dashboard exactly (97% / 35% / weekly 0%).
|
|
436
|
+
const CURSOR_DASHBOARD_RPC_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService";
|
|
437
|
+
|
|
438
|
+
/** The monthly pools: included models (`auto`) and third-party models (`api`). */
|
|
439
|
+
export function parseCursorCurrentPeriodUsage(
|
|
440
|
+
provider: string,
|
|
441
|
+
body: unknown,
|
|
442
|
+
fetchedAt = Date.now(),
|
|
443
|
+
credentialHash?: string,
|
|
444
|
+
): UsageSnapshot | undefined {
|
|
445
|
+
const source = record(body);
|
|
446
|
+
const planUsage = record(source.planUsage ?? source.plan_usage);
|
|
447
|
+
const cycleStart = epochMs(source.billingCycleStart ?? source.billing_cycle_start);
|
|
448
|
+
const cycleEnd = epochMs(source.billingCycleEnd ?? source.billing_cycle_end);
|
|
449
|
+
const windowSeconds =
|
|
450
|
+
cycleStart !== undefined && cycleEnd !== undefined && cycleEnd > cycleStart
|
|
451
|
+
? Math.round((cycleEnd - cycleStart) / 1000)
|
|
452
|
+
: undefined;
|
|
453
|
+
const periodWindow = (value: unknown): UsageWindow | undefined => {
|
|
454
|
+
const usedPercent = percent(value);
|
|
455
|
+
if (usedPercent === undefined || cycleEnd === undefined) return undefined;
|
|
456
|
+
return {
|
|
457
|
+
usedPercent,
|
|
458
|
+
resetAt: cycleEnd,
|
|
459
|
+
...(windowSeconds !== undefined ? { windowSeconds } : {}),
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
const primary = periodWindow(planUsage.autoPercentUsed ?? planUsage.auto_percent_used);
|
|
463
|
+
const secondary = periodWindow(planUsage.apiPercentUsed ?? planUsage.api_percent_used);
|
|
464
|
+
if (!primary && !secondary) return undefined;
|
|
465
|
+
return {
|
|
466
|
+
provider,
|
|
467
|
+
family: "cursor",
|
|
468
|
+
fetchedAt,
|
|
469
|
+
credentialHash,
|
|
470
|
+
primary,
|
|
471
|
+
secondary,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The Grok Bot weekly bucket, from the same dashboard service. */
|
|
476
|
+
export function parseCursorSandUsage(body: unknown): UsageWindow | undefined {
|
|
477
|
+
const source = record(body);
|
|
478
|
+
const usedPercent = percent(source.usagePercent ?? source.usage_percent);
|
|
479
|
+
const resetAt = epochMs(source.nextResetTimestampUtc ?? source.next_reset_timestamp_utc);
|
|
480
|
+
const startAt = epochMs(source.currentPeriodStart ?? source.current_period_start);
|
|
481
|
+
if (usedPercent === undefined || resetAt === undefined) return undefined;
|
|
482
|
+
const windowSeconds =
|
|
483
|
+
startAt !== undefined && resetAt > startAt
|
|
484
|
+
? Math.round((resetAt - startAt) / 1000)
|
|
485
|
+
: undefined;
|
|
486
|
+
return {
|
|
487
|
+
usedPercent,
|
|
488
|
+
resetAt,
|
|
489
|
+
...(windowSeconds !== undefined ? { windowSeconds } : {}),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function fetchCursorUsageSnapshot(
|
|
494
|
+
provider: string,
|
|
495
|
+
credential: UsageCredential,
|
|
496
|
+
options: {
|
|
497
|
+
fetchImpl?: typeof fetch;
|
|
498
|
+
timeoutMs?: number;
|
|
499
|
+
credentialHash?: string;
|
|
500
|
+
} = {},
|
|
501
|
+
): Promise<UsageSnapshot> {
|
|
502
|
+
if (credential.type !== "oauth" || !credential.access) {
|
|
503
|
+
throw new UsageFetchError(`${provider} has no OAuth access token`);
|
|
504
|
+
}
|
|
505
|
+
const fetchedAt = Date.now();
|
|
506
|
+
// The plan-only snapshot is the old, always-honest answer. Quota numbers are an enrichment
|
|
507
|
+
// on top of it: a network hiccup must not blank the footer or report a healthy account as
|
|
508
|
+
// "usage unavailable", so every failure except 401 falls back to it.
|
|
509
|
+
const fallback = (): UsageSnapshot => ({
|
|
510
|
+
provider,
|
|
511
|
+
family: "cursor",
|
|
512
|
+
fetchedAt,
|
|
513
|
+
credentialHash: options.credentialHash,
|
|
514
|
+
plan: "subscription",
|
|
515
|
+
});
|
|
516
|
+
const controller = new AbortController();
|
|
517
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
|
|
518
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
519
|
+
const call = async (method: string): Promise<Response> =>
|
|
520
|
+
fetchImpl(`${CURSOR_DASHBOARD_RPC_URL}/${method}`, {
|
|
521
|
+
method: "POST",
|
|
522
|
+
headers: {
|
|
523
|
+
Authorization: `Bearer ${credential.access}`,
|
|
524
|
+
Accept: "application/json",
|
|
525
|
+
"Content-Type": "application/json",
|
|
526
|
+
"Connect-Protocol-Version": "1",
|
|
527
|
+
},
|
|
528
|
+
body: "{}",
|
|
529
|
+
signal: controller.signal,
|
|
530
|
+
});
|
|
531
|
+
try {
|
|
532
|
+
const [period, sand] = await Promise.allSettled([
|
|
533
|
+
call("GetCurrentPeriodUsage"),
|
|
534
|
+
call("GetSandUsageStatus"),
|
|
535
|
+
]);
|
|
536
|
+
// A 401 is the one failure worth surfacing: the caller refreshes the token and retries.
|
|
537
|
+
for (const result of [period, sand]) {
|
|
538
|
+
if (result.status === "fulfilled" && result.value.status === 401) {
|
|
539
|
+
throw new UsageFetchError(
|
|
540
|
+
`${provider} Cursor usage endpoint returned HTTP 401`,
|
|
541
|
+
401,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const bodyOf = async (result: PromiseSettledResult<Response>): Promise<unknown> => {
|
|
546
|
+
if (result.status !== "fulfilled" || !result.value.ok) return undefined;
|
|
547
|
+
try {
|
|
548
|
+
return await result.value.json();
|
|
549
|
+
} catch {
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const periodBody = await bodyOf(period);
|
|
554
|
+
const sandBody = await bodyOf(sand);
|
|
555
|
+
const periodSnapshot =
|
|
556
|
+
periodBody !== undefined
|
|
557
|
+
? parseCursorCurrentPeriodUsage(provider, periodBody, fetchedAt, options.credentialHash)
|
|
558
|
+
: undefined;
|
|
559
|
+
const sandWindow = sandBody !== undefined ? parseCursorSandUsage(sandBody) : undefined;
|
|
560
|
+
if (!periodSnapshot && !sandWindow) return fallback();
|
|
561
|
+
const planName = record(sandBody).cursorPlanName;
|
|
562
|
+
return {
|
|
563
|
+
provider,
|
|
564
|
+
family: "cursor",
|
|
565
|
+
fetchedAt,
|
|
566
|
+
credentialHash: options.credentialHash,
|
|
567
|
+
plan: typeof planName === "string" && planName ? planName : "subscription",
|
|
568
|
+
primary: periodSnapshot?.primary,
|
|
569
|
+
secondary: periodSnapshot?.secondary,
|
|
570
|
+
tertiary: sandWindow,
|
|
571
|
+
};
|
|
572
|
+
} catch (error) {
|
|
573
|
+
if (error instanceof UsageFetchError) throw error;
|
|
574
|
+
return fallback();
|
|
575
|
+
} finally {
|
|
576
|
+
clearTimeout(timer);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** SuperGrok / X Premium OAuth billing probe. Not Cursor Grok and not XAI_API_KEY. */
|
|
581
|
+
export const XAI_SUBSCRIPTION_USAGE_URL =
|
|
582
|
+
"https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
|
583
|
+
|
|
584
|
+
function decodeJwtPayload(token: string): Record<string, any> | undefined {
|
|
585
|
+
const parts = token.split(".");
|
|
586
|
+
if (parts.length !== 3) return undefined;
|
|
587
|
+
try {
|
|
588
|
+
let payload = parts[1].replaceAll("-", "+").replaceAll("_", "/");
|
|
589
|
+
payload += "=".repeat((4 - (payload.length % 4)) % 4);
|
|
590
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
|
591
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, any>) : undefined;
|
|
592
|
+
} catch {
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function jwtClaimString(payload: Record<string, any>, key: string): string | undefined {
|
|
598
|
+
const value = payload[key];
|
|
599
|
+
if (typeof value !== "string") return undefined;
|
|
600
|
+
const trimmed = value.trim();
|
|
601
|
+
return trimmed ? trimmed : undefined;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* xAI billing requires the authenticated user id in `x-userid`.
|
|
606
|
+
* Read it from the access-token JWT (`sub`, then `principal_id`). Never invent it
|
|
607
|
+
* and never treat a generic OAuth `accountId` as an xAI user id.
|
|
608
|
+
*/
|
|
609
|
+
export function xaiUserIdFromAccessToken(token: string): string | undefined {
|
|
610
|
+
const payload = decodeJwtPayload(token);
|
|
611
|
+
if (!payload) return undefined;
|
|
612
|
+
return jwtClaimString(payload, "sub") ?? jwtClaimString(payload, "principal_id");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let cachedXaiClientVersion: string | undefined;
|
|
616
|
+
|
|
617
|
+
/** Truthful host id. Never impersonate an official Grok CLI version. */
|
|
618
|
+
export function xaiHostClientVersion(): string {
|
|
619
|
+
if (cachedXaiClientVersion) return cachedXaiClientVersion;
|
|
620
|
+
try {
|
|
621
|
+
const pkg = JSON.parse(
|
|
622
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8"),
|
|
623
|
+
) as { name?: unknown; version?: unknown };
|
|
624
|
+
const name =
|
|
625
|
+
typeof pkg.name === "string" && pkg.name.trim() ? pkg.name.trim() : "pi-multi-account";
|
|
626
|
+
const version =
|
|
627
|
+
typeof pkg.version === "string" && pkg.version.trim() ? pkg.version.trim() : undefined;
|
|
628
|
+
cachedXaiClientVersion = version ? `${name}/${version}` : name;
|
|
629
|
+
} catch {
|
|
630
|
+
cachedXaiClientVersion = "pi-multi-account";
|
|
631
|
+
}
|
|
632
|
+
return cachedXaiClientVersion;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function isGrokBuildProduct(name: unknown): boolean {
|
|
636
|
+
if (typeof name !== "string") return false;
|
|
637
|
+
const normalized = name.trim().toLowerCase().replace(/[_-]/g, "");
|
|
638
|
+
return normalized === "productgrokbuild" || normalized === "grokbuild";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function grokBuildUsagePercent(productUsage: unknown): number | undefined {
|
|
642
|
+
if (!Array.isArray(productUsage)) return undefined;
|
|
643
|
+
for (const item of productUsage) {
|
|
644
|
+
const product = record(item);
|
|
645
|
+
if (!isGrokBuildProduct(product.product)) continue;
|
|
646
|
+
const value = percent(product.usagePercent);
|
|
647
|
+
if (value !== undefined) return value;
|
|
648
|
+
}
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
type XaiUsagePeriod = Pick<UsageWindow, "resetAt" | "windowSeconds">;
|
|
653
|
+
|
|
654
|
+
function xaiUsagePeriod(value: unknown, fetchedAt: number): XaiUsagePeriod | undefined {
|
|
655
|
+
const source = record(value);
|
|
656
|
+
const start = epochMs(source.start);
|
|
657
|
+
const end = epochMs(source.end);
|
|
658
|
+
if (
|
|
659
|
+
start === undefined ||
|
|
660
|
+
end === undefined ||
|
|
661
|
+
end <= start ||
|
|
662
|
+
fetchedAt < start ||
|
|
663
|
+
fetchedAt >= end
|
|
664
|
+
) {
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
resetAt: end,
|
|
669
|
+
windowSeconds: Math.round((end - start) / 1000),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function xaiUsageSnapshot(
|
|
674
|
+
provider: string,
|
|
675
|
+
usedPercent: number,
|
|
676
|
+
period: XaiUsagePeriod,
|
|
677
|
+
fetchedAt: number,
|
|
678
|
+
credentialHash?: string,
|
|
679
|
+
): UsageSnapshot {
|
|
680
|
+
return {
|
|
681
|
+
provider,
|
|
682
|
+
family: "xai",
|
|
683
|
+
fetchedAt,
|
|
684
|
+
credentialHash,
|
|
685
|
+
primary: { usedPercent, ...period },
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function parseXaiUsageBody(
|
|
690
|
+
provider: string,
|
|
691
|
+
body: unknown,
|
|
692
|
+
fetchedAt = Date.now(),
|
|
693
|
+
credentialHash?: string,
|
|
694
|
+
): UsageSnapshot | undefined {
|
|
695
|
+
const source = record(body);
|
|
696
|
+
if (source.config === undefined || source.config === null) return undefined;
|
|
697
|
+
const config = record(source.config);
|
|
698
|
+
const modernPeriod = xaiUsagePeriod(config.currentPeriod, fetchedAt);
|
|
699
|
+
let modernUsedPercent = percent(config.creditUsagePercent);
|
|
700
|
+
if (modernUsedPercent === undefined) {
|
|
701
|
+
modernUsedPercent = grokBuildUsagePercent(config.productUsage);
|
|
702
|
+
}
|
|
703
|
+
if (modernUsedPercent !== undefined) {
|
|
704
|
+
return modernPeriod
|
|
705
|
+
? xaiUsageSnapshot(
|
|
706
|
+
provider,
|
|
707
|
+
modernUsedPercent,
|
|
708
|
+
modernPeriod,
|
|
709
|
+
fetchedAt,
|
|
710
|
+
credentialHash,
|
|
711
|
+
)
|
|
712
|
+
: undefined;
|
|
713
|
+
}
|
|
714
|
+
// A period alone is not evidence of unused quota. Private endpoint schema
|
|
715
|
+
// drift must never manufacture headroom and clear a real cooldown.
|
|
716
|
+
if (config.currentPeriod !== undefined) return undefined;
|
|
717
|
+
|
|
718
|
+
const legacyPeriod = xaiUsagePeriod(
|
|
719
|
+
{ start: config.billingPeriodStart, end: config.billingPeriodEnd },
|
|
720
|
+
fetchedAt,
|
|
721
|
+
);
|
|
722
|
+
if (!legacyPeriod) return undefined;
|
|
723
|
+
const monthlyLimit = finiteNumber(record(config.monthlyLimit).val);
|
|
724
|
+
const used = finiteNumber(record(config.used).val);
|
|
725
|
+
if (monthlyLimit === undefined || monthlyLimit <= 0 || used === undefined || used < 0) return undefined;
|
|
726
|
+
const legacyUsedPercent = percent((used / monthlyLimit) * 100);
|
|
727
|
+
return legacyUsedPercent === undefined
|
|
728
|
+
? undefined
|
|
729
|
+
: xaiUsageSnapshot(
|
|
730
|
+
provider,
|
|
731
|
+
legacyUsedPercent,
|
|
732
|
+
legacyPeriod,
|
|
733
|
+
fetchedAt,
|
|
734
|
+
credentialHash,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function fetchXaiUsageSnapshot(
|
|
739
|
+
provider: string,
|
|
740
|
+
credential: UsageCredential,
|
|
741
|
+
options: {
|
|
742
|
+
fetchImpl?: typeof fetch;
|
|
743
|
+
timeoutMs?: number;
|
|
744
|
+
credentialHash?: string;
|
|
745
|
+
} = {},
|
|
746
|
+
): Promise<UsageSnapshot> {
|
|
747
|
+
if (credential.type !== "oauth" || !credential.access) {
|
|
748
|
+
// XAI_API_KEY shares the `xai` provider id. It has no SuperGrok billing session,
|
|
749
|
+
// so do not fall through to a doomed OAuth probe that blanks the footer.
|
|
750
|
+
return {
|
|
751
|
+
provider,
|
|
752
|
+
family: "xai",
|
|
753
|
+
fetchedAt: Date.now(),
|
|
754
|
+
credentialHash: options.credentialHash,
|
|
755
|
+
plan: "api-key · no usage endpoint",
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const userId = xaiUserIdFromAccessToken(credential.access);
|
|
759
|
+
if (!userId) {
|
|
760
|
+
throw new UsageFetchError(`${provider} xAI access token has no user id`);
|
|
761
|
+
}
|
|
762
|
+
const controller = new AbortController();
|
|
763
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
|
|
764
|
+
try {
|
|
765
|
+
const response = await (options.fetchImpl ?? fetch)(XAI_SUBSCRIPTION_USAGE_URL, {
|
|
766
|
+
method: "GET",
|
|
767
|
+
headers: {
|
|
768
|
+
Authorization: `Bearer ${credential.access}`,
|
|
769
|
+
Accept: "application/json",
|
|
770
|
+
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
771
|
+
"x-userid": userId,
|
|
772
|
+
"x-grok-client-version": xaiHostClientVersion(),
|
|
773
|
+
"x-grok-client-mode": "headless",
|
|
774
|
+
},
|
|
775
|
+
signal: controller.signal,
|
|
776
|
+
});
|
|
777
|
+
if (!response.ok) {
|
|
778
|
+
throw new UsageFetchError(
|
|
779
|
+
`${provider} usage endpoint returned HTTP ${response.status}`,
|
|
780
|
+
response.status,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
const snapshot = parseXaiUsageBody(
|
|
784
|
+
provider,
|
|
785
|
+
await response.json(),
|
|
786
|
+
Date.now(),
|
|
787
|
+
options.credentialHash,
|
|
788
|
+
);
|
|
789
|
+
if (!snapshot) {
|
|
790
|
+
throw new UsageFetchError(`${provider} usage endpoint returned no quota window`);
|
|
791
|
+
}
|
|
792
|
+
return snapshot;
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (error instanceof UsageFetchError) throw error;
|
|
795
|
+
if ((error as any)?.name === "AbortError") {
|
|
796
|
+
throw new UsageFetchError(`${provider} usage request timed out`);
|
|
797
|
+
}
|
|
798
|
+
throw new UsageFetchError(
|
|
799
|
+
`${provider} usage request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
800
|
+
);
|
|
801
|
+
} finally {
|
|
802
|
+
clearTimeout(timer);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export const ZAI_CODING_CN_USAGE_URL = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
|
|
807
|
+
|
|
808
|
+
/** Only provider-reported model credit windows; MCP counts are not model quota. */
|
|
809
|
+
export function parseZaiCodingCnUsageBody(
|
|
810
|
+
provider: string, body: unknown, fetchedAt = Date.now(), credentialHash?: string,
|
|
811
|
+
): UsageSnapshot | undefined {
|
|
812
|
+
const source = record(body);
|
|
813
|
+
if (source.success === false || (source.code !== undefined && ![0, 200, "0", "200"].includes(source.code))) return undefined;
|
|
814
|
+
const data = record(source.data);
|
|
815
|
+
if (!Array.isArray(data.limits)) return undefined;
|
|
816
|
+
const snapshot: UsageSnapshot = { provider, family: "zai-coding-cn", fetchedAt, credentialHash };
|
|
817
|
+
if (typeof data.level === "string" && data.level.trim()) snapshot.plan = data.level.trim();
|
|
818
|
+
for (const value of data.limits) {
|
|
819
|
+
const item = record(value);
|
|
820
|
+
if (item.type !== "CREDIT_LIMIT" && item.type !== "TOKENS_LIMIT") continue;
|
|
821
|
+
const unit = finiteNumber(item.unit);
|
|
822
|
+
const count = finiteNumber(item.number);
|
|
823
|
+
const seconds = unit === 3 && count === 5 ? 5 * 3600 : unit === 6 && count === 1 ? 7 * 86400 : undefined;
|
|
824
|
+
const usedPercent = percent(item.percentage);
|
|
825
|
+
const resetAt = epochMs(item.nextResetTime);
|
|
826
|
+
if (!seconds || usedPercent === undefined || resetAt === undefined || resetAt <= fetchedAt) continue;
|
|
827
|
+
const key = seconds === 5 * 3600 ? "primary" : "secondary";
|
|
828
|
+
// Duplicate windows cannot make a depleted account appear healthier.
|
|
829
|
+
if (!snapshot[key] || usedPercent > snapshot[key]!.usedPercent)
|
|
830
|
+
snapshot[key] = { usedPercent, resetAt, windowSeconds: seconds };
|
|
831
|
+
}
|
|
832
|
+
return snapshot.primary || snapshot.secondary ? snapshot : undefined;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function fetchZaiCodingCnUsageSnapshot(
|
|
836
|
+
provider: string, credential: UsageCredential,
|
|
837
|
+
options: { fetchImpl?: typeof fetch; timeoutMs?: number; credentialHash?: string },
|
|
838
|
+
): Promise<UsageSnapshot> {
|
|
839
|
+
if (credential.type !== "api_key" || !credential.key) throw new UsageFetchError(`${provider} has no Coding Plan API key`);
|
|
840
|
+
try {
|
|
841
|
+
const response = await (options.fetchImpl ?? fetch)(ZAI_CODING_CN_USAGE_URL, {
|
|
842
|
+
headers: { Authorization: credential.key, Accept: "application/json" },
|
|
843
|
+
redirect: "error",
|
|
844
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
845
|
+
});
|
|
846
|
+
if (!response.ok) throw new UsageFetchError(`${provider} usage endpoint returned HTTP ${response.status}`, response.status);
|
|
847
|
+
const snapshot = parseZaiCodingCnUsageBody(provider, await response.json(), Date.now(), options.credentialHash);
|
|
848
|
+
if (!snapshot) throw new UsageFetchError(`${provider} usage endpoint returned no current quota window`);
|
|
849
|
+
return snapshot;
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (error instanceof UsageFetchError) throw error;
|
|
852
|
+
// Never echo provider bodies or fetch errors that could embed a credential.
|
|
853
|
+
throw new UsageFetchError(`${provider} usage request failed or timed out`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export async function fetchUsageSnapshot(
|
|
858
|
+
provider: string,
|
|
859
|
+
credential: UsageCredential,
|
|
860
|
+
options: {
|
|
861
|
+
fetchImpl?: typeof fetch;
|
|
862
|
+
timeoutMs?: number;
|
|
863
|
+
credentialHash?: string;
|
|
864
|
+
} = {},
|
|
865
|
+
): Promise<UsageSnapshot> {
|
|
866
|
+
const family = usageFamily(provider);
|
|
867
|
+
if (!family) throw new UsageFetchError(`Usage is not supported for ${provider}`);
|
|
868
|
+
|
|
869
|
+
if (family === "ollama") {
|
|
870
|
+
return fetchOllamaUsageSnapshot(provider, credential, options);
|
|
871
|
+
}
|
|
872
|
+
if (family === "cursor") {
|
|
873
|
+
return fetchCursorUsageSnapshot(provider, credential, options);
|
|
874
|
+
}
|
|
875
|
+
if (family === "kimi-coding") {
|
|
876
|
+
// Kimi For Coding is a subscription behind an API key, and it publishes no quota endpoint —
|
|
877
|
+
// /usage, /quota, /me, /subscription and the Moonshot balance path all 404 against
|
|
878
|
+
// api.kimi.com/coding. Falling through to the OAuth branch made every probe throw
|
|
879
|
+
// "has no OAuth access token" for a healthy key, blanking the footer and filling the log.
|
|
880
|
+
return {
|
|
881
|
+
provider,
|
|
882
|
+
family: "kimi-coding",
|
|
883
|
+
fetchedAt: Date.now(),
|
|
884
|
+
credentialHash: options.credentialHash,
|
|
885
|
+
plan: "subscription · no usage endpoint",
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (family === "qwen") {
|
|
889
|
+
// Qwen/Alibaba exposes no usage/quota endpoint over its API-key plans, so we
|
|
890
|
+
// report the plan honestly instead of attempting (and failing) an OAuth usage
|
|
891
|
+
// fetch. Keeps `limits` from throwing "not supported".
|
|
892
|
+
return {
|
|
893
|
+
provider,
|
|
894
|
+
family: "qwen",
|
|
895
|
+
fetchedAt: Date.now(),
|
|
896
|
+
credentialHash: options.credentialHash,
|
|
897
|
+
plan: "api-key · no usage endpoint",
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
if (family === "xai") return fetchXaiUsageSnapshot(provider, credential, options);
|
|
901
|
+
if (family === "zai-coding-cn") return fetchZaiCodingCnUsageSnapshot(provider, credential, options);
|
|
902
|
+
if (credential.type !== "oauth" || !credential.access) {
|
|
903
|
+
throw new UsageFetchError(`${provider} has no OAuth access token`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const controller = new AbortController();
|
|
907
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
|
|
908
|
+
const headers: Record<string, string> = {
|
|
909
|
+
Authorization: `Bearer ${credential.access}`,
|
|
910
|
+
Accept: "application/json",
|
|
911
|
+
};
|
|
912
|
+
let url: string;
|
|
913
|
+
if (family === "codex") {
|
|
914
|
+
url = "https://chatgpt.com/backend-api/wham/usage";
|
|
915
|
+
if (credential.accountId) headers["ChatGPT-Account-Id"] = credential.accountId;
|
|
916
|
+
} else {
|
|
917
|
+
url = "https://api.anthropic.com/api/oauth/usage";
|
|
918
|
+
headers["anthropic-beta"] = "oauth-2025-04-20";
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const response = await (options.fetchImpl ?? fetch)(url, {
|
|
923
|
+
method: "GET",
|
|
924
|
+
headers,
|
|
925
|
+
signal: controller.signal,
|
|
926
|
+
});
|
|
927
|
+
if (!response.ok) {
|
|
928
|
+
throw new UsageFetchError(`${provider} usage endpoint returned HTTP ${response.status}`, response.status);
|
|
929
|
+
}
|
|
930
|
+
const body = await response.json();
|
|
931
|
+
const snapshot =
|
|
932
|
+
family === "codex"
|
|
933
|
+
? parseCodexUsageBody(provider, body, Date.now(), options.credentialHash)
|
|
934
|
+
: parseAnthropicUsageBody(provider, body, Date.now(), options.credentialHash);
|
|
935
|
+
if (!snapshot) throw new UsageFetchError(`${provider} usage endpoint returned no 5h/7d windows`);
|
|
936
|
+
return snapshot;
|
|
937
|
+
} catch (error) {
|
|
938
|
+
if (error instanceof UsageFetchError) throw error;
|
|
939
|
+
if ((error as any)?.name === "AbortError") throw new UsageFetchError(`${provider} usage request timed out`);
|
|
940
|
+
throw new UsageFetchError(`${provider} usage request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
941
|
+
} finally {
|
|
942
|
+
clearTimeout(timer);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export function providerUsageLabel(provider: string): string {
|
|
947
|
+
const index = provider.match(/-account-(\d+)$/)?.[1];
|
|
948
|
+
if (provider.startsWith("openai-codex")) return index ? `Codex A${index}` : "Codex";
|
|
949
|
+
if (provider.startsWith("anthropic")) return index ? `Claude A${index}` : "Claude";
|
|
950
|
+
if (provider.startsWith("ollama")) return index ? `Ollama A${index}` : "Ollama";
|
|
951
|
+
if (provider.startsWith("cursor")) return index ? `Cursor A${index}` : "Cursor";
|
|
952
|
+
if (provider.startsWith("kimi-coding")) return index ? `Kimi A${index}` : "Kimi";
|
|
953
|
+
if (provider.startsWith("xai")) return index ? `xAI A${index}` : "xAI";
|
|
954
|
+
if (provider.startsWith("zai-coding-cn")) return index ? `GLM CN A${index}` : "GLM CN";
|
|
955
|
+
if (provider.startsWith("alibaba") || /^qwen/i.test(provider)) return index ? `Qwen A${index}` : "Qwen/Alibaba";
|
|
956
|
+
return provider;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export function remainingPercent(window: UsageWindow): number {
|
|
960
|
+
return Math.max(0, Math.round(100 - window.usedPercent));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export function formatResetDuration(resetAt: number, now = Date.now()): string {
|
|
964
|
+
const minutes = Math.max(0, Math.ceil((resetAt - now) / 60_000));
|
|
965
|
+
if (minutes < 60) return `${minutes}m`;
|
|
966
|
+
const hours = Math.floor(minutes / 60);
|
|
967
|
+
const restMinutes = minutes % 60;
|
|
968
|
+
if (hours < 24) return restMinutes ? `${hours}h${restMinutes}m` : `${hours}h`;
|
|
969
|
+
const days = Math.floor(hours / 24);
|
|
970
|
+
const restHours = hours % 24;
|
|
971
|
+
return restHours ? `${days}d${restHours}h` : `${days}d`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Keep an email readable in a one-line footer without letting it dominate the line. */
|
|
975
|
+
export function shortAccount(account: string | undefined): string | undefined {
|
|
976
|
+
if (!account) return undefined;
|
|
977
|
+
const local = account.includes("@") ? account.slice(0, account.indexOf("@")) : account;
|
|
978
|
+
return local.length > 18 ? `${local.slice(0, 17)}…` : local;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Where a window sits in the snapshot: the two rotation windows, or an extra metered pool. */
|
|
982
|
+
export type UsageWindowPosition = "primary" | "secondary" | "tertiary";
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Name a quota window by how long it actually is.
|
|
986
|
+
*
|
|
987
|
+
* The label used to be positional — whatever sat in the "primary" slot was called `5h` — but a
|
|
988
|
+
* Codex free plan meters a THIRTY-DAY window there. A number that resets next month then read as
|
|
989
|
+
* one resetting this afternoon, which is a materially different decision about whether to wait.
|
|
990
|
+
*/
|
|
991
|
+
export function windowLabel(
|
|
992
|
+
window: UsageWindow,
|
|
993
|
+
family: UsageFamily,
|
|
994
|
+
position: UsageWindowPosition,
|
|
995
|
+
): string {
|
|
996
|
+
// Cursor's pools are products, not durations: included models, the third-party pool, and the
|
|
997
|
+
// Grok Bot weekly bucket.
|
|
998
|
+
if (family === "cursor") {
|
|
999
|
+
if (position === "primary") return "models";
|
|
1000
|
+
if (position === "secondary") return "other";
|
|
1001
|
+
return "grok bot";
|
|
1002
|
+
}
|
|
1003
|
+
if (family === "ollama") return position === "primary" ? "session" : "weekly";
|
|
1004
|
+
const seconds = window.windowSeconds;
|
|
1005
|
+
if (!seconds) return position === "primary" ? "5h" : position === "secondary" ? "7d" : "usage";
|
|
1006
|
+
if (seconds >= 20 * 86_400) return "30d";
|
|
1007
|
+
if (seconds >= 6 * 86_400) return "7d";
|
|
1008
|
+
if (seconds >= 20 * 3_600) return "24h";
|
|
1009
|
+
return `${Math.max(1, Math.round(seconds / 3_600))}h`;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export function formatUsageCompact(snapshot: UsageSnapshot, now = Date.now()): string {
|
|
1013
|
+
const who = shortAccount(snapshot.account);
|
|
1014
|
+
const parts = [
|
|
1015
|
+
who
|
|
1016
|
+
? `${providerUsageLabel(snapshot.provider)} · ${who}`
|
|
1017
|
+
: providerUsageLabel(snapshot.provider),
|
|
1018
|
+
];
|
|
1019
|
+
// The plan is what decides how much quota those percentages are a percentage OF — a free slot
|
|
1020
|
+
// at 60% left and a Plus slot at 60% left are not comparable amounts of work.
|
|
1021
|
+
if (snapshot.plan && (snapshot.primary || snapshot.secondary || snapshot.tertiary)) parts.push(snapshot.plan);
|
|
1022
|
+
// The account's own answer, when it gave one. A percentage is arithmetic on one window and can
|
|
1023
|
+
// disagree with reality in both directions — an account reading 0% left was answering
|
|
1024
|
+
// `allowed: true`, and showing only the 0% is what makes a working account look dead.
|
|
1025
|
+
if (snapshot.serviceable === true) parts.push("ok");
|
|
1026
|
+
else if (snapshot.serviceable === false) parts.push("spent");
|
|
1027
|
+
if (snapshot.primary) {
|
|
1028
|
+
parts.push(
|
|
1029
|
+
`${windowLabel(snapshot.primary, snapshot.family, "primary")} ${remainingPercent(snapshot.primary)}% left/${formatResetDuration(snapshot.primary.resetAt, now)}`,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
if (snapshot.secondary) {
|
|
1033
|
+
parts.push(
|
|
1034
|
+
`${windowLabel(snapshot.secondary, snapshot.family, "secondary")} ${remainingPercent(snapshot.secondary)}% left/${formatResetDuration(snapshot.secondary.resetAt, now)}`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
if (snapshot.tertiary) {
|
|
1038
|
+
parts.push(
|
|
1039
|
+
`${windowLabel(snapshot.tertiary, snapshot.family, "tertiary")} ${remainingPercent(snapshot.tertiary)}% left/${formatResetDuration(snapshot.tertiary.resetAt, now)}`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (!snapshot.primary && !snapshot.secondary && !snapshot.tertiary && snapshot.plan) {
|
|
1043
|
+
if (snapshot.family === "ollama") {
|
|
1044
|
+
parts.push(`${snapshot.plan} · quota unavailable`);
|
|
1045
|
+
} else {
|
|
1046
|
+
parts.push(snapshot.plan);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return parts.join(" | ");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
export function formatUsageDetails(snapshot: UsageSnapshot, now = Date.now()): string {
|
|
1053
|
+
const lines = [
|
|
1054
|
+
`Limits for ${providerUsageLabel(snapshot.provider)}${snapshot.account ? ` — ${snapshot.account}` : ""}${snapshot.plan ? ` (${snapshot.plan})` : ""}`,
|
|
1055
|
+
];
|
|
1056
|
+
if (snapshot.serviceable !== undefined)
|
|
1057
|
+
lines.push(
|
|
1058
|
+
snapshot.serviceable
|
|
1059
|
+
? "The account reports it can be used right now."
|
|
1060
|
+
: "The account reports it is currently blocked, whatever the percentages below say.",
|
|
1061
|
+
);
|
|
1062
|
+
if (!snapshot.primary && !snapshot.secondary && !snapshot.tertiary && snapshot.plan) {
|
|
1063
|
+
if (snapshot.family === "ollama") {
|
|
1064
|
+
lines.push(
|
|
1065
|
+
`Plan: ${snapshot.plan}. Session/weekly quota is currently unavailable — check https://ollama.com/settings`,
|
|
1066
|
+
);
|
|
1067
|
+
} else {
|
|
1068
|
+
lines.push(`Status: ${snapshot.plan}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
for (const [position, window] of [
|
|
1072
|
+
["primary", snapshot.primary],
|
|
1073
|
+
["secondary", snapshot.secondary],
|
|
1074
|
+
["tertiary", snapshot.tertiary],
|
|
1075
|
+
] as const) {
|
|
1076
|
+
if (!window) continue;
|
|
1077
|
+
const label =
|
|
1078
|
+
snapshot.family === "ollama" && position === "primary"
|
|
1079
|
+
? "session"
|
|
1080
|
+
: windowLabel(window, snapshot.family, position);
|
|
1081
|
+
lines.push(
|
|
1082
|
+
`${label}: ${remainingPercent(window)}% left (${Math.round(window.usedPercent)}% used), resets in ${formatResetDuration(window.resetAt, now)} at ${new Date(window.resetAt).toLocaleString()}`,
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
if (snapshot.credits?.unlimited) lines.push("Credits: unlimited");
|
|
1086
|
+
else if (snapshot.credits?.balance !== undefined) lines.push(`Credits: ${snapshot.credits.balance}`);
|
|
1087
|
+
lines.push(`Updated ${formatResetDuration(now, snapshot.fetchedAt)} ago`);
|
|
1088
|
+
return lines.join("\n");
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
export function usageColor(snapshot: UsageSnapshot): "success" | "warning" | "error" {
|
|
1092
|
+
const remaining = [snapshot.primary, snapshot.secondary]
|
|
1093
|
+
.filter((window): window is UsageWindow => !!window)
|
|
1094
|
+
.map(remainingPercent);
|
|
1095
|
+
const lowest = remaining.length > 0 ? Math.min(...remaining) : 100;
|
|
1096
|
+
if (lowest <= 10) return "error";
|
|
1097
|
+
if (lowest <= 30) return "warning";
|
|
1098
|
+
return "success";
|
|
1099
|
+
}
|