@jameslovespancakes/pi-plus 1.0.0
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/LICENSE +21 -0
- package/README.md +190 -0
- package/config/pi-plus.example.json +60 -0
- package/config/skills/model-routing/SKILL.md +86 -0
- package/images/board_demo.png +0 -0
- package/images/pi-plus.svg +10 -0
- package/images/pi-plus_demo.png +0 -0
- package/images/provider_demo.png +0 -0
- package/images/remote_demo.png +0 -0
- package/images/usage_demo.png +0 -0
- package/package.json +67 -0
- package/server/board-server.mjs +641 -0
- package/server/package.json +17 -0
- package/src/core/accounts/registry.ts +93 -0
- package/src/core/anthropic/client-identity.ts +241 -0
- package/src/core/anthropic/models.ts +69 -0
- package/src/core/anthropic/oauth.ts +208 -0
- package/src/core/anthropic/quota.ts +253 -0
- package/src/core/anthropic/routing.ts +168 -0
- package/src/core/anthropic/store.ts +225 -0
- package/src/core/anthropic/vendor/README.md +36 -0
- package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
- package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
- package/src/core/anthropic/xxhash64.ts +33 -0
- package/src/core/catalog/quality.ts +314 -0
- package/src/core/codex/oauth.ts +129 -0
- package/src/core/codex/quota.ts +88 -0
- package/src/core/codex/store.ts +97 -0
- package/src/core/config.ts +169 -0
- package/src/core/env.ts +58 -0
- package/src/core/exec/process.ts +146 -0
- package/src/core/exec/ssh-config.ts +157 -0
- package/src/core/oauth/pkce.ts +88 -0
- package/src/core/policy/policy.ts +183 -0
- package/src/core/quota/pool.ts +64 -0
- package/src/core/quota/usage-source.ts +289 -0
- package/src/core/store.ts +43 -0
- package/src/domains/agents/board-setup.ts +409 -0
- package/src/domains/agents/index.ts +462 -0
- package/src/domains/models/catalog-tool.ts +361 -0
- package/src/domains/models/index.ts +14 -0
- package/src/domains/models/policy-gate.ts +169 -0
- package/src/domains/models/provider-picker.ts +208 -0
- package/src/domains/remote/config-path.ts +41 -0
- package/src/domains/remote/index.ts +866 -0
- package/src/domains/remote/setup.ts +425 -0
- package/src/domains/setup/index.ts +220 -0
- package/src/domains/subscriptions/accounts-picker.ts +178 -0
- package/src/domains/subscriptions/accounts.ts +242 -0
- package/src/domains/subscriptions/footer.ts +182 -0
- package/src/domains/subscriptions/index.ts +42 -0
- package/src/domains/subscriptions/provider.ts +219 -0
- package/src/domains/subscriptions/providers/anthropic.ts +149 -0
- package/src/domains/subscriptions/providers/codex.ts +148 -0
- package/src/domains/subscriptions/routing.ts +72 -0
- package/src/services/usage-service.ts +186 -0
- package/src/ui/format.ts +73 -0
- package/src/ui/usage-bars.ts +154 -0
- package/src/vendor/anthropic.ts +109 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) helpers.
|
|
3
|
+
*
|
|
4
|
+
* Provider-agnostic: nothing here knows about Anthropic. Any OAuth provider we
|
|
5
|
+
* add later shares this file, and only its endpoints and scopes differ.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
9
|
+
let binary = "";
|
|
10
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
11
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PkcePair {
|
|
15
|
+
verifier: string;
|
|
16
|
+
challenge: string;
|
|
17
|
+
method: "S256";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 64 random bytes as the verifier, SHA-256 as the challenge. */
|
|
21
|
+
export async function generatePkce(): Promise<PkcePair> {
|
|
22
|
+
const buffer = new Uint8Array(64);
|
|
23
|
+
crypto.getRandomValues(buffer);
|
|
24
|
+
const verifier = base64UrlEncode(buffer);
|
|
25
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
26
|
+
return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)), method: "S256" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Opaque anti-CSRF value echoed back by the authorization server. */
|
|
30
|
+
export function generateState(): string {
|
|
31
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Accepts whatever the user pasted back: a full callback URL, a bare
|
|
36
|
+
* `code#state` pair, or a raw query string. Returns undefined when none match.
|
|
37
|
+
*/
|
|
38
|
+
export function parseCallback(input: string): { code: string; state: string } | undefined {
|
|
39
|
+
const trimmed = input.trim();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(trimmed);
|
|
43
|
+
const code = url.searchParams.get("code");
|
|
44
|
+
const state = url.searchParams.get("state");
|
|
45
|
+
if (code && state) return { code, state };
|
|
46
|
+
} catch {
|
|
47
|
+
// Not a URL; fall through to the manual formats.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const [head, tail] = trimmed.split("#");
|
|
51
|
+
if (head && tail) return { code: head, state: tail };
|
|
52
|
+
|
|
53
|
+
const params = new URLSearchParams(trimmed);
|
|
54
|
+
const code = params.get("code");
|
|
55
|
+
const state = params.get("state");
|
|
56
|
+
return code && state ? { code, state } : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const TRANSIENT_CODES = new Set([
|
|
60
|
+
"EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH",
|
|
61
|
+
"ENETUNREACH", "ENOTFOUND", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Worth retrying: a dropped connection rather than a rejected credential. */
|
|
65
|
+
export function isTransientNetworkError(error: unknown): boolean {
|
|
66
|
+
const field = (name: string) =>
|
|
67
|
+
error && typeof error === "object" && typeof (error as any)[name] === "string"
|
|
68
|
+
? (error as any)[name] as string
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
const code = field("code");
|
|
72
|
+
if (code && TRANSIENT_CODES.has(code)) return true;
|
|
73
|
+
|
|
74
|
+
const message = error instanceof Error ? error.message : (field("message") ?? String(error));
|
|
75
|
+
if (message.includes("fetch failed")) return true;
|
|
76
|
+
return [...TRANSIENT_CODES].some((candidate) => message.includes(candidate));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `Retry-After` as seconds, accepting both the numeric and HTTP-date forms. */
|
|
80
|
+
export function parseRetryAfter(value: string | null | undefined): number | undefined {
|
|
81
|
+
if (!value) return undefined;
|
|
82
|
+
const seconds = Number(value);
|
|
83
|
+
if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds);
|
|
84
|
+
const date = Date.parse(value);
|
|
85
|
+
if (!Number.isFinite(date)) return undefined;
|
|
86
|
+
const delta = Math.ceil((date - Date.now()) / 1000);
|
|
87
|
+
return delta > 0 ? delta : undefined;
|
|
88
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { readConfig, updateConfig } from "../config.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Approval policy for model selection.
|
|
5
|
+
*
|
|
6
|
+
* Subscription providers are free to use. Metered providers (OpenRouter and
|
|
7
|
+
* anything else that bills per token) require an explicit approval before a
|
|
8
|
+
* request is allowed to leave the machine.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface PolicyFile {
|
|
12
|
+
autoApprove: string[];
|
|
13
|
+
requireApproval: string[];
|
|
14
|
+
deny: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_POLICY: PolicyFile = {
|
|
18
|
+
autoApprove: ["anthropic/*", "openai-codex/*"],
|
|
19
|
+
requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
|
|
20
|
+
deny: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type Decision =
|
|
24
|
+
| { allowed: true; reason: "auto" | "approved" }
|
|
25
|
+
| { allowed: false; reason: "denied" | "needs-approval"; message: string };
|
|
26
|
+
|
|
27
|
+
const approvals = new Map<string, number>();
|
|
28
|
+
|
|
29
|
+
export function loadPolicy(): PolicyFile {
|
|
30
|
+
return readConfig().policy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function savePolicy(next: PolicyFile): void {
|
|
34
|
+
updateConfig((config) => {
|
|
35
|
+
config.policy = {
|
|
36
|
+
autoApprove: next.autoApprove ?? DEFAULT_POLICY.autoApprove,
|
|
37
|
+
requireApproval: next.requireApproval ?? DEFAULT_POLICY.requireApproval,
|
|
38
|
+
deny: next.deny ?? DEFAULT_POLICY.deny,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matches(pattern: string, value: string): boolean {
|
|
44
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
45
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchesAny(patterns: string[], value: string): boolean {
|
|
49
|
+
return patterns.some((pattern) => matches(pattern, value));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Provider ids that require approval, derived from the policy patterns so the
|
|
54
|
+
* toggle list always reflects the configured file rather than a hardcoded set.
|
|
55
|
+
*/
|
|
56
|
+
export function gatedProviders(): string[] {
|
|
57
|
+
const names = loadPolicy().requireApproval
|
|
58
|
+
.map((pattern) => pattern.split("/")[0])
|
|
59
|
+
.filter((name) => name && !name.includes("*"));
|
|
60
|
+
return [...new Set(names)].sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ProviderState = "auto" | "approved" | "blocked" | "denied";
|
|
64
|
+
|
|
65
|
+
/** How the policy currently treats a provider, for display and toggling. */
|
|
66
|
+
export function providerState(provider: string): ProviderState {
|
|
67
|
+
const current = loadPolicy();
|
|
68
|
+
if (matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) return "denied";
|
|
69
|
+
if (matchesAny(current.requireApproval, `${provider}/*`)) {
|
|
70
|
+
return isApproved(provider) ? "approved" : "blocked";
|
|
71
|
+
}
|
|
72
|
+
return "auto";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Flips whether a provider may be used.
|
|
77
|
+
*
|
|
78
|
+
* Auto-approved providers are moved into `requireApproval` so the switch is
|
|
79
|
+
* reversible; gated ones just gain or lose their session grant. Denied
|
|
80
|
+
* providers are left alone; `deny` is an explicit, deliberate block.
|
|
81
|
+
*/
|
|
82
|
+
export function toggleProvider(provider: string): ProviderState {
|
|
83
|
+
const state = providerState(provider);
|
|
84
|
+
if (state === "denied") return state;
|
|
85
|
+
|
|
86
|
+
if (state === "auto") {
|
|
87
|
+
const current = loadPolicy();
|
|
88
|
+
savePolicy({
|
|
89
|
+
...current,
|
|
90
|
+
autoApprove: current.autoApprove.filter((pattern) => !matches(pattern, `${provider}/*`) && pattern !== `${provider}/*`),
|
|
91
|
+
requireApproval: [...new Set([...current.requireApproval, `${provider}/*`])],
|
|
92
|
+
});
|
|
93
|
+
revoke(provider);
|
|
94
|
+
return "blocked";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (state === "approved") {
|
|
98
|
+
revoke(provider);
|
|
99
|
+
return "blocked";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
approve(provider);
|
|
103
|
+
return "approved";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Grant approval for a provider until `untilMs` (omitted means this session). */
|
|
107
|
+
export function approve(provider: string, durationMs?: number): void {
|
|
108
|
+
approvals.set(provider, durationMs ? Date.now() + durationMs : Number.MAX_SAFE_INTEGER);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function revoke(provider: string): void {
|
|
112
|
+
approvals.delete(provider);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isApproved(provider: string): boolean {
|
|
116
|
+
const until = approvals.get(provider);
|
|
117
|
+
return until !== undefined && Date.now() < until;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Flips a provider's approval and reports the resulting state. */
|
|
121
|
+
export function toggleApproval(provider: string): boolean {
|
|
122
|
+
if (isApproved(provider)) {
|
|
123
|
+
revoke(provider);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
approve(provider);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ProviderApproval {
|
|
131
|
+
provider: string;
|
|
132
|
+
approved: boolean;
|
|
133
|
+
until?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Current approval state for every gated provider, for display. */
|
|
137
|
+
export function approvalStates(): ProviderApproval[] {
|
|
138
|
+
return gatedProviders().map((provider) => {
|
|
139
|
+
const until = approvals.get(provider);
|
|
140
|
+
const approved = until !== undefined && Date.now() < until;
|
|
141
|
+
return { provider, approved, until: approved && until !== Number.MAX_SAFE_INTEGER ? until : undefined };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function checkModel(provider: string, modelId: string): Decision {
|
|
146
|
+
const current = loadPolicy();
|
|
147
|
+
const ref = `${provider}/${modelId}`;
|
|
148
|
+
|
|
149
|
+
if (matchesAny(current.deny, ref) || matchesAny(current.deny, `${provider}/*`)) {
|
|
150
|
+
return { allowed: false, reason: "denied", message: `${ref} is denied by model policy.` };
|
|
151
|
+
}
|
|
152
|
+
if (matchesAny(current.autoApprove, ref)) return { allowed: true, reason: "auto" };
|
|
153
|
+
|
|
154
|
+
const gated = matchesAny(current.requireApproval, ref);
|
|
155
|
+
if (!gated) return { allowed: true, reason: "auto" };
|
|
156
|
+
|
|
157
|
+
if (!isApproved(provider)) {
|
|
158
|
+
return {
|
|
159
|
+
allowed: false,
|
|
160
|
+
reason: "needs-approval",
|
|
161
|
+
message:
|
|
162
|
+
`${ref} is a metered (pay-per-token) model and is not approved in this session. `
|
|
163
|
+
+ `Use a subscription model such as anthropic/* or openai-codex/*, or ask the user to run `
|
|
164
|
+
+ `/provider approve ${provider}.`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { allowed: true, reason: "approved" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function policySummary(): string {
|
|
172
|
+
const current = loadPolicy();
|
|
173
|
+
const active = approvalStates()
|
|
174
|
+
.filter((entry) => entry.approved)
|
|
175
|
+
.map((entry) => `${entry.provider}${entry.until ? ` (until ${new Date(entry.until).toLocaleTimeString()})` : " (session)"}`);
|
|
176
|
+
return [
|
|
177
|
+
"Model approval policy",
|
|
178
|
+
` auto-approved: ${current.autoApprove.join(", ") || "none"}`,
|
|
179
|
+
` needs approval: ${current.requireApproval.join(", ") || "none"}`,
|
|
180
|
+
` denied: ${current.deny.join(", ") || "none"}`,
|
|
181
|
+
` approved now: ${active.join(", ") || "none"}`,
|
|
182
|
+
].join("\n");
|
|
183
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type UsageRow = {
|
|
2
|
+
group: string;
|
|
3
|
+
label: string;
|
|
4
|
+
remaining: number;
|
|
5
|
+
resetAt?: number;
|
|
6
|
+
checkedAt?: number;
|
|
7
|
+
stale?: boolean;
|
|
8
|
+
capacity?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* How old a usage sample may be and still be poolable.
|
|
13
|
+
*
|
|
14
|
+
* This was 6 minutes when every refresh fetched the usage endpoint live.
|
|
15
|
+
* Quota now comes from response headers, backed by a poll at most once per
|
|
16
|
+
* 10 minutes, so a 6 minute bound marked idle accounts stale almost all the
|
|
17
|
+
* time. It must stay comfortably above that poll interval; the underlying
|
|
18
|
+
* windows are 5 hours and 7 days, so a sample minutes old is still accurate.
|
|
19
|
+
*/
|
|
20
|
+
export const CLAUDE_FRESH_MS = 12 * 60_000;
|
|
21
|
+
export const isClaudeAccount = (row: UsageRow) => row.group.startsWith("Claude ") && !row.group.startsWith("Claude pool ×");
|
|
22
|
+
export const isFresh = (row: UsageRow, now = Date.now()) => !row.stale && !!row.checkedAt
|
|
23
|
+
&& now - row.checkedAt < CLAUDE_FRESH_MS && (!row.resetAt || row.resetAt > now);
|
|
24
|
+
|
|
25
|
+
/** Percent of combined capacity, not a claim that quota transfers between accounts.
|
|
26
|
+
* Without published capacities this is explicitly an equal-account estimate.
|
|
27
|
+
*/
|
|
28
|
+
export function combinedWindow(rows: UsageRow[], label: string, expected: number, now = Date.now(), allowPartial = false) {
|
|
29
|
+
const matching = rows.filter((r) => isClaudeAccount(r) && r.label === label && isFresh(r, now));
|
|
30
|
+
const partial = matching.length !== expected;
|
|
31
|
+
if (!expected || !matching.length || (partial && !allowPartial)) return undefined;
|
|
32
|
+
const weighted = matching.every((r) => typeof r.capacity === "number" && r.capacity > 0);
|
|
33
|
+
const total = matching.reduce((sum, r) => sum + (weighted ? r.capacity! : 1), 0);
|
|
34
|
+
const remaining = matching.reduce((sum, r) => sum + r.remaining * (weighted ? r.capacity! : 1), 0) / total;
|
|
35
|
+
const resets = matching.map((r) => r.resetAt).filter((t): t is number => !!t && t > now);
|
|
36
|
+
return { label, remaining, resetAt: resets.length ? Math.min(...resets) : undefined, estimated: !weighted, partial };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function scopedLabels(rows: UsageRow[], modelId?: string): string[] {
|
|
40
|
+
const labels = [...new Set(rows.filter(isClaudeAccount).map((r) => r.label).filter((l) => l.startsWith("7d ")))];
|
|
41
|
+
const model = modelId?.toLowerCase() ?? "";
|
|
42
|
+
return labels.sort((a, b) => Number(matchesScope(b, model)) - Number(matchesScope(a, model)) || a.localeCompare(b));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchesScope(label: string, model: string): boolean {
|
|
46
|
+
const family = label.slice(3).toLowerCase();
|
|
47
|
+
return model.includes(family) || (family === "fable" && model.includes("mythos"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An account must pass ALL applicable windows; independent averages cannot answer this. */
|
|
51
|
+
export function poolAvailability(rows: UsageRow[], expected: number, modelId?: string, now = Date.now()) {
|
|
52
|
+
const groups = [...new Set(rows.filter(isClaudeAccount).map((r) => r.group))];
|
|
53
|
+
let ready = 0;
|
|
54
|
+
let unknown = Math.max(0, expected - groups.length);
|
|
55
|
+
for (const group of groups) {
|
|
56
|
+
const account = rows.filter((r) => r.group === group);
|
|
57
|
+
const required = ["5h", "7d", ...scopedLabels(account).filter((l) => matchesScope(l, modelId?.toLowerCase() ?? ""))];
|
|
58
|
+
const windows = required.map((label) => account.find((r) => r.label === label));
|
|
59
|
+
if (windows.some((r) => r && isFresh(r, now) && r.remaining <= 0)) continue;
|
|
60
|
+
if (windows.some((r) => !r || !isFresh(r, now))) { unknown++; continue; }
|
|
61
|
+
ready++;
|
|
62
|
+
}
|
|
63
|
+
return { ready, unknown, total: expected };
|
|
64
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadAccounts, saveAccount, type Account as AnthropicAccount } from "../anthropic/store.ts";
|
|
5
|
+
import { refreshToken } from "../anthropic/oauth.ts";
|
|
6
|
+
import { loadCodexAccounts } from "../codex/store.ts";
|
|
7
|
+
import type { UsageRow } from "./pool.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetches subscription quota from the Claude and Codex endpoints.
|
|
11
|
+
*
|
|
12
|
+
* Pure data access: no pi imports, no module-level mutable state, no rendering.
|
|
13
|
+
* Everything here returns values so it can be tested without a live agent.
|
|
14
|
+
* Scheduling, caching and retention belong to services/usage-service.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TIMEOUT_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
export interface SourceResult {
|
|
20
|
+
rows: UsageRow[];
|
|
21
|
+
errors: string[];
|
|
22
|
+
groups: string[];
|
|
23
|
+
codexPlan?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pct(value: unknown): number | undefined {
|
|
27
|
+
if (value == null || typeof value === "boolean" || (typeof value === "string" && !value.trim())) return undefined;
|
|
28
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
29
|
+
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resetToMs(value: unknown): number | undefined {
|
|
33
|
+
if (typeof value === "number" && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value;
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
const parsed = Date.parse(value);
|
|
36
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function claudeUsage(group: string, token: string): Promise<{ rows: UsageRow[]; error?: string }> {
|
|
42
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
43
|
+
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json" },
|
|
44
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) return { rows: [], error: `${group}: HTTP ${response.status}` };
|
|
47
|
+
const body = await response.json() as any;
|
|
48
|
+
|
|
49
|
+
const rows: UsageRow[] = [];
|
|
50
|
+
const push = (label: string, window: any) => {
|
|
51
|
+
const used = pct(window?.utilization);
|
|
52
|
+
if (used === undefined) return;
|
|
53
|
+
rows.push({
|
|
54
|
+
group,
|
|
55
|
+
label,
|
|
56
|
+
remaining: 100 - used,
|
|
57
|
+
resetAt: resetToMs(window?.resets_at),
|
|
58
|
+
capacity: typeof window?.limit_dollars === "number" && window.limit_dollars > 0 ? window.limit_dollars : undefined,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
push("5h", body.five_hour);
|
|
62
|
+
push("7d", body.seven_day);
|
|
63
|
+
push("7d Opus", body.seven_day_opus ?? body.seven_day_omelette);
|
|
64
|
+
push("7d Sonnet", body.seven_day_sonnet);
|
|
65
|
+
|
|
66
|
+
for (const limit of Array.isArray(body.limits) ? body.limits : []) {
|
|
67
|
+
const scoped = limit?.scope?.model?.display_name;
|
|
68
|
+
const used = pct(limit?.percent);
|
|
69
|
+
if (!scoped || used === undefined) continue;
|
|
70
|
+
const label = `7d ${scoped}`;
|
|
71
|
+
if (rows.some((row) => row.label === label)) continue;
|
|
72
|
+
rows.push({ group, label, remaining: 100 - used, resetAt: resetToMs(limit?.resets_at) });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const extra = body.extra_usage;
|
|
76
|
+
if (extra?.is_enabled && pct(extra?.utilization) !== undefined) {
|
|
77
|
+
rows.push({ group, label: "Extra", remaining: 100 - pct(extra.utilization)! });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return rows.length
|
|
81
|
+
? { rows: rows.map((row) => ({ ...row, checkedAt: Date.now() })) }
|
|
82
|
+
: { rows: [], error: `${group}: usage windows unavailable` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* HUD polling must never wait indefinitely on OAuth refresh or retry it behind
|
|
87
|
+
* a live agent turn. The provider owns request-time refresh/retry policy.
|
|
88
|
+
*/
|
|
89
|
+
async function fallbackAccountToken(account: AnthropicAccount): Promise<string | undefined> {
|
|
90
|
+
const valid = typeof account.expires === "number" && Date.now() + 60_000 < account.expires;
|
|
91
|
+
if (valid && account.access) return account.access;
|
|
92
|
+
if (!account.refresh) return account.access;
|
|
93
|
+
|
|
94
|
+
const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
|
|
95
|
+
saveAccount({
|
|
96
|
+
...account,
|
|
97
|
+
access: refreshed.access,
|
|
98
|
+
refresh: refreshed.refresh,
|
|
99
|
+
expires: refreshed.expires,
|
|
100
|
+
lastRefreshedAt: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
return refreshed.access;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds usage rows from a stored quota snapshot.
|
|
108
|
+
*
|
|
109
|
+
* Snapshots are refreshed for free from response headers on every request, so
|
|
110
|
+
* serving the HUD from them avoids touching `/api/oauth/usage` at all. That
|
|
111
|
+
* endpoint rate limits aggressively, and /usage polling several accounts was a
|
|
112
|
+
* reliable way to get 429s and then show nothing.
|
|
113
|
+
*
|
|
114
|
+
* Returns undefined when there is no snapshot yet, so the caller can fetch.
|
|
115
|
+
*/
|
|
116
|
+
function rowsFromSnapshot(group: string, quota: any): UsageRow[] | undefined {
|
|
117
|
+
if (!quota) return undefined;
|
|
118
|
+
const rows: UsageRow[] = [];
|
|
119
|
+
const push = (label: string, window: any) => {
|
|
120
|
+
if (typeof window?.remainingPercent !== "number") return;
|
|
121
|
+
rows.push({
|
|
122
|
+
group,
|
|
123
|
+
label,
|
|
124
|
+
remaining: window.remainingPercent,
|
|
125
|
+
resetAt: resetToMs(window.resetsAt),
|
|
126
|
+
// Required: pool.isFresh discards any row without it, which would make
|
|
127
|
+
// every cached row pool as "n/a".
|
|
128
|
+
checkedAt: window.checkedAt ?? quota.checkedAt ?? Date.now(),
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
push("5h", quota.five_hour);
|
|
132
|
+
push("7d", quota.seven_day);
|
|
133
|
+
for (const scoped of Array.isArray(quota.scoped) ? quota.scoped : []) {
|
|
134
|
+
if (typeof scoped?.remainingPercent !== "number" || !scoped?.id) continue;
|
|
135
|
+
push(`7d ${scoped.id}`, scoped);
|
|
136
|
+
}
|
|
137
|
+
return rows.length ? rows : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function fetchClaudeRows(ctx: any): Promise<{ rows: UsageRow[]; errors: string[]; groups: string[] }> {
|
|
141
|
+
const rows: UsageRow[] = [];
|
|
142
|
+
const errors: string[] = [];
|
|
143
|
+
const accounts: Array<{ group: string; token?: string; account?: AnthropicAccount }> = [];
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const token = (await ctx.modelRegistry.getProviderAuth("anthropic"))?.auth?.apiKey;
|
|
147
|
+
if (token) accounts.push({ group: "Claude Personal", token });
|
|
148
|
+
else errors.push("Claude Personal: not logged in");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
errors.push(`Claude Personal: ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const storage = loadAccounts();
|
|
155
|
+
for (const account of storage?.accounts ?? []) {
|
|
156
|
+
if (account.type !== "oauth" || account.enabled === false) continue;
|
|
157
|
+
accounts.push({ group: `Claude ${account.label ?? account.id.slice(0, 8)}`, account });
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
errors.push(`Claude accounts: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const entry of accounts) {
|
|
164
|
+
try {
|
|
165
|
+
// Cached snapshot first: it is free, current, and cannot be throttled.
|
|
166
|
+
const cached = rowsFromSnapshot(entry.group, entry.account?.quota);
|
|
167
|
+
if (cached) {
|
|
168
|
+
rows.push(...cached);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const token = entry.token ?? (entry.account ? await fallbackAccountToken(entry.account) : undefined);
|
|
173
|
+
if (!token) {
|
|
174
|
+
errors.push(`${entry.group}: no token`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const result = await claudeUsage(entry.group, token);
|
|
178
|
+
rows.push(...result.rows);
|
|
179
|
+
if (result.error) errors.push(result.error);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
+
errors.push(/invalid_grant/i.test(message)
|
|
183
|
+
? `${entry.group}: login expired, run /accounts reauth ${entry.account?.label ?? entry.account?.id ?? ""}`.trim()
|
|
184
|
+
: `${entry.group}: ${message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { rows, errors, groups: [...new Set(["Claude Personal", ...accounts.map((entry) => entry.group)])] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function codexAccountId(): string | undefined {
|
|
192
|
+
// Prefer an enabled account from our own pool, so /usage reflects the
|
|
193
|
+
// accounts /accounts manages rather than only pi's single credential.
|
|
194
|
+
try {
|
|
195
|
+
const pooled = loadCodexAccounts().accounts.find((a) => a.enabled !== false && a.accountId);
|
|
196
|
+
if (pooled?.accountId) return pooled.accountId;
|
|
197
|
+
} catch { /* fall through */ }
|
|
198
|
+
try {
|
|
199
|
+
const auth = JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8"));
|
|
200
|
+
const credential = auth["openai-codex"];
|
|
201
|
+
if (credential?.accountId) return credential.accountId;
|
|
202
|
+
if (credential?.account_id) return credential.account_id;
|
|
203
|
+
} catch { /* fall through */ }
|
|
204
|
+
try {
|
|
205
|
+
const codex = JSON.parse(readFileSync(join(homedir(), ".codex", "auth.json"), "utf8"));
|
|
206
|
+
return codex?.tokens?.account_id ?? codex?.tokens?.accountId;
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function fetchCodexRows(ctx: any): Promise<{ rows: UsageRow[]; error?: string; plan?: string }> {
|
|
213
|
+
try {
|
|
214
|
+
const token = (await ctx.modelRegistry.getProviderAuth("openai-codex"))?.auth?.apiKey;
|
|
215
|
+
const accountId = codexAccountId();
|
|
216
|
+
if (!token) return { rows: [], error: "Codex: not logged in" };
|
|
217
|
+
if (!accountId) return { rows: [], error: "Codex: no ChatGPT account id" };
|
|
218
|
+
|
|
219
|
+
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
220
|
+
headers: {
|
|
221
|
+
Authorization: `Bearer ${token}`,
|
|
222
|
+
"ChatGPT-Account-Id": accountId,
|
|
223
|
+
Accept: "application/json",
|
|
224
|
+
Origin: "https://chatgpt.com",
|
|
225
|
+
Referer: "https://chatgpt.com/",
|
|
226
|
+
"User-Agent": "Mozilla/5.0",
|
|
227
|
+
},
|
|
228
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
229
|
+
});
|
|
230
|
+
if (!response.ok) return { rows: [], error: `Codex: HTTP ${response.status}` };
|
|
231
|
+
const body = await response.json() as any;
|
|
232
|
+
|
|
233
|
+
const rows: UsageRow[] = [];
|
|
234
|
+
const push = (label: string, window: any) => {
|
|
235
|
+
const used = pct(window?.used_percent);
|
|
236
|
+
if (used === undefined) return;
|
|
237
|
+
rows.push({ group: "Codex", label, remaining: 100 - used, resetAt: resetToMs(window?.reset_at), checkedAt: Date.now() });
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const kindOf = (window: any): string | undefined => {
|
|
241
|
+
const seconds = Number(window?.limit_window_seconds);
|
|
242
|
+
if (!Number.isFinite(seconds)) return undefined;
|
|
243
|
+
if (seconds <= 21_600) return "5h";
|
|
244
|
+
if (seconds >= 500_000) return "weekly";
|
|
245
|
+
return `${Math.round(seconds / 86400)}d`;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
for (const window of [body.rate_limit?.primary_window, body.rate_limit?.secondary_window]) {
|
|
249
|
+
const kind = kindOf(window);
|
|
250
|
+
if (kind) push(kind, window);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const extra of Array.isArray(body.additional_rate_limits) ? body.additional_rate_limits : []) {
|
|
254
|
+
const name = String(extra?.limit_name ?? "extra").replace(/^GPT-[\d.]+-Codex-/i, "");
|
|
255
|
+
for (const window of [extra?.rate_limit?.primary_window, extra?.rate_limit?.secondary_window]) {
|
|
256
|
+
const kind = kindOf(window);
|
|
257
|
+
const used = pct(window?.used_percent);
|
|
258
|
+
if (!kind || used === undefined) continue;
|
|
259
|
+
rows.push({
|
|
260
|
+
group: "Codex",
|
|
261
|
+
label: `${name} ${kind}`,
|
|
262
|
+
remaining: 100 - used,
|
|
263
|
+
resetAt: resetToMs(window?.reset_at),
|
|
264
|
+
checkedAt: Date.now(),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const credits = body.credits;
|
|
270
|
+
if (credits?.has_credits && credits?.balance) {
|
|
271
|
+
rows.push({ group: "Codex", label: `credits ${credits.balance}`, remaining: 100, checkedAt: Date.now() });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { rows, plan: typeof body.plan_type === "string" ? body.plan_type : undefined };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return { rows: [], error: `Codex: ${error instanceof Error ? error.message : String(error)}` };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One full poll of every configured subscription source. */
|
|
281
|
+
export async function fetchAll(ctx: any): Promise<SourceResult> {
|
|
282
|
+
const [claude, codex] = await Promise.all([fetchClaudeRows(ctx), fetchCodexRows(ctx)]);
|
|
283
|
+
return {
|
|
284
|
+
rows: [...claude.rows, ...codex.rows],
|
|
285
|
+
errors: [...claude.errors, codex.error].filter((error): error is string => !!error),
|
|
286
|
+
groups: claude.groups,
|
|
287
|
+
codexPlan: codex.plan,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Small JSON config helper shared by every domain.
|
|
7
|
+
*
|
|
8
|
+
* Writes go through a temp file + rename so a crash mid-write cannot leave a
|
|
9
|
+
* truncated config behind; several of these files hold auth state.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function agentDir(): string {
|
|
13
|
+
return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function agentPath(...parts: string[]): string {
|
|
17
|
+
return join(agentDir(), ...parts);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readJson<T>(path: string, fallback: T): T {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Best-effort atomic write. Returns false instead of throwing. */
|
|
29
|
+
export function writeJson(path: string, value: unknown, pretty = false): boolean {
|
|
30
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(temp, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
|
|
33
|
+
renameSync(temp, path);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(path, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|