From 9b96040e073fbcf52b7b0d486b5aae408b130f05 Mon Sep 17 00:00:00 2001 From: gongchunru Date: Tue, 17 Mar 2026 15:46:41 +0800 Subject: [PATCH 1/4] fix(antigravity): prefer cloud tier over stale ls plan --- plugins/antigravity/plugin.js | 161 ++++++++++++++++++++++++++--- plugins/antigravity/plugin.test.js | 122 ++++++++++++++++++++++ 2 files changed, 266 insertions(+), 17 deletions(-) diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 0b6ac696..08cfe329 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -6,6 +6,7 @@ "https://cloudcode-pa.googleapis.com", ] var FETCH_MODELS_PATH = "/v1internal:fetchAvailableModels" + var LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist" var GOOGLE_OAUTH_URL = "https://oauth2.googleapis.com/token" var GOOGLE_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" var GOOGLE_CLIENT_SECRET = "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" @@ -173,6 +174,27 @@ } } + function pushUniqueToken(tokens, token) { + if (typeof token !== "string" || !token) return + for (var i = 0; i < tokens.length; i++) { + if (tokens[i] === token) return + } + tokens.push(token) + } + + function collectTokens(ctx, apiKey, proto) { + var tokens = [] + if (proto && proto.accessToken) { + if (!proto.expirySeconds || proto.expirySeconds > Math.floor(Date.now() / 1000)) { + pushUniqueToken(tokens, proto.accessToken) + } + } + + pushUniqueToken(tokens, loadCachedToken(ctx)) + pushUniqueToken(tokens, apiKey) + return tokens + } + // --- LS discovery --- function discoverLs(ctx) { @@ -322,6 +344,55 @@ return lines } + function readFirstStringDeep(value, keys) { + if (!value || typeof value !== "object") return null + + for (var i = 0; i < keys.length; i++) { + var direct = value[keys[i]] + if (typeof direct === "string" && direct.trim()) return direct.trim() + } + + var nested = Object.values(value) + for (var j = 0; j < nested.length; j++) { + var found = readFirstStringDeep(nested[j], keys) + if (found) return found + } + return null + } + + function mapTierToPlan(value) { + if (!value) return null + var normalized = String(value).trim().toLowerCase() + if (!normalized) return null + if (normalized.indexOf("ultra") !== -1) return "Ultra" + if (normalized.indexOf("pro") !== -1) return "Pro" + if (normalized.indexOf("free") !== -1) return "Free" + if (normalized === "standard-tier") return "Paid" + if (normalized === "legacy-tier") return "Legacy" + if (normalized.indexOf("workspace") !== -1) return "Workspace" + return null + } + + function planRank(value) { + var normalized = String(value || "").trim().toLowerCase() + if (normalized === "ultra") return 3 + if (normalized === "pro") return 2 + if (normalized === "free") return 1 + return 0 + } + + function extractTierValue(data) { + if (!data || typeof data !== "object") return null + var paidTier = data.paidTier && typeof data.paidTier === "object" ? data.paidTier : null + var currentTier = data.currentTier && typeof data.currentTier === "object" ? data.currentTier : null + + return ( + readFirstStringDeep(paidTier, ["id", "name", "slug", "quotaTier"]) || + readFirstStringDeep(currentTier, ["id", "name", "slug", "quotaTier"]) || + readFirstStringDeep(data, ["tier", "userTier", "subscriptionTier"]) + ) + } + // --- Cloud Code API --- function probeCloudCode(ctx, token) { @@ -349,6 +420,51 @@ return null } + function fetchCloudCodePlan(ctx, token) { + for (var i = 0; i < CLOUD_CODE_URLS.length; i++) { + try { + var resp = ctx.host.http.request({ + method: "POST", + url: CLOUD_CODE_URLS[i] + LOAD_CODE_ASSIST_PATH, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + token, + "User-Agent": "antigravity", + }, + bodyText: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }), + timeoutMs: 15000, + }) + if (ctx.util.isAuthStatus(resp.status)) return { _authFailed: true } + if (resp.status >= 200 && resp.status < 300) { + var data = ctx.util.tryParseJson(resp.bodyText) + return { plan: mapTierToPlan(extractTierValue(data)) } + } + } catch (e) { + ctx.host.log.warn("Cloud Code plan request failed (" + CLOUD_CODE_URLS[i] + "): " + String(e)) + } + } + return null + } + + function resolveCloudCodePlan(ctx, tokens, refreshTokenValue, allowRefresh) { + for (var i = 0; i < tokens.length; i++) { + var result = fetchCloudCodePlan(ctx, tokens[i]) + if (result && !result._authFailed && result.plan) return result.plan + } + + if (allowRefresh !== false && refreshTokenValue) { + var refreshed = refreshAccessToken(ctx, refreshTokenValue) + if (refreshed) { + var refreshedResult = fetchCloudCodePlan(ctx, refreshed) + if (refreshedResult && !refreshedResult._authFailed && refreshedResult.plan) { + return refreshedResult.plan + } + } + } + + return null + } + function parseCloudCodeModels(data) { var modelsObj = data && data.models if (!modelsObj || typeof modelsObj !== "object") return [] @@ -375,7 +491,7 @@ // --- LS probe --- - function probeLs(ctx, apiKey) { + function probeLs(ctx, apiKey, tokens, refreshTokenValue) { var discovery = discoverLs(ctx) if (!discovery) return null @@ -431,6 +547,12 @@ var ps = data.userStatus.planStatus || {} var pi = ps.planInfo || {} plan = pi.planName || null + var cloudOverridePlan = resolveCloudCodePlan(ctx, tokens || [], refreshTokenValue, false) + if (planRank(cloudOverridePlan) > planRank(plan)) { + plan = cloudOverridePlan + } + } else { + plan = resolveCloudCodePlan(ctx, tokens || [], refreshTokenValue, true) } return { plan: plan, lines: lines } @@ -441,40 +563,45 @@ function probe(ctx) { var apiKey = loadApiKey(ctx) var proto = loadProtoTokens(ctx) + var tokens = collectTokens(ctx, apiKey, proto) - var lsResult = probeLs(ctx, apiKey) + var lsResult = probeLs(ctx, apiKey, tokens, proto && proto.refreshToken) if (lsResult) return lsResult - var tokens = [] - if (proto && proto.accessToken) { - if (!proto.expirySeconds || proto.expirySeconds > Math.floor(Date.now() / 1000)) { - tokens.push(proto.accessToken) - } - } - - var cached = loadCachedToken(ctx) - if (cached && cached !== (proto && proto.accessToken)) tokens.push(cached) - - if (apiKey && apiKey !== (proto && proto.accessToken) && apiKey !== cached) tokens.push(apiKey) - if (tokens.length === 0) throw "Start Antigravity and try again." var ccData = null + var cloudPlan = null + var winningToken = null for (var i = 0; i < tokens.length; i++) { ccData = probeCloudCode(ctx, tokens[i]) - if (ccData && !ccData._authFailed) break + if (ccData && !ccData._authFailed) { + winningToken = tokens[i] + break + } ccData = null } if (!ccData && proto && proto.refreshToken) { var refreshed = refreshAccessToken(ctx, proto.refreshToken) - if (refreshed) ccData = probeCloudCode(ctx, refreshed) + if (refreshed) { + ccData = probeCloudCode(ctx, refreshed) + if (ccData && !ccData._authFailed) winningToken = refreshed + } } if (ccData && !ccData._authFailed) { var configs = parseCloudCodeModels(ccData) var lines = buildModelLines(ctx, configs) - if (lines.length > 0) return { plan: null, lines: lines } + if (lines.length > 0) { + cloudPlan = resolveCloudCodePlan( + ctx, + winningToken ? [winningToken] : tokens, + proto && proto.refreshToken, + true + ) + return { plan: cloudPlan, lines: lines } + } } throw "Start Antigravity and try again." diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 534a4a88..cbdc899b 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -93,6 +93,16 @@ function makeCloudCodeResponse(overrides) { ) } +function makeLoadCodeAssistResponse(overrides) { + return Object.assign( + { + currentTier: { id: "free-tier" }, + paidTier: null, + }, + overrides + ) +} + function makeAuthStatusJson(overrides) { return JSON.stringify( Object.assign({ apiKey: "test-api-key-123", email: "user@example.com", name: "Test User" }, overrides) @@ -202,6 +212,41 @@ describe("antigravity plugin", () => { expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) }) + it("prefers Cloud tier over stale LS plan when Cloud identifies Ultra", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ planName: "Pro" }) + + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 200, bodyText: JSON.stringify(response) } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { id: "free-tier" }, + paidTier: { id: "ultra" }, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Ultra") + expect(result.lines.map((l) => l.label)).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) + }) + it("deduplicates models by normalized label (keeps worst-case fraction)", async () => { const ctx = makeCtx() const discovery = makeDiscovery() @@ -660,6 +705,53 @@ describe("antigravity plugin", () => { expect(ccCalls.length).toBe(0) }) + it("fills plan from loadCodeAssist when LS falls back to GetCommandModelConfigs", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 500, bodyText: "" } + } + if (url.includes("GetCommandModelConfigs")) { + return { + status: 200, + bodyText: JSON.stringify({ + clientModelConfigs: [ + { + label: "Gemini 3 Pro (High)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M8" }, + quotaInfo: { remainingFraction: 0.7, resetTime: "2026-02-08T09:10:56Z" }, + }, + ], + }), + } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { id: "free-tier" }, + paidTier: { id: "ultra" }, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Ultra") + expect(result.lines.map((l) => l.label)).toEqual(["Gemini Pro"]) + }) + it("Cloud Code treats models without quotaInfo as depleted (100% used)", async () => { const ctx = makeCtx() const futureExpiry = Math.floor(Date.now() / 1000) + 3600 @@ -721,6 +813,36 @@ describe("antigravity plugin", () => { expect(result.lines.length).toBeGreaterThan(0) }) + it("fills plan from loadCodeAssist during Cloud Code fallback", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-access", "1//refresh-token", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(null) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("fetchAvailableModels")) { + return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { name: "Google AI Pro" }, + paidTier: null, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + expect(result.lines.length).toBeGreaterThan(0) + }) + it("handles missing protobuf data gracefully (falls back to apiKey)", async () => { const ctx = makeCtx() setupSqliteMock(ctx, makeAuthStatusJson()) From c3a09abf37cf11fdf9d04b5519c6af63df640f47 Mon Sep 17 00:00:00 2001 From: gongchunru Date: Tue, 17 Mar 2026 15:51:40 +0800 Subject: [PATCH 2/4] test(antigravity): expand tier fallback coverage --- plugins/antigravity/plugin.test.js | 281 +++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index cbdc899b..247e76b4 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -247,6 +247,62 @@ describe("antigravity plugin", () => { expect(result.lines.map((l) => l.label)).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) }) + it("keeps LS plan when Cloud tier lookup throws", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ planName: "Pro" }) + + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 200, bodyText: JSON.stringify(response) } + } + if (url.includes("loadCodeAssist")) { + throw new Error("loadCodeAssist unavailable") + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + }) + + it("keeps LS plan when Cloud tier lookup returns no supported tier", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ planName: "Pro" }) + + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 200, bodyText: JSON.stringify(response) } + } + if (url.includes("loadCodeAssist")) { + return { status: 500, bodyText: "" } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + }) + it("deduplicates models by normalized label (keeps worst-case fraction)", async () => { const ctx = makeCtx() const discovery = makeDiscovery() @@ -752,6 +808,53 @@ describe("antigravity plugin", () => { expect(result.lines.map((l) => l.label)).toEqual(["Gemini Pro"]) }) + it("handles GetUserStatus throw and still resolves plan from loadCodeAssist fallback", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + throw new Error("boom") + } + if (url.includes("GetCommandModelConfigs")) { + return { + status: 200, + bodyText: JSON.stringify({ + clientModelConfigs: [ + { + label: "Gemini 3 Flash", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M18" }, + quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T09:10:56Z" }, + }, + ], + }), + } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { name: "Google AI Pro" }, + paidTier: null, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + expect(result.lines.map((l) => l.label)).toEqual(["Gemini Flash"]) + }) + it("Cloud Code treats models without quotaInfo as depleted (100% used)", async () => { const ctx = makeCtx() const futureExpiry = Math.floor(Date.now() / 1000) + 3600 @@ -843,6 +946,165 @@ describe("antigravity plugin", () => { expect(result.lines.length).toBeGreaterThan(0) }) + it("maps standard-tier to Paid during Cloud Code fallback", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-access", "1//refresh-token", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(null) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("fetchAvailableModels")) { + return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { id: "standard-tier" }, + paidTier: null, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Paid") + }) + + it("maps legacy-tier to Legacy during Cloud Code fallback", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-access", "1//refresh-token", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(null) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("fetchAvailableModels")) { + return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { id: "legacy-tier" }, + paidTier: null, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Legacy") + }) + + it("maps workspace tier during Cloud Code fallback", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-access", "1//refresh-token", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(null) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("fetchAvailableModels")) { + return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { name: "Workspace" }, + paidTier: null, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Workspace") + }) + + it("leaves plan empty for unsupported Cloud tier labels", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-access", "1//refresh-token", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(null) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("fetchAvailableModels")) { + return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) } + } + if (url.includes("loadCodeAssist")) { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { id: "mystery-tier" }, + paidTier: null, + })), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBeNull() + expect(result.lines.length).toBeGreaterThan(0) + }) + + it("refreshes token to recover Cloud tier after loadCodeAssist auth failure", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-access", "1//refresh-token", futureExpiry)) + ctx.host.ls.discover.mockReturnValue(null) + + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + const auth = opts.headers && opts.headers.Authorization + if (url.includes("fetchAvailableModels")) { + if (auth === "Bearer ya29.test-access") { + return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) } + } + return { status: 500, bodyText: "" } + } + if (url.includes("loadCodeAssist")) { + if (auth === "Bearer ya29.test-access") { + return { status: 401, bodyText: '{"error":"unauthorized"}' } + } + if (auth === "Bearer ya29.plan-refreshed") { + return { + status: 200, + bodyText: JSON.stringify(makeLoadCodeAssistResponse({ + currentTier: { id: "free-tier" }, + paidTier: { id: "ultra" }, + })), + } + } + return { status: 500, bodyText: "" } + } + if (url.includes("oauth2.googleapis.com")) { + return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.plan-refreshed", expires_in: 3600 }) } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Ultra") + }) + it("handles missing protobuf data gracefully (falls back to apiKey)", async () => { const ctx = makeCtx() setupSqliteMock(ctx, makeAuthStatusJson()) @@ -1475,4 +1737,23 @@ describe("antigravity plugin", () => { expect(result.lines.length).toBeGreaterThan(0) expect(ccCalls).toBe(2) }) + + it("throws when every Cloud Code base URL returns non-2xx and no refresh token is available", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, makeAuthStatusJson()) + ctx.host.ls.discover.mockReturnValue(null) + + let ccCalls = 0 + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("fetchAvailableModels")) { + ccCalls += 1 + return { status: 500, bodyText: "{}" } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.") + expect(ccCalls).toBe(2) + }) }) From 1295e9d333a45ed953f07b3092419cebed8f92f6 Mon Sep 17 00:00:00 2001 From: gongchunru Date: Tue, 17 Mar 2026 21:50:31 +0800 Subject: [PATCH 3/4] perf(antigravity): avoid cloud lookup on ls fast path --- plugins/antigravity/plugin.js | 40 +++++++++++++++++++--- plugins/antigravity/plugin.test.js | 54 +++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 08cfe329..d52a63d3 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -10,6 +10,7 @@ var GOOGLE_OAUTH_URL = "https://oauth2.googleapis.com/token" var GOOGLE_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" var GOOGLE_CLIENT_SECRET = "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" + var PLAN_CACHE_MAX_AGE_MS = 30 * 60 * 1000 var CC_MODEL_BLACKLIST = { "MODEL_CHAT_20706": true, "MODEL_CHAT_23310": true, @@ -174,6 +175,33 @@ } } + function loadCachedPlan(ctx) { + var path = ctx.app.pluginDataDir + "/plan.json" + try { + if (!ctx.host.fs.exists(path)) return null + var data = ctx.util.tryParseJson(ctx.host.fs.readText(path)) + if (!data || typeof data.plan !== "string" || !data.plan || !data.updatedAtMs) return null + if (Date.now() - Number(data.updatedAtMs) > PLAN_CACHE_MAX_AGE_MS) return null + return data.plan + } catch (e) { + ctx.host.log.warn("failed to read cached plan: " + String(e)) + return null + } + } + + function cachePlan(ctx, plan) { + if (typeof plan !== "string" || !plan) return + var path = ctx.app.pluginDataDir + "/plan.json" + try { + ctx.host.fs.writeText(path, JSON.stringify({ + plan: plan, + updatedAtMs: Date.now(), + })) + } catch (e) { + ctx.host.log.warn("failed to cache plan: " + String(e)) + } + } + function pushUniqueToken(tokens, token) { if (typeof token !== "string" || !token) return for (var i = 0; i < tokens.length; i++) { @@ -449,7 +477,10 @@ function resolveCloudCodePlan(ctx, tokens, refreshTokenValue, allowRefresh) { for (var i = 0; i < tokens.length; i++) { var result = fetchCloudCodePlan(ctx, tokens[i]) - if (result && !result._authFailed && result.plan) return result.plan + if (result && !result._authFailed && result.plan) { + cachePlan(ctx, result.plan) + return result.plan + } } if (allowRefresh !== false && refreshTokenValue) { @@ -457,6 +488,7 @@ if (refreshed) { var refreshedResult = fetchCloudCodePlan(ctx, refreshed) if (refreshedResult && !refreshedResult._authFailed && refreshedResult.plan) { + cachePlan(ctx, refreshedResult.plan) return refreshedResult.plan } } @@ -547,9 +579,9 @@ var ps = data.userStatus.planStatus || {} var pi = ps.planInfo || {} plan = pi.planName || null - var cloudOverridePlan = resolveCloudCodePlan(ctx, tokens || [], refreshTokenValue, false) - if (planRank(cloudOverridePlan) > planRank(plan)) { - plan = cloudOverridePlan + var cachedOverridePlan = loadCachedPlan(ctx) + if (planRank(cachedOverridePlan) > planRank(plan)) { + plan = cachedOverridePlan } } else { plan = resolveCloudCodePlan(ctx, tokens || [], refreshTokenValue, true) diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 247e76b4..ce5189a4 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -103,6 +103,14 @@ function makeLoadCodeAssistResponse(overrides) { ) } +function writePlanCache(ctx, plan, updatedAtMs) { + const cachePath = ctx.app.pluginDataDir + "/plan.json" + ctx.host.fs.writeText(cachePath, JSON.stringify({ + plan, + updatedAtMs: updatedAtMs ?? Date.now(), + })) +} + function makeAuthStatusJson(overrides) { return JSON.stringify( Object.assign({ apiKey: "test-api-key-123", email: "user@example.com", name: "Test User" }, overrides) @@ -212,23 +220,20 @@ describe("antigravity plugin", () => { expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) }) - it("prefers Cloud tier over stale LS plan when Cloud identifies Ultra", async () => { + it("prefers cached Cloud tier over stale LS plan without calling Cloud in LS fast path", async () => { const ctx = makeCtx() const futureExpiry = Math.floor(Date.now() / 1000) + 3600 setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) - const discovery = makeDiscovery() - const response = makeUserStatusResponse({ planName: "Pro" }) + ctx.host.ls.discover.mockReturnValue(null) - ctx.host.ls.discover.mockReturnValue(discovery) + let loadCodeAssistCalls = 0 ctx.host.http.request.mockImplementation((opts) => { const url = String(opts.url) - if (url.includes("GetUnleashData")) { - return { status: 200, bodyText: "{}" } - } - if (url.includes("GetUserStatus")) { - return { status: 200, bodyText: JSON.stringify(response) } + if (url.includes("fetchAvailableModels")) { + return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) } } if (url.includes("loadCodeAssist")) { + loadCodeAssistCalls += 1 return { status: 200, bodyText: JSON.stringify(makeLoadCodeAssistResponse({ @@ -241,13 +246,35 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() + const cloudFallbackResult = plugin.probe(ctx) + + expect(cloudFallbackResult.plan).toBe("Ultra") + expect(loadCodeAssistCalls).toBe(1) + + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ planName: "Pro" }) + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 200, bodyText: JSON.stringify(response) } + } + if (url.includes("loadCodeAssist")) { + throw new Error("should not call Cloud tier lookup from LS fast path") + } + return { status: 500, bodyText: "" } + }) + const result = plugin.probe(ctx) expect(result.plan).toBe("Ultra") expect(result.lines.map((l) => l.label)).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) }) - it("keeps LS plan when Cloud tier lookup throws", async () => { + it("keeps LS plan when no cached override exists", async () => { const ctx = makeCtx() const futureExpiry = Math.floor(Date.now() / 1000) + 3600 setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) @@ -264,7 +291,7 @@ describe("antigravity plugin", () => { return { status: 200, bodyText: JSON.stringify(response) } } if (url.includes("loadCodeAssist")) { - throw new Error("loadCodeAssist unavailable") + throw new Error("should not call Cloud tier lookup from LS fast path") } return { status: 500, bodyText: "" } }) @@ -275,10 +302,11 @@ describe("antigravity plugin", () => { expect(result.plan).toBe("Pro") }) - it("keeps LS plan when Cloud tier lookup returns no supported tier", async () => { + it("ignores stale cached override on LS fast path", async () => { const ctx = makeCtx() const futureExpiry = Math.floor(Date.now() / 1000) + 3600 setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + writePlanCache(ctx, "Ultra", Date.now() - (31 * 60 * 1000)) const discovery = makeDiscovery() const response = makeUserStatusResponse({ planName: "Pro" }) @@ -292,7 +320,7 @@ describe("antigravity plugin", () => { return { status: 200, bodyText: JSON.stringify(response) } } if (url.includes("loadCodeAssist")) { - return { status: 500, bodyText: "" } + throw new Error("should not call Cloud tier lookup from LS fast path") } return { status: 500, bodyText: "" } }) From ad1c2cdb493ee24c4df3f7024005ab7ae282858e Mon Sep 17 00:00:00 2001 From: gongchunru Date: Wed, 18 Mar 2026 13:22:03 +0800 Subject: [PATCH 4/4] fix(antigravity): harden plan cache handling --- plugins/antigravity/plugin.js | 49 ++++++++++------ plugins/antigravity/plugin.test.js | 90 +++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index d52a63d3..7a1b4592 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -65,7 +65,7 @@ // --- SQLite credential reading --- - function loadApiKey(ctx) { + function loadAuthStatus(ctx) { try { var rows = ctx.host.sqlite.query( STATE_DB, @@ -74,8 +74,8 @@ var parsed = ctx.util.tryParseJson(rows) if (!parsed || !parsed.length || !parsed[0].value) return null var auth = ctx.util.tryParseJson(parsed[0].value) - if (!auth || !auth.apiKey) return null - return auth.apiKey + if (!auth || typeof auth !== "object") return null + return auth } catch (e) { ctx.host.log.warn("failed to read auth from antigravity DB: " + String(e)) return null @@ -175,13 +175,24 @@ } } - function loadCachedPlan(ctx) { + function normalizeAccountId(value) { + if (typeof value !== "string") return null + var normalized = value.trim().toLowerCase() + return normalized || null + } + + function loadCachedPlan(ctx, accountId) { + if (!accountId) return null var path = ctx.app.pluginDataDir + "/plan.json" try { if (!ctx.host.fs.exists(path)) return null var data = ctx.util.tryParseJson(ctx.host.fs.readText(path)) if (!data || typeof data.plan !== "string" || !data.plan || !data.updatedAtMs) return null - if (Date.now() - Number(data.updatedAtMs) > PLAN_CACHE_MAX_AGE_MS) return null + var updatedAtMs = Number(data.updatedAtMs) + if (!Number.isFinite(updatedAtMs)) return null + if (updatedAtMs > Date.now()) return null + if (Date.now() - updatedAtMs > PLAN_CACHE_MAX_AGE_MS) return null + if (normalizeAccountId(data.accountId) !== accountId) return null return data.plan } catch (e) { ctx.host.log.warn("failed to read cached plan: " + String(e)) @@ -189,12 +200,13 @@ } } - function cachePlan(ctx, plan) { - if (typeof plan !== "string" || !plan) return + function cachePlan(ctx, plan, accountId) { + if (typeof plan !== "string" || !plan || !accountId) return var path = ctx.app.pluginDataDir + "/plan.json" try { ctx.host.fs.writeText(path, JSON.stringify({ plan: plan, + accountId: accountId, updatedAtMs: Date.now(), })) } catch (e) { @@ -402,7 +414,7 @@ } function planRank(value) { - var normalized = String(value || "").trim().toLowerCase() + var normalized = String(mapTierToPlan(value) || "").trim().toLowerCase() if (normalized === "ultra") return 3 if (normalized === "pro") return 2 if (normalized === "free") return 1 @@ -474,11 +486,11 @@ return null } - function resolveCloudCodePlan(ctx, tokens, refreshTokenValue, allowRefresh) { + function resolveCloudCodePlan(ctx, tokens, refreshTokenValue, allowRefresh, accountId) { for (var i = 0; i < tokens.length; i++) { var result = fetchCloudCodePlan(ctx, tokens[i]) if (result && !result._authFailed && result.plan) { - cachePlan(ctx, result.plan) + cachePlan(ctx, result.plan, accountId) return result.plan } } @@ -488,7 +500,7 @@ if (refreshed) { var refreshedResult = fetchCloudCodePlan(ctx, refreshed) if (refreshedResult && !refreshedResult._authFailed && refreshedResult.plan) { - cachePlan(ctx, refreshedResult.plan) + cachePlan(ctx, refreshedResult.plan, accountId) return refreshedResult.plan } } @@ -523,7 +535,7 @@ // --- LS probe --- - function probeLs(ctx, apiKey, tokens, refreshTokenValue) { + function probeLs(ctx, apiKey, accountId, tokens, refreshTokenValue) { var discovery = discoverLs(ctx) if (!discovery) return null @@ -579,12 +591,12 @@ var ps = data.userStatus.planStatus || {} var pi = ps.planInfo || {} plan = pi.planName || null - var cachedOverridePlan = loadCachedPlan(ctx) + var cachedOverridePlan = loadCachedPlan(ctx, accountId) if (planRank(cachedOverridePlan) > planRank(plan)) { plan = cachedOverridePlan } } else { - plan = resolveCloudCodePlan(ctx, tokens || [], refreshTokenValue, true) + plan = resolveCloudCodePlan(ctx, tokens || [], refreshTokenValue, true, accountId) } return { plan: plan, lines: lines } @@ -593,11 +605,13 @@ // --- Probe --- function probe(ctx) { - var apiKey = loadApiKey(ctx) + var auth = loadAuthStatus(ctx) + var apiKey = auth && typeof auth.apiKey === "string" ? auth.apiKey : null + var accountId = normalizeAccountId(auth && auth.email) var proto = loadProtoTokens(ctx) var tokens = collectTokens(ctx, apiKey, proto) - var lsResult = probeLs(ctx, apiKey, tokens, proto && proto.refreshToken) + var lsResult = probeLs(ctx, apiKey, accountId, tokens, proto && proto.refreshToken) if (lsResult) return lsResult if (tokens.length === 0) throw "Start Antigravity and try again." @@ -630,7 +644,8 @@ ctx, winningToken ? [winningToken] : tokens, proto && proto.refreshToken, - true + true, + accountId ) return { plan: cloudPlan, lines: lines } } diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index ce5189a4..0fb7c0bc 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -103,10 +103,11 @@ function makeLoadCodeAssistResponse(overrides) { ) } -function writePlanCache(ctx, plan, updatedAtMs) { +function writePlanCache(ctx, plan, updatedAtMs, accountId) { const cachePath = ctx.app.pluginDataDir + "/plan.json" ctx.host.fs.writeText(cachePath, JSON.stringify({ plan, + accountId: accountId ?? "user@example.com", updatedAtMs: updatedAtMs ?? Date.now(), })) } @@ -331,6 +332,93 @@ describe("antigravity plugin", () => { expect(result.plan).toBe("Pro") }) + it("ignores cached override from a different account on LS fast path", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock( + ctx, + makeAuthStatusJson({ email: "current@example.com" }), + makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry) + ) + writePlanCache(ctx, "Ultra", Date.now(), "other@example.com") + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ planName: "Pro" }) + + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 200, bodyText: JSON.stringify(response) } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + }) + + it("ignores cached plan with non-numeric updatedAtMs", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + const cachePath = ctx.app.pluginDataDir + "/plan.json" + ctx.host.fs.writeText(cachePath, JSON.stringify({ + plan: "Ultra", + accountId: "user@example.com", + updatedAtMs: "not-a-number", + })) + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ planName: "Pro" }) + + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 200, bodyText: JSON.stringify(response) } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + }) + + it("prefers cached Ultra over longer LS Pro labels", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry)) + writePlanCache(ctx, "Ultra", Date.now(), "user@example.com") + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ planName: "Google AI Pro" }) + + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + return { status: 200, bodyText: JSON.stringify(response) } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Ultra") + }) + it("deduplicates models by normalized label (keeps worst-case fraction)", async () => { const ctx = makeCtx() const discovery = makeDiscovery()