openusage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/openusage +91 -0
- package/package.json +33 -0
- package/plugins/amp/icon.svg +6 -0
- package/plugins/amp/plugin.js +175 -0
- package/plugins/amp/plugin.json +20 -0
- package/plugins/amp/plugin.test.js +365 -0
- package/plugins/antigravity/icon.svg +3 -0
- package/plugins/antigravity/plugin.js +484 -0
- package/plugins/antigravity/plugin.json +17 -0
- package/plugins/antigravity/plugin.test.js +1356 -0
- package/plugins/claude/icon.svg +3 -0
- package/plugins/claude/plugin.js +565 -0
- package/plugins/claude/plugin.json +28 -0
- package/plugins/claude/plugin.test.js +1012 -0
- package/plugins/codex/icon.svg +3 -0
- package/plugins/codex/plugin.js +673 -0
- package/plugins/codex/plugin.json +30 -0
- package/plugins/codex/plugin.test.js +1071 -0
- package/plugins/copilot/icon.svg +3 -0
- package/plugins/copilot/plugin.js +264 -0
- package/plugins/copilot/plugin.json +20 -0
- package/plugins/copilot/plugin.test.js +529 -0
- package/plugins/cursor/icon.svg +3 -0
- package/plugins/cursor/plugin.js +526 -0
- package/plugins/cursor/plugin.json +24 -0
- package/plugins/cursor/plugin.test.js +1168 -0
- package/plugins/factory/icon.svg +1 -0
- package/plugins/factory/plugin.js +407 -0
- package/plugins/factory/plugin.json +19 -0
- package/plugins/factory/plugin.test.js +833 -0
- package/plugins/gemini/icon.svg +4 -0
- package/plugins/gemini/plugin.js +413 -0
- package/plugins/gemini/plugin.json +20 -0
- package/plugins/gemini/plugin.test.js +735 -0
- package/plugins/jetbrains-ai-assistant/icon.svg +3 -0
- package/plugins/jetbrains-ai-assistant/plugin.js +357 -0
- package/plugins/jetbrains-ai-assistant/plugin.json +17 -0
- package/plugins/jetbrains-ai-assistant/plugin.test.js +338 -0
- package/plugins/kimi/icon.svg +3 -0
- package/plugins/kimi/plugin.js +358 -0
- package/plugins/kimi/plugin.json +19 -0
- package/plugins/kimi/plugin.test.js +619 -0
- package/plugins/minimax/icon.svg +4 -0
- package/plugins/minimax/plugin.js +388 -0
- package/plugins/minimax/plugin.json +17 -0
- package/plugins/minimax/plugin.test.js +943 -0
- package/plugins/perplexity/icon.svg +1 -0
- package/plugins/perplexity/plugin.js +378 -0
- package/plugins/perplexity/plugin.json +15 -0
- package/plugins/perplexity/plugin.test.js +602 -0
- package/plugins/windsurf/icon.svg +3 -0
- package/plugins/windsurf/plugin.js +218 -0
- package/plugins/windsurf/plugin.json +16 -0
- package/plugins/windsurf/plugin.test.js +455 -0
- package/plugins/zai/icon.svg +5 -0
- package/plugins/zai/plugin.js +156 -0
- package/plugins/zai/plugin.json +18 -0
- package/plugins/zai/plugin.test.js +396 -0
|
@@ -0,0 +1,1356 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { makeCtx } from "../test-helpers.js"
|
|
3
|
+
|
|
4
|
+
const loadPlugin = async () => {
|
|
5
|
+
await import("./plugin.js")
|
|
6
|
+
return globalThis.__openusage_plugin
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// --- Fixtures ---
|
|
10
|
+
|
|
11
|
+
function makeDiscovery(overrides) {
|
|
12
|
+
return Object.assign(
|
|
13
|
+
{ pid: 12345, csrf: "test-csrf-token", ports: [42001, 42002], extensionPort: null },
|
|
14
|
+
overrides
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeUserStatusResponse(overrides) {
|
|
19
|
+
var base = {
|
|
20
|
+
userStatus: {
|
|
21
|
+
planStatus: {
|
|
22
|
+
planInfo: {
|
|
23
|
+
planName: "Pro",
|
|
24
|
+
monthlyPromptCredits: 50000,
|
|
25
|
+
monthlyFlowCredits: 150000,
|
|
26
|
+
monthlyFlexCreditPurchaseAmount: 25000,
|
|
27
|
+
},
|
|
28
|
+
availablePromptCredits: 500,
|
|
29
|
+
availableFlowCredits: 100,
|
|
30
|
+
usedFlexCredits: 5000,
|
|
31
|
+
},
|
|
32
|
+
cascadeModelConfigData: {
|
|
33
|
+
clientModelConfigs: [
|
|
34
|
+
{
|
|
35
|
+
label: "Gemini 3.1 Pro (High)",
|
|
36
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M37" },
|
|
37
|
+
quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: "Gemini 3.1 Pro (Low)",
|
|
41
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M36" },
|
|
42
|
+
quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: "Gemini 3 Flash",
|
|
46
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M18" },
|
|
47
|
+
quotaInfo: { remainingFraction: 1.0, resetTime: "2026-02-08T09:10:56Z" },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: "Claude Sonnet 4.6 (Thinking)",
|
|
51
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M35" },
|
|
52
|
+
quotaInfo: { resetTime: "2026-02-26T15:23:41Z" },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: "Claude Opus 4.6 (Thinking)",
|
|
56
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M26" },
|
|
57
|
+
quotaInfo: { resetTime: "2026-02-26T15:23:41Z" },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: "GPT-OSS 120B (Medium)",
|
|
61
|
+
modelOrAlias: { model: "MODEL_OPENAI_GPT_OSS_120B_MEDIUM" },
|
|
62
|
+
quotaInfo: { resetTime: "2026-02-26T15:23:41Z" },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
if (overrides) {
|
|
69
|
+
if (overrides.planName !== undefined) base.userStatus.planStatus.planInfo.planName = overrides.planName
|
|
70
|
+
if (overrides.configs !== undefined) base.userStatus.cascadeModelConfigData.clientModelConfigs = overrides.configs
|
|
71
|
+
if (overrides.planStatus !== undefined) base.userStatus.planStatus = overrides.planStatus
|
|
72
|
+
}
|
|
73
|
+
return base
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeCloudCodeResponse(overrides) {
|
|
77
|
+
return Object.assign(
|
|
78
|
+
{
|
|
79
|
+
models: {
|
|
80
|
+
"gemini-3-pro": {
|
|
81
|
+
displayName: "Gemini 3 Pro",
|
|
82
|
+
model: "gemini-3-pro",
|
|
83
|
+
quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T10:00:00Z" },
|
|
84
|
+
},
|
|
85
|
+
"claude-sonnet-4.5": {
|
|
86
|
+
displayName: "Claude Sonnet 4.5",
|
|
87
|
+
model: "claude-sonnet-4.5",
|
|
88
|
+
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-02-08T10:00:00Z" },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
overrides
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeAuthStatusJson(overrides) {
|
|
97
|
+
return JSON.stringify(
|
|
98
|
+
Object.assign({ apiKey: "test-api-key-123", email: "user@example.com", name: "Test User" }, overrides)
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setupLsMock(ctx, discovery, responseBody) {
|
|
103
|
+
ctx.host.ls.discover.mockReturnValue(discovery)
|
|
104
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
105
|
+
if (String(opts.url).includes("GetUnleashData")) {
|
|
106
|
+
return { status: 200, bodyText: "{}" }
|
|
107
|
+
}
|
|
108
|
+
return { status: 200, bodyText: JSON.stringify(responseBody) }
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setupSqliteMock(ctx, authJson, protoBase64) {
|
|
113
|
+
ctx.host.sqlite.query.mockImplementation((db, sql) => {
|
|
114
|
+
if (sql.includes("agentManagerInitState") && protoBase64) {
|
|
115
|
+
return JSON.stringify([{ value: protoBase64 }])
|
|
116
|
+
}
|
|
117
|
+
if (sql.includes("antigravityAuthStatus") && authJson) {
|
|
118
|
+
return JSON.stringify([{ value: authJson }])
|
|
119
|
+
}
|
|
120
|
+
return "[]"
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makeProtobufBase64(ctx, accessToken, refreshToken, expirySeconds) {
|
|
125
|
+
function encodeVarint(n) {
|
|
126
|
+
var bytes = ""
|
|
127
|
+
while (n > 0x7f) {
|
|
128
|
+
bytes += String.fromCharCode((n & 0x7f) | 0x80)
|
|
129
|
+
n = Math.floor(n / 128)
|
|
130
|
+
}
|
|
131
|
+
bytes += String.fromCharCode(n & 0x7f)
|
|
132
|
+
return bytes
|
|
133
|
+
}
|
|
134
|
+
function encodeField(fieldNum, wireType, data) {
|
|
135
|
+
var tag = encodeVarint(fieldNum * 8 + wireType)
|
|
136
|
+
if (wireType === 2) return tag + encodeVarint(data.length) + data
|
|
137
|
+
if (wireType === 0) return tag + encodeVarint(data)
|
|
138
|
+
return ""
|
|
139
|
+
}
|
|
140
|
+
var inner = ""
|
|
141
|
+
if (accessToken) inner += encodeField(1, 2, accessToken)
|
|
142
|
+
if (refreshToken) inner += encodeField(3, 2, refreshToken)
|
|
143
|
+
if (expirySeconds !== null && expirySeconds !== undefined) {
|
|
144
|
+
var tsMsg = encodeField(1, 0, expirySeconds)
|
|
145
|
+
inner += encodeField(4, 2, tsMsg)
|
|
146
|
+
}
|
|
147
|
+
var outer = encodeField(6, 2, inner)
|
|
148
|
+
return ctx.base64.encode(outer)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Tests ---
|
|
152
|
+
|
|
153
|
+
describe("antigravity plugin", () => {
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
delete globalThis.__openusage_plugin
|
|
156
|
+
vi.resetModules()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("throws when LS not found and no DB credentials", async () => {
|
|
160
|
+
const ctx = makeCtx()
|
|
161
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
162
|
+
const plugin = await loadPlugin()
|
|
163
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("throws when no working port found and no DB credentials", async () => {
|
|
167
|
+
const ctx = makeCtx()
|
|
168
|
+
ctx.host.ls.discover.mockReturnValue(makeDiscovery())
|
|
169
|
+
ctx.host.http.request.mockImplementation(() => {
|
|
170
|
+
throw new Error("connection refused")
|
|
171
|
+
})
|
|
172
|
+
const plugin = await loadPlugin()
|
|
173
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("throws when both GetUserStatus and GetCommandModelConfigs fail", async () => {
|
|
177
|
+
const ctx = makeCtx()
|
|
178
|
+
ctx.host.ls.discover.mockReturnValue(makeDiscovery())
|
|
179
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
180
|
+
if (String(opts.url).includes("GetUnleashData")) {
|
|
181
|
+
return { status: 200, bodyText: "{}" }
|
|
182
|
+
}
|
|
183
|
+
return { status: 500, bodyText: "" }
|
|
184
|
+
})
|
|
185
|
+
const plugin = await loadPlugin()
|
|
186
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("returns models + plan from GetUserStatus", async () => {
|
|
190
|
+
const ctx = makeCtx()
|
|
191
|
+
const discovery = makeDiscovery()
|
|
192
|
+
const response = makeUserStatusResponse()
|
|
193
|
+
setupLsMock(ctx, discovery, response)
|
|
194
|
+
|
|
195
|
+
const plugin = await loadPlugin()
|
|
196
|
+
const result = plugin.probe(ctx)
|
|
197
|
+
|
|
198
|
+
expect(result.plan).toBe("Pro")
|
|
199
|
+
|
|
200
|
+
// Model lines exist — 3 pool lines
|
|
201
|
+
const labels = result.lines.map((l) => l.label)
|
|
202
|
+
expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("deduplicates models by normalized label (keeps worst-case fraction)", async () => {
|
|
206
|
+
const ctx = makeCtx()
|
|
207
|
+
const discovery = makeDiscovery()
|
|
208
|
+
const response = makeUserStatusResponse()
|
|
209
|
+
setupLsMock(ctx, discovery, response)
|
|
210
|
+
|
|
211
|
+
const plugin = await loadPlugin()
|
|
212
|
+
const result = plugin.probe(ctx)
|
|
213
|
+
|
|
214
|
+
// Both Gemini 3.1 Pro variants have frac=0.8 → used = 20%
|
|
215
|
+
const pro = result.lines.find((l) => l.label === "Gemini Pro")
|
|
216
|
+
expect(pro).toBeTruthy()
|
|
217
|
+
expect(pro.used).toBe(20) // (1 - 0.8) * 100
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("orders: Gemini (Pro, Flash), Claude (Opus, Sonnet), then others", async () => {
|
|
221
|
+
const ctx = makeCtx()
|
|
222
|
+
const discovery = makeDiscovery()
|
|
223
|
+
const response = makeUserStatusResponse()
|
|
224
|
+
setupLsMock(ctx, discovery, response)
|
|
225
|
+
|
|
226
|
+
const plugin = await loadPlugin()
|
|
227
|
+
const result = plugin.probe(ctx)
|
|
228
|
+
|
|
229
|
+
const labels = result.lines.map((l) => l.label)
|
|
230
|
+
|
|
231
|
+
expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("falls back to GetCommandModelConfigs when GetUserStatus fails", async () => {
|
|
235
|
+
const ctx = makeCtx()
|
|
236
|
+
ctx.host.ls.discover.mockReturnValue(makeDiscovery())
|
|
237
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
238
|
+
if (String(opts.url).includes("GetUnleashData")) {
|
|
239
|
+
return { status: 200, bodyText: "{}" }
|
|
240
|
+
}
|
|
241
|
+
if (String(opts.url).includes("GetUserStatus")) {
|
|
242
|
+
return { status: 500, bodyText: "" }
|
|
243
|
+
}
|
|
244
|
+
if (String(opts.url).includes("GetCommandModelConfigs")) {
|
|
245
|
+
return {
|
|
246
|
+
status: 200,
|
|
247
|
+
bodyText: JSON.stringify({
|
|
248
|
+
clientModelConfigs: [
|
|
249
|
+
{
|
|
250
|
+
label: "Gemini 3 Pro (High)",
|
|
251
|
+
modelOrAlias: { model: "M7" },
|
|
252
|
+
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-02-08T09:10:56Z" },
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
}),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { status: 500, bodyText: "" }
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const plugin = await loadPlugin()
|
|
262
|
+
const result = plugin.probe(ctx)
|
|
263
|
+
|
|
264
|
+
expect(result.plan).toBeNull()
|
|
265
|
+
|
|
266
|
+
// Model lines present
|
|
267
|
+
const pro = result.lines.find((l) => l.label === "Gemini Pro")
|
|
268
|
+
expect(pro).toBeTruthy()
|
|
269
|
+
expect(pro.used).toBe(40) // (1 - 0.6) * 100
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("uses extension port as fallback when all ports fail probing", async () => {
|
|
273
|
+
const ctx = makeCtx()
|
|
274
|
+
ctx.host.ls.discover.mockReturnValue(makeDiscovery({ ports: [99999], extensionPort: 42010 }))
|
|
275
|
+
|
|
276
|
+
let usedPort = null
|
|
277
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
278
|
+
const url = String(opts.url)
|
|
279
|
+
if (url.includes("GetUnleashData") && url.includes("99999")) {
|
|
280
|
+
throw new Error("refused")
|
|
281
|
+
}
|
|
282
|
+
if (url.includes("GetUserStatus")) {
|
|
283
|
+
usedPort = parseInt(url.match(/:(\d+)\//)[1])
|
|
284
|
+
return {
|
|
285
|
+
status: 200,
|
|
286
|
+
bodyText: JSON.stringify(makeUserStatusResponse()),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { status: 200, bodyText: "{}" }
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const plugin = await loadPlugin()
|
|
293
|
+
const result = plugin.probe(ctx)
|
|
294
|
+
expect(usedPort).toBe(42010)
|
|
295
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("treats models with no quotaInfo as depleted (100% used)", async () => {
|
|
299
|
+
const ctx = makeCtx()
|
|
300
|
+
const discovery = makeDiscovery()
|
|
301
|
+
const response = makeUserStatusResponse({
|
|
302
|
+
configs: [
|
|
303
|
+
{ label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } },
|
|
304
|
+
{ label: "Claude Opus 4.6 (Thinking)", modelOrAlias: { model: "M26" } },
|
|
305
|
+
],
|
|
306
|
+
})
|
|
307
|
+
setupLsMock(ctx, discovery, response)
|
|
308
|
+
|
|
309
|
+
const plugin = await loadPlugin()
|
|
310
|
+
const result = plugin.probe(ctx)
|
|
311
|
+
const claude = result.lines.find((l) => l.label === "Claude")
|
|
312
|
+
expect(claude).toBeTruthy()
|
|
313
|
+
expect(claude.used).toBe(100)
|
|
314
|
+
expect(claude.limit).toBe(100)
|
|
315
|
+
expect(claude.resetsAt).toBeUndefined()
|
|
316
|
+
expect(result.lines.find((l) => l.label === "Gemini Pro")).toBeTruthy()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it("dedup picks depleted variant (no quotaInfo) over non-depleted sibling", async () => {
|
|
320
|
+
const ctx = makeCtx()
|
|
321
|
+
const discovery = makeDiscovery()
|
|
322
|
+
const response = makeUserStatusResponse({
|
|
323
|
+
configs: [
|
|
324
|
+
{ label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" }, quotaInfo: { remainingFraction: 0.75, resetTime: "2026-02-08T09:10:56Z" } },
|
|
325
|
+
{ label: "Gemini 3 Pro (Low)", modelOrAlias: { model: "M8" } },
|
|
326
|
+
],
|
|
327
|
+
})
|
|
328
|
+
setupLsMock(ctx, discovery, response)
|
|
329
|
+
|
|
330
|
+
const plugin = await loadPlugin()
|
|
331
|
+
const result = plugin.probe(ctx)
|
|
332
|
+
const pro = result.lines.find((l) => l.label === "Gemini Pro")
|
|
333
|
+
expect(pro).toBeTruthy()
|
|
334
|
+
expect(pro.used).toBe(100)
|
|
335
|
+
expect(pro.resetsAt).toBeUndefined()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("returns lines when all models are depleted (no quotaInfo)", async () => {
|
|
339
|
+
const ctx = makeCtx()
|
|
340
|
+
const discovery = makeDiscovery()
|
|
341
|
+
const response = makeUserStatusResponse({
|
|
342
|
+
configs: [
|
|
343
|
+
{ label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" } },
|
|
344
|
+
{ label: "Claude Opus 4.6 (Thinking)", modelOrAlias: { model: "M26" } },
|
|
345
|
+
],
|
|
346
|
+
})
|
|
347
|
+
setupLsMock(ctx, discovery, response)
|
|
348
|
+
|
|
349
|
+
const plugin = await loadPlugin()
|
|
350
|
+
const result = plugin.probe(ctx)
|
|
351
|
+
expect(result).toBeTruthy()
|
|
352
|
+
const labels = result.lines.map((l) => l.label)
|
|
353
|
+
expect(labels).toEqual(["Gemini Pro", "Claude"])
|
|
354
|
+
expect(result.lines.every((l) => l.used === 100)).toBe(true)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it("skips configs with missing or empty labels", async () => {
|
|
358
|
+
const ctx = makeCtx()
|
|
359
|
+
const discovery = makeDiscovery()
|
|
360
|
+
const response = makeUserStatusResponse({
|
|
361
|
+
configs: [
|
|
362
|
+
{ label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } },
|
|
363
|
+
{ label: "", modelOrAlias: { model: "M99" }, quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" } },
|
|
364
|
+
{ modelOrAlias: { model: "M100" }, quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T09:10:56Z" } },
|
|
365
|
+
],
|
|
366
|
+
})
|
|
367
|
+
setupLsMock(ctx, discovery, response)
|
|
368
|
+
|
|
369
|
+
const plugin = await loadPlugin()
|
|
370
|
+
const result = plugin.probe(ctx)
|
|
371
|
+
expect(result.lines.length).toBe(1)
|
|
372
|
+
expect(result.lines[0].label).toBe("Gemini Pro")
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it("includes resetsAt on model lines", async () => {
|
|
376
|
+
const ctx = makeCtx()
|
|
377
|
+
const discovery = makeDiscovery()
|
|
378
|
+
const response = makeUserStatusResponse()
|
|
379
|
+
setupLsMock(ctx, discovery, response)
|
|
380
|
+
|
|
381
|
+
const plugin = await loadPlugin()
|
|
382
|
+
const result = plugin.probe(ctx)
|
|
383
|
+
const pro = result.lines.find((l) => l.label === "Gemini Pro")
|
|
384
|
+
expect(pro.resetsAt).toBe("2026-02-08T09:10:56Z")
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it("clamps remainingFraction outside 0-1 range", async () => {
|
|
388
|
+
const ctx = makeCtx()
|
|
389
|
+
const discovery = makeDiscovery()
|
|
390
|
+
const response = makeUserStatusResponse({
|
|
391
|
+
configs: [
|
|
392
|
+
{ label: "Gemini Pro (Over)", modelOrAlias: { model: "M1" }, quotaInfo: { remainingFraction: 1.5, resetTime: "2026-02-08T09:10:56Z" } },
|
|
393
|
+
{ label: "Gemini Flash (Neg)", modelOrAlias: { model: "M2" }, quotaInfo: { remainingFraction: -0.3, resetTime: "2026-02-08T09:10:56Z" } },
|
|
394
|
+
],
|
|
395
|
+
})
|
|
396
|
+
setupLsMock(ctx, discovery, response)
|
|
397
|
+
|
|
398
|
+
const plugin = await loadPlugin()
|
|
399
|
+
const result = plugin.probe(ctx)
|
|
400
|
+
const over = result.lines.find((l) => l.label === "Gemini Pro")
|
|
401
|
+
const neg = result.lines.find((l) => l.label === "Gemini Flash")
|
|
402
|
+
expect(over.used).toBe(0) // clamped to 1.0 → 0% used
|
|
403
|
+
expect(neg.used).toBe(100) // clamped to 0.0 → 100% used
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it("handles missing resetTime gracefully", async () => {
|
|
407
|
+
const ctx = makeCtx()
|
|
408
|
+
const discovery = makeDiscovery()
|
|
409
|
+
const response = makeUserStatusResponse({
|
|
410
|
+
configs: [
|
|
411
|
+
{ label: "Gemini Pro (No Reset)", modelOrAlias: { model: "M1" }, quotaInfo: { remainingFraction: 0.5 } },
|
|
412
|
+
],
|
|
413
|
+
})
|
|
414
|
+
setupLsMock(ctx, discovery, response)
|
|
415
|
+
|
|
416
|
+
const plugin = await loadPlugin()
|
|
417
|
+
const result = plugin.probe(ctx)
|
|
418
|
+
const line = result.lines.find((l) => l.label === "Gemini Pro")
|
|
419
|
+
expect(line).toBeTruthy()
|
|
420
|
+
expect(line.used).toBe(50)
|
|
421
|
+
expect(line.resetsAt).toBeUndefined()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it("probes ports with HTTPS first, then HTTP, picks first success", async () => {
|
|
425
|
+
const ctx = makeCtx()
|
|
426
|
+
ctx.host.ls.discover.mockReturnValue(makeDiscovery({ ports: [10001, 10002] }))
|
|
427
|
+
|
|
428
|
+
const probed = []
|
|
429
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
430
|
+
const url = String(opts.url)
|
|
431
|
+
if (url.includes("GetUnleashData")) {
|
|
432
|
+
const port = parseInt(url.match(/:(\d+)\//)[1])
|
|
433
|
+
const scheme = url.startsWith("https") ? "https" : "http"
|
|
434
|
+
probed.push({ port, scheme })
|
|
435
|
+
// Port 10001 refuses both, port 10002 accepts HTTPS
|
|
436
|
+
if (port === 10002 && scheme === "https") return { status: 200, bodyText: "{}" }
|
|
437
|
+
throw new Error("refused")
|
|
438
|
+
}
|
|
439
|
+
return { status: 200, bodyText: JSON.stringify(makeUserStatusResponse()) }
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
const plugin = await loadPlugin()
|
|
443
|
+
plugin.probe(ctx)
|
|
444
|
+
// Should try HTTPS then HTTP on 10001 (both fail), then HTTPS on 10002 (success)
|
|
445
|
+
expect(probed).toEqual([
|
|
446
|
+
{ port: 10001, scheme: "https" },
|
|
447
|
+
{ port: 10001, scheme: "http" },
|
|
448
|
+
{ port: 10002, scheme: "https" },
|
|
449
|
+
])
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it("includes apiKey in LS metadata when DB has credentials", async () => {
|
|
453
|
+
const ctx = makeCtx()
|
|
454
|
+
setupSqliteMock(ctx, makeAuthStatusJson())
|
|
455
|
+
const discovery = makeDiscovery()
|
|
456
|
+
ctx.host.ls.discover.mockReturnValue(discovery)
|
|
457
|
+
|
|
458
|
+
let capturedMetadata = null
|
|
459
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
460
|
+
const url = String(opts.url)
|
|
461
|
+
if (url.includes("GetUnleashData")) {
|
|
462
|
+
return { status: 200, bodyText: "{}" }
|
|
463
|
+
}
|
|
464
|
+
if (url.includes("GetUserStatus")) {
|
|
465
|
+
const body = JSON.parse(opts.bodyText)
|
|
466
|
+
capturedMetadata = body.metadata
|
|
467
|
+
return { status: 200, bodyText: JSON.stringify(makeUserStatusResponse()) }
|
|
468
|
+
}
|
|
469
|
+
return { status: 200, bodyText: "{}" }
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
const plugin = await loadPlugin()
|
|
473
|
+
plugin.probe(ctx)
|
|
474
|
+
|
|
475
|
+
expect(capturedMetadata).toBeTruthy()
|
|
476
|
+
expect(capturedMetadata.apiKey).toBe("test-api-key-123")
|
|
477
|
+
expect(capturedMetadata.ideName).toBe("antigravity")
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it("works without apiKey when SQLite returns empty", async () => {
|
|
481
|
+
const ctx = makeCtx()
|
|
482
|
+
const discovery = makeDiscovery()
|
|
483
|
+
const response = makeUserStatusResponse()
|
|
484
|
+
setupLsMock(ctx, discovery, response)
|
|
485
|
+
|
|
486
|
+
let capturedMetadata = null
|
|
487
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
488
|
+
const url = String(opts.url)
|
|
489
|
+
if (url.includes("GetUnleashData")) {
|
|
490
|
+
return { status: 200, bodyText: "{}" }
|
|
491
|
+
}
|
|
492
|
+
if (url.includes("GetUserStatus")) {
|
|
493
|
+
const body = JSON.parse(opts.bodyText)
|
|
494
|
+
capturedMetadata = body.metadata
|
|
495
|
+
return { status: 200, bodyText: JSON.stringify(response) }
|
|
496
|
+
}
|
|
497
|
+
return { status: 200, bodyText: "{}" }
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
const plugin = await loadPlugin()
|
|
501
|
+
const result = plugin.probe(ctx)
|
|
502
|
+
|
|
503
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
504
|
+
expect(capturedMetadata).toBeTruthy()
|
|
505
|
+
expect(capturedMetadata.apiKey).toBeUndefined()
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it("falls back to Cloud Code API when LS is not available", async () => {
|
|
509
|
+
const ctx = makeCtx()
|
|
510
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
511
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
512
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
513
|
+
|
|
514
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
515
|
+
const url = String(opts.url)
|
|
516
|
+
if (url.includes("fetchAvailableModels")) {
|
|
517
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
518
|
+
}
|
|
519
|
+
return { status: 500, bodyText: "" }
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
const plugin = await loadPlugin()
|
|
523
|
+
const result = plugin.probe(ctx)
|
|
524
|
+
|
|
525
|
+
expect(result.plan).toBeNull()
|
|
526
|
+
const labels = result.lines.map((l) => l.label)
|
|
527
|
+
expect(labels).toContain("Gemini Pro")
|
|
528
|
+
expect(labels).toContain("Claude")
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it("Cloud Code sends correct Authorization header with proto token", async () => {
|
|
532
|
+
const ctx = makeCtx()
|
|
533
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
534
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.proto-token", "1//refresh", futureExpiry))
|
|
535
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
536
|
+
|
|
537
|
+
let capturedHeaders = null
|
|
538
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
539
|
+
const url = String(opts.url)
|
|
540
|
+
if (url.includes("fetchAvailableModels")) {
|
|
541
|
+
capturedHeaders = opts.headers
|
|
542
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
543
|
+
}
|
|
544
|
+
return { status: 500, bodyText: "" }
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
const plugin = await loadPlugin()
|
|
548
|
+
plugin.probe(ctx)
|
|
549
|
+
|
|
550
|
+
expect(capturedHeaders).toBeTruthy()
|
|
551
|
+
expect(capturedHeaders.Authorization).toBe("Bearer ya29.proto-token")
|
|
552
|
+
expect(capturedHeaders["User-Agent"]).toBe("antigravity")
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it("Cloud Code returns null on 401/403 (invalid token, no refresh)", async () => {
|
|
556
|
+
const ctx = makeCtx()
|
|
557
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
558
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.bad-token", null, futureExpiry))
|
|
559
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
560
|
+
|
|
561
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
562
|
+
const url = String(opts.url)
|
|
563
|
+
if (url.includes("fetchAvailableModels")) {
|
|
564
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
565
|
+
}
|
|
566
|
+
return { status: 500, bodyText: "" }
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
const plugin = await loadPlugin()
|
|
570
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it("Cloud Code tries multiple base URLs", async () => {
|
|
574
|
+
const ctx = makeCtx()
|
|
575
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
576
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
577
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
578
|
+
|
|
579
|
+
const calledUrls = []
|
|
580
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
581
|
+
const url = String(opts.url)
|
|
582
|
+
if (url.includes("fetchAvailableModels")) {
|
|
583
|
+
calledUrls.push(url)
|
|
584
|
+
if (url.includes("daily-cloudcode")) {
|
|
585
|
+
throw new Error("network error")
|
|
586
|
+
}
|
|
587
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
588
|
+
}
|
|
589
|
+
return { status: 500, bodyText: "" }
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
const plugin = await loadPlugin()
|
|
593
|
+
const result = plugin.probe(ctx)
|
|
594
|
+
|
|
595
|
+
expect(calledUrls.length).toBe(2)
|
|
596
|
+
expect(calledUrls[0]).toContain("daily-cloudcode-pa.googleapis.com")
|
|
597
|
+
expect(calledUrls[1]).toContain("cloudcode-pa.googleapis.com")
|
|
598
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it("Cloud Code correctly parses model quota response", async () => {
|
|
602
|
+
const ctx = makeCtx()
|
|
603
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
604
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
605
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
606
|
+
|
|
607
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
608
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
609
|
+
return {
|
|
610
|
+
status: 200,
|
|
611
|
+
bodyText: JSON.stringify({
|
|
612
|
+
models: {
|
|
613
|
+
"gemini-3-pro-high": {
|
|
614
|
+
displayName: "Gemini 3 Pro (High)",
|
|
615
|
+
quotaInfo: { remainingFraction: 0.7, resetTime: "2026-02-08T12:00:00Z" },
|
|
616
|
+
},
|
|
617
|
+
"gemini-3-pro-low": {
|
|
618
|
+
displayName: "Gemini 3 Pro (Low)",
|
|
619
|
+
quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T12:00:00Z" },
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
}),
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return { status: 500, bodyText: "" }
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
const plugin = await loadPlugin()
|
|
629
|
+
const result = plugin.probe(ctx)
|
|
630
|
+
|
|
631
|
+
const pro = result.lines.find((l) => l.label === "Gemini Pro")
|
|
632
|
+
expect(pro).toBeTruthy()
|
|
633
|
+
expect(pro.used).toBe(30)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it("skips Cloud Code when no credentials available", async () => {
|
|
637
|
+
const ctx = makeCtx()
|
|
638
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
639
|
+
|
|
640
|
+
const plugin = await loadPlugin()
|
|
641
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
642
|
+
expect(ctx.host.http.request).not.toHaveBeenCalled()
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it("LS takes priority over Cloud Code when both available", async () => {
|
|
646
|
+
const ctx = makeCtx()
|
|
647
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
648
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
649
|
+
const discovery = makeDiscovery()
|
|
650
|
+
const response = makeUserStatusResponse()
|
|
651
|
+
setupLsMock(ctx, discovery, response)
|
|
652
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
653
|
+
|
|
654
|
+
const plugin = await loadPlugin()
|
|
655
|
+
const result = plugin.probe(ctx)
|
|
656
|
+
|
|
657
|
+
expect(result.plan).toBe("Pro")
|
|
658
|
+
const calls = ctx.host.http.request.mock.calls.map((c) => String(c[0].url))
|
|
659
|
+
const ccCalls = calls.filter((u) => u.includes("fetchAvailableModels"))
|
|
660
|
+
expect(ccCalls.length).toBe(0)
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it("Cloud Code treats models without quotaInfo as depleted (100% used)", async () => {
|
|
664
|
+
const ctx = makeCtx()
|
|
665
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
666
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
667
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
668
|
+
|
|
669
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
670
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
671
|
+
return {
|
|
672
|
+
status: 200,
|
|
673
|
+
bodyText: JSON.stringify({
|
|
674
|
+
models: {
|
|
675
|
+
"valid-model": {
|
|
676
|
+
displayName: "Gemini 3 Pro",
|
|
677
|
+
quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T12:00:00Z" },
|
|
678
|
+
},
|
|
679
|
+
"no-quota": {
|
|
680
|
+
displayName: "Gemini Flash (No Quota)",
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
}),
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return { status: 500, bodyText: "" }
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
const plugin = await loadPlugin()
|
|
690
|
+
const result = plugin.probe(ctx)
|
|
691
|
+
|
|
692
|
+
const noQuota = result.lines.find((l) => l.label === "Gemini Flash")
|
|
693
|
+
expect(noQuota).toBeTruthy()
|
|
694
|
+
expect(noQuota.used).toBe(100)
|
|
695
|
+
expect(noQuota.limit).toBe(100)
|
|
696
|
+
expect(noQuota.resetsAt).toBeUndefined()
|
|
697
|
+
expect(result.lines.find((l) => l.label === "Gemini Pro")).toBeTruthy()
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it("decodes protobuf tokens from SQLite", async () => {
|
|
701
|
+
const ctx = makeCtx()
|
|
702
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
703
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.test-access", "1//refresh-token", futureExpiry)
|
|
704
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
705
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
706
|
+
|
|
707
|
+
let capturedAuth = null
|
|
708
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
709
|
+
const url = String(opts.url)
|
|
710
|
+
if (url.includes("fetchAvailableModels")) {
|
|
711
|
+
capturedAuth = opts.headers.Authorization
|
|
712
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
713
|
+
}
|
|
714
|
+
return { status: 500, bodyText: "" }
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
const plugin = await loadPlugin()
|
|
718
|
+
const result = plugin.probe(ctx)
|
|
719
|
+
|
|
720
|
+
expect(capturedAuth).toBe("Bearer ya29.test-access")
|
|
721
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
it("handles missing protobuf data gracefully (falls back to apiKey)", async () => {
|
|
725
|
+
const ctx = makeCtx()
|
|
726
|
+
setupSqliteMock(ctx, makeAuthStatusJson())
|
|
727
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
728
|
+
|
|
729
|
+
let capturedAuth = null
|
|
730
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
731
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
732
|
+
capturedAuth = opts.headers.Authorization
|
|
733
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
734
|
+
}
|
|
735
|
+
return { status: 500, bodyText: "" }
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
const plugin = await loadPlugin()
|
|
739
|
+
const result = plugin.probe(ctx)
|
|
740
|
+
|
|
741
|
+
expect(capturedAuth).toBe("Bearer test-api-key-123")
|
|
742
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it("handles corrupt protobuf base64 gracefully (falls back to apiKey)", async () => {
|
|
746
|
+
const ctx = makeCtx()
|
|
747
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), "not-valid-protobuf!!!")
|
|
748
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
749
|
+
|
|
750
|
+
let capturedAuth = null
|
|
751
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
752
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
753
|
+
capturedAuth = opts.headers.Authorization
|
|
754
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
755
|
+
}
|
|
756
|
+
return { status: 500, bodyText: "" }
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
const plugin = await loadPlugin()
|
|
760
|
+
const result = plugin.probe(ctx)
|
|
761
|
+
|
|
762
|
+
expect(capturedAuth).toBe("Bearer test-api-key-123")
|
|
763
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it("handles protobuf with no refresh_token or expiry", async () => {
|
|
767
|
+
const ctx = makeCtx()
|
|
768
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.access-only", null, null)
|
|
769
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
770
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
771
|
+
|
|
772
|
+
let capturedAuth = null
|
|
773
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
774
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
775
|
+
capturedAuth = opts.headers.Authorization
|
|
776
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
777
|
+
}
|
|
778
|
+
return { status: 500, bodyText: "" }
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
const plugin = await loadPlugin()
|
|
782
|
+
plugin.probe(ctx)
|
|
783
|
+
|
|
784
|
+
expect(capturedAuth).toBe("Bearer ya29.access-only")
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it("sends correct form-urlencoded POST to Google OAuth", async () => {
|
|
788
|
+
const ctx = makeCtx()
|
|
789
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
790
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.expired", "1//my-refresh", futureExpiry)
|
|
791
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
792
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
793
|
+
|
|
794
|
+
let oauthBody = null
|
|
795
|
+
let oauthHeaders = null
|
|
796
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
797
|
+
const url = String(opts.url)
|
|
798
|
+
if (url.includes("oauth2.googleapis.com")) {
|
|
799
|
+
oauthBody = opts.bodyText
|
|
800
|
+
oauthHeaders = opts.headers
|
|
801
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.refreshed-token" }) }
|
|
802
|
+
}
|
|
803
|
+
if (url.includes("fetchAvailableModels")) {
|
|
804
|
+
if (opts.headers.Authorization === "Bearer ya29.refreshed-token") {
|
|
805
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
806
|
+
}
|
|
807
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
808
|
+
}
|
|
809
|
+
return { status: 500, bodyText: "" }
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
const plugin = await loadPlugin()
|
|
813
|
+
plugin.probe(ctx)
|
|
814
|
+
|
|
815
|
+
expect(oauthHeaders["Content-Type"]).toBe("application/x-www-form-urlencoded")
|
|
816
|
+
expect(oauthBody).toContain("client_id=")
|
|
817
|
+
expect(oauthBody).toContain("client_secret=")
|
|
818
|
+
expect(oauthBody).toContain("refresh_token=" + encodeURIComponent("1//my-refresh"))
|
|
819
|
+
expect(oauthBody).toContain("grant_type=refresh_token")
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
it("throws when all tokens fail and refresh returns invalid_grant", async () => {
|
|
823
|
+
const ctx = makeCtx()
|
|
824
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
825
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.expired", "1//bad-refresh", futureExpiry)
|
|
826
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
827
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
828
|
+
|
|
829
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
830
|
+
const url = String(opts.url)
|
|
831
|
+
if (url.includes("oauth2.googleapis.com")) {
|
|
832
|
+
return { status: 400, bodyText: '{"error":"invalid_grant"}' }
|
|
833
|
+
}
|
|
834
|
+
if (url.includes("fetchAvailableModels")) {
|
|
835
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
836
|
+
}
|
|
837
|
+
return { status: 500, bodyText: "" }
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
const plugin = await loadPlugin()
|
|
841
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it("tries proto token first, then apiKey on auth failure", async () => {
|
|
845
|
+
const ctx = makeCtx()
|
|
846
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
847
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.proto-first", "1//refresh", futureExpiry)
|
|
848
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
849
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
850
|
+
|
|
851
|
+
const capturedTokens = []
|
|
852
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
853
|
+
const url = String(opts.url)
|
|
854
|
+
if (url.includes("fetchAvailableModels")) {
|
|
855
|
+
capturedTokens.push(opts.headers.Authorization)
|
|
856
|
+
if (opts.headers.Authorization === "Bearer ya29.proto-first") {
|
|
857
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
858
|
+
}
|
|
859
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
860
|
+
}
|
|
861
|
+
return { status: 500, bodyText: "" }
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
const plugin = await loadPlugin()
|
|
865
|
+
const result = plugin.probe(ctx)
|
|
866
|
+
|
|
867
|
+
expect(capturedTokens[0]).toBe("Bearer ya29.proto-first")
|
|
868
|
+
expect(capturedTokens[capturedTokens.length - 1]).toBe("Bearer test-api-key-123")
|
|
869
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it("tries both tokens before refreshing", async () => {
|
|
873
|
+
const ctx = makeCtx()
|
|
874
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
875
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.both-fail", "1//refresh", futureExpiry)
|
|
876
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
877
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
878
|
+
|
|
879
|
+
const capturedTokens = []
|
|
880
|
+
let refreshCalled = false
|
|
881
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
882
|
+
const url = String(opts.url)
|
|
883
|
+
if (url.includes("oauth2.googleapis.com")) {
|
|
884
|
+
refreshCalled = true
|
|
885
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.refreshed" }) }
|
|
886
|
+
}
|
|
887
|
+
if (url.includes("fetchAvailableModels")) {
|
|
888
|
+
capturedTokens.push(opts.headers.Authorization)
|
|
889
|
+
if (opts.headers.Authorization === "Bearer ya29.refreshed") {
|
|
890
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
891
|
+
}
|
|
892
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
893
|
+
}
|
|
894
|
+
return { status: 500, bodyText: "" }
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
const plugin = await loadPlugin()
|
|
898
|
+
const result = plugin.probe(ctx)
|
|
899
|
+
|
|
900
|
+
expect(refreshCalled).toBe(true)
|
|
901
|
+
expect(capturedTokens.filter((t) => t === "Bearer ya29.both-fail").length).toBeGreaterThan(0)
|
|
902
|
+
expect(capturedTokens.filter((t) => t === "Bearer test-api-key-123").length).toBeGreaterThan(0)
|
|
903
|
+
expect(capturedTokens[capturedTokens.length - 1]).toBe("Bearer ya29.refreshed")
|
|
904
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
it("deduplicates identical tokens", async () => {
|
|
908
|
+
const ctx = makeCtx()
|
|
909
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
910
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.same-token", "1//refresh", futureExpiry)
|
|
911
|
+
setupSqliteMock(ctx, makeAuthStatusJson({ apiKey: "ya29.same-token" }), protoB64)
|
|
912
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
913
|
+
|
|
914
|
+
const capturedTokens = []
|
|
915
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
916
|
+
const url = String(opts.url)
|
|
917
|
+
if (url.includes("oauth2.googleapis.com")) {
|
|
918
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.refreshed" }) }
|
|
919
|
+
}
|
|
920
|
+
if (url.includes("fetchAvailableModels")) {
|
|
921
|
+
capturedTokens.push(opts.headers.Authorization)
|
|
922
|
+
if (opts.headers.Authorization === "Bearer ya29.same-token") {
|
|
923
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
924
|
+
}
|
|
925
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
926
|
+
}
|
|
927
|
+
return { status: 500, bodyText: "" }
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
const plugin = await loadPlugin()
|
|
931
|
+
const result = plugin.probe(ctx)
|
|
932
|
+
|
|
933
|
+
const sameTokenCalls = capturedTokens.filter((t) => t === "Bearer ya29.same-token")
|
|
934
|
+
expect(sameTokenCalls.length).toBe(1)
|
|
935
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
it("uses apiKey as only token when proto data unavailable", async () => {
|
|
939
|
+
const ctx = makeCtx()
|
|
940
|
+
setupSqliteMock(ctx, makeAuthStatusJson())
|
|
941
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
942
|
+
|
|
943
|
+
let capturedAuth = null
|
|
944
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
945
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
946
|
+
capturedAuth = opts.headers.Authorization
|
|
947
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
948
|
+
}
|
|
949
|
+
return { status: 500, bodyText: "" }
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
const plugin = await loadPlugin()
|
|
953
|
+
plugin.probe(ctx)
|
|
954
|
+
|
|
955
|
+
expect(capturedAuth).toBe("Bearer test-api-key-123")
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
it("caches refreshed token to pluginDataDir", async () => {
|
|
959
|
+
const ctx = makeCtx()
|
|
960
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
961
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.will-fail", "1//refresh", futureExpiry)
|
|
962
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
963
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
964
|
+
|
|
965
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
966
|
+
const url = String(opts.url)
|
|
967
|
+
if (url.includes("oauth2.googleapis.com")) {
|
|
968
|
+
return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.refreshed", expires_in: 3599 }) }
|
|
969
|
+
}
|
|
970
|
+
if (url.includes("fetchAvailableModels")) {
|
|
971
|
+
if (opts.headers.Authorization === "Bearer ya29.refreshed") {
|
|
972
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
973
|
+
}
|
|
974
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
975
|
+
}
|
|
976
|
+
return { status: 500, bodyText: "" }
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
const plugin = await loadPlugin()
|
|
980
|
+
plugin.probe(ctx)
|
|
981
|
+
|
|
982
|
+
const cachePath = ctx.app.pluginDataDir + "/auth.json"
|
|
983
|
+
expect(ctx.host.fs.writeText).toHaveBeenCalledWith(cachePath, expect.any(String))
|
|
984
|
+
const cached = JSON.parse(ctx.host.fs.writeText.mock.calls.find((c) => c[0] === cachePath)[1])
|
|
985
|
+
expect(cached.accessToken).toBe("ya29.refreshed")
|
|
986
|
+
expect(cached.expiresAtMs).toBeGreaterThan(Date.now())
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
it("uses cached token before falling back to apiKey", async () => {
|
|
990
|
+
const ctx = makeCtx()
|
|
991
|
+
setupSqliteMock(ctx, makeAuthStatusJson())
|
|
992
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
993
|
+
|
|
994
|
+
const cachePath = ctx.app.pluginDataDir + "/auth.json"
|
|
995
|
+
ctx.host.fs.writeText(cachePath, JSON.stringify({
|
|
996
|
+
accessToken: "ya29.cached-token",
|
|
997
|
+
expiresAtMs: Date.now() + 3600000,
|
|
998
|
+
}))
|
|
999
|
+
|
|
1000
|
+
const capturedTokens = []
|
|
1001
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1002
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1003
|
+
capturedTokens.push(opts.headers.Authorization)
|
|
1004
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
1005
|
+
}
|
|
1006
|
+
return { status: 500, bodyText: "" }
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
const plugin = await loadPlugin()
|
|
1010
|
+
plugin.probe(ctx)
|
|
1011
|
+
|
|
1012
|
+
expect(capturedTokens[0]).toBe("Bearer ya29.cached-token")
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
it("skips expired cached token", async () => {
|
|
1016
|
+
const ctx = makeCtx()
|
|
1017
|
+
setupSqliteMock(ctx, makeAuthStatusJson())
|
|
1018
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1019
|
+
|
|
1020
|
+
const cachePath = ctx.app.pluginDataDir + "/auth.json"
|
|
1021
|
+
ctx.host.fs.writeText(cachePath, JSON.stringify({
|
|
1022
|
+
accessToken: "ya29.expired-cache",
|
|
1023
|
+
expiresAtMs: Date.now() - 1000,
|
|
1024
|
+
}))
|
|
1025
|
+
|
|
1026
|
+
let capturedAuth = null
|
|
1027
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1028
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1029
|
+
capturedAuth = opts.headers.Authorization
|
|
1030
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
1031
|
+
}
|
|
1032
|
+
return { status: 500, bodyText: "" }
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
const plugin = await loadPlugin()
|
|
1036
|
+
plugin.probe(ctx)
|
|
1037
|
+
|
|
1038
|
+
expect(capturedAuth).toBe("Bearer test-api-key-123")
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
it("skips expired proto token and falls back to next token", async () => {
|
|
1042
|
+
const ctx = makeCtx()
|
|
1043
|
+
const pastExpiry = Math.floor(Date.now() / 1000) - 3600
|
|
1044
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.expired-proto-token", "1//refresh", pastExpiry))
|
|
1045
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1046
|
+
|
|
1047
|
+
let capturedAuth = null
|
|
1048
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1049
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1050
|
+
capturedAuth = opts.headers.Authorization
|
|
1051
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
1052
|
+
}
|
|
1053
|
+
return { status: 500, bodyText: "" }
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
const plugin = await loadPlugin()
|
|
1057
|
+
plugin.probe(ctx)
|
|
1058
|
+
|
|
1059
|
+
expect(capturedAuth).toBe("Bearer test-api-key-123")
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
it("handles missing/corrupt cache file gracefully", async () => {
|
|
1063
|
+
const ctx = makeCtx()
|
|
1064
|
+
setupSqliteMock(ctx, makeAuthStatusJson())
|
|
1065
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1066
|
+
|
|
1067
|
+
const cachePath = ctx.app.pluginDataDir + "/auth.json"
|
|
1068
|
+
ctx.host.fs.writeText(cachePath, "{bad json")
|
|
1069
|
+
|
|
1070
|
+
let capturedAuth = null
|
|
1071
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1072
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1073
|
+
capturedAuth = opts.headers.Authorization
|
|
1074
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
1075
|
+
}
|
|
1076
|
+
return { status: 500, bodyText: "" }
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
const plugin = await loadPlugin()
|
|
1080
|
+
plugin.probe(ctx)
|
|
1081
|
+
|
|
1082
|
+
expect(capturedAuth).toBe("Bearer test-api-key-123")
|
|
1083
|
+
})
|
|
1084
|
+
|
|
1085
|
+
it("Cloud Code skips models with isInternal flag", async () => {
|
|
1086
|
+
const ctx = makeCtx()
|
|
1087
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1088
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test", "1//r", futureExpiry))
|
|
1089
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1090
|
+
|
|
1091
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1092
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1093
|
+
return {
|
|
1094
|
+
status: 200,
|
|
1095
|
+
bodyText: JSON.stringify({
|
|
1096
|
+
models: {
|
|
1097
|
+
"chat_20706": {
|
|
1098
|
+
model: "MODEL_CHAT_20706",
|
|
1099
|
+
isInternal: true,
|
|
1100
|
+
quotaInfo: { remainingFraction: 1, resetTime: "2026-02-08T10:00:00Z" },
|
|
1101
|
+
},
|
|
1102
|
+
"gemini-3-flash": {
|
|
1103
|
+
displayName: "Gemini 3 Flash",
|
|
1104
|
+
model: "MODEL_PLACEHOLDER_M18",
|
|
1105
|
+
quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T10:00:00Z" },
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
}),
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return { status: 500, bodyText: "" }
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
const plugin = await loadPlugin()
|
|
1115
|
+
const result = plugin.probe(ctx)
|
|
1116
|
+
|
|
1117
|
+
const labels = result.lines.map((l) => l.label)
|
|
1118
|
+
expect(labels).toContain("Gemini Flash")
|
|
1119
|
+
expect(labels).not.toContain("chat_20706")
|
|
1120
|
+
expect(labels).not.toContain("MODEL_CHAT_20706")
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
it("Cloud Code skips models with empty or missing displayName", async () => {
|
|
1124
|
+
const ctx = makeCtx()
|
|
1125
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1126
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test", "1//r", futureExpiry))
|
|
1127
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1128
|
+
|
|
1129
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1130
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1131
|
+
return {
|
|
1132
|
+
status: 200,
|
|
1133
|
+
bodyText: JSON.stringify({
|
|
1134
|
+
models: {
|
|
1135
|
+
"tab_flash_lite": {
|
|
1136
|
+
displayName: "",
|
|
1137
|
+
model: "SOME_MODEL",
|
|
1138
|
+
quotaInfo: { remainingFraction: 1, resetTime: "2026-02-08T10:00:00Z" },
|
|
1139
|
+
},
|
|
1140
|
+
"no_display_name": {
|
|
1141
|
+
model: "ANOTHER_MODEL",
|
|
1142
|
+
quotaInfo: { remainingFraction: 1, resetTime: "2026-02-08T10:00:00Z" },
|
|
1143
|
+
},
|
|
1144
|
+
"gemini-3-pro": {
|
|
1145
|
+
displayName: "Gemini 3 Pro",
|
|
1146
|
+
model: "MODEL_PLACEHOLDER_M8",
|
|
1147
|
+
quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T10:00:00Z" },
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
}),
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return { status: 500, bodyText: "" }
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
const plugin = await loadPlugin()
|
|
1157
|
+
const result = plugin.probe(ctx)
|
|
1158
|
+
|
|
1159
|
+
const labels = result.lines.map((l) => l.label)
|
|
1160
|
+
expect(labels).toEqual(["Gemini Pro"])
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
it("Cloud Code skips blacklisted model IDs", async () => {
|
|
1164
|
+
const ctx = makeCtx()
|
|
1165
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1166
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test", "1//r", futureExpiry))
|
|
1167
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1168
|
+
|
|
1169
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1170
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1171
|
+
return {
|
|
1172
|
+
status: 200,
|
|
1173
|
+
bodyText: JSON.stringify({
|
|
1174
|
+
models: {
|
|
1175
|
+
"gemini-2.5-flash": {
|
|
1176
|
+
displayName: "Gemini 2.5 Flash",
|
|
1177
|
+
model: "MODEL_GOOGLE_GEMINI_2_5_FLASH",
|
|
1178
|
+
quotaInfo: { remainingFraction: 1, resetTime: "2026-02-08T10:00:00Z" },
|
|
1179
|
+
},
|
|
1180
|
+
"gemini-2.5-pro": {
|
|
1181
|
+
displayName: "Gemini 2.5 Pro",
|
|
1182
|
+
model: "MODEL_GOOGLE_GEMINI_2_5_PRO",
|
|
1183
|
+
quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T10:00:00Z" },
|
|
1184
|
+
},
|
|
1185
|
+
"claude-sonnet-4.5": {
|
|
1186
|
+
displayName: "Claude Sonnet 4.5",
|
|
1187
|
+
model: "MODEL_CLAUDE_4_5_SONNET",
|
|
1188
|
+
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-02-08T10:00:00Z" },
|
|
1189
|
+
},
|
|
1190
|
+
},
|
|
1191
|
+
}),
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return { status: 500, bodyText: "" }
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
const plugin = await loadPlugin()
|
|
1198
|
+
const result = plugin.probe(ctx)
|
|
1199
|
+
|
|
1200
|
+
const labels = result.lines.map((l) => l.label)
|
|
1201
|
+
expect(labels).toEqual(["Claude"])
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
it("Cloud Code keeps non-blacklisted models with valid displayName", async () => {
|
|
1205
|
+
const ctx = makeCtx()
|
|
1206
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1207
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test", "1//r", futureExpiry))
|
|
1208
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1209
|
+
|
|
1210
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1211
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1212
|
+
return {
|
|
1213
|
+
status: 200,
|
|
1214
|
+
bodyText: JSON.stringify({
|
|
1215
|
+
models: {
|
|
1216
|
+
"gemini-3-pro-high": {
|
|
1217
|
+
displayName: "Gemini 3 Pro (High)",
|
|
1218
|
+
model: "MODEL_PLACEHOLDER_M8",
|
|
1219
|
+
quotaInfo: { remainingFraction: 0.7, resetTime: "2026-02-08T10:00:00Z" },
|
|
1220
|
+
},
|
|
1221
|
+
"claude-opus-4-6-thinking": {
|
|
1222
|
+
displayName: "Claude Opus 4.6 (Thinking)",
|
|
1223
|
+
model: "MODEL_PLACEHOLDER_M26",
|
|
1224
|
+
quotaInfo: { remainingFraction: 1, resetTime: "2026-02-08T10:00:00Z" },
|
|
1225
|
+
},
|
|
1226
|
+
"gpt-oss-120b": {
|
|
1227
|
+
displayName: "GPT-OSS 120B (Medium)",
|
|
1228
|
+
model: "MODEL_OPENAI_GPT_OSS_120B_MEDIUM",
|
|
1229
|
+
quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T10:00:00Z" },
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
}),
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return { status: 500, bodyText: "" }
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
const plugin = await loadPlugin()
|
|
1239
|
+
const result = plugin.probe(ctx)
|
|
1240
|
+
|
|
1241
|
+
const labels = result.lines.map((l) => l.label)
|
|
1242
|
+
expect(labels).toEqual(["Gemini Pro", "Claude"])
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
it("LS filters out blacklisted model IDs (Claude Opus 4.5)", async () => {
|
|
1246
|
+
const ctx = makeCtx()
|
|
1247
|
+
const discovery = makeDiscovery()
|
|
1248
|
+
const response = makeUserStatusResponse({
|
|
1249
|
+
configs: [
|
|
1250
|
+
{
|
|
1251
|
+
label: "Gemini 3 Pro (High)",
|
|
1252
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M8" },
|
|
1253
|
+
quotaInfo: { remainingFraction: 0.75, resetTime: "2026-02-08T09:10:56Z" },
|
|
1254
|
+
},
|
|
1255
|
+
{
|
|
1256
|
+
label: "Claude Opus 4.5 (Thinking)",
|
|
1257
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M12" },
|
|
1258
|
+
quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" },
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
label: "Claude Opus 4.6 (Thinking)",
|
|
1262
|
+
modelOrAlias: { model: "MODEL_PLACEHOLDER_M26" },
|
|
1263
|
+
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-02-08T09:10:56Z" },
|
|
1264
|
+
},
|
|
1265
|
+
],
|
|
1266
|
+
})
|
|
1267
|
+
setupLsMock(ctx, discovery, response)
|
|
1268
|
+
|
|
1269
|
+
const plugin = await loadPlugin()
|
|
1270
|
+
const result = plugin.probe(ctx)
|
|
1271
|
+
|
|
1272
|
+
const labels = result.lines.map((l) => l.label)
|
|
1273
|
+
expect(labels).toEqual(["Gemini Pro", "Claude"])
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
it("LS still takes priority over Cloud Code with proto tokens (no regression)", async () => {
|
|
1277
|
+
const ctx = makeCtx()
|
|
1278
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1279
|
+
const protoB64 = makeProtobufBase64(ctx, "ya29.proto-token", "1//refresh", futureExpiry)
|
|
1280
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
1281
|
+
const discovery = makeDiscovery()
|
|
1282
|
+
const response = makeUserStatusResponse()
|
|
1283
|
+
setupLsMock(ctx, discovery, response)
|
|
1284
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), protoB64)
|
|
1285
|
+
|
|
1286
|
+
const plugin = await loadPlugin()
|
|
1287
|
+
const result = plugin.probe(ctx)
|
|
1288
|
+
|
|
1289
|
+
expect(result.plan).toBe("Pro")
|
|
1290
|
+
const calls = ctx.host.http.request.mock.calls.map((c) => String(c[0].url))
|
|
1291
|
+
expect(calls.filter((u) => u.includes("fetchAvailableModels")).length).toBe(0)
|
|
1292
|
+
expect(calls.filter((u) => u.includes("oauth2.googleapis.com")).length).toBe(0)
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
it("throws when Cloud Code returns no models", async () => {
|
|
1296
|
+
const ctx = makeCtx()
|
|
1297
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1298
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
1299
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1300
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1301
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1302
|
+
return { status: 200, bodyText: JSON.stringify({}) }
|
|
1303
|
+
}
|
|
1304
|
+
return { status: 500, bodyText: "" }
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
const plugin = await loadPlugin()
|
|
1308
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
it("handles refresh response missing access_token", async () => {
|
|
1312
|
+
const ctx = makeCtx()
|
|
1313
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1314
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.will-fail", "1//refresh", futureExpiry))
|
|
1315
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1316
|
+
|
|
1317
|
+
let oauthCalls = 0
|
|
1318
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1319
|
+
const url = String(opts.url)
|
|
1320
|
+
if (url.includes("oauth2.googleapis.com")) {
|
|
1321
|
+
oauthCalls += 1
|
|
1322
|
+
return { status: 200, bodyText: JSON.stringify({ expires_in: 3600 }) }
|
|
1323
|
+
}
|
|
1324
|
+
if (url.includes("fetchAvailableModels")) {
|
|
1325
|
+
return { status: 401, bodyText: '{"error":"unauthorized"}' }
|
|
1326
|
+
}
|
|
1327
|
+
return { status: 500, bodyText: "" }
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
const plugin = await loadPlugin()
|
|
1331
|
+
expect(() => plugin.probe(ctx)).toThrow("Start Antigravity and try again.")
|
|
1332
|
+
expect(oauthCalls).toBe(1)
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
it("continues to next Cloud Code base URL after non-2xx response", async () => {
|
|
1336
|
+
const ctx = makeCtx()
|
|
1337
|
+
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
|
|
1338
|
+
setupSqliteMock(ctx, makeAuthStatusJson(), makeProtobufBase64(ctx, "ya29.test-token", "1//refresh", futureExpiry))
|
|
1339
|
+
ctx.host.ls.discover.mockReturnValue(null)
|
|
1340
|
+
|
|
1341
|
+
let ccCalls = 0
|
|
1342
|
+
ctx.host.http.request.mockImplementation((opts) => {
|
|
1343
|
+
if (String(opts.url).includes("fetchAvailableModels")) {
|
|
1344
|
+
ccCalls += 1
|
|
1345
|
+
if (ccCalls === 1) return { status: 500, bodyText: "{}" }
|
|
1346
|
+
return { status: 200, bodyText: JSON.stringify(makeCloudCodeResponse()) }
|
|
1347
|
+
}
|
|
1348
|
+
return { status: 500, bodyText: "" }
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
const plugin = await loadPlugin()
|
|
1352
|
+
const result = plugin.probe(ctx)
|
|
1353
|
+
expect(result.lines.length).toBeGreaterThan(0)
|
|
1354
|
+
expect(ccCalls).toBe(2)
|
|
1355
|
+
})
|
|
1356
|
+
})
|