diff --git a/README.md b/README.md index d992761a..91402885 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Amp**](docs/providers/amp.md) / free tier, bonus, credits - [**Antigravity**](docs/providers/antigravity.md) / all models +- [**Antigravity IDE**](docs/providers/antigravity-ide.md) / all models - [**Claude**](docs/providers/claude.md) / session, weekly, extra usage, local token usage (ccusage) - [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions diff --git a/docs/providers/antigravity-ide.md b/docs/providers/antigravity-ide.md new file mode 100644 index 00000000..7521e925 --- /dev/null +++ b/docs/providers/antigravity-ide.md @@ -0,0 +1,52 @@ +# Antigravity IDE + +> Antigravity IDE is the standalone 2.0 version of [Antigravity](antigravity.md). Shares the same Codeium language server binary and Connect-RPC protocol. + +## Overview + +- **Vendor:** Google +- **Protocol:** Connect RPC v1 (JSON over HTTP) on local language server +- **Service:** `exa.language_server_pb.LanguageServerService` +- **Auth:** CSRF token from process args +- **Quota:** fraction (0.0–1.0, where 1.0 = 100% remaining) +- **Quota window:** 5 hours +- **Requires:** Antigravity IDE running (language server process) + +## Differences from Antigravity + +| | Antigravity | Antigravity IDE | +|---|---|---| +| App bundle | `Antigravity.app` | `Antigravity IDE.app` | +| LS marker (`--app_data_dir`) | `antigravity` | `antigravity-ide` | +| State DB path | `~/Library/Application Support/Antigravity/...` | `~/Library/Application Support/Antigravity IDE/...` | +| OAuth in SQLite | ✅ `antigravityUnifiedStateSync.oauthToken` | ❌ Not present | +| Cloud Code API fallback | ✅ | ❌ (no OAuth tokens) | + +## Discovery + +Same as [Antigravity](antigravity.md#discovery), but with marker `antigravity-ide`: + +```bash +# Find process +ps -ax -o pid=,command= | grep 'language_server_macos.*antigravity-ide' +# Match: --app_data_dir antigravity-ide +``` + +## Endpoints + +Identical to [Antigravity](antigravity.md#endpoints) — same LS binary, same RPC service: + +- `GetUserStatus` (primary) +- `GetCommandModelConfigs` (fallback) + +Metadata uses `ideName: "antigravity-ide"` and `extensionName: "antigravity-ide"`. + +## Plugin Strategy + +1. Discover LS process via `ctx.host.ls.discover()` with marker `antigravity-ide` +2. Probe ports with `GetUnleashData` to find the Connect-RPC endpoint +3. Call `GetUserStatus` for plan name + per-model quota +4. Fall back to `GetCommandModelConfigs` if `GetUserStatus` fails +5. If LS not found or all calls fail: error "Start Antigravity IDE and try again." + +No Cloud Code API fallback — Antigravity IDE's SQLite does not contain OAuth tokens. diff --git a/plugins/antigravity-ide/icon.svg b/plugins/antigravity-ide/icon.svg new file mode 100644 index 00000000..4794cc58 --- /dev/null +++ b/plugins/antigravity-ide/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/antigravity-ide/plugin.js b/plugins/antigravity-ide/plugin.js new file mode 100644 index 00000000..d6983d84 --- /dev/null +++ b/plugins/antigravity-ide/plugin.js @@ -0,0 +1,238 @@ +(function () { + var LS_SERVICE = "exa.language_server_pb.LanguageServerService" + var CC_MODEL_BLACKLIST = { + "MODEL_CHAT_20706": true, + "MODEL_CHAT_23310": true, + "MODEL_GOOGLE_GEMINI_2_5_FLASH": true, + "MODEL_GOOGLE_GEMINI_2_5_FLASH_THINKING": true, + "MODEL_GOOGLE_GEMINI_2_5_FLASH_LITE": true, + "MODEL_GOOGLE_GEMINI_2_5_PRO": true, + "MODEL_PLACEHOLDER_M19": true, + "MODEL_PLACEHOLDER_M9": true, + "MODEL_PLACEHOLDER_M12": true, + } + + // --- LS discovery --- + + function discoverLs(ctx) { + return ctx.host.ls.discover({ + processName: "language_server_macos", + markers: ["antigravity-ide"], + csrfFlag: "--csrf_token", + portFlag: "--extension_server_port", + }) + } + + function probePort(ctx, scheme, port, csrf) { + ctx.host.http.request({ + method: "POST", + url: scheme + "://127.0.0.1:" + port + "/" + LS_SERVICE + "/GetUnleashData", + headers: { + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + "x-codeium-csrf-token": csrf, + }, + bodyText: JSON.stringify({ + context: { + properties: { + devMode: "false", + extensionVersion: "unknown", + ide: "antigravity-ide", + ideVersion: "unknown", + os: "macos", + }, + }, + }), + timeoutMs: 5000, + dangerouslyIgnoreTls: scheme === "https", + }) + // Any HTTP response means this port is alive (even 400 validation errors). + return true + } + + function findWorkingPort(ctx, discovery) { + var ports = discovery.ports || [] + for (var i = 0; i < ports.length; i++) { + var port = ports[i] + // Try HTTPS first (LS may use self-signed cert), then HTTP + try { if (probePort(ctx, "https", port, discovery.csrf)) return { port: port, scheme: "https" } } catch (e) { /* ignore */ } + try { if (probePort(ctx, "http", port, discovery.csrf)) return { port: port, scheme: "http" } } catch (e) { /* ignore */ } + ctx.host.log.info("port " + port + " probe failed on both schemes") + } + if (discovery.extensionPort) return { port: discovery.extensionPort, scheme: "http" } + return null + } + + function callLs(ctx, port, scheme, csrf, method, body) { + var resp = ctx.host.http.request({ + method: "POST", + url: scheme + "://127.0.0.1:" + port + "/" + LS_SERVICE + "/" + method, + headers: { + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + "x-codeium-csrf-token": csrf, + }, + bodyText: JSON.stringify(body || {}), + timeoutMs: 10000, + dangerouslyIgnoreTls: scheme === "https", + }) + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("callLs " + method + " returned " + resp.status) + return null + } + return ctx.util.tryParseJson(resp.bodyText) + } + + // --- Line builders --- + + function normalizeLabel(label) { + // "Gemini 3 Pro (High)" -> "Gemini 3 Pro" + return label.replace(/\s*\([^)]*\)\s*$/, "").trim() + } + + function poolLabel(normalizedLabel) { + var lower = normalizedLabel.toLowerCase() + if (lower.indexOf("gemini") !== -1 && lower.indexOf("pro") !== -1) return "Gemini Pro" + if (lower.indexOf("gemini") !== -1 && lower.indexOf("flash") !== -1) return "Gemini Flash" + // All non-Gemini models (Claude, GPT-OSS, etc.) share a single quota pool + return "Claude" + } + + function modelSortKey(label) { + var lower = label.toLowerCase() + // Gemini Pro variants first, then other Gemini, then Claude Opus, then other Claude, then rest + if (lower.indexOf("gemini") !== -1 && lower.indexOf("pro") !== -1) return "0a_" + label + if (lower.indexOf("gemini") !== -1) return "0b_" + label + if (lower.indexOf("claude") !== -1 && lower.indexOf("opus") !== -1) return "1a_" + label + if (lower.indexOf("claude") !== -1) return "1b_" + label + return "2_" + label + } + + var QUOTA_PERIOD_MS = 5 * 60 * 60 * 1000 // 5 hours + + function modelLine(ctx, label, remainingFraction, resetTime) { + var clamped = Math.max(0, Math.min(1, remainingFraction)) + var used = Math.round((1 - clamped) * 100) + return ctx.line.progress({ + label: label, + used: used, + limit: 100, + format: { kind: "percent" }, + resetsAt: resetTime || undefined, + periodDurationMs: QUOTA_PERIOD_MS, + }) + } + + function buildModelLines(ctx, configs) { + var deduped = {} + for (var i = 0; i < configs.length; i++) { + var c = configs[i] + var label = (typeof c.label === "string") ? c.label.trim() : "" + if (!label) continue + var qi = c.quotaInfo + var frac = (qi && typeof qi.remainingFraction === "number") ? qi.remainingFraction : 0 + var rtime = (qi && qi.resetTime) || undefined + var pool = poolLabel(normalizeLabel(label)) + if (!deduped[pool] || frac < deduped[pool].remainingFraction) { + deduped[pool] = { + label: pool, + remainingFraction: frac, + resetTime: rtime, + } + } + } + + var models = [] + var keys = Object.keys(deduped) + for (var i = 0; i < keys.length; i++) { + var m = deduped[keys[i]] + m.sortKey = modelSortKey(m.label) + models.push(m) + } + + models.sort(function (a, b) { + return a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0 + }) + + var lines = [] + for (var i = 0; i < models.length; i++) { + lines.push(modelLine(ctx, models[i].label, models[i].remainingFraction, models[i].resetTime)) + } + return lines + } + + // --- Probe --- + + function probe(ctx) { + var discovery = discoverLs(ctx) + if (!discovery) throw "Start Antigravity IDE and try again." + + var found = findWorkingPort(ctx, discovery) + if (!found) throw "Start Antigravity IDE and try again." + + ctx.host.log.info("using LS at " + found.scheme + "://127.0.0.1:" + found.port) + + var metadata = { + ideName: "antigravity-ide", + extensionName: "antigravity-ide", + ideVersion: "unknown", + locale: "en", + } + + // Try GetUserStatus first, fall back to GetCommandModelConfigs + var data = null + try { + data = callLs(ctx, found.port, found.scheme, discovery.csrf, "GetUserStatus", { metadata: metadata }) + } catch (e) { + ctx.host.log.warn("GetUserStatus threw: " + String(e)) + } + var hasUserStatus = data && data.userStatus + + if (!hasUserStatus) { + ctx.host.log.warn("GetUserStatus failed, trying GetCommandModelConfigs") + data = callLs(ctx, found.port, found.scheme, discovery.csrf, "GetCommandModelConfigs", { metadata: metadata }) + } + + // Parse model configs + var configs + if (hasUserStatus) { + configs = (data.userStatus.cascadeModelConfigData || {}).clientModelConfigs || [] + } else if (data && data.clientModelConfigs) { + configs = data.clientModelConfigs + } else { + throw "Start Antigravity IDE and try again." + } + + var filtered = [] + for (var j = 0; j < configs.length; j++) { + var mid = configs[j].modelOrAlias && configs[j].modelOrAlias.model + if (mid && CC_MODEL_BLACKLIST[mid]) continue + filtered.push(configs[j]) + } + + var lines = buildModelLines(ctx, filtered) + if (lines.length === 0) throw "Start Antigravity IDE and try again." + + var plan = null + if (hasUserStatus) { + // Prefer userTier.name (Google's own subscription system) over the legacy + // planInfo.planName field inherited from Windsurf/Codeium, which always + // returns "Pro" for all paid tiers including Google AI Ultra. + var ut = data.userStatus.userTier + var userTierName = + ut && typeof ut.name === "string" && ut.name.trim() ? ut.name.trim() : null + if (userTierName) { + plan = userTierName + } else { + var ps = data.userStatus.planStatus || {} + var pi = ps.planInfo || {} + plan = + typeof pi.planName === "string" && pi.planName.trim() ? pi.planName.trim() : null + } + } + + return { plan: plan, lines: lines } + } + + globalThis.__openusage_plugin = { id: "antigravity-ide", probe: probe } +})() diff --git a/plugins/antigravity-ide/plugin.json b/plugins/antigravity-ide/plugin.json new file mode 100644 index 00000000..837e6e07 --- /dev/null +++ b/plugins/antigravity-ide/plugin.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "antigravity-ide", + "name": "Antigravity IDE", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#000000", + "lines": [ + { + "type": "progress", + "label": "Gemini Pro", + "scope": "overview", + "primaryOrder": 1 + }, + { + "type": "progress", + "label": "Gemini Flash", + "scope": "overview" + }, + { + "type": "progress", + "label": "Claude", + "scope": "overview" + } + ] +} \ No newline at end of file diff --git a/plugins/antigravity-ide/plugin.test.js b/plugins/antigravity-ide/plugin.test.js new file mode 100644 index 00000000..987979cb --- /dev/null +++ b/plugins/antigravity-ide/plugin.test.js @@ -0,0 +1,411 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +// --- Fixtures --- + +function makeDiscovery(overrides) { + return Object.assign( + { pid: 12345, csrf: "test-csrf-token", ports: [42001, 42002], extensionPort: null }, + overrides + ) +} + +function makeUserStatusResponse(overrides) { + var base = { + userStatus: { + planStatus: { + planInfo: { + planName: "Pro", + monthlyPromptCredits: 50000, + monthlyFlowCredits: 150000, + monthlyFlexCreditPurchaseAmount: 25000, + }, + availablePromptCredits: 500, + availableFlowCredits: 100, + usedFlexCredits: 5000, + }, + cascadeModelConfigData: { + clientModelConfigs: [ + { + label: "Gemini 3.1 Pro (High)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M37" }, + quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Gemini 3.1 Pro (Low)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M36" }, + quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Gemini 3.5 Flash (High)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M19" }, + quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Gemini 3.5 Flash (Medium)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M20" }, + quotaInfo: { remainingFraction: 0.95, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Gemini 3 Flash", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M18" }, + quotaInfo: { remainingFraction: 1.0, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Claude Sonnet 4.6 (Thinking)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M35" }, + quotaInfo: { resetTime: "2026-02-26T15:23:41Z" }, + }, + { + label: "Claude Opus 4.6 (Thinking)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M26" }, + quotaInfo: { resetTime: "2026-02-26T15:23:41Z" }, + }, + { + label: "GPT-OSS 120B (Medium)", + modelOrAlias: { model: "MODEL_OPENAI_GPT_OSS_120B_MEDIUM" }, + quotaInfo: { resetTime: "2026-02-26T15:23:41Z" }, + }, + ], + }, + }, + } + if (overrides) { + if (overrides.planName !== undefined) base.userStatus.planStatus.planInfo.planName = overrides.planName + if (overrides.configs !== undefined) base.userStatus.cascadeModelConfigData.clientModelConfigs = overrides.configs + if (overrides.planStatus !== undefined) base.userStatus.planStatus = overrides.planStatus + if (overrides.userTier !== undefined) base.userStatus.userTier = overrides.userTier + } + return base +} + +function setupLsMock(ctx, discovery, responseBody) { + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + return { status: 200, bodyText: JSON.stringify(responseBody) } + }) +} + +// --- Tests --- + +describe("antigravity-ide plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("registers with correct id", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(null) + const plugin = await loadPlugin() + expect(plugin.id).toBe("antigravity-ide") + }) + + it("throws when LS not found", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(null) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Start Antigravity IDE and try again.") + }) + + it("throws when no working port found", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + ctx.host.http.request.mockImplementation(() => { + throw new Error("connection refused") + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Start Antigravity IDE and try again.") + }) + + it("throws when both GetUserStatus and GetCommandModelConfigs fail", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + return { status: 500, bodyText: "" } + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Start Antigravity IDE and try again.") + }) + + it("returns models + plan from GetUserStatus", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + const labels = result.lines.map((l) => l.label) + expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) + }) + + it("deduplicates models by normalized label (keeps worst-case fraction)", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const pro = result.lines.find((l) => l.label === "Gemini Pro") + expect(pro).toBeTruthy() + expect(pro.used).toBe(20) // (1 - 0.8) * 100 + }) + + it("orders: Gemini (Pro, Flash), Claude (Opus, Sonnet), then others", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const labels = result.lines.map((l) => l.label) + expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) + }) + + it("falls back to GetCommandModelConfigs when GetUserStatus fails", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (String(opts.url).includes("GetUserStatus")) { + return { status: 500, bodyText: "" } + } + if (String(opts.url).includes("GetCommandModelConfigs")) { + return { + status: 200, + bodyText: JSON.stringify({ + clientModelConfigs: [ + { + label: "Gemini 3 Pro (High)", + modelOrAlias: { model: "M7" }, + quotaInfo: { remainingFraction: 0.6, resetTime: "2026-02-08T09:10:56Z" }, + }, + ], + }), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBeNull() + const pro = result.lines.find((l) => l.label === "Gemini Pro") + expect(pro).toBeTruthy() + expect(pro.used).toBe(40) // (1 - 0.6) * 100 + }) + + it("uses extension port as fallback when all ports fail probing", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery({ ports: [99999], extensionPort: 42010 })) + + let usedPort = null + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData") && url.includes("99999")) { + throw new Error("refused") + } + if (url.includes("GetUserStatus")) { + usedPort = parseInt(url.match(/:(\d+)\//)[1]) + return { + status: 200, + bodyText: JSON.stringify(makeUserStatusResponse()), + } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(usedPort).toBe(42010) + expect(result.lines.length).toBeGreaterThan(0) + }) + + it("treats models with no quotaInfo as depleted (100% used)", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "Claude Opus 4.6 (Thinking)", modelOrAlias: { model: "M26" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const claude = result.lines.find((l) => l.label === "Claude") + expect(claude).toBeTruthy() + expect(claude.used).toBe(100) + expect(claude.limit).toBe(100) + expect(claude.resetsAt).toBeUndefined() + expect(result.lines.find((l) => l.label === "Gemini Pro")).toBeTruthy() + }) + + it("skips configs with missing or empty labels", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "", modelOrAlias: { model: "M99" }, quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" } }, + { modelOrAlias: { model: "M100" }, quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T09:10:56Z" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.length).toBe(1) + expect(result.lines[0].label).toBe("Gemini Pro") + }) + + it("includes resetsAt on model lines", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const pro = result.lines.find((l) => l.label === "Gemini Pro") + expect(pro.resetsAt).toBe("2026-02-08T09:10:56Z") + }) + + it("clamps remainingFraction outside 0-1 range", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini Pro (Over)", modelOrAlias: { model: "M1" }, quotaInfo: { remainingFraction: 1.5, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "Gemini Flash (Neg)", modelOrAlias: { model: "M2" }, quotaInfo: { remainingFraction: -0.3, resetTime: "2026-02-08T09:10:56Z" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const over = result.lines.find((l) => l.label === "Gemini Pro") + const neg = result.lines.find((l) => l.label === "Gemini Flash") + expect(over.used).toBe(0) // clamped to 1.0 → 0% used + expect(neg.used).toBe(100) // clamped to 0.0 → 100% used + }) + + it("probes ports with HTTPS first, then HTTP, picks first success", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery({ ports: [10001, 10002] })) + + const probed = [] + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + const port = parseInt(url.match(/:(\d+)\//)[1]) + const scheme = url.startsWith("https") ? "https" : "http" + probed.push({ port, scheme }) + if (port === 10002 && scheme === "https") return { status: 200, bodyText: "{}" } + throw new Error("refused") + } + return { status: 200, bodyText: JSON.stringify(makeUserStatusResponse()) } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + expect(probed).toEqual([ + { port: 10001, scheme: "https" }, + { port: 10001, scheme: "http" }, + { port: 10002, scheme: "https" }, + ]) + }) + + it("never sends apiKey in LS metadata", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + let capturedMetadata = null + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + const body = JSON.parse(opts.bodyText) + capturedMetadata = body.metadata + return { status: 200, bodyText: JSON.stringify(response) } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBeGreaterThan(0) + expect(capturedMetadata).toBeTruthy() + expect(capturedMetadata.apiKey).toBeUndefined() + expect(capturedMetadata.ideName).toBe("antigravity-ide") + expect(capturedMetadata.extensionName).toBe("antigravity-ide") + }) + + it("sends antigravity-ide marker to ls.discover", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(null) + const plugin = await loadPlugin() + try { plugin.probe(ctx) } catch (e) { /* expected */ } + expect(ctx.host.ls.discover).toHaveBeenCalledWith({ + processName: "language_server_macos", + markers: ["antigravity-ide"], + csrfFlag: "--csrf_token", + portFlag: "--extension_server_port", + }) + }) + + it("prefers userTier.name over planInfo.planName", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ userTier: { name: "Ultra" } }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBe("Ultra") + }) + + it("filters blacklisted model IDs", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini 3 Pro (High)", modelOrAlias: { model: "MODEL_PLACEHOLDER_M37" }, quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "Blacklisted Model", modelOrAlias: { model: "MODEL_GOOGLE_GEMINI_2_5_PRO" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.length).toBe(1) + expect(result.lines[0].label).toBe("Gemini Pro") + }) +})