|
|
@@ -0,0 +1,184 @@
|
|
|
+import * as path from "path";
|
|
|
+import * as os from "os";
|
|
|
+import * as fs from "fs";
|
|
|
+import type {
|
|
|
+ Account,
|
|
|
+ AccountsConfig,
|
|
|
+ TokenResponse,
|
|
|
+ LoadCodeAssistResponse,
|
|
|
+ QuotaResponse,
|
|
|
+ AccountQuotaResult,
|
|
|
+ ModelQuota,
|
|
|
+} from "./types";
|
|
|
+
|
|
|
+// API endpoints
|
|
|
+const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
|
+const CLOUDCODE_BASE_URL = "https://cloudcode-pa.googleapis.com";
|
|
|
+
|
|
|
+// Timing constants
|
|
|
+const DEFAULT_RESET_MS = 86_400_000; // 24 hours - fallback when API doesn't provide reset time
|
|
|
+const ACCOUNT_FETCH_DELAY_MS = 200; // Delay between account fetches to avoid rate limiting
|
|
|
+const CLOUDCODE_METADATA = {
|
|
|
+ ideType: "ANTIGRAVITY",
|
|
|
+ platform: "PLATFORM_UNSPECIFIED",
|
|
|
+ pluginType: "GEMINI",
|
|
|
+};
|
|
|
+
|
|
|
+// Client credentials (from opencode-antigravity-auth)
|
|
|
+const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
|
|
+const CLIENT_SECRET = "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";
|
|
|
+
|
|
|
+// Config paths
|
|
|
+const isWindows = os.platform() === "win32";
|
|
|
+const configBase = isWindows
|
|
|
+ ? path.join(os.homedir(), "AppData", "Roaming", "opencode")
|
|
|
+ : path.join(os.homedir(), ".config", "opencode");
|
|
|
+
|
|
|
+const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
|
+const dataBase = isWindows ? configBase : path.join(xdgData, "opencode");
|
|
|
+
|
|
|
+export const CONFIG_PATHS = [
|
|
|
+ path.join(configBase, "antigravity-accounts.json"),
|
|
|
+ path.join(dataBase, "antigravity-accounts.json"),
|
|
|
+];
|
|
|
+
|
|
|
+export function loadAccountsConfig(): AccountsConfig | null {
|
|
|
+ for (const p of CONFIG_PATHS) {
|
|
|
+ if (fs.existsSync(p)) {
|
|
|
+ return JSON.parse(fs.readFileSync(p, "utf-8")) as AccountsConfig;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshToken(refreshToken: string): Promise<string> {
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ client_id: CLIENT_ID,
|
|
|
+ client_secret: CLIENT_SECRET,
|
|
|
+ refresh_token: refreshToken,
|
|
|
+ grant_type: "refresh_token",
|
|
|
+ });
|
|
|
+
|
|
|
+ const res = await fetch(GOOGLE_TOKEN_URL, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
|
+ body: params.toString(),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) throw new Error(`Token refresh failed (${res.status})`);
|
|
|
+ const data = (await res.json()) as TokenResponse;
|
|
|
+ return data.access_token;
|
|
|
+}
|
|
|
+
|
|
|
+async function loadCodeAssist(accessToken: string): Promise<LoadCodeAssistResponse> {
|
|
|
+ const res = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:loadCodeAssist`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${accessToken}`,
|
|
|
+ "Content-Type": "application/json",
|
|
|
+ "User-Agent": "antigravity",
|
|
|
+ },
|
|
|
+ body: JSON.stringify({ metadata: CLOUDCODE_METADATA }),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) throw new Error(`loadCodeAssist failed (${res.status})`);
|
|
|
+ return (await res.json()) as LoadCodeAssistResponse;
|
|
|
+}
|
|
|
+
|
|
|
+function extractProjectId(project: unknown): string | undefined {
|
|
|
+ if (typeof project === "string" && project) return project;
|
|
|
+ if (project && typeof project === "object" && "id" in project) {
|
|
|
+ const id = (project as { id?: string }).id;
|
|
|
+ if (id) return id;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchModels(accessToken: string, projectId?: string): Promise<QuotaResponse> {
|
|
|
+ const payload = projectId ? { project: projectId } : {};
|
|
|
+ const res = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:fetchAvailableModels`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${accessToken}`,
|
|
|
+ "Content-Type": "application/json",
|
|
|
+ "User-Agent": "antigravity",
|
|
|
+ },
|
|
|
+ body: JSON.stringify(payload),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) throw new Error(`fetchModels failed (${res.status})`);
|
|
|
+ return (await res.json()) as QuotaResponse;
|
|
|
+}
|
|
|
+
|
|
|
+function formatDuration(ms: number): string {
|
|
|
+ const seconds = Math.floor(Math.abs(ms) / 1000);
|
|
|
+ const h = Math.floor(seconds / 3600);
|
|
|
+ const m = Math.floor((seconds % 3600) / 60);
|
|
|
+ if (h > 0) return `${h}h${m}m`;
|
|
|
+ return `${m}m`;
|
|
|
+}
|
|
|
+
|
|
|
+// Filter out internal/test models
|
|
|
+const EXCLUDED_PATTERNS = ["chat_", "rev19", "gemini 2.5", "gemini 3 pro image"];
|
|
|
+
|
|
|
+export async function fetchAccountQuota(account: Account): Promise<AccountQuotaResult> {
|
|
|
+ try {
|
|
|
+ const accessToken = await refreshToken(account.refreshToken);
|
|
|
+ let projectId = account.projectId || account.managedProjectId;
|
|
|
+
|
|
|
+ if (!projectId) {
|
|
|
+ const codeAssist = await loadCodeAssist(accessToken);
|
|
|
+ projectId = extractProjectId(codeAssist.cloudaicompanionProject);
|
|
|
+ }
|
|
|
+
|
|
|
+ const quotaRes = await fetchModels(accessToken, projectId);
|
|
|
+ if (!quotaRes.models) {
|
|
|
+ return { email: account.email, success: true, models: [] };
|
|
|
+ }
|
|
|
+
|
|
|
+ const now = Date.now();
|
|
|
+ const models: ModelQuota[] = [];
|
|
|
+
|
|
|
+ for (const [key, info] of Object.entries(quotaRes.models)) {
|
|
|
+ const qi = info.quotaInfo;
|
|
|
+ if (!qi) continue;
|
|
|
+
|
|
|
+ const label = info.displayName || key;
|
|
|
+ const lower = label.toLowerCase();
|
|
|
+ if (EXCLUDED_PATTERNS.some((p) => lower.includes(p))) continue;
|
|
|
+
|
|
|
+ const pct = Math.min(100, Math.max(0, (qi.remainingFraction ?? 0) * 100));
|
|
|
+ let resetMs = DEFAULT_RESET_MS;
|
|
|
+ if (qi.resetTime) {
|
|
|
+ const parsed = new Date(qi.resetTime).getTime();
|
|
|
+ if (!isNaN(parsed)) resetMs = Math.max(0, parsed - now);
|
|
|
+ }
|
|
|
+
|
|
|
+ models.push({
|
|
|
+ name: label,
|
|
|
+ percent: pct,
|
|
|
+ resetIn: formatDuration(resetMs),
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sort by name
|
|
|
+ models.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
+ return { email: account.email, success: true, models };
|
|
|
+ } catch (err) {
|
|
|
+ return {
|
|
|
+ email: account.email,
|
|
|
+ success: false,
|
|
|
+ error: err instanceof Error ? err.message : String(err),
|
|
|
+ models: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export async function fetchAllQuotas(accounts: Account[]): Promise<AccountQuotaResult[]> {
|
|
|
+ const results: AccountQuotaResult[] = [];
|
|
|
+ for (let i = 0; i < accounts.length; i++) {
|
|
|
+ if (i > 0) await new Promise((r) => setTimeout(r, ACCOUNT_FETCH_DELAY_MS));
|
|
|
+ results.push(await fetchAccountQuota(accounts[i]));
|
|
|
+ }
|
|
|
+ return results;
|
|
|
+}
|