@cad0p/pi-usage 0.0.0-stage → 0.11.2
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 +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- package/src/sticky.js +319 -0
package/src/sticky.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota snapshot persistence and transient failure resilience.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the last good snapshot when a refresh fails transiently (429, timeout, 5xx).
|
|
5
|
+
* Permanent failures (expired sign-in, missing credential, rejected session)
|
|
6
|
+
* are passed through, because hiding them would be misleading.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { isAuthFailure } from "./providers/backoff.js";
|
|
14
|
+
|
|
15
|
+
/** Maximum age for a sticky / last-good snapshot before it is considered stale (4 hours). */
|
|
16
|
+
export const DEFAULT_MAX_STICKY_AGE_MS = 4 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Rebuild the provider list in the report's own order, falling back to the
|
|
20
|
+
* byFamily grouping when a caller passed an inconsistent report (empty
|
|
21
|
+
* `providers`).
|
|
22
|
+
*
|
|
23
|
+
* @param {import("./engine.js").PiQuotaReport} report
|
|
24
|
+
* @param {Map<string, import("./model.js").QuotaResult>} replacements
|
|
25
|
+
* @param {Record<string, import("./model.js").QuotaResult[]>} byFamily
|
|
26
|
+
* @returns {import("./model.js").QuotaResult[]}
|
|
27
|
+
*/
|
|
28
|
+
function orderedProviders(report, replacements, byFamily) {
|
|
29
|
+
if (report.providers.length === 0) {
|
|
30
|
+
return Object.values(byFamily).flat();
|
|
31
|
+
}
|
|
32
|
+
return report.providers.map((provider) => replacements.get(provider.family) ?? provider);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Failures that are expected to clear on their own.
|
|
37
|
+
*
|
|
38
|
+
* `backing off` matters: that is the message this project's own backoff layer
|
|
39
|
+
* emits, and leaving it out made the "keep the last good values" path decline to
|
|
40
|
+
* act exactly while a provider was throttled — the one case it exists for.
|
|
41
|
+
*/
|
|
42
|
+
const TRANSIENT =
|
|
43
|
+
/HTTP 5\d\d|429|rate limited|throttle|backing off|timed out|timeout|ECONNRESET|socket|network|fetch failed|ENOTFOUND|ETIMEDOUT|EPIPE|ECONNREFUSED|aborted|hang up|request failed/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string | null} error
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function isTransientError(error) {
|
|
50
|
+
if (!error) return false;
|
|
51
|
+
if (isAuthFailure(error)) return false;
|
|
52
|
+
return TRANSIENT.test(error);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge a fresh report over a previous one, preserving healthy windows when
|
|
57
|
+
* a provider fails transiently.
|
|
58
|
+
*
|
|
59
|
+
* @param {import("./engine.js").PiQuotaReport | null} previous
|
|
60
|
+
* @param {import("./engine.js").PiQuotaReport} next
|
|
61
|
+
* @param {{ now?: number, maxAgeMs?: number }} [options]
|
|
62
|
+
* @returns {{ report: import("./engine.js").PiQuotaReport, reused: string[] }}
|
|
63
|
+
*/
|
|
64
|
+
export function mergeSticky(previous, next, options = {}) {
|
|
65
|
+
if (!previous) return { report: next, reused: [] };
|
|
66
|
+
const nextTime = next.generatedAt ? Date.parse(next.generatedAt) : NaN;
|
|
67
|
+
const now = options.now ?? (!Number.isNaN(nextTime) ? nextTime : Date.now());
|
|
68
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_STICKY_AGE_MS;
|
|
69
|
+
|
|
70
|
+
/** @type {string[]} */
|
|
71
|
+
const reused = [];
|
|
72
|
+
/** @type {Record<string, import("./model.js").QuotaResult[]>} */
|
|
73
|
+
const byFamily = {};
|
|
74
|
+
/** @type {Map<string, import("./model.js").QuotaResult>} */
|
|
75
|
+
const replacements = new Map();
|
|
76
|
+
|
|
77
|
+
for (const [family, results] of Object.entries(next.byFamily)) {
|
|
78
|
+
const fresh = results[0];
|
|
79
|
+
const prior = previous.byFamily[family]?.[0];
|
|
80
|
+
const priorTimestamp = prior?.updatedAt ? Date.parse(prior.updatedAt) : NaN;
|
|
81
|
+
const isPriorFresh = Number.isNaN(priorTimestamp) || now - priorTimestamp <= maxAgeMs;
|
|
82
|
+
const hasExpiredToken = fresh?.expiresInMin !== null && fresh?.expiresInMin !== undefined && fresh.expiresInMin <= 0;
|
|
83
|
+
const keepPrior =
|
|
84
|
+
fresh &&
|
|
85
|
+
prior &&
|
|
86
|
+
!fresh.ok &&
|
|
87
|
+
prior.ok &&
|
|
88
|
+
prior.windows.length > 0 &&
|
|
89
|
+
!hasExpiredToken &&
|
|
90
|
+
!isAuthFailure(fresh.error) &&
|
|
91
|
+
isPriorFresh &&
|
|
92
|
+
isTransientError(fresh.error);
|
|
93
|
+
|
|
94
|
+
if (!keepPrior) {
|
|
95
|
+
byFamily[family] = results;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
reused.push(family);
|
|
100
|
+
const priorList = previous.byFamily[family] || (prior ? [prior] : []);
|
|
101
|
+
const carriedList = priorList.map((item) => ({
|
|
102
|
+
...item,
|
|
103
|
+
account: item.account,
|
|
104
|
+
updatedAt: item.updatedAt,
|
|
105
|
+
windows: (item.windows ?? []).map((win) => {
|
|
106
|
+
if (!win.resetsAt) return win;
|
|
107
|
+
const parsedMs = Date.parse(win.resetsAt);
|
|
108
|
+
if (Number.isNaN(parsedMs)) return win;
|
|
109
|
+
return { ...win, resetsInSec: Math.max(0, Math.round((parsedMs - now) / 1000)) };
|
|
110
|
+
}),
|
|
111
|
+
}));
|
|
112
|
+
byFamily[family] = carriedList;
|
|
113
|
+
if (carriedList[0]) replacements.set(family, carriedList[0]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Rebuild in the report's own order so the rings never jump position between
|
|
117
|
+
// refreshes; the map is only a lookup.
|
|
118
|
+
const providers = orderedProviders(next, replacements, byFamily);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
report: {
|
|
122
|
+
...next,
|
|
123
|
+
providers,
|
|
124
|
+
byFamily,
|
|
125
|
+
warnings: reused.length > 0
|
|
126
|
+
? [...next.warnings, `reused the previous snapshot for: ${reused.join(", ")} (transient upstream error)`]
|
|
127
|
+
: next.warnings,
|
|
128
|
+
},
|
|
129
|
+
reused,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function resolveLastPublishedPath(options = {}) {
|
|
138
|
+
if (options.path) return options.path;
|
|
139
|
+
const env = options.env ?? process.env;
|
|
140
|
+
const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
|
|
141
|
+
return join(cacheHome, "pi-quota", "last-published.json");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
|
|
146
|
+
* @returns {import("./engine.js").PiQuotaReport | null}
|
|
147
|
+
*/
|
|
148
|
+
export function loadLastPublished(options = {}) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(readFileSync(resolveLastPublishedPath(options), "utf-8"));
|
|
151
|
+
return parsed?.report && Array.isArray(parsed.report.providers) ? parsed.report : null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {import("./engine.js").PiQuotaReport} report
|
|
159
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
|
|
160
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
161
|
+
*/
|
|
162
|
+
export function saveLastPublished(report, options = {}) {
|
|
163
|
+
const path = resolveLastPublishedPath(options);
|
|
164
|
+
try {
|
|
165
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
166
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
167
|
+
writeFileSync(temporary, JSON.stringify({ savedAt: Date.now(), report }, null, 2), {
|
|
168
|
+
encoding: "utf-8",
|
|
169
|
+
mode: 0o600,
|
|
170
|
+
});
|
|
171
|
+
renameSync(temporary, path);
|
|
172
|
+
return { ok: true };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return { ok: false, error: /** @type {{ message?: string }} */ (error)?.message ?? String(error) };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Per-family "last good" store.
|
|
180
|
+
*
|
|
181
|
+
* Keeps the last *healthy* snapshot per family and restores it when that
|
|
182
|
+
* family fails transiently. Permanent failures (expired sign-in, missing
|
|
183
|
+
* credential) are never masked, and every restore is labelled with the age of
|
|
184
|
+
* the data so nothing is presented as fresher than it is.
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
export function resolveLastGoodPath(options = {}) {
|
|
192
|
+
if (options.path) return options.path;
|
|
193
|
+
const env = options.env ?? process.env;
|
|
194
|
+
const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
|
|
195
|
+
return join(cacheHome, "pi-quota", "last-good.json");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
|
|
200
|
+
* @returns {Record<string, { results?: import("./model.js").QuotaResult[], result: import("./model.js").QuotaResult, savedAt: number }>}
|
|
201
|
+
*/
|
|
202
|
+
export function readLastGood(options = {}) {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(readFileSync(resolveLastGoodPath(options), "utf-8"));
|
|
205
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
206
|
+
} catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {Record<string, { results?: import("./model.js").QuotaResult[], result: import("./model.js").QuotaResult, savedAt: number }>} state
|
|
213
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
function writeLastGood(state, options = {}) {
|
|
217
|
+
const path = resolveLastGoodPath(options);
|
|
218
|
+
try {
|
|
219
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
220
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
221
|
+
writeFileSync(temporary, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
222
|
+
renameSync(temporary, path);
|
|
223
|
+
return true;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Update the store from a report and restore transiently-failed families.
|
|
231
|
+
*
|
|
232
|
+
* @param {import("./engine.js").PiQuotaReport} report
|
|
233
|
+
* @param {{
|
|
234
|
+
* now?: number,
|
|
235
|
+
* env?: Record<string, string | undefined>,
|
|
236
|
+
* home?: string,
|
|
237
|
+
* path?: string,
|
|
238
|
+
* maxAgeMs?: number,
|
|
239
|
+
* }} [options]
|
|
240
|
+
* @returns {{ report: import("./engine.js").PiQuotaReport, restored: string[], saved: string[] }}
|
|
241
|
+
*/
|
|
242
|
+
export function mergeLastGood(report, options = {}) {
|
|
243
|
+
const now = options.now ?? Date.now();
|
|
244
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_STICKY_AGE_MS;
|
|
245
|
+
const state = readLastGood(options);
|
|
246
|
+
|
|
247
|
+
/** @type {string[]} */
|
|
248
|
+
const restored = [];
|
|
249
|
+
/** @type {string[]} */
|
|
250
|
+
const saved = [];
|
|
251
|
+
/** @type {Record<string, import("./model.js").QuotaResult[]>} */
|
|
252
|
+
const byFamily = {};
|
|
253
|
+
/** @type {Map<string, import("./model.js").QuotaResult>} */
|
|
254
|
+
const replacements = new Map();
|
|
255
|
+
/** @type {string[]} */
|
|
256
|
+
const warnings = [...report.warnings];
|
|
257
|
+
|
|
258
|
+
for (const [family, results] of Object.entries(report.byFamily)) {
|
|
259
|
+
const fresh = results[0];
|
|
260
|
+
if (fresh?.ok && fresh.windows.length > 0) {
|
|
261
|
+
state[family] = { results, result: fresh, savedAt: now };
|
|
262
|
+
saved.push(family);
|
|
263
|
+
byFamily[family] = results;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const stored = state[family];
|
|
268
|
+
const ageMs = stored ? now - stored.savedAt : Infinity;
|
|
269
|
+
const isStoredFresh = ageMs <= maxAgeMs;
|
|
270
|
+
const hasExpiredToken = fresh?.expiresInMin !== null && fresh?.expiresInMin !== undefined && fresh.expiresInMin <= 0;
|
|
271
|
+
const canRestore =
|
|
272
|
+
stored?.result?.ok &&
|
|
273
|
+
stored.result.windows.length > 0 &&
|
|
274
|
+
!hasExpiredToken &&
|
|
275
|
+
!isAuthFailure(fresh?.error ?? null) &&
|
|
276
|
+
isStoredFresh &&
|
|
277
|
+
isTransientError(fresh?.error ?? null);
|
|
278
|
+
if (!canRestore) {
|
|
279
|
+
if (stored?.result?.ok && !isStoredFresh && isTransientError(fresh?.error ?? null)) {
|
|
280
|
+
warnings.push(
|
|
281
|
+
`${family}: discarded last known values because they are stale (${Math.round(ageMs / 60000)} min old, limit ${Math.round(maxAgeMs / 60000)} min)`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
byFamily[family] = results;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const ageMinutes = Math.max(0, Math.round((now - stored.savedAt) / 60000));
|
|
289
|
+
const storedList = stored.results || (stored.result ? [stored.result] : []);
|
|
290
|
+
const carriedList = storedList.map((item) => ({
|
|
291
|
+
...item,
|
|
292
|
+
error: null,
|
|
293
|
+
note: `last known values, ${ageMinutes} min old (upstream temporarily unavailable)`,
|
|
294
|
+
windows: (item.windows ?? []).map((win) => {
|
|
295
|
+
if (!win.resetsAt) return win;
|
|
296
|
+
const parsedMs = Date.parse(win.resetsAt);
|
|
297
|
+
if (Number.isNaN(parsedMs)) return win;
|
|
298
|
+
return { ...win, resetsInSec: Math.max(0, Math.round((parsedMs - now) / 1000)) };
|
|
299
|
+
}),
|
|
300
|
+
}));
|
|
301
|
+
restored.push(family);
|
|
302
|
+
warnings.push(
|
|
303
|
+
`${family}: showing the last known values (${ageMinutes} min old) because the upstream call failed transiently`,
|
|
304
|
+
);
|
|
305
|
+
byFamily[family] = carriedList;
|
|
306
|
+
if (carriedList[0]) replacements.set(family, carriedList[0]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (saved.length > 0) writeLastGood(state, options);
|
|
310
|
+
|
|
311
|
+
// Same reason as above: keep the canonical order.
|
|
312
|
+
const providers = orderedProviders(report, replacements, byFamily);
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
report: { ...report, providers, byFamily, warnings },
|
|
316
|
+
restored,
|
|
317
|
+
saved,
|
|
318
|
+
};
|
|
319
|
+
}
|