@jameslovespancakes/pi-plus 1.0.21 → 1.0.23
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 +27 -4
- package/package.json +1 -1
- package/src/core/anthropic/quota.ts +13 -167
- package/src/core/anthropic/store.ts +2 -0
- package/src/core/anthropic/usage-cache.ts +141 -0
- package/src/core/env.ts +0 -2
- package/src/core/file-lease.ts +32 -0
- package/src/core/gemini/client.ts +51 -4
- package/src/core/gemini/oauth.ts +55 -4
- package/src/core/oauth/callback-server.ts +1 -1
- package/src/core/policy/openrouter.ts +54 -0
- package/src/core/policy/policy.ts +112 -133
- package/src/core/quota/usage-source.ts +30 -92
- package/src/domains/claude-remote/index.ts +21 -14
- package/src/domains/claude-remote/picker.ts +12 -3
- package/src/domains/models/policy-gate.ts +50 -52
- package/src/domains/models/provider-picker.ts +19 -8
- package/src/domains/setup/index.ts +4 -5
- package/src/domains/subscriptions/provider.ts +21 -24
- package/src/domains/subscriptions/providers/gemini.ts +1 -5
- package/src/domains/workflows/runtime/agent-session-providers.ts +7 -2
- package/src/services/usage-service.ts +2 -1
package/README.md
CHANGED
|
@@ -37,9 +37,10 @@ the app's Stop button stops the agent. Your selected model still runs through pi
|
|
|
37
37
|
──────────────────────────────────────
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
**`/claude-remote`** toggles On/Off
|
|
41
|
-
|
|
42
|
-
is green when connected
|
|
40
|
+
**`/claude-remote`** toggles On/Off for this session only. Startup, new sessions,
|
|
41
|
+
resumes, forks, and reloads always start Off; old auto-start preferences are
|
|
42
|
+
ignored. Off disconnects immediately. The footer dot is green when connected
|
|
43
|
+
and red otherwise.
|
|
43
44
|
|
|
44
45
|
Off by default. Requires your primary Anthropic OAuth login via `/login`.
|
|
45
46
|
Experimental: text input and completed-message mirroring, not token streaming.
|
|
@@ -88,6 +89,16 @@ usage in the footer. Supports Anthropic, OpenAI Codex, Gemini, Kimi Code, and xA
|
|
|
88
89
|
**`/routing quota-aware`** uses reported capacity; **`/routing sequential`**
|
|
89
90
|
follows account order. **`/usage`** refreshes the bars.
|
|
90
91
|
|
|
92
|
+
Claude usage comes from response headers first, with a shared cached status
|
|
93
|
+
fetch when needed. Cooldowns survive restarts and are respected by `/usage`;
|
|
94
|
+
usage checks never generate model responses or consume inference tokens.
|
|
95
|
+
|
|
96
|
+
Gemini login and reauthorization confirm account access before reporting success.
|
|
97
|
+
If Google requires verification, its verification page opens through pi's auth UI;
|
|
98
|
+
complete it, then choose **check again**. Credentials are saved only after the
|
|
99
|
+
server confirms access. Background quota checks never open a browser; they point
|
|
100
|
+
you to `/login gemini` or `/accounts reauth gemini <name>` when verification is needed.
|
|
101
|
+
|
|
91
102
|
Quota visibility depends on the provider. Where no usage endpoint exists,
|
|
92
103
|
pi-plus shows the last observed rate-limit reading rather than inventing one.
|
|
93
104
|
|
|
@@ -114,11 +125,23 @@ boundary, including workflow subagents—not just through prompt instructions.
|
|
|
114
125
|
──────────────────────────────────────
|
|
115
126
|
Providers
|
|
116
127
|
› ● Anthropic Allowed
|
|
117
|
-
● OpenRouter
|
|
128
|
+
● OpenRouter Off
|
|
118
129
|
──────────────────────────────────────
|
|
119
130
|
```
|
|
120
131
|
|
|
121
132
|
**`/provider`** opens the picker. Enter or Space toggles access in place.
|
|
133
|
+
OpenRouter cycles **Off → On → On (ZDR) → Off**. Grants are session-local;
|
|
134
|
+
explicit policy denials stay locked. Other providers keep their existing toggles.
|
|
135
|
+
|
|
136
|
+
**On (ZDR)** restricts OpenRouter inference to Zero Data Retention endpoints,
|
|
137
|
+
including workflow agents and compaction. No eligible endpoint means an error,
|
|
138
|
+
never a non-ZDR retry; fallback among ZDR endpoints is allowed. Native Chat
|
|
139
|
+
Completions and Anthropic Messages routes are supported; unsupported APIs or
|
|
140
|
+
custom endpoints fail closed. Ordinary On does not remove account-level privacy
|
|
141
|
+
rules. ZDR does not cover separately enabled search plugins or local session logs.
|
|
142
|
+
|
|
143
|
+
Commands: `/provider approve openrouter`, `/provider zdr openrouter`, and
|
|
144
|
+
`/provider remove openrouter`.
|
|
122
145
|
|
|
123
146
|
## Workflows
|
|
124
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import {
|
|
3
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { acquireFileLease } from "../file-lease.ts";
|
|
4
3
|
import { refreshAbortSignal } from "../accounts/routing.ts";
|
|
5
4
|
import {
|
|
6
5
|
configPath as defaultConfigPath,
|
|
@@ -12,10 +11,7 @@ import {
|
|
|
12
11
|
} from "./store.ts";
|
|
13
12
|
import { refreshToken, type RefreshOptions, type TokenSet } from "./oauth.ts";
|
|
14
13
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
18
|
-
const TIMEOUT_MS = 10_000;
|
|
14
|
+
/** Quota parsing and pooled credential refresh. Status I/O lives in usage-cache.ts. */
|
|
19
15
|
/** Refresh early enough to absorb transient OAuth rate limits before expiry. */
|
|
20
16
|
export const ACCESS_REFRESH_WINDOW_MS = 4 * 60 * 60_000;
|
|
21
17
|
/** Background refresh cadence; each process adds a small startup jitter. */
|
|
@@ -24,26 +20,8 @@ const REFRESH_LOCK_TTL_MS = 60_000;
|
|
|
24
20
|
const REFRESH_JOIN_WAIT_MS = 16_000;
|
|
25
21
|
/** Snapshot lifetime and minimum interval between polls. */
|
|
26
22
|
export const QUOTA_FRESH_MS = 10 * 60_000;
|
|
27
|
-
/** After a 429, wait at least this long before touching the endpoint again. */
|
|
28
|
-
export const QUOTA_BACKOFF_MS = 15 * 60_000;
|
|
29
|
-
|
|
30
|
-
/** Per-account earliest next poll, set when the endpoint rate limits us. */
|
|
31
|
-
const blockedUntil = new Map<string, number>();
|
|
32
|
-
|
|
33
|
-
/** True when a recent 429 means this account must not be polled yet. */
|
|
34
|
-
export function isPollBlocked(accountId: string, now = Date.now()): boolean {
|
|
35
|
-
const until = blockedUntil.get(accountId);
|
|
36
|
-
if (until === undefined) return false;
|
|
37
|
-
if (now >= until) { blockedUntil.delete(accountId); return false; }
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Visible for tests; clears the backoff table. */
|
|
42
|
-
export function resetPollBackoff(): void {
|
|
43
|
-
blockedUntil.clear();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
23
|
const pct = (value: unknown): number | undefined => {
|
|
24
|
+
if (value == null || typeof value === "boolean" || (typeof value === "string" && !value.trim())) return undefined;
|
|
47
25
|
const n = typeof value === "number" ? value : Number(value);
|
|
48
26
|
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : undefined;
|
|
49
27
|
};
|
|
@@ -58,6 +36,7 @@ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
|
|
|
58
36
|
remainingPercent: 100 - used,
|
|
59
37
|
resetsAt: typeof raw?.resets_at === "string" ? raw.resets_at : undefined,
|
|
60
38
|
checkedAt: now,
|
|
39
|
+
capacity: typeof raw?.limit_dollars === "number" && raw.limit_dollars > 0 ? raw.limit_dollars : undefined,
|
|
61
40
|
};
|
|
62
41
|
};
|
|
63
42
|
|
|
@@ -79,9 +58,15 @@ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
|
|
|
79
58
|
})
|
|
80
59
|
.filter(Boolean);
|
|
81
60
|
|
|
61
|
+
for (const [id, raw] of [["opus", body?.seven_day_opus ?? body?.seven_day_omelette], ["sonnet", body?.seven_day_sonnet]] as const) {
|
|
62
|
+
const value = window(raw);
|
|
63
|
+
if (value && !scoped.some((entry: { id: string }) => entry.id === id)) scoped.push({ id, ...value });
|
|
64
|
+
}
|
|
65
|
+
|
|
82
66
|
return {
|
|
83
67
|
five_hour: window(body?.five_hour),
|
|
84
68
|
seven_day: window(body?.seven_day),
|
|
69
|
+
extra: body?.extra_usage?.is_enabled ? window(body.extra_usage) : undefined,
|
|
85
70
|
scoped: scoped.length ? scoped : undefined,
|
|
86
71
|
checkedAt: now,
|
|
87
72
|
source: "poll",
|
|
@@ -114,7 +99,7 @@ export function parseQuotaHeaders(
|
|
|
114
99
|
|
|
115
100
|
const window = (prefix: string) => {
|
|
116
101
|
const raw = get(`${prefix}-utilization`);
|
|
117
|
-
if (raw === undefined) return undefined;
|
|
102
|
+
if (raw === undefined || !raw.trim()) return undefined;
|
|
118
103
|
const fraction = Number(raw);
|
|
119
104
|
if (!Number.isFinite(fraction)) return undefined;
|
|
120
105
|
|
|
@@ -137,60 +122,6 @@ export function parseQuotaHeaders(
|
|
|
137
122
|
return { five_hour, seven_day, checkedAt: now, source: "headers" };
|
|
138
123
|
}
|
|
139
124
|
|
|
140
|
-
/** Merges header quota while preserving polled model limits. */
|
|
141
|
-
export function applyQuotaHeaders(
|
|
142
|
-
accountId: string,
|
|
143
|
-
headers: Record<string, unknown> | undefined,
|
|
144
|
-
now = Date.now(),
|
|
145
|
-
): boolean {
|
|
146
|
-
const fresh = parseQuotaHeaders(headers, now);
|
|
147
|
-
if (!fresh) return false;
|
|
148
|
-
|
|
149
|
-
const storage = loadAccounts();
|
|
150
|
-
const account = storage?.accounts.find((a) => a.id === accountId);
|
|
151
|
-
if (!account) return false;
|
|
152
|
-
|
|
153
|
-
// Skip unchanged values to avoid credential writes on every response.
|
|
154
|
-
const previous = account.quota;
|
|
155
|
-
const unchanged =
|
|
156
|
-
previous?.five_hour?.usedPercent === fresh.five_hour?.usedPercent
|
|
157
|
-
&& previous?.seven_day?.usedPercent === fresh.seven_day?.usedPercent;
|
|
158
|
-
if (unchanged) return false;
|
|
159
|
-
|
|
160
|
-
saveAccount({ ...account, quota: { ...fresh, scoped: previous?.scoped } });
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Polls one account and records rate-limit backoff. */
|
|
165
|
-
export async function pollQuota(
|
|
166
|
-
accessToken: string,
|
|
167
|
-
accountId?: string,
|
|
168
|
-
): Promise<QuotaSnapshot | undefined> {
|
|
169
|
-
if (accountId && isPollBlocked(accountId)) return undefined;
|
|
170
|
-
try {
|
|
171
|
-
const response = await fetch(QUOTA_URL, {
|
|
172
|
-
headers: {
|
|
173
|
-
Authorization: `Bearer ${accessToken}`,
|
|
174
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
175
|
-
Accept: "application/json",
|
|
176
|
-
},
|
|
177
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
178
|
-
});
|
|
179
|
-
if (response.status === 429 && accountId) {
|
|
180
|
-
// `retry-after` is commonly 0 here, which is not a usable hint, so the
|
|
181
|
-
// floor is ours rather than the server's.
|
|
182
|
-
const hint = Number(response.headers.get("retry-after")) * 1000;
|
|
183
|
-
const wait = Number.isFinite(hint) && hint > 0 ? hint : QUOTA_BACKOFF_MS;
|
|
184
|
-
blockedUntil.set(accountId, Date.now() + Math.max(wait, QUOTA_BACKOFF_MS));
|
|
185
|
-
return undefined;
|
|
186
|
-
}
|
|
187
|
-
if (!response.ok) return undefined;
|
|
188
|
-
return parseQuota(await response.json());
|
|
189
|
-
} catch {
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
125
|
export function accessTokenNeedsRefresh(account: Account, now = Date.now()): boolean {
|
|
195
126
|
return !account.access
|
|
196
127
|
|| typeof account.expires !== "number"
|
|
@@ -205,14 +136,6 @@ export interface EnsureAccessTokenOptions {
|
|
|
205
136
|
refresh?: RefreshTokenFn;
|
|
206
137
|
}
|
|
207
138
|
|
|
208
|
-
export interface RefreshAllQuotaOptions extends EnsureAccessTokenOptions {
|
|
209
|
-
poll?: typeof pollQuota;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
interface RefreshLock {
|
|
213
|
-
release(): void;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
139
|
const refreshes = new Map<string, Promise<string | undefined>>();
|
|
217
140
|
|
|
218
141
|
function refreshLockPath(accountId: string, config: string): string {
|
|
@@ -220,45 +143,6 @@ function refreshLockPath(accountId: string, config: string): string {
|
|
|
220
143
|
return `${statePath(config)}.refresh-${id}.lock`;
|
|
221
144
|
}
|
|
222
145
|
|
|
223
|
-
/** Cross-process exclusion for Anthropic's rotating refresh tokens. */
|
|
224
|
-
function acquireRefreshLock(accountId: string, config: string, now: number): RefreshLock | undefined {
|
|
225
|
-
const path = refreshLockPath(accountId, config);
|
|
226
|
-
const owner = randomUUID();
|
|
227
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
228
|
-
|
|
229
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
230
|
-
try {
|
|
231
|
-
const fd = openSync(path, "wx", 0o600);
|
|
232
|
-
try {
|
|
233
|
-
writeFileSync(fd, JSON.stringify({ owner, expiresAt: now + REFRESH_LOCK_TTL_MS }), "utf8");
|
|
234
|
-
} finally {
|
|
235
|
-
closeSync(fd);
|
|
236
|
-
}
|
|
237
|
-
return {
|
|
238
|
-
release() {
|
|
239
|
-
try {
|
|
240
|
-
const current = JSON.parse(readFileSync(path, "utf8"));
|
|
241
|
-
if (current?.owner === owner) rmSync(path, { force: true });
|
|
242
|
-
} catch {
|
|
243
|
-
// A stale or externally removed lock needs no cleanup.
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
};
|
|
247
|
-
} catch (error: any) {
|
|
248
|
-
if (error?.code !== "EEXIST") throw error;
|
|
249
|
-
try {
|
|
250
|
-
const current = JSON.parse(readFileSync(path, "utf8"));
|
|
251
|
-
if (Number(current?.expiresAt) > now) return undefined;
|
|
252
|
-
rmSync(path, { force: true });
|
|
253
|
-
} catch {
|
|
254
|
-
// A malformed lock is stale; remove it and retry once.
|
|
255
|
-
try { rmSync(path, { force: true }); } catch { /* best effort */ }
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
146
|
function storedAccount(accountId: string, config: string): Account | undefined {
|
|
263
147
|
return loadAccounts(config)?.accounts.find((candidate) => candidate.id === accountId);
|
|
264
148
|
}
|
|
@@ -304,7 +188,7 @@ async function refreshAccountNow(
|
|
|
304
188
|
if (!accessTokenNeedsRefresh(latest, now())) return latest.access;
|
|
305
189
|
if (!latest.refresh) return usableAccess(latest, now());
|
|
306
190
|
|
|
307
|
-
const lock =
|
|
191
|
+
const lock = acquireFileLease(refreshLockPath(account.id, config), REFRESH_LOCK_TTL_MS, now());
|
|
308
192
|
if (!lock) return joinConcurrentRefresh(latest, config, now);
|
|
309
193
|
|
|
310
194
|
try {
|
|
@@ -381,41 +265,3 @@ export async function refreshDueAccessTokens(
|
|
|
381
265
|
}));
|
|
382
266
|
return results.filter(Boolean).length;
|
|
383
267
|
}
|
|
384
|
-
|
|
385
|
-
/** Refreshes due access tokens, then polls only stale quota snapshots. */
|
|
386
|
-
export async function refreshAllQuota(
|
|
387
|
-
force = false,
|
|
388
|
-
config = defaultConfigPath(),
|
|
389
|
-
options: RefreshAllQuotaOptions = {},
|
|
390
|
-
): Promise<number> {
|
|
391
|
-
await refreshDueAccessTokens(config, options);
|
|
392
|
-
const storage = loadAccounts(config);
|
|
393
|
-
if (!storage) return 0;
|
|
394
|
-
const now = options.now?.() ?? Date.now();
|
|
395
|
-
const poll = options.poll ?? pollQuota;
|
|
396
|
-
|
|
397
|
-
const stale = storage.accounts.filter(
|
|
398
|
-
(a) => a.enabled !== false && a.type === "oauth"
|
|
399
|
-
&& (force || !isFresh(a.quota, now))
|
|
400
|
-
&& !isPollBlocked(a.id, now));
|
|
401
|
-
|
|
402
|
-
const results = await Promise.all(stale.map(async (account) => {
|
|
403
|
-
try {
|
|
404
|
-
const token = await ensureAccessToken(account, { ...options, config });
|
|
405
|
-
if (!token) return false;
|
|
406
|
-
const quota = await poll(token, account.id);
|
|
407
|
-
if (!quota) return false;
|
|
408
|
-
// Do not restore the pre-refresh account snapshot here. That used to
|
|
409
|
-
// overwrite a freshly rotated access/refresh pair with the now-invalid
|
|
410
|
-
// old pair immediately after a successful quota poll.
|
|
411
|
-
const current = storedAccount(account.id, config);
|
|
412
|
-
if (!current || current.access !== token) return false;
|
|
413
|
-
saveAccount({ ...current, quota }, config);
|
|
414
|
-
return true;
|
|
415
|
-
} catch {
|
|
416
|
-
return false;
|
|
417
|
-
}
|
|
418
|
-
}));
|
|
419
|
-
|
|
420
|
-
return results.filter(Boolean).length;
|
|
421
|
-
}
|
|
@@ -20,6 +20,7 @@ export const CONFIG_FILE = "anthropic-auth.json";
|
|
|
20
20
|
export const STATE_FILE = "anthropic-auth-state.json";
|
|
21
21
|
|
|
22
22
|
export interface QuotaWindow {
|
|
23
|
+
capacity?: number;
|
|
23
24
|
remainingPercent?: number;
|
|
24
25
|
usedPercent?: number;
|
|
25
26
|
resetsAt?: string;
|
|
@@ -30,6 +31,7 @@ export interface QuotaWindow {
|
|
|
30
31
|
export interface QuotaSnapshot {
|
|
31
32
|
five_hour?: QuotaWindow;
|
|
32
33
|
seven_day?: QuotaWindow;
|
|
34
|
+
extra?: QuotaWindow;
|
|
33
35
|
scoped?: QuotaWindow[];
|
|
34
36
|
checkedAt?: number;
|
|
35
37
|
source?: "poll" | "headers";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { acquireFileLease } from "../file-lease.ts";
|
|
3
|
+
import { readJson, writeJson } from "../store.ts";
|
|
4
|
+
import { cachedAnthropicAccountIdentity } from "./identity.ts";
|
|
5
|
+
import { isFresh, parseQuota, parseQuotaHeaders, QUOTA_FRESH_MS } from "./quota.ts";
|
|
6
|
+
import { loadAccounts, statePath, type QuotaSnapshot } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
export const QUOTA_BACKOFF_MS = 15 * 60_000;
|
|
9
|
+
const MAX_BACKOFF_MS = 2 * 60 * 60_000;
|
|
10
|
+
const LEASE_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
export interface ClaudeUsageAccount {
|
|
13
|
+
access: string;
|
|
14
|
+
identity?: string;
|
|
15
|
+
id?: string;
|
|
16
|
+
/** Read old snapshots without maintaining a second quota store. */
|
|
17
|
+
quota?: QuotaSnapshot;
|
|
18
|
+
}
|
|
19
|
+
interface UsageCache {
|
|
20
|
+
quota?: QuotaSnapshot;
|
|
21
|
+
nextPollAt?: number;
|
|
22
|
+
failures?: number;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
27
|
+
function cachePath(account: ClaudeUsageAccount): string {
|
|
28
|
+
const key = account.identity ? `identity:${account.identity}` : account.id ? `account:${account.id}` : `token:${account.access}`;
|
|
29
|
+
return `${statePath()}.usage-${hash(key)}.json`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cachePaths(account: ClaudeUsageAccount): string[] {
|
|
33
|
+
// The token alias preserves cooldown when identity discovery is temporarily unavailable.
|
|
34
|
+
return [...new Set([cachePath(account), cachePath({ access: account.access })])];
|
|
35
|
+
}
|
|
36
|
+
function lockCache(paths: string[], now: number): (() => void) | undefined {
|
|
37
|
+
const leases: Array<{ release(): void }> = [];
|
|
38
|
+
const release = () => leases.forEach((lease) => lease.release());
|
|
39
|
+
try {
|
|
40
|
+
for (const path of paths) {
|
|
41
|
+
const lease = acquireFileLease(`${path}.lock`, LEASE_MS, now);
|
|
42
|
+
if (!lease) { release(); return undefined; }
|
|
43
|
+
leases.push(lease);
|
|
44
|
+
}
|
|
45
|
+
return release;
|
|
46
|
+
} catch (error) { release(); throw error; }
|
|
47
|
+
}
|
|
48
|
+
const readCache = (paths: string[]): UsageCache => paths.map((path) => readJson<UsageCache>(path, {}))
|
|
49
|
+
.sort((a, b) => (b.nextPollAt ?? 0) - (a.nextPollAt ?? 0))[0] ?? {};
|
|
50
|
+
const saveCache = (paths: string[], cache: UsageCache): boolean => paths.every((path) => writeJson(path, cache, false, 0o600));
|
|
51
|
+
|
|
52
|
+
/** Preserve missing windows and each window's own observation time. */
|
|
53
|
+
function mergeQuota(previous: QuotaSnapshot | undefined, next: QuotaSnapshot): QuotaSnapshot {
|
|
54
|
+
const merged = { ...previous, ...next };
|
|
55
|
+
for (const key of ["five_hour", "seven_day"] as const) {
|
|
56
|
+
const old = previous?.[key], fresh = next[key];
|
|
57
|
+
const observedAt = fresh?.checkedAt ?? next.checkedAt ?? 0;
|
|
58
|
+
merged[key] = fresh && observedAt >= (old?.checkedAt ?? 0) ? {
|
|
59
|
+
...old, ...fresh,
|
|
60
|
+
resetsAt: fresh.resetsAt ?? (Date.parse(old?.resetsAt ?? "") > observedAt ? old?.resetsAt : undefined),
|
|
61
|
+
} : old;
|
|
62
|
+
}
|
|
63
|
+
merged.scoped = next.scoped ?? previous?.scoped;
|
|
64
|
+
merged.extra = next.extra ?? previous?.extra;
|
|
65
|
+
return merged;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Shared disk cache, with token-keyed observations as a fallback before identity discovery. */
|
|
69
|
+
export function cachedClaudeQuota(account: ClaudeUsageAccount): QuotaSnapshot | undefined {
|
|
70
|
+
const samples = [account.quota, ...cachePaths(account).map((path) => readJson<UsageCache>(path, {}).quota)]
|
|
71
|
+
.filter((quota): quota is QuotaSnapshot => !!quota)
|
|
72
|
+
.sort((a, b) => (a.checkedAt ?? 0) - (b.checkedAt ?? 0));
|
|
73
|
+
return samples.reduce<QuotaSnapshot | undefined>((merged, sample) => mergeQuota(merged, sample), undefined);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Free telemetry from the actual request credential; never use a global last-routed account. */
|
|
77
|
+
export function observeClaudeQuota(access: string, headers: Record<string, unknown>, now = Date.now()): void {
|
|
78
|
+
const fresh = parseQuotaHeaders(headers, now);
|
|
79
|
+
if (!fresh) return;
|
|
80
|
+
const account = loadAccounts()?.accounts.find((candidate) => candidate.access === access);
|
|
81
|
+
const target = { access, id: account?.id, identity: account?.identity ?? cachedAnthropicAccountIdentity(access), quota: account?.quota };
|
|
82
|
+
const paths = cachePaths(target);
|
|
83
|
+
const release = lockCache(paths, now);
|
|
84
|
+
if (!release) return; // An in-flight status fetch will refresh this account instead.
|
|
85
|
+
try {
|
|
86
|
+
saveCache(paths, { ...readCache(paths), quota: mergeQuota(cachedClaudeQuota(target), fresh) });
|
|
87
|
+
} finally { release(); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasFreshWindows(quota: QuotaSnapshot | undefined, now: number): boolean {
|
|
91
|
+
return [quota?.five_hour, quota?.seven_day].every((window) =>
|
|
92
|
+
window && isFresh({ checkedAt: window.checkedAt ?? quota?.checkedAt }, now));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The only Claude status fetch path. Manual refresh and process restarts cannot bypass its cooldown. */
|
|
96
|
+
export async function readClaudeQuota(account: ClaudeUsageAccount, now = Date.now()): Promise<{ quota?: QuotaSnapshot; error?: string }> {
|
|
97
|
+
const paths = cachePaths(account);
|
|
98
|
+
const cached = () => cachedClaudeQuota(account);
|
|
99
|
+
const initial = cached();
|
|
100
|
+
if (hasFreshWindows(initial, now)) return { quota: initial };
|
|
101
|
+
const release = lockCache(paths, now);
|
|
102
|
+
if (!release) return { quota: cached() };
|
|
103
|
+
try {
|
|
104
|
+
const current = readCache(paths);
|
|
105
|
+
const quota = cached();
|
|
106
|
+
if (hasFreshWindows(quota, now)) return { quota };
|
|
107
|
+
if ((current.nextPollAt ?? 0) > now) return { quota, error: current.error };
|
|
108
|
+
|
|
109
|
+
// Reserve before I/O: even a crash or failed final write must not trigger a retry storm.
|
|
110
|
+
if (!saveCache(paths, { ...current, quota, nextPollAt: now + QUOTA_BACKOFF_MS })) {
|
|
111
|
+
return { quota, error: "Could not persist usage cooldown" };
|
|
112
|
+
}
|
|
113
|
+
let error: string;
|
|
114
|
+
let retryAfter = 0;
|
|
115
|
+
try {
|
|
116
|
+
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
117
|
+
headers: { Authorization: `Bearer ${account.access}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json" },
|
|
118
|
+
signal: AbortSignal.timeout(10_000),
|
|
119
|
+
});
|
|
120
|
+
if (response.ok) {
|
|
121
|
+
const fresh = parseQuota(await response.json(), now);
|
|
122
|
+
if (fresh.five_hour || fresh.seven_day) {
|
|
123
|
+
saveCache(paths, { quota: fresh, nextPollAt: now + QUOTA_FRESH_MS, failures: 0 });
|
|
124
|
+
return { quota: fresh };
|
|
125
|
+
}
|
|
126
|
+
error = "Usage windows unavailable";
|
|
127
|
+
} else {
|
|
128
|
+
error = `HTTP ${response.status}`;
|
|
129
|
+
const hint = response.headers.get("retry-after");
|
|
130
|
+
if (hint) retryAfter = /^\s*\d+(\.\d+)?\s*$/.test(hint) ? Number(hint) * 1000 : Date.parse(hint) - now;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
error = "Usage request failed"; // Never echo tokens or raw provider bodies.
|
|
134
|
+
}
|
|
135
|
+
const failures = Math.min((current.failures ?? 0) + 1, 8);
|
|
136
|
+
const backoff = Math.min(MAX_BACKOFF_MS, QUOTA_BACKOFF_MS * 2 ** (failures - 1));
|
|
137
|
+
const wait = Math.max(backoff, Number.isFinite(retryAfter) ? retryAfter : 0);
|
|
138
|
+
saveCache(paths, { quota, failures, error, nextPollAt: now + wait });
|
|
139
|
+
return { quota, error };
|
|
140
|
+
} finally { release(); }
|
|
141
|
+
}
|
package/src/core/env.ts
CHANGED
|
@@ -15,7 +15,6 @@ export type EnvKey =
|
|
|
15
15
|
| "AGENT_BOARD_NAME"
|
|
16
16
|
| "AGENT_BOARD_MODE"
|
|
17
17
|
| "AGENT_BOARD_SSH"
|
|
18
|
-
| "PI_CLAUDE_REMOTE"
|
|
19
18
|
| "PI_CLAUDE_REMOTE_ALLOW_INBOUND"
|
|
20
19
|
| "CLAUDE_TRUSTED_DEVICE_TOKEN";
|
|
21
20
|
|
|
@@ -26,7 +25,6 @@ export const ENV_KEYS: { key: EnvKey; label: string; secret: boolean }[] = [
|
|
|
26
25
|
{ key: "AGENT_BOARD_NAME", label: "Agent board display name", secret: false },
|
|
27
26
|
{ key: "AGENT_BOARD_MODE", label: "Agent board deployment (local|remote|external)", secret: false },
|
|
28
27
|
{ key: "AGENT_BOARD_SSH", label: "Agent board SSH host, when remote", secret: false },
|
|
29
|
-
{ key: "PI_CLAUDE_REMOTE", label: "Claude Remote auto-start (1|0)", secret: false },
|
|
30
28
|
{ key: "PI_CLAUDE_REMOTE_ALLOW_INBOUND", label: "Claude Remote input (1|0)", secret: false },
|
|
31
29
|
{ key: "CLAUDE_TRUSTED_DEVICE_TOKEN", label: "Claude trusted-device token (optional)", secret: true },
|
|
32
30
|
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Non-blocking cross-process lease. Never delete a live or partially written lock. */
|
|
6
|
+
export function acquireFileLease(path: string, ttlMs: number, now = Date.now()): { release(): void } | undefined {
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
const owner = randomUUID();
|
|
9
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
const fd = openSync(path, "wx", 0o600);
|
|
12
|
+
try { writeFileSync(fd, JSON.stringify({ owner, expiresAt: now + ttlMs }), "utf8"); }
|
|
13
|
+
finally { closeSync(fd); }
|
|
14
|
+
return { release() {
|
|
15
|
+
try {
|
|
16
|
+
if (JSON.parse(readFileSync(path, "utf8")).owner === owner) rmSync(path, { force: true });
|
|
17
|
+
} catch { /* already removed */ }
|
|
18
|
+
} };
|
|
19
|
+
} catch (error: any) {
|
|
20
|
+
if (error?.code !== "EEXIST") throw error;
|
|
21
|
+
try {
|
|
22
|
+
let expiresAt: number;
|
|
23
|
+
try { expiresAt = Number(JSON.parse(readFileSync(path, "utf8")).expiresAt); }
|
|
24
|
+
catch { expiresAt = NaN; }
|
|
25
|
+
if (!Number.isFinite(expiresAt)) expiresAt = statSync(path).mtimeMs + ttlMs;
|
|
26
|
+
if (expiresAt > now) return undefined;
|
|
27
|
+
rmSync(path, { force: true });
|
|
28
|
+
} catch { return undefined; }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
@@ -57,7 +57,14 @@ function timeoutSignal(signal?: AbortSignal): AbortSignal {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** POSTs to one endpoint; undefined for any non-2xx or transport failure. */
|
|
60
|
-
async function postJson(
|
|
60
|
+
async function postJson(
|
|
61
|
+
endpoint: string,
|
|
62
|
+
path: string,
|
|
63
|
+
token: string,
|
|
64
|
+
body: unknown,
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
onFailure?: (status: number, body: unknown) => void,
|
|
67
|
+
): Promise<unknown> {
|
|
61
68
|
try {
|
|
62
69
|
const response = await fetch(`${endpoint}/v1internal:${path}`, {
|
|
63
70
|
method: "POST",
|
|
@@ -65,7 +72,10 @@ async function postJson(endpoint: string, path: string, token: string, body: unk
|
|
|
65
72
|
body: JSON.stringify(body),
|
|
66
73
|
signal: timeoutSignal(signal),
|
|
67
74
|
});
|
|
68
|
-
if (!response.ok)
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
if (onFailure) onFailure(response.status, await response.json().catch(() => undefined));
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
69
79
|
return await response.json();
|
|
70
80
|
} catch (error) {
|
|
71
81
|
// A caller abort is a decision, not an endpoint failure to route around.
|
|
@@ -154,6 +164,28 @@ export async function fetchUserEmail(token: string, signal?: AbortSignal): Promi
|
|
|
154
164
|
}
|
|
155
165
|
}
|
|
156
166
|
|
|
167
|
+
/** Only Google's observed verification destination may be opened during login. */
|
|
168
|
+
function verificationUrl(value: unknown): string | undefined {
|
|
169
|
+
if (typeof value !== "string") return undefined;
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(value);
|
|
172
|
+
if (url.protocol === "https:" && url.hostname === "accounts.google.com"
|
|
173
|
+
&& !url.port && !url.username && !url.password && url.pathname === "/signin/continue") return url.href;
|
|
174
|
+
} catch { /* An invalid link must never reach the browser. */ }
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class GeminiVerificationRequiredError extends Error {
|
|
179
|
+
readonly verificationUrl?: string;
|
|
180
|
+
|
|
181
|
+
constructor(url?: unknown) {
|
|
182
|
+
super("Google account verification required. Sign in to Antigravity with this account to continue.");
|
|
183
|
+
this.name = "GeminiVerificationRequiredError";
|
|
184
|
+
// The challenge URL is for the interactive auth UI, not serialized errors.
|
|
185
|
+
Object.defineProperty(this, "verificationUrl", { value: verificationUrl(url), enumerable: false });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
157
189
|
/** One `retrieveUserQuota` bucket: a runtime model's remaining share of its window. */
|
|
158
190
|
export interface QuotaBucket {
|
|
159
191
|
modelId: string;
|
|
@@ -173,8 +205,22 @@ export async function fetchUserQuota(
|
|
|
173
205
|
projectId: string,
|
|
174
206
|
signal?: AbortSignal,
|
|
175
207
|
): Promise<QuotaBucket[]> {
|
|
208
|
+
let lastStatus: number | undefined;
|
|
209
|
+
let verificationRequired: GeminiVerificationRequiredError | undefined;
|
|
210
|
+
const onFailure = (status: number, body: unknown) => {
|
|
211
|
+
lastStatus = status;
|
|
212
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : undefined;
|
|
213
|
+
if (status !== 403 || !error) return;
|
|
214
|
+
const detail = Array.isArray(error.details) ? error.details.find((item) => isRecord(item)
|
|
215
|
+
&& item["@type"] === "type.googleapis.com/google.rpc.ErrorInfo"
|
|
216
|
+
&& item.domain === "cloudcode-pa.googleapis.com" && item.reason === "VALIDATION_REQUIRED") : undefined;
|
|
217
|
+
if (detail || (typeof error.message === "string" && /verify your account/i.test(error.message))) {
|
|
218
|
+
const candidate = new GeminiVerificationRequiredError(detail?.metadata?.validation_url);
|
|
219
|
+
if (!verificationRequired?.verificationUrl) verificationRequired = candidate;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
176
222
|
for (const endpoint of GEMINI_ENDPOINTS) {
|
|
177
|
-
const answer = await postJson(endpoint, "retrieveUserQuota", token, { project: projectId }, signal);
|
|
223
|
+
const answer = await postJson(endpoint, "retrieveUserQuota", token, { project: projectId }, signal, onFailure);
|
|
178
224
|
if (!isRecord(answer)) continue;
|
|
179
225
|
const buckets = Array.isArray(answer.buckets) ? answer.buckets : [];
|
|
180
226
|
return buckets.flatMap((bucket): QuotaBucket[] => {
|
|
@@ -190,7 +236,8 @@ export async function fetchUserQuota(
|
|
|
190
236
|
}];
|
|
191
237
|
});
|
|
192
238
|
}
|
|
193
|
-
|
|
239
|
+
if (verificationRequired) throw verificationRequired;
|
|
240
|
+
throw new Error(`Gemini did not return quota from any endpoint${lastStatus ? ` (HTTP ${lastStatus})` : ""}.`);
|
|
194
241
|
}
|
|
195
242
|
|
|
196
243
|
/** One entry of `fetchAvailableModels`, keyed by its runtime model id. */
|