mirror of
https://github.com/NoeFabris/opencode-antigravity-auth.git
synced 2026-05-13 15:46:05 +00:00
feat: add quota check and account management to auth login
- Add enabled field to AccountMetadataV3 for account enable/disable - Add getEnabledAccounts/getTotalAccountCount methods to AccountManager - Add quota.ts with quota checking logic via fetchAvailableModels API - Integrate 'Check quotas' and 'Manage accounts' options into auth menu - Display quota status for Claude, Gemini 3 Pro, and Gemini 3 Flash - Add standalone check-quota.mjs script for manual verification - Update tsconfig to exclude temp_research folder
This commit is contained in:
218
scripts/check-quota.mjs
Normal file
218
scripts/check-quota.mjs
Normal file
@@ -0,0 +1,218 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const CLIENT_SECRET = "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";
|
||||
const CLOUD_CODE_BASE = "https://cloudcode-pa.googleapis.com";
|
||||
const USER_AGENT = "antigravity/windows/amd64";
|
||||
const FALLBACK_PROJECT_ID = "bamboo-precept-lgxtn";
|
||||
|
||||
function getDefaultAccountsPath() {
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "opencode", "antigravity-accounts.json");
|
||||
}
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
return join(xdgConfig, "opencode", "antigravity-accounts.json");
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let path = getDefaultAccountsPath();
|
||||
let accountIndex = null;
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "--path" && args[i + 1]) {
|
||||
path = args[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--account" && args[i + 1]) {
|
||||
const parsed = Number.parseInt(args[i + 1], 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
accountIndex = parsed - 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { path, accountIndex };
|
||||
}
|
||||
|
||||
async function postJson(url, token, body, extraHeaders = {}) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken) {
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Token refresh failed (${response.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
async function loadProjectId(accessToken) {
|
||||
const body = { metadata: { ideType: "ANTIGRAVITY" } };
|
||||
const response = await postJson(`${CLOUD_CODE_BASE}/v1internal:loadCodeAssist`, accessToken, body);
|
||||
if (!response.ok) {
|
||||
return "";
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (typeof payload.cloudaicompanionProject === "string") {
|
||||
return payload.cloudaicompanionProject;
|
||||
}
|
||||
if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === "string") {
|
||||
return payload.cloudaicompanionProject.id;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function classifyGroup(modelName) {
|
||||
const lower = modelName.toLowerCase();
|
||||
if (lower.includes("claude")) return "claude";
|
||||
if (!lower.includes("gemini-3")) return null;
|
||||
if (lower.includes("flash")) return "gemini-flash";
|
||||
return "gemini-pro";
|
||||
}
|
||||
|
||||
function updateGroup(groups, group, remainingFraction, resetTime) {
|
||||
const entry = groups[group] || { count: 0 };
|
||||
entry.count += 1;
|
||||
if (typeof remainingFraction === "number") {
|
||||
if (entry.remaining === undefined) {
|
||||
entry.remaining = remainingFraction;
|
||||
} else {
|
||||
entry.remaining = Math.min(entry.remaining, remainingFraction);
|
||||
}
|
||||
}
|
||||
if (resetTime) {
|
||||
const timestamp = Date.parse(resetTime);
|
||||
if (Number.isFinite(timestamp)) {
|
||||
if (!entry.resetTime) {
|
||||
entry.resetTime = resetTime;
|
||||
} else {
|
||||
const existing = Date.parse(entry.resetTime);
|
||||
if (!Number.isFinite(existing) || timestamp < existing) {
|
||||
entry.resetTime = resetTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groups[group] = entry;
|
||||
}
|
||||
|
||||
function formatDuration(targetTime) {
|
||||
const delta = targetTime - Date.now();
|
||||
if (delta <= 0) return "now";
|
||||
const totalSeconds = Math.round(delta / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function printGroup(label, entry) {
|
||||
if (!entry || entry.count === 0) return;
|
||||
const remaining = typeof entry.remaining === "number" ? Math.round(entry.remaining * 100) : null;
|
||||
const status = remaining === null ? "UNKNOWN" : remaining <= 0 ? "LIMITED" : "OK";
|
||||
const details = [];
|
||||
if (remaining !== null) details.push(`remaining ${remaining}%`);
|
||||
if (entry.resetTime) {
|
||||
const time = formatDuration(Date.parse(entry.resetTime));
|
||||
details.push(`resets in ${time}`);
|
||||
}
|
||||
const suffix = details.length ? ` (${details.join(", ")})` : "";
|
||||
console.log(` ${label}: ${status}${suffix}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const { path, accountIndex } = parseArgs();
|
||||
const payload = JSON.parse(readFileSync(path, "utf8"));
|
||||
const accounts = payload.accounts || [];
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log("No accounts found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = accountIndex === null
|
||||
? accounts.map((account, index) => ({ account, index }))
|
||||
: accounts
|
||||
.map((account, index) => ({ account, index }))
|
||||
.filter((item) => item.index === accountIndex);
|
||||
|
||||
for (const { account, index } of selected) {
|
||||
const label = account.email || `Account ${index + 1}`;
|
||||
const disabled = account.enabled === false ? " (disabled)" : "";
|
||||
console.log(`\n${index + 1}. ${label}${disabled}`);
|
||||
|
||||
try {
|
||||
const accessToken = await refreshAccessToken(account.refreshToken);
|
||||
let projectId = await loadProjectId(accessToken);
|
||||
if (!projectId) {
|
||||
projectId = account.managedProjectId || account.projectId || FALLBACK_PROJECT_ID;
|
||||
}
|
||||
console.log(` project: ${projectId}`);
|
||||
|
||||
const body = projectId ? { project: projectId } : {};
|
||||
const response = await postJson(
|
||||
`${CLOUD_CODE_BASE}/v1internal:fetchAvailableModels`,
|
||||
accessToken,
|
||||
body,
|
||||
);
|
||||
console.log(` fetchAvailableModels: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
console.log(` error: ${text.trim().slice(0, 200)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const groups = {};
|
||||
const models = data.models || {};
|
||||
for (const [modelName, info] of Object.entries(models)) {
|
||||
const group = classifyGroup(modelName);
|
||||
if (!group) continue;
|
||||
if (!info || !info.quotaInfo) continue;
|
||||
const remaining = info.quotaInfo.remainingFraction ?? 0;
|
||||
updateGroup(groups, group, remaining, info.quotaInfo.resetTime);
|
||||
}
|
||||
|
||||
printGroup("Claude", groups["claude"]);
|
||||
printGroup("Gemini 3 Pro", groups["gemini-pro"]);
|
||||
printGroup("Gemini 3 Flash", groups["gemini-flash"]);
|
||||
} catch (error) {
|
||||
console.log(` error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
108
src/plugin.ts
108
src/plugin.ts
@@ -37,6 +37,7 @@ import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackof
|
||||
import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker";
|
||||
import { loadConfig, initRuntimeConfig, type AntigravityConfig } from "./plugin/config";
|
||||
import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery";
|
||||
import { checkAccountsQuota } from "./plugin/quota";
|
||||
import { initDiskSignatureCache } from "./plugin/cache";
|
||||
import { createProactiveRefreshQueue, type ProactiveRefreshQueue } from "./plugin/refresh-queue";
|
||||
import { initLogger, createLogger } from "./plugin/logger";
|
||||
@@ -306,6 +307,7 @@ async function persistAccountPool(
|
||||
managedProjectId: parts.managedProjectId,
|
||||
addedAt: now,
|
||||
lastUsed: now,
|
||||
enabled: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1830,39 +1832,93 @@ export const createAntigravityPlugin = (providerId: string) => async (
|
||||
let refreshAccountIndex: number | undefined;
|
||||
const existingStorage = await loadAccounts();
|
||||
if (existingStorage && existingStorage.accounts.length > 0) {
|
||||
const now = Date.now();
|
||||
const existingAccounts = existingStorage.accounts.map((acc, idx) => {
|
||||
let status: 'active' | 'rate-limited' | 'expired' | 'unknown' = 'unknown';
|
||||
|
||||
const rateLimits = acc.rateLimitResetTimes;
|
||||
if (rateLimits) {
|
||||
const isRateLimited = Object.values(rateLimits).some(
|
||||
(resetTime) => typeof resetTime === 'number' && resetTime > now
|
||||
);
|
||||
if (isRateLimited) {
|
||||
status = 'rate-limited';
|
||||
let menuResult;
|
||||
while (true) {
|
||||
const now = Date.now();
|
||||
const existingAccounts = existingStorage.accounts.map((acc, idx) => {
|
||||
let status: 'active' | 'rate-limited' | 'expired' | 'unknown' = 'unknown';
|
||||
|
||||
const rateLimits = acc.rateLimitResetTimes;
|
||||
if (rateLimits) {
|
||||
const isRateLimited = Object.values(rateLimits).some(
|
||||
(resetTime) => typeof resetTime === 'number' && resetTime > now
|
||||
);
|
||||
if (isRateLimited) {
|
||||
status = 'rate-limited';
|
||||
} else {
|
||||
status = 'active';
|
||||
}
|
||||
} else {
|
||||
status = 'active';
|
||||
}
|
||||
} else {
|
||||
status = 'active';
|
||||
|
||||
if (acc.coolingDownUntil && acc.coolingDownUntil > now) {
|
||||
status = 'rate-limited';
|
||||
}
|
||||
|
||||
return {
|
||||
email: acc.email,
|
||||
index: idx,
|
||||
addedAt: acc.addedAt,
|
||||
lastUsed: acc.lastUsed,
|
||||
status,
|
||||
isCurrentAccount: idx === (existingStorage.activeIndex ?? 0),
|
||||
enabled: acc.enabled !== false,
|
||||
};
|
||||
});
|
||||
|
||||
menuResult = await promptLoginMode(existingAccounts);
|
||||
|
||||
if (menuResult.mode === "check") {
|
||||
console.log("\nChecking quotas for all accounts...");
|
||||
const results = await checkAccountsQuota(existingStorage.accounts, client, providerId);
|
||||
for (const res of results) {
|
||||
const label = res.email || `Account ${res.index + 1}`;
|
||||
const disabledStr = res.disabled ? " (disabled)" : "";
|
||||
console.log(`\n${res.index + 1}. ${label}${disabledStr}`);
|
||||
if (res.status === "error") {
|
||||
console.log(` Error: ${res.error}`);
|
||||
continue;
|
||||
}
|
||||
if (!res.quota || Object.keys(res.quota.groups).length === 0) {
|
||||
console.log(" No quota information available.");
|
||||
if (res.quota?.error) console.log(` Error: ${res.quota.error}`);
|
||||
continue;
|
||||
}
|
||||
const printGrp = (name: string, group: any) => {
|
||||
if (!group) return;
|
||||
const remaining = typeof group.remainingFraction === 'number'
|
||||
? `${Math.round(group.remainingFraction * 100)}%`
|
||||
: 'UNKNOWN';
|
||||
const resetStr = group.resetTime ? `, resets in ${formatWaitTime(Date.parse(group.resetTime) - Date.now())}` : '';
|
||||
console.log(` ${name}: ${remaining}${resetStr}`);
|
||||
};
|
||||
printGrp("Claude", res.quota.groups.claude);
|
||||
printGrp("Gemini 3 Pro", res.quota.groups["gemini-pro"]);
|
||||
printGrp("Gemini 3 Flash", res.quota.groups["gemini-flash"]);
|
||||
if (res.updatedAccount) {
|
||||
existingStorage.accounts[res.index] = res.updatedAccount;
|
||||
await saveAccounts(existingStorage);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (acc.coolingDownUntil && acc.coolingDownUntil > now) {
|
||||
status = 'rate-limited';
|
||||
if (menuResult.mode === "manage") {
|
||||
if (menuResult.toggleAccountIndex !== undefined) {
|
||||
const acc = existingStorage.accounts[menuResult.toggleAccountIndex];
|
||||
if (acc) {
|
||||
acc.enabled = acc.enabled === false;
|
||||
await saveAccounts(existingStorage);
|
||||
console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
email: acc.email,
|
||||
index: idx,
|
||||
addedAt: acc.addedAt,
|
||||
lastUsed: acc.lastUsed,
|
||||
status,
|
||||
isCurrentAccount: idx === (existingStorage.activeIndex ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
const menuResult = await promptLoginMode(existingAccounts);
|
||||
break;
|
||||
}
|
||||
|
||||
if (menuResult.mode === "cancel") {
|
||||
return {
|
||||
|
||||
@@ -127,6 +127,7 @@ export interface ManagedAccount {
|
||||
parts: RefreshParts;
|
||||
access?: string;
|
||||
expires?: number;
|
||||
enabled: boolean;
|
||||
rateLimitResetTimes: RateLimitStateV3;
|
||||
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
|
||||
coolingDownUntil?: number;
|
||||
@@ -277,6 +278,7 @@ export class AccountManager {
|
||||
},
|
||||
access: matchesFallback ? authFallback?.access : undefined,
|
||||
expires: matchesFallback ? authFallback?.expires : undefined,
|
||||
enabled: acc.enabled !== false,
|
||||
rateLimitResetTimes: acc.rateLimitResetTimes ?? {},
|
||||
lastSwitchReason: acc.lastSwitchReason,
|
||||
coolingDownUntil: acc.coolingDownUntil,
|
||||
@@ -319,6 +321,7 @@ export class AccountManager {
|
||||
parts: authParts,
|
||||
access: authFallback.access,
|
||||
expires: authFallback.expires,
|
||||
enabled: true,
|
||||
rateLimitResetTimes: {},
|
||||
touchedForQuota: {},
|
||||
};
|
||||
@@ -342,6 +345,7 @@ export class AccountManager {
|
||||
parts,
|
||||
access: authFallback.access,
|
||||
expires: authFallback.expires,
|
||||
enabled: true,
|
||||
rateLimitResetTimes: {},
|
||||
touchedForQuota: {},
|
||||
},
|
||||
@@ -354,9 +358,17 @@ export class AccountManager {
|
||||
}
|
||||
|
||||
getAccountCount(): number {
|
||||
return this.getEnabledAccounts().length;
|
||||
}
|
||||
|
||||
getTotalAccountCount(): number {
|
||||
return this.accounts.length;
|
||||
}
|
||||
|
||||
getEnabledAccounts(): ManagedAccount[] {
|
||||
return this.accounts.filter((account) => account.enabled);
|
||||
}
|
||||
|
||||
getAccountsSnapshot(): ManagedAccount[] {
|
||||
return this.accounts.map((a) => ({ ...a, parts: { ...a.parts }, rateLimitResetTimes: { ...a.rateLimitResetTimes } }));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function promptAddAnotherAccount(currentCount: number): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
export type LoginMode = "add" | "fresh" | "cancel";
|
||||
export type LoginMode = "add" | "fresh" | "manage" | "check" | "cancel";
|
||||
|
||||
export interface ExistingAccountInfo {
|
||||
email?: string;
|
||||
@@ -38,12 +38,14 @@ export interface ExistingAccountInfo {
|
||||
lastUsed?: number;
|
||||
status?: AccountStatus;
|
||||
isCurrentAccount?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginMenuResult {
|
||||
mode: LoginMode;
|
||||
deleteAccountIndex?: number;
|
||||
refreshAccountIndex?: number;
|
||||
toggleAccountIndex?: number;
|
||||
deleteAll?: boolean;
|
||||
}
|
||||
|
||||
@@ -58,7 +60,7 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]):
|
||||
console.log("");
|
||||
|
||||
while (true) {
|
||||
const answer = await rl.question("(a)dd new account(s) or (f)resh start? [a/f]: ");
|
||||
const answer = await rl.question("(a)dd new, (f)resh start, (m)anage, (c)heck quotas? [a/f/m/c]: ");
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
|
||||
if (normalized === "a" || normalized === "add") {
|
||||
@@ -67,8 +69,14 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]):
|
||||
if (normalized === "f" || normalized === "fresh") {
|
||||
return { mode: "fresh" };
|
||||
}
|
||||
if (normalized === "m" || normalized === "manage") {
|
||||
return { mode: "manage" };
|
||||
}
|
||||
if (normalized === "c" || normalized === "check") {
|
||||
return { mode: "check" };
|
||||
}
|
||||
|
||||
console.log("Please enter 'a' to add accounts or 'f' to start fresh.");
|
||||
console.log("Please enter 'a', 'f', 'm', or 'c'.");
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
@@ -87,6 +95,7 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]):
|
||||
lastUsed: acc.lastUsed,
|
||||
status: acc.status,
|
||||
isCurrentAccount: acc.isCurrentAccount,
|
||||
enabled: acc.enabled,
|
||||
}));
|
||||
|
||||
console.log("");
|
||||
@@ -98,6 +107,12 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]):
|
||||
case "add":
|
||||
return { mode: "add" };
|
||||
|
||||
case "check":
|
||||
return { mode: "check" };
|
||||
|
||||
case "manage":
|
||||
return { mode: "manage" };
|
||||
|
||||
case "select-account": {
|
||||
const accountAction = await showAccountDetails(action.account);
|
||||
if (accountAction === "delete") {
|
||||
@@ -106,6 +121,9 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]):
|
||||
if (accountAction === "refresh") {
|
||||
return { mode: "add", refreshAccountIndex: action.account.index };
|
||||
}
|
||||
if (accountAction === "toggle") {
|
||||
return { mode: "manage", toggleAccountIndex: action.account.index };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
273
src/plugin/quota.ts
Normal file
273
src/plugin/quota.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
ANTIGRAVITY_ENDPOINT_PROD,
|
||||
ANTIGRAVITY_HEADERS,
|
||||
ANTIGRAVITY_PROVIDER_ID,
|
||||
} from "../constants";
|
||||
import { accessTokenExpired, formatRefreshParts, parseRefreshParts } from "./auth";
|
||||
import { ensureProjectContext } from "./project";
|
||||
import { refreshAccessToken } from "./token";
|
||||
import { getModelFamily } from "./transform/model-resolver";
|
||||
import type { PluginClient, OAuthAuthDetails } from "./types";
|
||||
import type { AccountMetadataV3 } from "./storage";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
|
||||
export type QuotaGroup = "claude" | "gemini-pro" | "gemini-flash";
|
||||
|
||||
export interface QuotaGroupSummary {
|
||||
remainingFraction?: number;
|
||||
resetTime?: string;
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
export interface QuotaSummary {
|
||||
groups: Partial<Record<QuotaGroup, QuotaGroupSummary>>;
|
||||
modelCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type AccountQuotaStatus = "ok" | "disabled" | "error";
|
||||
|
||||
export interface AccountQuotaResult {
|
||||
index: number;
|
||||
email?: string;
|
||||
status: AccountQuotaStatus;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
quota?: QuotaSummary;
|
||||
updatedAccount?: AccountMetadataV3;
|
||||
}
|
||||
|
||||
interface FetchAvailableModelsResponse {
|
||||
models?: Record<string, FetchAvailableModelEntry>;
|
||||
}
|
||||
|
||||
interface FetchAvailableModelEntry {
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number;
|
||||
resetTime?: string;
|
||||
};
|
||||
displayName?: string;
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
function buildAuthFromAccount(account: AccountMetadataV3): OAuthAuthDetails {
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: formatRefreshParts({
|
||||
refreshToken: account.refreshToken,
|
||||
projectId: account.projectId,
|
||||
managedProjectId: account.managedProjectId,
|
||||
}),
|
||||
access: undefined,
|
||||
expires: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRemainingFraction(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseResetTime(resetTime?: string): number | null {
|
||||
if (!resetTime) return null;
|
||||
const timestamp = Date.parse(resetTime);
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function classifyQuotaGroup(modelName: string, displayName?: string): QuotaGroup | null {
|
||||
const combined = `${modelName} ${displayName ?? ""}`.toLowerCase();
|
||||
if (combined.includes("claude")) {
|
||||
return "claude";
|
||||
}
|
||||
const isGemini3 = combined.includes("gemini-3") || combined.includes("gemini 3");
|
||||
if (!isGemini3) {
|
||||
return null;
|
||||
}
|
||||
const family = getModelFamily(modelName);
|
||||
return family === "gemini-flash" ? "gemini-flash" : "gemini-pro";
|
||||
}
|
||||
|
||||
function aggregateQuota(models?: Record<string, FetchAvailableModelEntry>): QuotaSummary {
|
||||
const groups: Partial<Record<QuotaGroup, QuotaGroupSummary>> = {};
|
||||
if (!models) {
|
||||
return { groups, modelCount: 0 };
|
||||
}
|
||||
|
||||
let totalCount = 0;
|
||||
for (const [modelName, entry] of Object.entries(models)) {
|
||||
const group = classifyQuotaGroup(modelName, entry.displayName ?? entry.modelName);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
const quotaInfo = entry.quotaInfo;
|
||||
const remainingFraction = quotaInfo
|
||||
? normalizeRemainingFraction(quotaInfo.remainingFraction ?? 0)
|
||||
: undefined;
|
||||
const resetTime = quotaInfo?.resetTime;
|
||||
const resetTimestamp = parseResetTime(resetTime);
|
||||
|
||||
totalCount += 1;
|
||||
|
||||
const existing = groups[group];
|
||||
const nextCount = (existing?.modelCount ?? 0) + 1;
|
||||
const nextRemaining =
|
||||
remainingFraction === undefined
|
||||
? existing?.remainingFraction
|
||||
: existing?.remainingFraction === undefined
|
||||
? remainingFraction
|
||||
: Math.min(existing.remainingFraction, remainingFraction);
|
||||
|
||||
let nextResetTime = existing?.resetTime;
|
||||
if (resetTimestamp !== null) {
|
||||
if (!existing?.resetTime) {
|
||||
nextResetTime = resetTime;
|
||||
} else {
|
||||
const existingTimestamp = parseResetTime(existing.resetTime);
|
||||
if (existingTimestamp === null || resetTimestamp < existingTimestamp) {
|
||||
nextResetTime = resetTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups[group] = {
|
||||
remainingFraction: nextRemaining,
|
||||
resetTime: nextResetTime,
|
||||
modelCount: nextCount,
|
||||
};
|
||||
}
|
||||
|
||||
return { groups, modelCount: totalCount };
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailableModels(
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
): Promise<FetchAvailableModelsResponse> {
|
||||
const endpoint = ANTIGRAVITY_ENDPOINT_PROD;
|
||||
const quotaUserAgent = ANTIGRAVITY_HEADERS["User-Agent"] || "antigravity/windows/amd64";
|
||||
const errors: string[] = [];
|
||||
|
||||
const body = projectId ? { project: projectId } : {};
|
||||
const response = await fetchWithTimeout(`${endpoint}/v1internal:fetchAvailableModels`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": quotaUserAgent,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return (await response.json()) as FetchAvailableModelsResponse;
|
||||
}
|
||||
|
||||
const message = await response.text().catch(() => "");
|
||||
const snippet = message.trim().slice(0, 200);
|
||||
errors.push(
|
||||
`fetchAvailableModels ${response.status} at ${endpoint}${snippet ? `: ${snippet}` : ""}`,
|
||||
);
|
||||
|
||||
throw new Error(errors.join("; ") || "fetchAvailableModels failed");
|
||||
}
|
||||
|
||||
function applyAccountUpdates(account: AccountMetadataV3, auth: OAuthAuthDetails): AccountMetadataV3 | undefined {
|
||||
const parts = parseRefreshParts(auth.refresh);
|
||||
if (!parts.refreshToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updated: AccountMetadataV3 = {
|
||||
...account,
|
||||
refreshToken: parts.refreshToken,
|
||||
projectId: parts.projectId ?? account.projectId,
|
||||
managedProjectId: parts.managedProjectId ?? account.managedProjectId,
|
||||
};
|
||||
|
||||
const changed =
|
||||
updated.refreshToken !== account.refreshToken ||
|
||||
updated.projectId !== account.projectId ||
|
||||
updated.managedProjectId !== account.managedProjectId;
|
||||
|
||||
return changed ? updated : undefined;
|
||||
}
|
||||
|
||||
export async function checkAccountsQuota(
|
||||
accounts: AccountMetadataV3[],
|
||||
client: PluginClient,
|
||||
providerId = ANTIGRAVITY_PROVIDER_ID,
|
||||
): Promise<AccountQuotaResult[]> {
|
||||
const results: AccountQuotaResult[] = [];
|
||||
|
||||
for (const [index, account] of accounts.entries()) {
|
||||
const disabled = account.enabled === false;
|
||||
|
||||
let auth = buildAuthFromAccount(account);
|
||||
|
||||
try {
|
||||
if (accessTokenExpired(auth)) {
|
||||
const refreshed = await refreshAccessToken(auth, client, providerId);
|
||||
if (!refreshed) {
|
||||
throw new Error("Token refresh failed");
|
||||
}
|
||||
auth = refreshed;
|
||||
}
|
||||
|
||||
const projectContext = await ensureProjectContext(auth);
|
||||
auth = projectContext.auth;
|
||||
const updatedAccount = applyAccountUpdates(account, auth);
|
||||
|
||||
let quotaResult: QuotaSummary;
|
||||
try {
|
||||
const response = await fetchAvailableModels(
|
||||
auth.access ?? "",
|
||||
projectContext.effectiveProjectId,
|
||||
);
|
||||
quotaResult = aggregateQuota(response.models);
|
||||
} catch (error) {
|
||||
quotaResult = {
|
||||
groups: {},
|
||||
modelCount: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
results.push({
|
||||
index,
|
||||
email: account.email,
|
||||
status: "ok",
|
||||
disabled,
|
||||
quota: quotaResult,
|
||||
updatedAccount,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
index,
|
||||
email: account.email,
|
||||
status: "error",
|
||||
disabled,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -182,6 +182,7 @@ export interface AccountMetadataV3 {
|
||||
managedProjectId?: string;
|
||||
addedAt: number;
|
||||
lastUsed: number;
|
||||
enabled?: boolean;
|
||||
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
|
||||
rateLimitResetTimes?: RateLimitStateV3;
|
||||
coolingDownUntil?: number;
|
||||
|
||||
@@ -11,15 +11,18 @@ export interface AccountInfo {
|
||||
lastUsed?: number;
|
||||
status?: AccountStatus;
|
||||
isCurrentAccount?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type AuthMenuAction =
|
||||
| { type: 'add' }
|
||||
| { type: 'select-account'; account: AccountInfo }
|
||||
| { type: 'delete-all' }
|
||||
| { type: 'check' }
|
||||
| { type: 'manage' }
|
||||
| { type: 'cancel' };
|
||||
|
||||
export type AccountAction = 'back' | 'delete' | 'refresh' | 'cancel';
|
||||
export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'cancel';
|
||||
|
||||
function formatRelativeTime(timestamp: number | undefined): string {
|
||||
if (!timestamp) return 'never';
|
||||
@@ -48,11 +51,14 @@ function getStatusBadge(status: AccountStatus | undefined): string {
|
||||
export async function showAuthMenu(accounts: AccountInfo[]): Promise<AuthMenuAction> {
|
||||
const items: MenuItem<AuthMenuAction>[] = [
|
||||
{ label: 'Add new account', value: { type: 'add' } },
|
||||
{ label: 'Check quotas', value: { type: 'check' } },
|
||||
{ label: 'Manage accounts (enable/disable)', value: { type: 'manage' } },
|
||||
|
||||
...accounts.map(account => {
|
||||
const badge = getStatusBadge(account.status);
|
||||
const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : '';
|
||||
const label = account.email || `Account ${account.index + 1}`;
|
||||
const fullLabel = badge ? `${label} ${badge}` : label;
|
||||
const fullLabel = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`;
|
||||
|
||||
return {
|
||||
label: fullLabel,
|
||||
@@ -84,9 +90,10 @@ export async function showAuthMenu(accounts: AccountInfo[]): Promise<AuthMenuAct
|
||||
export async function showAccountDetails(account: AccountInfo): Promise<AccountAction> {
|
||||
const label = account.email || `Account ${account.index + 1}`;
|
||||
const badge = getStatusBadge(account.status);
|
||||
const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : '';
|
||||
|
||||
console.log('');
|
||||
console.log(`${ANSI.bold}Account: ${label}${badge ? ' ' + badge : ''}${ANSI.reset}`);
|
||||
console.log(`${ANSI.bold}Account: ${label}${badge ? ' ' + badge : ''}${disabledBadge}${ANSI.reset}`);
|
||||
console.log(`${ANSI.dim}Added: ${formatDate(account.addedAt)}${ANSI.reset}`);
|
||||
console.log(`${ANSI.dim}Last used: ${formatRelativeTime(account.lastUsed)}${ANSI.reset}`);
|
||||
console.log('');
|
||||
@@ -94,6 +101,7 @@ export async function showAccountDetails(account: AccountInfo): Promise<AccountA
|
||||
while (true) {
|
||||
const result = await select([
|
||||
{ label: 'Back', value: 'back' as const },
|
||||
{ label: account.enabled === false ? 'Enable account' : 'Disable account', value: 'toggle' as const, color: account.enabled === false ? 'green' : 'yellow' },
|
||||
{ label: 'Refresh token', value: 'refresh' as const, color: 'cyan' },
|
||||
{ label: 'Delete this account', value: 'delete' as const, color: 'red' },
|
||||
], {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"include": ["src/**/*", "scripts/**/*"],
|
||||
"exclude": ["node_modules", "dist", "temp_research", "script"],
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
|
||||
Reference in New Issue
Block a user