diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6fee731..49ffce4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer. +- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren. - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. - Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. diff --git a/src/infra/provider-usage.fetch.antigravity.test.ts b/src/infra/provider-usage.fetch.antigravity.test.ts new file mode 100644 index 000000000..a3c108021 --- /dev/null +++ b/src/infra/provider-usage.fetch.antigravity.test.ts @@ -0,0 +1,578 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js"; + +const makeResponse = (status: number, body: unknown): Response => { + const payload = typeof body === "string" ? body : JSON.stringify(body); + const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); +}; + +describe("fetchAntigravityUsage", () => { + it("returns 3 windows when both endpoints succeed", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 750, + planInfo: { monthlyPromptCredits: 1000 }, + planType: "Standard", + currentTier: { id: "tier1", name: "Standard Tier" }, + }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-pro-1.5": { + quotaInfo: { + remainingFraction: 0.6, + resetTime: "2026-01-08T00:00:00Z", + isExhausted: false, + }, + }, + "gemini-flash-2.0": { + quotaInfo: { + remainingFraction: 0.8, + resetTime: "2026-01-08T00:00:00Z", + isExhausted: false, + }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.provider).toBe("google-antigravity"); + expect(snapshot.displayName).toBe("Antigravity"); + expect(snapshot.windows).toHaveLength(3); + expect(snapshot.plan).toBe("Standard Tier"); + expect(snapshot.error).toBeUndefined(); + + const creditsWindow = snapshot.windows.find((w) => w.label === "Credits"); + expect(creditsWindow?.usedPercent).toBe(25); // (1000 - 750) / 1000 * 100 + + const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-1.5"); + expect(proWindow?.usedPercent).toBe(40); // (1 - 0.6) * 100 + expect(proWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime()); + + const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-2.0"); + expect(flashWindow?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100 + expect(flashWindow?.resetAt).toBe(new Date("2026-01-08T00:00:00Z").getTime()); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("returns Credits only when loadCodeAssist succeeds but fetchAvailableModels fails", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 250, + planInfo: { monthlyPromptCredits: 1000 }, + currentTier: { name: "Free" }, + }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(403, { error: { message: "Permission denied" } }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.provider).toBe("google-antigravity"); + expect(snapshot.windows).toHaveLength(1); + expect(snapshot.plan).toBe("Free"); + expect(snapshot.error).toBeUndefined(); + + const creditsWindow = snapshot.windows[0]; + expect(creditsWindow?.label).toBe("Credits"); + expect(creditsWindow?.usedPercent).toBe(75); // (1000 - 250) / 1000 * 100 + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("returns model IDs when fetchAvailableModels succeeds but loadCodeAssist fails", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(500, "Internal server error"); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-pro-1.5": { + quotaInfo: { remainingFraction: 0.5, resetTime: "2026-01-08T00:00:00Z" }, + }, + "gemini-flash-2.0": { + quotaInfo: { remainingFraction: 0.7, resetTime: "2026-01-08T00:00:00Z" }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.provider).toBe("google-antigravity"); + expect(snapshot.windows).toHaveLength(2); + expect(snapshot.error).toBeUndefined(); + + const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-1.5"); + expect(proWindow?.usedPercent).toBe(50); // (1 - 0.5) * 100 + + const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-2.0"); + expect(flashWindow?.usedPercent).toBeCloseTo(30, 1); // (1 - 0.7) * 100 + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("uses cloudaicompanionProject string as project id", async () => { + let capturedBody: string | undefined; + const mockFetch = vi.fn, ReturnType>( + async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 900, + planInfo: { monthlyPromptCredits: 1000 }, + cloudaicompanionProject: "projects/alpha", + }); + } + + if (url.includes("fetchAvailableModels")) { + capturedBody = init?.body?.toString(); + return makeResponse(200, { models: {} }); + } + + return makeResponse(404, "not found"); + }, + ); + + await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(capturedBody).toBe(JSON.stringify({ project: "projects/alpha" })); + }); + + it("uses cloudaicompanionProject object id when present", async () => { + let capturedBody: string | undefined; + const mockFetch = vi.fn, ReturnType>( + async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 900, + planInfo: { monthlyPromptCredits: 1000 }, + cloudaicompanionProject: { id: "projects/beta" }, + }); + } + + if (url.includes("fetchAvailableModels")) { + capturedBody = init?.body?.toString(); + return makeResponse(200, { models: {} }); + } + + return makeResponse(404, "not found"); + }, + ); + + await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(capturedBody).toBe(JSON.stringify({ project: "projects/beta" })); + }); + + it("returns error snapshot when both endpoints fail", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(403, { error: { message: "Access denied" } }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(403, "Forbidden"); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.provider).toBe("google-antigravity"); + expect(snapshot.windows).toHaveLength(0); + expect(snapshot.error).toBe("Access denied"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("returns Token expired when fetchAvailableModels returns 401 and no windows", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(500, "Boom"); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(401, { error: { message: "Unauthorized" } }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.error).toBe("Token expired"); + expect(snapshot.windows).toHaveLength(0); + }); + + it("extracts plan info from currentTier.name", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 500, + planInfo: { monthlyPromptCredits: 1000 }, + planType: "Basic", + currentTier: { id: "tier2", name: "Premium Tier" }, + }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(500, "Error"); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.plan).toBe("Premium Tier"); + }); + + it("falls back to planType when currentTier.name is missing", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 500, + planInfo: { monthlyPromptCredits: 1000 }, + planType: "Basic Plan", + }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(500, "Error"); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.plan).toBe("Basic Plan"); + }); + + it("includes reset times in model windows", async () => { + const resetTime = "2026-01-10T12:00:00Z"; + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(500, "Error"); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-pro-experimental": { + quotaInfo: { remainingFraction: 0.3, resetTime }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-experimental"); + expect(proWindow?.resetAt).toBe(new Date(resetTime).getTime()); + }); + + it("parses string numbers correctly", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: "600", + planInfo: { monthlyPromptCredits: "1000" }, + }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-flash-lite": { + quotaInfo: { remainingFraction: "0.9" }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.windows).toHaveLength(2); + + const creditsWindow = snapshot.windows.find((w) => w.label === "Credits"); + expect(creditsWindow?.usedPercent).toBe(40); // (1000 - 600) / 1000 * 100 + + const flashWindow = snapshot.windows.find((w) => w.label === "gemini-flash-lite"); + expect(flashWindow?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100 + }); + + it("skips internal models", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 500, + planInfo: { monthlyPromptCredits: 1000 }, + cloudaicompanionProject: "projects/internal", + }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + chat_hidden: { quotaInfo: { remainingFraction: 0.1 } }, + tab_hidden: { quotaInfo: { remainingFraction: 0.2 } }, + "gemini-pro-1.5": { quotaInfo: { remainingFraction: 0.7 } }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.windows.map((w) => w.label)).toEqual(["Credits", "gemini-pro-1.5"]); + }); + + it("sorts models by usage and shows individual model IDs", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(500, "Error"); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-pro-1.0": { + quotaInfo: { remainingFraction: 0.8 }, + }, + "gemini-pro-1.5": { + quotaInfo: { remainingFraction: 0.3 }, + }, + "gemini-flash-1.5": { + quotaInfo: { remainingFraction: 0.6 }, + }, + "gemini-flash-2.0": { + quotaInfo: { remainingFraction: 0.9 }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.windows).toHaveLength(4); + // Should be sorted by usage (highest first) + expect(snapshot.windows[0]?.label).toBe("gemini-pro-1.5"); + expect(snapshot.windows[0]?.usedPercent).toBe(70); // (1 - 0.3) * 100 + expect(snapshot.windows[1]?.label).toBe("gemini-flash-1.5"); + expect(snapshot.windows[1]?.usedPercent).toBe(40); // (1 - 0.6) * 100 + expect(snapshot.windows[2]?.label).toBe("gemini-pro-1.0"); + expect(snapshot.windows[2]?.usedPercent).toBeCloseTo(20, 1); // (1 - 0.8) * 100 + expect(snapshot.windows[3]?.label).toBe("gemini-flash-2.0"); + expect(snapshot.windows[3]?.usedPercent).toBeCloseTo(10, 1); // (1 - 0.9) * 100 + }); + + it("returns Token expired error on 401 from loadCodeAssist", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(401, { error: { message: "Unauthorized" } }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.error).toBe("Token expired"); + expect(snapshot.windows).toHaveLength(0); + expect(mockFetch).toHaveBeenCalledTimes(1); // Should stop early on 401 + }); + + it("handles empty models array gracefully", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 800, + planInfo: { monthlyPromptCredits: 1000 }, + }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { models: {} }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.windows).toHaveLength(1); + const creditsWindow = snapshot.windows[0]; + expect(creditsWindow?.label).toBe("Credits"); + expect(creditsWindow?.usedPercent).toBe(20); + }); + + it("handles missing credits fields gracefully", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(200, { planType: "Free" }); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-flash-experimental": { + quotaInfo: { remainingFraction: 0.5 }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.windows).toHaveLength(1); + const flashWindow = snapshot.windows[0]; + expect(flashWindow?.label).toBe("gemini-flash-experimental"); + expect(flashWindow?.usedPercent).toBe(50); + expect(snapshot.plan).toBe("Free"); + }); + + it("handles invalid reset time gracefully", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + return makeResponse(500, "Error"); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-pro-test": { + quotaInfo: { remainingFraction: 0.4, resetTime: "invalid-date" }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + const proWindow = snapshot.windows.find((w) => w.label === "gemini-pro-test"); + expect(proWindow?.usedPercent).toBe(60); + expect(proWindow?.resetAt).toBeUndefined(); + }); + + it("handles network errors with graceful degradation", async () => { + const mockFetch = vi.fn, ReturnType>(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes("loadCodeAssist")) { + throw new Error("Network failure"); + } + + if (url.includes("fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-flash-stable": { + quotaInfo: { remainingFraction: 0.85 }, + }, + }, + }); + } + + return makeResponse(404, "not found"); + }); + + const snapshot = await fetchAntigravityUsage("token-123", 5000, mockFetch); + + expect(snapshot.windows).toHaveLength(1); + const flashWindow = snapshot.windows[0]; + expect(flashWindow?.label).toBe("gemini-flash-stable"); + expect(flashWindow?.usedPercent).toBeCloseTo(15, 1); + expect(snapshot.error).toBeUndefined(); + }); +}); diff --git a/src/infra/provider-usage.fetch.antigravity.ts b/src/infra/provider-usage.fetch.antigravity.ts new file mode 100644 index 000000000..b40b6d91e --- /dev/null +++ b/src/infra/provider-usage.fetch.antigravity.ts @@ -0,0 +1,284 @@ +import { logDebug } from "../logger.js"; +import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; +import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; + +type LoadCodeAssistResponse = { + availablePromptCredits?: number | string; + planInfo?: { monthlyPromptCredits?: number | string }; + planType?: string; + currentTier?: { id?: string; name?: string }; + cloudaicompanionProject?: string | { id?: string }; +}; + +type FetchAvailableModelsResponse = { + models?: Record< + string, + { + displayName?: string; + quotaInfo?: { + remainingFraction?: number | string; + resetTime?: string; + isExhausted?: boolean; + }; + } + >; +}; + +type ModelQuota = { + remainingFraction: number; + resetTime?: number; +}; + +type CreditsInfo = { + available: number; + monthly: number; +}; + +const BASE_URL = "https://cloudcode-pa.googleapis.com"; +const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist"; +const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; + +const METADATA = { + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}; + +function parseNumber(value: number | string | undefined): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function parseEpochMs(isoString: string | undefined): number | undefined { + if (!isoString?.trim()) return undefined; + try { + const ms = Date.parse(isoString); + if (Number.isFinite(ms)) return ms; + } catch { + // ignore parse errors + } + return undefined; +} + +async function parseErrorMessage(res: Response): Promise { + try { + const data = (await res.json()) as { error?: { message?: string } }; + const message = data?.error?.message?.trim(); + if (message) return message; + } catch { + // ignore parse errors + } + return `HTTP ${res.status}`; +} + +function extractCredits(data: LoadCodeAssistResponse): CreditsInfo | undefined { + const available = parseNumber(data.availablePromptCredits); + const monthly = parseNumber(data.planInfo?.monthlyPromptCredits); + if (available === undefined || monthly === undefined || monthly <= 0) return undefined; + return { available, monthly }; +} + +function extractPlanInfo(data: LoadCodeAssistResponse): string | undefined { + const tierName = data.currentTier?.name?.trim(); + if (tierName) return tierName; + const planType = data.planType?.trim(); + if (planType) return planType; + return undefined; +} + +function extractProjectId(data: LoadCodeAssistResponse): string | undefined { + const project = data.cloudaicompanionProject; + if (!project) return undefined; + if (typeof project === "string") return project.trim() ? project : undefined; + const projectId = typeof project.id === "string" ? project.id.trim() : undefined; + return projectId || undefined; +} + +function extractModelQuotas(data: FetchAvailableModelsResponse): Map { + const result = new Map(); + if (!data.models || typeof data.models !== "object") return result; + + for (const [modelId, modelInfo] of Object.entries(data.models)) { + const quotaInfo = modelInfo.quotaInfo; + if (!quotaInfo) continue; + + const remainingFraction = parseNumber(quotaInfo.remainingFraction); + if (remainingFraction === undefined) continue; + + const resetTime = parseEpochMs(quotaInfo.resetTime); + result.set(modelId, { remainingFraction, resetTime }); + } + + return result; +} + +function buildUsageWindows(opts: { + credits?: CreditsInfo; + modelQuotas?: Map; +}): UsageWindow[] { + const windows: UsageWindow[] = []; + + // Credits window (overall) + if (opts.credits) { + const { available, monthly } = opts.credits; + const used = monthly - available; + const usedPercent = clampPercent((used / monthly) * 100); + windows.push({ label: "Credits", usedPercent }); + } + + // Individual model windows + if (opts.modelQuotas && opts.modelQuotas.size > 0) { + const modelWindows: UsageWindow[] = []; + + for (const [modelId, quota] of opts.modelQuotas) { + const lowerModelId = modelId.toLowerCase(); + + // Skip internal models + if (lowerModelId.includes("chat_") || lowerModelId.includes("tab_")) { + continue; + } + + const usedPercent = clampPercent((1 - quota.remainingFraction) * 100); + const window: UsageWindow = { label: modelId, usedPercent }; + if (quota.resetTime) window.resetAt = quota.resetTime; + modelWindows.push(window); + } + + // Sort by usage (highest first) and take top 10 + modelWindows.sort((a, b) => b.usedPercent - a.usedPercent); + const topModels = modelWindows.slice(0, 10); + logDebug( + `[antigravity] Built ${topModels.length} model windows from ${opts.modelQuotas.size} total models`, + ); + for (const w of topModels) { + logDebug( + `[antigravity] ${w.label}: ${w.usedPercent.toFixed(1)}% used${w.resetAt ? ` (resets at ${new Date(w.resetAt).toISOString()})` : ""}`, + ); + } + windows.push(...topModels); + } + + return windows; +} + +export async function fetchAntigravityUsage( + token: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "antigravity", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + }; + + let credits: CreditsInfo | undefined; + let modelQuotas: Map | undefined; + let planInfo: string | undefined; + let lastError: string | undefined; + let projectId: string | undefined; + + // Fetch loadCodeAssist (credits + plan info) + try { + const res = await fetchJson( + `${BASE_URL}${LOAD_CODE_ASSIST_PATH}`, + { method: "POST", headers, body: JSON.stringify({ metadata: METADATA }) }, + timeoutMs, + fetchFn, + ); + + if (res.ok) { + const data = (await res.json()) as LoadCodeAssistResponse; + + // Extract project ID for subsequent calls + projectId = extractProjectId(data); + + credits = extractCredits(data); + planInfo = extractPlanInfo(data); + logDebug( + `[antigravity] Credits: ${credits ? `${credits.available}/${credits.monthly}` : "none"}${planInfo ? ` (plan: ${planInfo})` : ""}`, + ); + } else { + lastError = await parseErrorMessage(res); + // Fatal auth errors - stop early + if (res.status === 401) { + return { + provider: "google-antigravity", + displayName: PROVIDER_LABELS["google-antigravity"], + windows: [], + error: "Token expired", + }; + } + } + } catch { + lastError = "Network error"; + } + + // Fetch fetchAvailableModels (model quotas) + if (!projectId) { + logDebug("[antigravity] Missing project id; requesting available models without project"); + } + try { + const body = JSON.stringify(projectId ? { project: projectId } : {}); + const res = await fetchJson( + `${BASE_URL}${FETCH_AVAILABLE_MODELS_PATH}`, + { method: "POST", headers, body }, + timeoutMs, + fetchFn, + ); + + if (res.ok) { + const data = (await res.json()) as FetchAvailableModelsResponse; + modelQuotas = extractModelQuotas(data); + logDebug(`[antigravity] Extracted ${modelQuotas.size} model quotas from API`); + for (const [modelId, quota] of modelQuotas) { + logDebug( + `[antigravity] ${modelId}: ${(quota.remainingFraction * 100).toFixed(1)}% remaining${quota.resetTime ? ` (resets ${new Date(quota.resetTime).toISOString()})` : ""}`, + ); + } + } else { + const err = await parseErrorMessage(res); + if (res.status === 401) { + lastError = "Token expired"; + } else if (!lastError) { + lastError = err; + } + } + } catch { + if (!lastError) lastError = "Network error"; + } + + // Build windows from available data + const windows = buildUsageWindows({ credits, modelQuotas }); + + // Return error only if we got nothing + if (windows.length === 0 && lastError) { + logDebug(`[antigravity] Returning error snapshot: ${lastError}`); + return { + provider: "google-antigravity", + displayName: PROVIDER_LABELS["google-antigravity"], + windows: [], + error: lastError, + }; + } + + const snapshot: ProviderUsageSnapshot = { + provider: "google-antigravity", + displayName: PROVIDER_LABELS["google-antigravity"], + windows, + plan: planInfo, + }; + + logDebug( + `[antigravity] Returning snapshot with ${windows.length} windows${planInfo ? ` (plan: ${planInfo})` : ""}`, + ); + logDebug(`[antigravity] Snapshot: ${JSON.stringify(snapshot, null, 2)}`); + + return snapshot; +} diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index e0bcd60c9..070396554 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,3 +1,4 @@ +export { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js"; export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 676ac9920..39a97a86c 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -1,5 +1,6 @@ import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; import { + fetchAntigravityUsage, fetchClaudeUsage, fetchCodexUsage, fetchCopilotUsage, @@ -57,8 +58,9 @@ export async function loadProviderUsageSummary( return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn); case "github-copilot": return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn); - case "google-gemini-cli": case "google-antigravity": + return await fetchAntigravityUsage(auth.token, timeoutMs, fetchFn); + case "google-gemini-cli": return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider); case "openai-codex": return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn);