shariq-pi-extensions 0.2.1 → 0.2.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/README.md +1 -1
- package/docs/EXTENSIONS.md +1 -1
- package/extensions/antigravity-provider/README.md +4 -1
- package/extensions/antigravity-provider/antigravity/accounts.ts +343 -0
- package/extensions/antigravity-provider/antigravity/cloud-code-assist.ts +92 -1
- package/extensions/antigravity-provider/antigravity/dashboard.ts +248 -0
- package/extensions/antigravity-provider/antigravity/index.ts +77 -1
- package/extensions/antigravity-provider/antigravity/oauth.ts +34 -2
- package/extensions/antigravity-provider/antigravity/quotas.ts +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Run `/reload` in an existing Pi session after installation; new sessions load th
|
|
|
22
22
|
|
|
23
23
|
The package contains:
|
|
24
24
|
|
|
25
|
-
- Antigravity provider
|
|
25
|
+
- Antigravity OAuth provider with multi-account quota-aware rotation and `/antigravity` dashboard
|
|
26
26
|
- structured user questions
|
|
27
27
|
- managed background terminals
|
|
28
28
|
- copy-all transcript support
|
package/docs/EXTENSIONS.md
CHANGED
|
@@ -6,7 +6,7 @@ Last verified: 2026-08-20
|
|
|
6
6
|
|
|
7
7
|
### [Antigravity Provider](../extensions/antigravity-provider/README.md)
|
|
8
8
|
|
|
9
|
-
Registers the `antigravity` provider and `/login antigravity` flow for supported Google Antigravity models.
|
|
9
|
+
Registers the `antigravity` provider and `/login antigravity` flow for supported Google Antigravity models. Repeating login adds or updates accounts in the secure `<agent-dir>/antigravity/accounts.json` pool. Requests use quota-aware least-recently-used balancing, rotate before streaming on account-specific auth/rate/quota/capacity failures, honor known reset times, and refresh stored OAuth tokens. `/antigravity` shows account state and live model quotas and can enable or disable accounts; `/antigravity.doctor` reports sanitized provider and rotation diagnostics.
|
|
10
10
|
|
|
11
11
|
### [Cursor provider](../extensions/cursor-provider/README.md)
|
|
12
12
|
|
|
@@ -4,11 +4,14 @@ Local persistent Pi provider for Google Antigravity-compatible models.
|
|
|
4
4
|
|
|
5
5
|
- Package module: `extensions/antigravity-provider/`
|
|
6
6
|
- Provider id: `antigravity`
|
|
7
|
-
- Login command: `/login antigravity`
|
|
7
|
+
- Login command: `/login antigravity` (repeat once per Google account)
|
|
8
|
+
- Account and quota dashboard: `/antigravity`
|
|
8
9
|
- Doctor command: `/antigravity.doctor`
|
|
9
10
|
|
|
10
11
|
The provider includes an IPv4 OAuth token-exchange fallback for Node environments where the default request fails. Its deterministic catalog follows Antigravity CLI 1.1.13 model identifiers and runtime behavior.
|
|
11
12
|
|
|
13
|
+
Successful logins are added to `<agent-dir>/antigravity/accounts.json`, written with owner-only permissions. Existing Pi OAuth credentials are migrated into that pool without exposing token values. Requests select the least recently used eligible account, skip disabled/cooling/exhausted accounts, refresh expiring OAuth tokens, and rotate to another account when an auth, rate, quota, or capacity failure occurs before response streaming begins. Cached per-model remaining quota and reset times guide selection; `/antigravity` refreshes the authoritative catalog and can reversibly enable or disable accounts.
|
|
14
|
+
|
|
12
15
|
Current public model IDs:
|
|
13
16
|
- `antigravity/gemini-3.7-flash`
|
|
14
17
|
- `antigravity/gemini-3.6-flash`
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { OAuthCredentials } from "./oauth.ts";
|
|
6
|
+
import { ANTIGRAVITY_ROUTING } from "./models.ts";
|
|
7
|
+
|
|
8
|
+
export const ANTIGRAVITY_STATE_DIR = path.join(getAgentDir(), "antigravity");
|
|
9
|
+
export const ANTIGRAVITY_ACCOUNTS_PATH = path.join(ANTIGRAVITY_STATE_DIR, "accounts.json");
|
|
10
|
+
|
|
11
|
+
const AUTH_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
|
|
12
|
+
const RATE_COOLDOWN_MS = 30 * 60 * 1_000;
|
|
13
|
+
const CAPACITY_COOLDOWN_MS = 60 * 1_000;
|
|
14
|
+
const REFRESH_SKEW_MS = 60 * 1_000;
|
|
15
|
+
|
|
16
|
+
export type AntigravityQuotaGroup = "gemini" | "non-gemini";
|
|
17
|
+
|
|
18
|
+
export interface AntigravityQuotaEntry {
|
|
19
|
+
modelId: string;
|
|
20
|
+
displayName?: string;
|
|
21
|
+
group: AntigravityQuotaGroup;
|
|
22
|
+
remainingFraction: number;
|
|
23
|
+
resetTime?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AntigravityAccount {
|
|
27
|
+
id: string;
|
|
28
|
+
email?: string;
|
|
29
|
+
refresh: string;
|
|
30
|
+
access: string;
|
|
31
|
+
expires: number;
|
|
32
|
+
projectId: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
addedAt: number;
|
|
35
|
+
lastUsedAt?: number;
|
|
36
|
+
cooldownUntil?: number;
|
|
37
|
+
cooldownReason?: "auth" | "rate" | "quota" | "capacity";
|
|
38
|
+
lastError?: string;
|
|
39
|
+
quota?: AntigravityQuotaEntry[];
|
|
40
|
+
quotaUpdatedAt?: number;
|
|
41
|
+
quotaError?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AntigravityAccountFile {
|
|
45
|
+
version: 1;
|
|
46
|
+
accounts: AntigravityAccount[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AntigravityAccountStatus extends AntigravityAccount {
|
|
50
|
+
active: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
54
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function credentialId(refresh: string, email?: string): string {
|
|
58
|
+
return createHash("sha256").update(refresh || email || "antigravity").digest("hex").slice(0, 20);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeAccount(value: unknown): AntigravityAccount | undefined {
|
|
62
|
+
if (!value || typeof value !== "object") return undefined;
|
|
63
|
+
const raw = value as Record<string, unknown>;
|
|
64
|
+
if (typeof raw.refresh !== "string" || !raw.refresh) return undefined;
|
|
65
|
+
if (typeof raw.access !== "string") return undefined;
|
|
66
|
+
const projectId = typeof raw.projectId === "string" ? raw.projectId : "";
|
|
67
|
+
if (!projectId) return undefined;
|
|
68
|
+
const email = typeof raw.email === "string" && raw.email ? raw.email : undefined;
|
|
69
|
+
const quota = Array.isArray(raw.quota)
|
|
70
|
+
? raw.quota.flatMap((item): AntigravityQuotaEntry[] => {
|
|
71
|
+
if (!item || typeof item !== "object") return [];
|
|
72
|
+
const entry = item as Record<string, unknown>;
|
|
73
|
+
if (typeof entry.modelId !== "string") return [];
|
|
74
|
+
if (entry.group !== "gemini" && entry.group !== "non-gemini") return [];
|
|
75
|
+
const remainingFraction = finiteNumber(entry.remainingFraction);
|
|
76
|
+
if (remainingFraction === undefined) return [];
|
|
77
|
+
return [{
|
|
78
|
+
modelId: entry.modelId,
|
|
79
|
+
displayName: typeof entry.displayName === "string" ? entry.displayName : undefined,
|
|
80
|
+
group: entry.group,
|
|
81
|
+
remainingFraction: Math.max(0, Math.min(1, remainingFraction)),
|
|
82
|
+
resetTime: typeof entry.resetTime === "string" ? entry.resetTime : undefined,
|
|
83
|
+
}];
|
|
84
|
+
})
|
|
85
|
+
: undefined;
|
|
86
|
+
return {
|
|
87
|
+
id: typeof raw.id === "string" && raw.id ? raw.id : credentialId(raw.refresh, email),
|
|
88
|
+
email,
|
|
89
|
+
refresh: raw.refresh,
|
|
90
|
+
access: raw.access,
|
|
91
|
+
expires: finiteNumber(raw.expires) ?? 0,
|
|
92
|
+
projectId,
|
|
93
|
+
disabled: raw.disabled === true,
|
|
94
|
+
addedAt: finiteNumber(raw.addedAt) ?? Date.now(),
|
|
95
|
+
lastUsedAt: finiteNumber(raw.lastUsedAt),
|
|
96
|
+
cooldownUntil: finiteNumber(raw.cooldownUntil),
|
|
97
|
+
cooldownReason: raw.cooldownReason === "auth" || raw.cooldownReason === "rate" || raw.cooldownReason === "quota" || raw.cooldownReason === "capacity" ? raw.cooldownReason : undefined,
|
|
98
|
+
lastError: typeof raw.lastError === "string" ? raw.lastError.slice(0, 300) : undefined,
|
|
99
|
+
quota,
|
|
100
|
+
quotaUpdatedAt: finiteNumber(raw.quotaUpdatedAt),
|
|
101
|
+
quotaError: typeof raw.quotaError === "string" ? raw.quotaError.slice(0, 300) : undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function loadAntigravityAccounts(): AntigravityAccount[] {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(fs.readFileSync(ANTIGRAVITY_ACCOUNTS_PATH, "utf8")) as Partial<AntigravityAccountFile>;
|
|
108
|
+
return Array.isArray(parsed.accounts)
|
|
109
|
+
? parsed.accounts.map(normalizeAccount).filter((account): account is AntigravityAccount => Boolean(account))
|
|
110
|
+
: [];
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveAntigravityAccounts(accounts: AntigravityAccount[]) {
|
|
117
|
+
fs.mkdirSync(ANTIGRAVITY_STATE_DIR, { recursive: true, mode: 0o700 });
|
|
118
|
+
fs.chmodSync(ANTIGRAVITY_STATE_DIR, 0o700);
|
|
119
|
+
const temporary = `${ANTIGRAVITY_ACCOUNTS_PATH}.${process.pid}.tmp`;
|
|
120
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, accounts }, null, 2)}\n`, { mode: 0o600 });
|
|
121
|
+
fs.renameSync(temporary, ANTIGRAVITY_ACCOUNTS_PATH);
|
|
122
|
+
fs.chmodSync(ANTIGRAVITY_ACCOUNTS_PATH, 0o600);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function replaceAccount(id: string, update: (account: AntigravityAccount) => AntigravityAccount) {
|
|
126
|
+
const accounts = loadAntigravityAccounts();
|
|
127
|
+
const index = accounts.findIndex((account) => account.id === id);
|
|
128
|
+
if (index < 0) return undefined;
|
|
129
|
+
const next = update(accounts[index]!);
|
|
130
|
+
accounts[index] = next;
|
|
131
|
+
saveAntigravityAccounts(accounts);
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function upsertAntigravityAccount(
|
|
136
|
+
credentials: OAuthCredentials,
|
|
137
|
+
options: { clearCooldown?: boolean } = {},
|
|
138
|
+
): AntigravityAccount {
|
|
139
|
+
const id = credentialId(credentials.refresh, credentials.email);
|
|
140
|
+
const accounts = loadAntigravityAccounts();
|
|
141
|
+
const index = accounts.findIndex((account) => account.id === id || Boolean(credentials.email && account.email === credentials.email));
|
|
142
|
+
const existing = index >= 0 ? accounts[index] : undefined;
|
|
143
|
+
const account: AntigravityAccount = {
|
|
144
|
+
...existing,
|
|
145
|
+
id,
|
|
146
|
+
email: credentials.email || existing?.email,
|
|
147
|
+
refresh: credentials.refresh,
|
|
148
|
+
access: credentials.access,
|
|
149
|
+
expires: credentials.expires,
|
|
150
|
+
projectId: credentials.projectId || existing?.projectId || "",
|
|
151
|
+
addedAt: existing?.addedAt ?? Date.now(),
|
|
152
|
+
disabled: existing?.disabled ?? false,
|
|
153
|
+
cooldownUntil: options.clearCooldown ? undefined : existing?.cooldownUntil,
|
|
154
|
+
cooldownReason: options.clearCooldown ? undefined : existing?.cooldownReason,
|
|
155
|
+
lastError: options.clearCooldown ? undefined : existing?.lastError,
|
|
156
|
+
};
|
|
157
|
+
if (index >= 0) accounts[index] = account;
|
|
158
|
+
else accounts.push(account);
|
|
159
|
+
saveAntigravityAccounts(accounts);
|
|
160
|
+
return account;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function setAntigravityAccountEnabled(id: string, enabled: boolean) {
|
|
164
|
+
return replaceAccount(id, (account) => ({
|
|
165
|
+
...account,
|
|
166
|
+
disabled: !enabled,
|
|
167
|
+
...(enabled ? { cooldownUntil: undefined, cooldownReason: undefined, lastError: undefined } : {}),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function removeAntigravityAccount(id: string) {
|
|
172
|
+
const accounts = loadAntigravityAccounts();
|
|
173
|
+
const next = accounts.filter((account) => account.id !== id);
|
|
174
|
+
if (next.length === accounts.length) return false;
|
|
175
|
+
saveAntigravityAccounts(next);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function quotaGroupForModel(modelId: string): AntigravityQuotaGroup {
|
|
180
|
+
return modelId.startsWith("gemini-") ? "gemini" : "non-gemini";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resetAt(entry: AntigravityQuotaEntry | undefined): number | undefined {
|
|
184
|
+
if (!entry?.resetTime) return undefined;
|
|
185
|
+
const value = Date.parse(entry.resetTime);
|
|
186
|
+
return Number.isFinite(value) ? value : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function quotaForModel(account: AntigravityAccount, modelId: string) {
|
|
190
|
+
const group = quotaGroupForModel(modelId);
|
|
191
|
+
const routing = ANTIGRAVITY_ROUTING[modelId];
|
|
192
|
+
const relatedIds = new Set([
|
|
193
|
+
modelId,
|
|
194
|
+
routing?.off,
|
|
195
|
+
routing?.defaultRequestId,
|
|
196
|
+
...Object.values(routing?.routing ?? {}),
|
|
197
|
+
].filter((value): value is string => typeof value === "string"));
|
|
198
|
+
const related = account.quota?.filter((entry) =>
|
|
199
|
+
relatedIds.has(entry.modelId) || entry.modelId.startsWith(`${modelId}-`)
|
|
200
|
+
) ?? [];
|
|
201
|
+
if (related.length) return related.sort((left, right) => left.remainingFraction - right.remainingFraction)[0];
|
|
202
|
+
const grouped = account.quota?.filter((entry) => entry.group === group) ?? [];
|
|
203
|
+
return grouped.sort((left, right) => left.remainingFraction - right.remainingFraction)[0];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function eligibleAntigravityAccounts(
|
|
207
|
+
accounts: AntigravityAccount[],
|
|
208
|
+
modelId: string,
|
|
209
|
+
now = Date.now(),
|
|
210
|
+
) {
|
|
211
|
+
return accounts
|
|
212
|
+
.filter((account) => {
|
|
213
|
+
if (account.disabled) return false;
|
|
214
|
+
if (account.cooldownUntil && account.cooldownUntil > now) return false;
|
|
215
|
+
const quota = quotaForModel(account, modelId);
|
|
216
|
+
const reset = resetAt(quota);
|
|
217
|
+
return !(quota && quota.remainingFraction <= 0 && reset && reset > now);
|
|
218
|
+
})
|
|
219
|
+
.sort((left, right) => (left.lastUsedAt ?? 0) - (right.lastUsedAt ?? 0));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function selectAntigravityAccounts(modelId: string, now = Date.now()) {
|
|
223
|
+
return eligibleAntigravityAccounts(loadAntigravityAccounts(), modelId, now);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function markAntigravityAccountUsed(id: string) {
|
|
227
|
+
return replaceAccount(id, (account) => ({ ...account, lastUsedAt: Date.now() }));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseRetryAfter(message: string, now = Date.now()): number | undefined {
|
|
231
|
+
const iso = message.match(/(?:reset(?:s| time)?(?: at| in)?|retry after)[^\n]*?(\d{4}-\d{2}-\d{2}T[^\s,;]+)/i)?.[1];
|
|
232
|
+
if (iso) {
|
|
233
|
+
const parsed = Date.parse(iso);
|
|
234
|
+
if (Number.isFinite(parsed) && parsed > now) return parsed;
|
|
235
|
+
}
|
|
236
|
+
const duration = message.match(/(?:reset(?:s)? in|retry after)\s*(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+(?:\.\d+)?)s)?/i);
|
|
237
|
+
if (!duration) return undefined;
|
|
238
|
+
const milliseconds = ((Number(duration[1] || 0) * 3600) + (Number(duration[2] || 0) * 60) + Number(duration[3] || 0)) * 1_000;
|
|
239
|
+
return milliseconds > 0 ? now + milliseconds : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function classifyAntigravityFailure(message: string, modelId: string, account?: AntigravityAccount) {
|
|
243
|
+
const lower = message.toLowerCase();
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
if (/\b401\b/.test(lower) || lower.includes("invalid_grant") || lower.includes("login expired") || lower.includes("credentials are invalid")) {
|
|
246
|
+
return { reason: "auth" as const, until: now + AUTH_COOLDOWN_MS };
|
|
247
|
+
}
|
|
248
|
+
if (/\b429\b/.test(lower) || lower.includes("rate limit") || lower.includes("quota reached") || lower.includes("resource exhausted")) {
|
|
249
|
+
const quotaReset = resetAt(account ? quotaForModel(account, modelId) : undefined);
|
|
250
|
+
const explicitReset = parseRetryAfter(message, now);
|
|
251
|
+
return {
|
|
252
|
+
reason: lower.includes("quota") || lower.includes("exhaust") ? "quota" as const : "rate" as const,
|
|
253
|
+
until: explicitReset || (quotaReset && quotaReset > now ? quotaReset : now + RATE_COOLDOWN_MS),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (/\b503\b/.test(lower) || /\b529\b/.test(lower) || lower.includes("capacity") || lower.includes("overloaded")) {
|
|
257
|
+
return { reason: "capacity" as const, until: now + CAPACITY_COOLDOWN_MS };
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function markAntigravityAccountFailure(id: string, modelId: string, message: string) {
|
|
263
|
+
const account = loadAntigravityAccounts().find((entry) => entry.id === id);
|
|
264
|
+
const failure = classifyAntigravityFailure(message, modelId, account);
|
|
265
|
+
if (!failure) return false;
|
|
266
|
+
replaceAccount(id, (current) => ({
|
|
267
|
+
...current,
|
|
268
|
+
cooldownUntil: failure.until,
|
|
269
|
+
cooldownReason: failure.reason,
|
|
270
|
+
lastError: message.slice(0, 300),
|
|
271
|
+
lastUsedAt: Date.now(),
|
|
272
|
+
}));
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function updateAntigravityAccountQuota(id: string, quota: AntigravityQuotaEntry[] | undefined, error?: string) {
|
|
277
|
+
return replaceAccount(id, (account) => ({
|
|
278
|
+
...account,
|
|
279
|
+
...(quota ? { quota, quotaUpdatedAt: Date.now(), quotaError: undefined } : { quotaError: error?.slice(0, 300) }),
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function parseAntigravityQuota(value: unknown): AntigravityQuotaEntry[] {
|
|
284
|
+
const entries: AntigravityQuotaEntry[] = [];
|
|
285
|
+
const seen = new Set<string>();
|
|
286
|
+
const visit = (node: unknown, keyHint?: string) => {
|
|
287
|
+
if (!node || typeof node !== "object") return;
|
|
288
|
+
if (Array.isArray(node)) {
|
|
289
|
+
for (const item of node) visit(item);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const raw = node as Record<string, unknown>;
|
|
293
|
+
const quota = raw.quotaInfo && typeof raw.quotaInfo === "object" ? raw.quotaInfo as Record<string, unknown> : raw;
|
|
294
|
+
const remaining = finiteNumber(quota.remainingFraction);
|
|
295
|
+
const modelId = [raw.modelId, raw.id, raw.name, raw.model, keyHint].find((item): item is string => typeof item === "string" && /gemini|claude|gpt-oss/i.test(item));
|
|
296
|
+
if (modelId && remaining !== undefined) {
|
|
297
|
+
const normalized = modelId.replace(/^models\//, "");
|
|
298
|
+
if (!seen.has(normalized)) {
|
|
299
|
+
seen.add(normalized);
|
|
300
|
+
entries.push({
|
|
301
|
+
modelId: normalized,
|
|
302
|
+
displayName: typeof raw.displayName === "string" ? raw.displayName : typeof raw.label === "string" ? raw.label : undefined,
|
|
303
|
+
group: normalized.startsWith("gemini-") ? "gemini" : "non-gemini",
|
|
304
|
+
remainingFraction: Math.max(0, Math.min(1, remaining)),
|
|
305
|
+
resetTime: typeof quota.resetTime === "string" ? quota.resetTime : undefined,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const [key, child] of Object.entries(raw)) {
|
|
310
|
+
if (child && typeof child === "object") visit(child, key);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
visit(value);
|
|
314
|
+
return entries;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const refreshes = new Map<string, Promise<AntigravityAccount>>();
|
|
318
|
+
|
|
319
|
+
export function resolveAntigravityAccount(
|
|
320
|
+
account: AntigravityAccount,
|
|
321
|
+
refresh: (credentials: OAuthCredentials) => Promise<OAuthCredentials>,
|
|
322
|
+
now = Date.now(),
|
|
323
|
+
): Promise<AntigravityAccount> {
|
|
324
|
+
if (account.access && account.expires > now + REFRESH_SKEW_MS) return Promise.resolve(account);
|
|
325
|
+
const existing = refreshes.get(account.id);
|
|
326
|
+
if (existing) return existing;
|
|
327
|
+
const pending = refresh({
|
|
328
|
+
refresh: account.refresh,
|
|
329
|
+
access: account.access,
|
|
330
|
+
expires: account.expires,
|
|
331
|
+
projectId: account.projectId,
|
|
332
|
+
email: account.email,
|
|
333
|
+
}).then(upsertAntigravityAccount).finally(() => refreshes.delete(account.id));
|
|
334
|
+
refreshes.set(account.id, pending);
|
|
335
|
+
return pending;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function antigravityAccountStatuses(now = Date.now()): AntigravityAccountStatus[] {
|
|
339
|
+
return loadAntigravityAccounts().map((account) => ({
|
|
340
|
+
...account,
|
|
341
|
+
active: !account.disabled && !(account.cooldownUntil && account.cooldownUntil > now),
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
@@ -19,9 +19,20 @@ import {
|
|
|
19
19
|
jsonOrTextError,
|
|
20
20
|
loadCodeAssist,
|
|
21
21
|
fetchAvailableRuntimeModel,
|
|
22
|
+
refreshAntigravityToken,
|
|
22
23
|
DEFAULT_PROJECT_ID,
|
|
23
24
|
} from "./oauth.ts";
|
|
24
25
|
import { getAntigravityRequestModelId, PROVIDER_ID } from "./models.ts";
|
|
26
|
+
import {
|
|
27
|
+
classifyAntigravityFailure,
|
|
28
|
+
loadAntigravityAccounts,
|
|
29
|
+
markAntigravityAccountFailure,
|
|
30
|
+
markAntigravityAccountUsed,
|
|
31
|
+
resolveAntigravityAccount,
|
|
32
|
+
selectAntigravityAccounts,
|
|
33
|
+
type AntigravityAccount,
|
|
34
|
+
} from "./accounts.ts";
|
|
35
|
+
import { refreshAntigravityQuotas } from "./quotas.ts";
|
|
25
36
|
|
|
26
37
|
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
|
|
27
38
|
"You are Antigravity, a powerful agentic AI coding assistant designed by Google DeepMind. " +
|
|
@@ -435,7 +446,7 @@ async function streamResponse(response: Response, stream: AssistantMessageEventS
|
|
|
435
446
|
return hasContent;
|
|
436
447
|
}
|
|
437
448
|
|
|
438
|
-
|
|
449
|
+
function streamAntigravitySingle(model: any, context: any, options?: any): any {
|
|
439
450
|
const stream = createAssistantMessageEventStream();
|
|
440
451
|
void (async () => {
|
|
441
452
|
const output = createOutput(model);
|
|
@@ -517,3 +528,83 @@ export function streamAntigravity(model: any, context: any, options?: any): any
|
|
|
517
528
|
})();
|
|
518
529
|
return stream;
|
|
519
530
|
}
|
|
531
|
+
|
|
532
|
+
function antigravityErrorEvent(model: any, message: string) {
|
|
533
|
+
const output = createOutput(model);
|
|
534
|
+
output.stopReason = "error";
|
|
535
|
+
output.errorMessage = message;
|
|
536
|
+
return { type: "error" as const, reason: "error" as const, error: output };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function antigravityEventError(event: any): string | undefined {
|
|
540
|
+
if (event?.type !== "error") return undefined;
|
|
541
|
+
return event.error?.errorMessage || event.error?.message || event.reason;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function streamAntigravity(model: any, context: any, options?: any): any {
|
|
545
|
+
const stream = createAssistantMessageEventStream();
|
|
546
|
+
void (async () => {
|
|
547
|
+
const stored = loadAntigravityAccounts();
|
|
548
|
+
const selected = selectAntigravityAccounts(model.id);
|
|
549
|
+
const candidates: Array<AntigravityAccount | undefined> = stored.length ? selected : [undefined];
|
|
550
|
+
if (!candidates.length) {
|
|
551
|
+
stream.push(antigravityErrorEvent(model, `No Antigravity account has available ${model.id} quota. Open /antigravity to refresh quotas or enable an account.`));
|
|
552
|
+
stream.end();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let lastError = "No Antigravity account completed the request.";
|
|
557
|
+
for (const candidate of candidates) {
|
|
558
|
+
if (options?.signal?.aborted) throw options.signal.reason ?? new Error("Request was aborted");
|
|
559
|
+
let account = candidate;
|
|
560
|
+
let apiKey = options?.apiKey;
|
|
561
|
+
try {
|
|
562
|
+
if (account) {
|
|
563
|
+
account = await resolveAntigravityAccount(account, refreshAntigravityToken);
|
|
564
|
+
apiKey = JSON.stringify({ token: account.access, projectId: account.projectId });
|
|
565
|
+
}
|
|
566
|
+
} catch (error) {
|
|
567
|
+
lastError = safeError(error);
|
|
568
|
+
if (account) markAntigravityAccountFailure(account.id, model.id, lastError);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const inner = streamAntigravitySingle(model, context, { ...options, apiKey });
|
|
573
|
+
const buffered: any[] = [];
|
|
574
|
+
let started = false;
|
|
575
|
+
let rotate = false;
|
|
576
|
+
for await (const event of inner as AsyncIterable<any>) {
|
|
577
|
+
const error = antigravityEventError(event);
|
|
578
|
+
if (error && !started && account) {
|
|
579
|
+
lastError = error;
|
|
580
|
+
const retryable = Boolean(classifyAntigravityFailure(error, model.id, account));
|
|
581
|
+
if (retryable) {
|
|
582
|
+
markAntigravityAccountFailure(account.id, model.id, error);
|
|
583
|
+
void refreshAntigravityQuotas({ force: true, signal: options?.signal }).catch(() => {});
|
|
584
|
+
rotate = true;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (!started && event?.type === "start") {
|
|
589
|
+
started = true;
|
|
590
|
+
if (account) markAntigravityAccountUsed(account.id);
|
|
591
|
+
for (const pending of buffered) stream.push(pending);
|
|
592
|
+
buffered.length = 0;
|
|
593
|
+
}
|
|
594
|
+
if (started || event?.type === "error") stream.push(event);
|
|
595
|
+
else buffered.push(event);
|
|
596
|
+
}
|
|
597
|
+
if (rotate) continue;
|
|
598
|
+
for (const pending of buffered) stream.push(pending);
|
|
599
|
+
stream.end();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
stream.push(antigravityErrorEvent(model, `All configured Antigravity accounts are cooling down, exhausted, or failed. Last error: ${lastError}`));
|
|
604
|
+
stream.end();
|
|
605
|
+
})().catch((error) => {
|
|
606
|
+
stream.push(antigravityErrorEvent(model, safeError(error)));
|
|
607
|
+
stream.end();
|
|
608
|
+
});
|
|
609
|
+
return stream;
|
|
610
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { frameBottom, frameTop, joinSides, meter, oneLine, padLine, stateLabel } from "../../shared/tui-dashboard.ts";
|
|
4
|
+
import type { AntigravityAccountStatus, AntigravityQuotaEntry } from "./accounts.ts";
|
|
5
|
+
|
|
6
|
+
export interface AntigravityDashboardSnapshot {
|
|
7
|
+
authentication: string;
|
|
8
|
+
modelCount: number;
|
|
9
|
+
accounts: AntigravityAccountStatus[];
|
|
10
|
+
warning?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
14
|
+
|
|
15
|
+
function remaining(value: number) {
|
|
16
|
+
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function until(timestamp: number | string | undefined) {
|
|
20
|
+
const value = typeof timestamp === "number" ? timestamp : timestamp ? Date.parse(timestamp) : Number.NaN;
|
|
21
|
+
if (!Number.isFinite(value) || value <= Date.now()) return "now";
|
|
22
|
+
const seconds = Math.max(0, Math.round((value - Date.now()) / 1_000));
|
|
23
|
+
const days = Math.floor(seconds / 86_400);
|
|
24
|
+
const hours = Math.floor((seconds % 86_400) / 3_600);
|
|
25
|
+
const minutes = Math.floor((seconds % 3_600) / 60);
|
|
26
|
+
if (days) return `${days}d ${hours}h`;
|
|
27
|
+
if (hours) return `${hours}h ${minutes}m`;
|
|
28
|
+
return `${minutes}m`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function groupQuota(quota: AntigravityQuotaEntry[] | undefined, group: "gemini" | "non-gemini") {
|
|
32
|
+
return (quota ?? []).filter((entry) => entry.group === group).sort((left, right) => left.remainingFraction - right.remainingFraction);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function accountState(account: AntigravityAccountStatus) {
|
|
36
|
+
if (account.disabled) return "disabled";
|
|
37
|
+
if (account.cooldownUntil && account.cooldownUntil > Date.now()) return account.cooldownReason === "auth" ? "auth cooldown" : "cooldown";
|
|
38
|
+
const quota = account.quota ?? [];
|
|
39
|
+
if (quota.length && quota.every((entry) => entry.remainingFraction <= 0 && (!entry.resetTime || Date.parse(entry.resetTime) > Date.now()))) return "exhausted";
|
|
40
|
+
if (account.quotaError && !quota.length) return "refresh error";
|
|
41
|
+
return quota.length ? "ready" : "not refreshed";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stateColor(state: string) {
|
|
45
|
+
if (state === "ready") return "success" as const;
|
|
46
|
+
if (state === "not refreshed" || state === "disabled") return "muted" as const;
|
|
47
|
+
if (state === "refresh error" || state === "auth cooldown") return "error" as const;
|
|
48
|
+
return "warning" as const;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compactGroup(label: string, entries: AntigravityQuotaEntry[]) {
|
|
52
|
+
if (!entries.length) return `${label}: unavailable`;
|
|
53
|
+
const worst = entries[0]!;
|
|
54
|
+
return `${label}: ${remaining(worst.remainingFraction)} remaining${worst.resetTime ? ` · resets ${until(worst.resetTime)}` : ""}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class AntigravityDashboard implements Component {
|
|
58
|
+
private selected = 0;
|
|
59
|
+
private refreshing = false;
|
|
60
|
+
private closed = false;
|
|
61
|
+
private readonly tui: TUI;
|
|
62
|
+
private readonly theme: Theme;
|
|
63
|
+
private readonly keys: KeybindingsManager;
|
|
64
|
+
private snapshot: AntigravityDashboardSnapshot;
|
|
65
|
+
private readonly refreshData: (force: boolean) => Promise<AntigravityDashboardSnapshot>;
|
|
66
|
+
private readonly toggleAccount: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>;
|
|
67
|
+
private readonly done: () => void;
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
tui: TUI,
|
|
71
|
+
theme: Theme,
|
|
72
|
+
keys: KeybindingsManager,
|
|
73
|
+
snapshot: AntigravityDashboardSnapshot,
|
|
74
|
+
refreshData: (force: boolean) => Promise<AntigravityDashboardSnapshot>,
|
|
75
|
+
toggleAccount: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>,
|
|
76
|
+
done: () => void,
|
|
77
|
+
) {
|
|
78
|
+
this.tui = tui;
|
|
79
|
+
this.theme = theme;
|
|
80
|
+
this.keys = keys;
|
|
81
|
+
this.snapshot = snapshot;
|
|
82
|
+
this.refreshData = refreshData;
|
|
83
|
+
this.toggleAccount = toggleAccount;
|
|
84
|
+
this.done = done;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
startRefresh(force: boolean) {
|
|
88
|
+
if (this.refreshing || this.closed) return;
|
|
89
|
+
this.refreshing = true;
|
|
90
|
+
this.tui.requestRender();
|
|
91
|
+
void this.refreshData(force)
|
|
92
|
+
.then((snapshot) => this.replaceSnapshot(snapshot))
|
|
93
|
+
.catch((error) => {
|
|
94
|
+
if (!this.closed) this.snapshot = { ...this.snapshot, warning: `Refresh failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
if (!this.closed) {
|
|
98
|
+
this.refreshing = false;
|
|
99
|
+
this.tui.requestRender();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private replaceSnapshot(snapshot: AntigravityDashboardSnapshot) {
|
|
105
|
+
if (this.closed) return;
|
|
106
|
+
const id = this.snapshot.accounts[this.selected]?.id;
|
|
107
|
+
this.snapshot = snapshot;
|
|
108
|
+
const index = id ? snapshot.accounts.findIndex((account) => account.id === id) : -1;
|
|
109
|
+
this.selected = index >= 0 ? index : Math.min(this.selected, Math.max(0, snapshot.accounts.length - 1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dispose() { this.closed = true; }
|
|
113
|
+
invalidate() {}
|
|
114
|
+
|
|
115
|
+
handleInput(data: string) {
|
|
116
|
+
const accounts = this.snapshot.accounts;
|
|
117
|
+
if (this.keys.matches(data, "tui.select.cancel")) {
|
|
118
|
+
this.closed = true;
|
|
119
|
+
this.done();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (this.keys.matches(data, "tui.select.up") || data === "k") {
|
|
123
|
+
if (accounts.length) this.selected = (this.selected - 1 + accounts.length) % accounts.length;
|
|
124
|
+
} else if (this.keys.matches(data, "tui.select.down") || data === "j") {
|
|
125
|
+
if (accounts.length) this.selected = (this.selected + 1) % accounts.length;
|
|
126
|
+
} else if (data === "r") {
|
|
127
|
+
this.startRefresh(true);
|
|
128
|
+
} else if (data === "d" && accounts[this.selected] && !this.refreshing) {
|
|
129
|
+
const account = accounts[this.selected]!;
|
|
130
|
+
this.refreshing = true;
|
|
131
|
+
void this.toggleAccount(account.id, account.disabled === true)
|
|
132
|
+
.then((snapshot) => this.replaceSnapshot(snapshot))
|
|
133
|
+
.catch((error) => {
|
|
134
|
+
if (!this.closed) this.snapshot = { ...this.snapshot, warning: error instanceof Error ? error.message : String(error) };
|
|
135
|
+
})
|
|
136
|
+
.finally(() => {
|
|
137
|
+
if (!this.closed) {
|
|
138
|
+
this.refreshing = false;
|
|
139
|
+
this.tui.requestRender();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
this.tui.requestRender();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
render(width: number) {
|
|
147
|
+
const rows = this.tui.terminal.rows || 30;
|
|
148
|
+
const bodyHeight = Math.max(8, rows - 7);
|
|
149
|
+
const accounts = this.snapshot.accounts;
|
|
150
|
+
this.selected = Math.min(this.selected, Math.max(0, accounts.length - 1));
|
|
151
|
+
const selected = accounts[this.selected];
|
|
152
|
+
const active = accounts.filter((account) => account.active).length;
|
|
153
|
+
const right = this.refreshing
|
|
154
|
+
? this.theme.fg("warning", "refreshing…")
|
|
155
|
+
: this.snapshot.warning
|
|
156
|
+
? this.theme.fg("warning", oneLine(this.snapshot.warning))
|
|
157
|
+
: this.theme.fg("muted", `${active}/${accounts.length} active · ${this.snapshot.modelCount} models`);
|
|
158
|
+
const title = ` ${this.theme.fg("accent", this.theme.bold("◆ ANTIGRAVITY"))} ${this.theme.fg("dim", `· ${this.snapshot.authentication}`)}`;
|
|
159
|
+
const lines = width >= 64
|
|
160
|
+
? [joinSides(title, `${right} `, width)]
|
|
161
|
+
: [truncateToWidth(title, width, ""), truncateToWidth(` ${right}`, width, "")];
|
|
162
|
+
lines.push(frameTop(this.theme, width, `ACCOUNTS ${accounts.length} · QUOTA REMAINING`));
|
|
163
|
+
const inner = Math.max(1, width - 2);
|
|
164
|
+
if (width >= 104 && selected) {
|
|
165
|
+
const leftWidth = Math.max(46, Math.floor((inner - 1) * 0.45));
|
|
166
|
+
const rightWidth = Math.max(24, inner - leftWidth - 1);
|
|
167
|
+
const list = this.renderAccounts(leftWidth, bodyHeight, true);
|
|
168
|
+
const detail = this.renderDetail(selected, rightWidth, bodyHeight);
|
|
169
|
+
for (let row = 0; row < bodyHeight; row++) {
|
|
170
|
+
lines.push(this.theme.fg("border", "│") + padLine(list[row] ?? "", leftWidth) + this.theme.fg("borderMuted", "│") + padLine(detail[row] ?? "", rightWidth) + this.theme.fg("border", "│"));
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
const list = this.renderAccounts(inner, bodyHeight, false);
|
|
174
|
+
for (let row = 0; row < bodyHeight; row++) lines.push(this.theme.fg("border", "│") + padLine(list[row] ?? "", inner) + this.theme.fg("border", "│"));
|
|
175
|
+
}
|
|
176
|
+
lines.push(frameBottom(this.theme, width));
|
|
177
|
+
lines.push(truncateToWidth(`${this.theme.fg("accent", " ↑↓ / j k")} ${this.theme.fg("dim", "select")} ${this.theme.fg("accent", "r")} ${this.theme.fg("dim", "refresh")} ${this.theme.fg("accent", "d")} ${this.theme.fg("dim", "enable/disable")} ${this.theme.fg("accent", "esc")} ${this.theme.fg("dim", "close")}`, width, ""));
|
|
178
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private renderAccounts(width: number, height: number, compact: boolean) {
|
|
182
|
+
const accounts = this.snapshot.accounts;
|
|
183
|
+
if (!accounts.length) return [this.theme.fg("muted", " No Antigravity accounts saved."), this.theme.fg("dim", " Run /login antigravity once per Google account.")];
|
|
184
|
+
const rowsPerAccount = compact ? 1 : 4;
|
|
185
|
+
const visibleCount = Math.max(1, Math.floor(height / rowsPerAccount));
|
|
186
|
+
const start = Math.min(Math.max(0, this.selected - Math.floor(visibleCount / 2)), Math.max(0, accounts.length - visibleCount));
|
|
187
|
+
const lines: string[] = [];
|
|
188
|
+
for (const [offset, account] of accounts.slice(start, start + visibleCount).entries()) {
|
|
189
|
+
const index = start + offset;
|
|
190
|
+
const selected = index === this.selected;
|
|
191
|
+
const state = accountState(account);
|
|
192
|
+
const marker = selected ? this.theme.fg("accent", "◆") : " ";
|
|
193
|
+
const title = this.theme.fg(selected ? "accent" : "text", oneLine(account.email || `account-${account.id.slice(0, 6)}`));
|
|
194
|
+
const first = joinSides(` ${marker} ${title}`, `${stateLabel(this.theme, stateColor(state), state)} `, width);
|
|
195
|
+
lines.push(selected ? this.theme.bg("selectedBg", padLine(first, width)) : first);
|
|
196
|
+
if (!compact) {
|
|
197
|
+
lines.push(` ${this.theme.fg("muted", compactGroup("Gemini", groupQuota(account.quota, "gemini")))}`);
|
|
198
|
+
lines.push(` ${this.theme.fg("muted", compactGroup("Claude / GPT", groupQuota(account.quota, "non-gemini")))}`);
|
|
199
|
+
lines.push("");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return lines.slice(0, height);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private renderDetail(account: AntigravityAccountStatus, width: number, height: number) {
|
|
206
|
+
const state = accountState(account);
|
|
207
|
+
const lines = [
|
|
208
|
+
` ${stateLabel(this.theme, stateColor(state), state)} ${this.theme.fg("accent", this.theme.bold(oneLine(account.email || `account-${account.id.slice(0, 6)}`)))}`,
|
|
209
|
+
` ${this.theme.fg("dim", account.quotaUpdatedAt ? `quota updated ${until(account.quotaUpdatedAt + 15 * 60 * 1_000)} refresh window` : "quota has not been refreshed")}`,
|
|
210
|
+
];
|
|
211
|
+
if (account.cooldownUntil && account.cooldownUntil > Date.now()) lines.push(` ${this.theme.fg("warning", `${account.cooldownReason || "rotation"} cooldown · ${until(account.cooldownUntil)}`)}`);
|
|
212
|
+
if (account.quotaError) lines.push(` ${this.theme.fg("error", oneLine(account.quotaError))}`);
|
|
213
|
+
if (account.lastError) lines.push(` ${this.theme.fg("warning", oneLine(account.lastError))}`);
|
|
214
|
+
lines.push("", ...this.renderQuotaGroup("Gemini", groupQuota(account.quota, "gemini"), width));
|
|
215
|
+
lines.push("", ...this.renderQuotaGroup("Claude / GPT-OSS", groupQuota(account.quota, "non-gemini"), width));
|
|
216
|
+
if (account.lastUsedAt) lines.push("", ` ${this.theme.fg("muted", "LAST USED")} ${this.theme.fg("text", new Date(account.lastUsedAt).toLocaleString())}`);
|
|
217
|
+
return lines.slice(0, height);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private renderQuotaGroup(name: string, entries: AntigravityQuotaEntry[], width: number) {
|
|
221
|
+
const lines = [` ${this.theme.fg("accent", this.theme.bold(`${name.toUpperCase()} · REMAINING`))}`];
|
|
222
|
+
if (!entries.length) return [...lines, ` ${this.theme.fg("muted", "Unavailable")}`];
|
|
223
|
+
const meterWidth = Math.max(10, width - 29);
|
|
224
|
+
for (const entry of entries.slice(0, 8)) {
|
|
225
|
+
const percent = entry.remainingFraction * 100;
|
|
226
|
+
const color = percent <= 0 ? "error" : percent <= 15 ? "warning" : "active";
|
|
227
|
+
const label = oneLine(entry.displayName || entry.modelId).slice(0, 16).padEnd(16);
|
|
228
|
+
lines.push(` ${this.theme.fg("muted", label)} ${meter(this.theme, percent, 100, meterWidth, color)} ${this.theme.fg("text", remaining(entry.remainingFraction))}${entry.resetTime ? ` ${this.theme.fg("dim", until(entry.resetTime))}` : ""}`);
|
|
229
|
+
}
|
|
230
|
+
return lines;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function openAntigravityDashboard(
|
|
235
|
+
ctx: ExtensionContext,
|
|
236
|
+
initial: AntigravityDashboardSnapshot,
|
|
237
|
+
refresh: (force: boolean) => Promise<AntigravityDashboardSnapshot>,
|
|
238
|
+
toggle: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>,
|
|
239
|
+
) {
|
|
240
|
+
await ctx.ui.custom<void>(
|
|
241
|
+
(tui, theme, keys, done) => {
|
|
242
|
+
const dashboard = new AntigravityDashboard(tui, theme, keys, initial, refresh, toggle, () => done(undefined));
|
|
243
|
+
queueMicrotask(() => dashboard.startRefresh(false));
|
|
244
|
+
return dashboard;
|
|
245
|
+
},
|
|
246
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" } },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
readStoredCredential,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
6
|
import {
|
|
3
7
|
PROVIDER_ID,
|
|
4
8
|
PROVIDER_NAME,
|
|
@@ -18,8 +22,43 @@ import {
|
|
|
18
22
|
DEFAULT_ENDPOINT,
|
|
19
23
|
} from "./oauth.ts";
|
|
20
24
|
import { streamAntigravity } from "./cloud-code-assist.ts";
|
|
25
|
+
import {
|
|
26
|
+
antigravityAccountStatuses,
|
|
27
|
+
setAntigravityAccountEnabled,
|
|
28
|
+
upsertAntigravityAccount,
|
|
29
|
+
} from "./accounts.ts";
|
|
30
|
+
import { refreshAntigravityQuotas } from "./quotas.ts";
|
|
31
|
+
import {
|
|
32
|
+
openAntigravityDashboard,
|
|
33
|
+
type AntigravityDashboardSnapshot,
|
|
34
|
+
} from "./dashboard.ts";
|
|
21
35
|
|
|
22
36
|
export default function antigravityProviderExtension(pi: ExtensionAPI) {
|
|
37
|
+
const dashboardSnapshot = (ctx: ExtensionContext): AntigravityDashboardSnapshot => {
|
|
38
|
+
const auth = ctx.modelRegistry.getProviderAuthStatus(PROVIDER_ID);
|
|
39
|
+
return {
|
|
40
|
+
authentication: auth.configured ? auth.source || auth.label || "configured" : "not authenticated",
|
|
41
|
+
modelCount: ANTIGRAVITY_MODELS.length,
|
|
42
|
+
accounts: antigravityAccountStatuses(),
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const migrateStoredCredential = () => {
|
|
47
|
+
try {
|
|
48
|
+
const credential = readStoredCredential(PROVIDER_ID) as any;
|
|
49
|
+
if (credential?.type !== "oauth" || typeof credential.refresh !== "string" || typeof credential.access !== "string") return;
|
|
50
|
+
upsertAntigravityAccount({
|
|
51
|
+
refresh: credential.refresh,
|
|
52
|
+
access: credential.access,
|
|
53
|
+
expires: typeof credential.expires === "number" ? credential.expires : 0,
|
|
54
|
+
projectId: typeof credential.projectId === "string" ? credential.projectId : undefined,
|
|
55
|
+
email: typeof credential.email === "string" ? credential.email : undefined,
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
// A malformed or unavailable Pi credential must not block extension startup.
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
23
62
|
pi.registerProvider(PROVIDER_ID, {
|
|
24
63
|
name: PROVIDER_NAME,
|
|
25
64
|
baseUrl: DEFAULT_ENDPOINT,
|
|
@@ -34,9 +73,43 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
|
|
|
34
73
|
streamSimple: streamAntigravity,
|
|
35
74
|
} as any);
|
|
36
75
|
|
|
76
|
+
pi.on("session_start", (_event, ctx) => {
|
|
77
|
+
(ctx.modelRegistry as any).authStorage?.reload?.();
|
|
78
|
+
migrateStoredCredential();
|
|
79
|
+
void refreshAntigravityQuotas({ signal: ctx.signal }).catch(() => {
|
|
80
|
+
// Cached quota state remains available; /antigravity reports refresh failures.
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
85
|
+
if (ctx.model?.provider !== PROVIDER_ID) return;
|
|
86
|
+
void refreshAntigravityQuotas({ signal: ctx.signal }).catch(() => {});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
pi.registerCommand("antigravity", {
|
|
90
|
+
description: "Open Antigravity accounts, rotation, and quota dashboard",
|
|
91
|
+
handler: async (_args, ctx) => {
|
|
92
|
+
migrateStoredCredential();
|
|
93
|
+
await openAntigravityDashboard(
|
|
94
|
+
ctx,
|
|
95
|
+
dashboardSnapshot(ctx),
|
|
96
|
+
async (force) => {
|
|
97
|
+
await refreshAntigravityQuotas({ force, signal: ctx.signal });
|
|
98
|
+
return dashboardSnapshot(ctx);
|
|
99
|
+
},
|
|
100
|
+
async (id, enabled) => {
|
|
101
|
+
setAntigravityAccountEnabled(id, enabled);
|
|
102
|
+
if (enabled) await refreshAntigravityQuotas({ force: true, signal: ctx.signal });
|
|
103
|
+
return dashboardSnapshot(ctx);
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
37
109
|
pi.registerCommand("antigravity.doctor", {
|
|
38
110
|
description: "Show sanitized Antigravity provider diagnostics",
|
|
39
111
|
handler: async (_args, ctx) => {
|
|
112
|
+
const accounts = antigravityAccountStatuses();
|
|
40
113
|
const lines = [
|
|
41
114
|
`provider=${PROVIDER_ID}`,
|
|
42
115
|
`lastResolvedRuntimeModel=${lastResolvedRuntimeModel || "none"}`,
|
|
@@ -48,6 +121,9 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
|
|
|
48
121
|
`lastError=${lastError || "none"}`,
|
|
49
122
|
"transport=native-streamSimple",
|
|
50
123
|
"runtimeCli=not-used",
|
|
124
|
+
`accountsConfigured=${accounts.length}`,
|
|
125
|
+
`accountsActive=${accounts.filter((account) => account.active).length}`,
|
|
126
|
+
"rotation=quota-aware-lru",
|
|
51
127
|
];
|
|
52
128
|
const text = lines.join("\n");
|
|
53
129
|
if (ctx.hasUI) ctx.ui.notify(`Antigravity doctor\n${text}`, "info");
|
|
@@ -320,6 +320,32 @@ function findDynamicModel(value: any, requestedId: string): DynamicModelInfo | u
|
|
|
320
320
|
return undefined;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
export async function fetchAntigravityQuotaCatalog(token: string, projectId: string, signal?: AbortSignal): Promise<unknown> {
|
|
324
|
+
let lastFailure = "Antigravity quota catalog is unavailable.";
|
|
325
|
+
const bodies = [{}, { cloudaicompanionProject: projectId }, { project: projectId }];
|
|
326
|
+
for (const endpoint of endpointCandidates()) {
|
|
327
|
+
for (const candidateBody of bodies) {
|
|
328
|
+
try {
|
|
329
|
+
const res = await fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: antigravityHeaders(token),
|
|
332
|
+
body: JSON.stringify(candidateBody),
|
|
333
|
+
signal: boundedFetchSignal(signal),
|
|
334
|
+
});
|
|
335
|
+
lastStatus = res.status;
|
|
336
|
+
lastEndpoint = endpoint;
|
|
337
|
+
if (res.ok) return await res.json();
|
|
338
|
+
lastFailure = `HTTP ${res.status}: ${jsonOrTextError(await res.text())}`;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (signal?.aborted) throw error;
|
|
341
|
+
lastFailure = safeError(error);
|
|
342
|
+
lastError = lastFailure;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
throw new Error(lastFailure);
|
|
347
|
+
}
|
|
348
|
+
|
|
323
349
|
export async function fetchAvailableRuntimeModel(token: string, projectId: string, requestedRuntimeModel: string, signal?: AbortSignal): Promise<DynamicModelInfo | undefined> {
|
|
324
350
|
const bodies = [{}, { cloudaicompanionProject: projectId }, { project: projectId }];
|
|
325
351
|
for (const endpoint of endpointCandidates()) {
|
|
@@ -463,13 +489,16 @@ export async function loginAntigravity(callbacks: OAuthCallbacks): Promise<OAuth
|
|
|
463
489
|
if (!tokenData.refresh_token) throw new Error("No refresh token received. Re-run /login antigravity and allow offline access.");
|
|
464
490
|
|
|
465
491
|
const [email, discoveredProject] = await Promise.all([getUserEmail(tokenData.access_token), loadCodeAssist(tokenData.access_token)]);
|
|
466
|
-
|
|
492
|
+
const credentials = {
|
|
467
493
|
refresh: tokenData.refresh_token,
|
|
468
494
|
access: tokenData.access_token,
|
|
469
495
|
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
|
|
470
496
|
projectId: discoveredProject || DEFAULT_PROJECT_ID,
|
|
471
497
|
email,
|
|
472
498
|
};
|
|
499
|
+
const { upsertAntigravityAccount } = await import("./accounts.ts");
|
|
500
|
+
upsertAntigravityAccount(credentials, { clearCooldown: true });
|
|
501
|
+
return credentials;
|
|
473
502
|
} finally {
|
|
474
503
|
server.close();
|
|
475
504
|
}
|
|
@@ -485,13 +514,16 @@ export async function refreshAntigravityToken(credentials: OAuthCredentials): Pr
|
|
|
485
514
|
if (!response.ok) throw new Error(`Antigravity token refresh failed: ${await response.text()}`);
|
|
486
515
|
const data = (await response.json()) as { access_token: string; expires_in: number; refresh_token?: string };
|
|
487
516
|
const discoveredProject = await loadCodeAssist(data.access_token);
|
|
488
|
-
|
|
517
|
+
const refreshed = {
|
|
489
518
|
...credentials,
|
|
490
519
|
refresh: data.refresh_token || credentials.refresh,
|
|
491
520
|
access: data.access_token,
|
|
492
521
|
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
493
522
|
projectId: discoveredProject || credentials.projectId || DEFAULT_PROJECT_ID,
|
|
494
523
|
};
|
|
524
|
+
const { upsertAntigravityAccount } = await import("./accounts.ts");
|
|
525
|
+
upsertAntigravityAccount(refreshed);
|
|
526
|
+
return refreshed;
|
|
495
527
|
}
|
|
496
528
|
|
|
497
529
|
export function getApiKey(credentials: OAuthCredentials): string {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
antigravityAccountStatuses,
|
|
3
|
+
parseAntigravityQuota,
|
|
4
|
+
resolveAntigravityAccount,
|
|
5
|
+
updateAntigravityAccountQuota,
|
|
6
|
+
type AntigravityAccountStatus,
|
|
7
|
+
} from "./accounts.ts";
|
|
8
|
+
import {
|
|
9
|
+
fetchAntigravityQuotaCatalog,
|
|
10
|
+
refreshAntigravityToken,
|
|
11
|
+
} from "./oauth.ts";
|
|
12
|
+
|
|
13
|
+
export const ANTIGRAVITY_QUOTA_TTL_MS = 15 * 60 * 1_000;
|
|
14
|
+
let refreshPromise: Promise<AntigravityAccountStatus[]> | undefined;
|
|
15
|
+
|
|
16
|
+
export function refreshAntigravityQuotas(options: { force?: boolean; signal?: AbortSignal } = {}) {
|
|
17
|
+
if (refreshPromise) return refreshPromise;
|
|
18
|
+
refreshPromise = (async () => {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const accounts = antigravityAccountStatuses(now);
|
|
21
|
+
for (const account of accounts) {
|
|
22
|
+
if (account.disabled) continue;
|
|
23
|
+
if (!options.force && account.quotaUpdatedAt && now - account.quotaUpdatedAt < ANTIGRAVITY_QUOTA_TTL_MS) continue;
|
|
24
|
+
try {
|
|
25
|
+
const resolved = await resolveAntigravityAccount(account, refreshAntigravityToken, now);
|
|
26
|
+
const catalog = await fetchAntigravityQuotaCatalog(resolved.access, resolved.projectId, options.signal);
|
|
27
|
+
const quota = parseAntigravityQuota(catalog);
|
|
28
|
+
if (!quota.length) throw new Error("Antigravity returned no recognizable quota entries.");
|
|
29
|
+
updateAntigravityAccountQuota(resolved.id, quota);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (options.signal?.aborted) throw error;
|
|
32
|
+
updateAntigravityAccountQuota(account.id, undefined, error instanceof Error ? error.message : String(error));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return antigravityAccountStatuses();
|
|
36
|
+
})().finally(() => {
|
|
37
|
+
refreshPromise = undefined;
|
|
38
|
+
});
|
|
39
|
+
return refreshPromise;
|
|
40
|
+
}
|