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.
Files changed (58) hide show
  1. package/bin/openusage +91 -0
  2. package/package.json +33 -0
  3. package/plugins/amp/icon.svg +6 -0
  4. package/plugins/amp/plugin.js +175 -0
  5. package/plugins/amp/plugin.json +20 -0
  6. package/plugins/amp/plugin.test.js +365 -0
  7. package/plugins/antigravity/icon.svg +3 -0
  8. package/plugins/antigravity/plugin.js +484 -0
  9. package/plugins/antigravity/plugin.json +17 -0
  10. package/plugins/antigravity/plugin.test.js +1356 -0
  11. package/plugins/claude/icon.svg +3 -0
  12. package/plugins/claude/plugin.js +565 -0
  13. package/plugins/claude/plugin.json +28 -0
  14. package/plugins/claude/plugin.test.js +1012 -0
  15. package/plugins/codex/icon.svg +3 -0
  16. package/plugins/codex/plugin.js +673 -0
  17. package/plugins/codex/plugin.json +30 -0
  18. package/plugins/codex/plugin.test.js +1071 -0
  19. package/plugins/copilot/icon.svg +3 -0
  20. package/plugins/copilot/plugin.js +264 -0
  21. package/plugins/copilot/plugin.json +20 -0
  22. package/plugins/copilot/plugin.test.js +529 -0
  23. package/plugins/cursor/icon.svg +3 -0
  24. package/plugins/cursor/plugin.js +526 -0
  25. package/plugins/cursor/plugin.json +24 -0
  26. package/plugins/cursor/plugin.test.js +1168 -0
  27. package/plugins/factory/icon.svg +1 -0
  28. package/plugins/factory/plugin.js +407 -0
  29. package/plugins/factory/plugin.json +19 -0
  30. package/plugins/factory/plugin.test.js +833 -0
  31. package/plugins/gemini/icon.svg +4 -0
  32. package/plugins/gemini/plugin.js +413 -0
  33. package/plugins/gemini/plugin.json +20 -0
  34. package/plugins/gemini/plugin.test.js +735 -0
  35. package/plugins/jetbrains-ai-assistant/icon.svg +3 -0
  36. package/plugins/jetbrains-ai-assistant/plugin.js +357 -0
  37. package/plugins/jetbrains-ai-assistant/plugin.json +17 -0
  38. package/plugins/jetbrains-ai-assistant/plugin.test.js +338 -0
  39. package/plugins/kimi/icon.svg +3 -0
  40. package/plugins/kimi/plugin.js +358 -0
  41. package/plugins/kimi/plugin.json +19 -0
  42. package/plugins/kimi/plugin.test.js +619 -0
  43. package/plugins/minimax/icon.svg +4 -0
  44. package/plugins/minimax/plugin.js +388 -0
  45. package/plugins/minimax/plugin.json +17 -0
  46. package/plugins/minimax/plugin.test.js +943 -0
  47. package/plugins/perplexity/icon.svg +1 -0
  48. package/plugins/perplexity/plugin.js +378 -0
  49. package/plugins/perplexity/plugin.json +15 -0
  50. package/plugins/perplexity/plugin.test.js +602 -0
  51. package/plugins/windsurf/icon.svg +3 -0
  52. package/plugins/windsurf/plugin.js +218 -0
  53. package/plugins/windsurf/plugin.json +16 -0
  54. package/plugins/windsurf/plugin.test.js +455 -0
  55. package/plugins/zai/icon.svg +5 -0
  56. package/plugins/zai/plugin.js +156 -0
  57. package/plugins/zai/plugin.json +18 -0
  58. package/plugins/zai/plugin.test.js +396 -0
@@ -0,0 +1,156 @@
1
+ (function () {
2
+ const BASE_URL = "https://api.z.ai"
3
+ const SUBSCRIPTION_URL = BASE_URL + "/api/biz/subscription/list"
4
+ const QUOTA_URL = BASE_URL + "/api/monitor/usage/quota/limit"
5
+ const PERIOD_MS = 5 * 60 * 60 * 1000
6
+ const MONTH_MS = 30 * 24 * 60 * 60 * 1000
7
+
8
+ function loadApiKey(ctx) {
9
+ const zai = ctx.host.env.get("ZAI_API_KEY")
10
+ if (typeof zai === "string" && zai.trim()) return zai.trim()
11
+
12
+ const glm = ctx.host.env.get("GLM_API_KEY")
13
+ if (typeof glm === "string" && glm.trim()) return glm.trim()
14
+
15
+ return null
16
+ }
17
+
18
+ function fetchSubscription(ctx, apiKey) {
19
+ try {
20
+ const resp = ctx.util.request({
21
+ method: "GET",
22
+ url: SUBSCRIPTION_URL,
23
+ headers: {
24
+ Authorization: "Bearer " + apiKey,
25
+ Accept: "application/json",
26
+ },
27
+ timeoutMs: 10000,
28
+ })
29
+ if (resp.status < 200 || resp.status >= 300) {
30
+ ctx.host.log.warn("subscription request failed: HTTP " + resp.status)
31
+ return null
32
+ }
33
+ const data = ctx.util.tryParseJson(resp.bodyText)
34
+ if (!data) return null
35
+ const list = data.data
36
+ if (!Array.isArray(list) || list.length === 0) return null
37
+ return {
38
+ productName: list[0].productName || null,
39
+ nextRenewTime: list[0].nextRenewTime || null,
40
+ }
41
+ } catch (e) {
42
+ ctx.host.log.warn("subscription request exception: " + String(e))
43
+ return null
44
+ }
45
+ }
46
+
47
+ function fetchQuota(ctx, apiKey) {
48
+ let resp
49
+ try {
50
+ resp = ctx.util.request({
51
+ method: "GET",
52
+ url: QUOTA_URL,
53
+ headers: {
54
+ Authorization: "Bearer " + apiKey,
55
+ Accept: "application/json",
56
+ },
57
+ timeoutMs: 10000,
58
+ })
59
+ } catch (e) {
60
+ ctx.host.log.error("usage request exception: " + String(e))
61
+ throw "Usage request failed. Check your connection."
62
+ }
63
+
64
+ if (ctx.util.isAuthStatus(resp.status)) {
65
+ throw "API key invalid. Check your Z.ai API key."
66
+ }
67
+
68
+ if (resp.status < 200 || resp.status >= 300) {
69
+ throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later."
70
+ }
71
+
72
+ const data = ctx.util.tryParseJson(resp.bodyText)
73
+ if (!data) {
74
+ throw "Usage response invalid. Try again later."
75
+ }
76
+
77
+ return data
78
+ }
79
+
80
+ function findLimit(limits, type) {
81
+ for (let i = 0; i < limits.length; i++) {
82
+ if (limits[i].type === type || limits[i].name === type) return limits[i]
83
+ }
84
+ return null
85
+ }
86
+
87
+ function probe(ctx) {
88
+ const apiKey = loadApiKey(ctx)
89
+ if (!apiKey) {
90
+ throw "No ZAI_API_KEY found. Set up environment variable first."
91
+ }
92
+
93
+ const sub = fetchSubscription(ctx, apiKey)
94
+ const plan = sub && sub.productName ? ctx.fmt.planLabel(sub.productName) : null
95
+
96
+ const quota = fetchQuota(ctx, apiKey)
97
+ const lines = []
98
+
99
+ const container = quota.data || quota
100
+ const limits = container.limits || container
101
+ if (!Array.isArray(limits) || limits.length === 0) {
102
+ lines.push(ctx.line.badge({ label: "Session", text: "No usage data", color: "#a3a3a3" }))
103
+ return { plan, lines }
104
+ }
105
+
106
+ const tokenLimit = findLimit(limits, "TOKENS_LIMIT")
107
+
108
+ if (!tokenLimit) {
109
+ lines.push(ctx.line.badge({ label: "Session", text: "No usage data", color: "#a3a3a3" }))
110
+ return { plan, lines }
111
+ }
112
+
113
+ const used = typeof tokenLimit.percentage === "number" ? tokenLimit.percentage : 0
114
+ const resetsAt = tokenLimit.nextResetTime ? ctx.util.toIso(tokenLimit.nextResetTime) : undefined
115
+
116
+ const progressOpts = {
117
+ label: "Session",
118
+ used,
119
+ limit: 100,
120
+ format: { kind: "percent" },
121
+ periodDurationMs: PERIOD_MS,
122
+ }
123
+ if (resetsAt) {
124
+ progressOpts.resetsAt = resetsAt
125
+ }
126
+ lines.push(ctx.line.progress(progressOpts))
127
+
128
+ const timeLimit = findLimit(limits, "TIME_LIMIT")
129
+
130
+ if (timeLimit) {
131
+ const webUsed = typeof timeLimit.currentValue === "number" ? timeLimit.currentValue : 0
132
+ const webTotal = typeof timeLimit.usage === "number" ? timeLimit.usage : 0
133
+ const now = new Date()
134
+ const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))
135
+ const webResetsAt = timeLimit.nextResetTime
136
+ ? ctx.util.toIso(timeLimit.nextResetTime)
137
+ : nextMonth.toISOString()
138
+
139
+ const webOpts = {
140
+ label: "Web Searches",
141
+ used: webUsed,
142
+ limit: webTotal,
143
+ format: { kind: "count", suffix: "/ " + webTotal },
144
+ periodDurationMs: MONTH_MS,
145
+ }
146
+ if (webResetsAt) {
147
+ webOpts.resetsAt = webResetsAt
148
+ }
149
+ lines.push(ctx.line.progress(webOpts))
150
+ }
151
+
152
+ return { plan, lines }
153
+ }
154
+
155
+ globalThis.__openusage_plugin = { id: "zai", probe }
156
+ })()
@@ -0,0 +1,18 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "zai",
4
+ "name": "Z.ai",
5
+ "version": "0.0.1",
6
+ "entry": "plugin.js",
7
+ "icon": "icon.svg",
8
+ "brandColor": "#2D2D2D",
9
+ "cli": {
10
+ "category": "env",
11
+ "envVarNames": ["ZAI_API_KEY", "GLM_API_KEY"],
12
+ "envKeyLabel": "Z.ai API Key"
13
+ },
14
+ "lines": [
15
+ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
16
+ { "type": "progress", "label": "Web Searches", "scope": "overview" }
17
+ ]
18
+ }
@@ -0,0 +1,396 @@
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
+ const mockEnvWithKey = (ctx, key, varName = "ZAI_API_KEY") => {
10
+ ctx.host.env.get.mockImplementation((name) => (name === varName ? key : null))
11
+ }
12
+
13
+ const QUOTA_RESPONSE = {
14
+ code: 200,
15
+ data: {
16
+ limits: [
17
+ {
18
+ type: "TOKENS_LIMIT",
19
+ usage: 800000000,
20
+ currentValue: 1900000,
21
+ percentage: 10,
22
+ nextResetTime: 1738368000000,
23
+ unit: 3,
24
+ number: 5,
25
+ },
26
+ {
27
+ type: "TIME_LIMIT",
28
+ usage: 4000,
29
+ currentValue: 1095,
30
+ percentage: 27,
31
+ remaining: 2905,
32
+ usageDetails: [
33
+ { modelCode: "search-prime", usage: 951 },
34
+ { modelCode: "web-reader", usage: 211 },
35
+ { modelCode: "zread", usage: 0 },
36
+ ],
37
+ unit: 5,
38
+ number: 1,
39
+ },
40
+ ],
41
+ },
42
+ }
43
+
44
+ const QUOTA_RESPONSE_NO_TIME_LIMIT = {
45
+ code: 200,
46
+ data: {
47
+ limits: [
48
+ {
49
+ type: "TOKENS_LIMIT",
50
+ usage: 800000000,
51
+ currentValue: 1900000,
52
+ percentage: 10,
53
+ nextResetTime: 1738368000000,
54
+ unit: 3,
55
+ number: 5,
56
+ },
57
+ ],
58
+ },
59
+ }
60
+
61
+ const SUBSCRIPTION_RESPONSE = {
62
+ data: [{ productName: "GLM Coding Max", nextRenewTime: "2026-03-12" }],
63
+ }
64
+
65
+ const mockHttp = (ctx) => {
66
+ ctx.host.http.request.mockImplementation((opts) => {
67
+ if (opts.url.includes("subscription")) {
68
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
69
+ }
70
+ return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE) }
71
+ })
72
+ }
73
+
74
+ describe("zai plugin", () => {
75
+ beforeEach(() => {
76
+ delete globalThis.__openusage_plugin
77
+ vi.resetModules()
78
+ })
79
+
80
+ it("throws when no env vars set", async () => {
81
+ const ctx = makeCtx()
82
+ const plugin = await loadPlugin()
83
+ expect(() => plugin.probe(ctx)).toThrow("No ZAI_API_KEY found. Set up environment variable first.")
84
+ })
85
+
86
+ it("uses ZAI_API_KEY when set", async () => {
87
+ const ctx = makeCtx()
88
+ mockEnvWithKey(ctx, "test-key")
89
+ mockHttp(ctx)
90
+
91
+ const plugin = await loadPlugin()
92
+ const result = plugin.probe(ctx)
93
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
94
+ })
95
+
96
+ it("falls back to GLM_API_KEY when ZAI_API_KEY is missing", async () => {
97
+ const ctx = makeCtx()
98
+ mockEnvWithKey(ctx, "glm-key", "GLM_API_KEY")
99
+ mockHttp(ctx)
100
+
101
+ const plugin = await loadPlugin()
102
+ const result = plugin.probe(ctx)
103
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
104
+ })
105
+
106
+ it("prefers ZAI_API_KEY over GLM_API_KEY", async () => {
107
+ const ctx = makeCtx()
108
+ ctx.host.env.get.mockImplementation((name) => {
109
+ if (name === "ZAI_API_KEY") return "zai-key"
110
+ if (name === "GLM_API_KEY") return "glm-key"
111
+ return null
112
+ })
113
+ mockHttp(ctx)
114
+
115
+ const plugin = await loadPlugin()
116
+ const result = plugin.probe(ctx)
117
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
118
+ const authHeader = ctx.host.http.request.mock.calls[0][0].headers.Authorization
119
+ expect(authHeader).toBe("Bearer zai-key")
120
+ })
121
+
122
+ it("renders session usage as percent from quota response", async () => {
123
+ const ctx = makeCtx()
124
+ mockEnvWithKey(ctx, "test-key")
125
+ mockHttp(ctx)
126
+
127
+ const plugin = await loadPlugin()
128
+ const result = plugin.probe(ctx)
129
+ const line = result.lines.find((l) => l.label === "Session")
130
+ expect(line).toBeTruthy()
131
+ expect(line.type).toBe("progress")
132
+ expect(line.used).toBe(10)
133
+ expect(line.limit).toBe(100)
134
+ expect(line.format).toEqual({ kind: "percent" })
135
+ expect(line.periodDurationMs).toBe(5 * 60 * 60 * 1000)
136
+ })
137
+
138
+ it("extracts plan name from subscription response", async () => {
139
+ const ctx = makeCtx()
140
+ mockEnvWithKey(ctx, "test-key")
141
+ mockHttp(ctx)
142
+
143
+ const plugin = await loadPlugin()
144
+ const result = plugin.probe(ctx)
145
+ expect(result.plan).toBe("GLM Coding Max")
146
+ })
147
+
148
+ it("handles subscription fetch failure gracefully", async () => {
149
+ const ctx = makeCtx()
150
+ mockEnvWithKey(ctx, "test-key")
151
+ ctx.host.http.request.mockImplementation((opts) => {
152
+ if (opts.url.includes("subscription")) {
153
+ return { status: 500, bodyText: "" }
154
+ }
155
+ return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE) }
156
+ })
157
+
158
+ const plugin = await loadPlugin()
159
+ const result = plugin.probe(ctx)
160
+ expect(result.plan).toBeNull()
161
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
162
+ })
163
+
164
+ it("throws on 401 from quota endpoint", async () => {
165
+ const ctx = makeCtx()
166
+ mockEnvWithKey(ctx, "test-key")
167
+ ctx.host.http.request.mockImplementation((opts) => {
168
+ if (opts.url.includes("subscription")) {
169
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
170
+ }
171
+ return { status: 401, bodyText: "" }
172
+ })
173
+
174
+ const plugin = await loadPlugin()
175
+ expect(() => plugin.probe(ctx)).toThrow("API key invalid")
176
+ })
177
+
178
+ it("throws on HTTP 500 from quota endpoint", async () => {
179
+ const ctx = makeCtx()
180
+ mockEnvWithKey(ctx, "test-key")
181
+ ctx.host.http.request.mockImplementation((opts) => {
182
+ if (opts.url.includes("subscription")) {
183
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
184
+ }
185
+ return { status: 500, bodyText: "" }
186
+ })
187
+
188
+ const plugin = await loadPlugin()
189
+ expect(() => plugin.probe(ctx)).toThrow("HTTP 500")
190
+ })
191
+
192
+ it("throws on network exception", async () => {
193
+ const ctx = makeCtx()
194
+ mockEnvWithKey(ctx, "test-key")
195
+ ctx.host.http.request.mockImplementation((opts) => {
196
+ if (opts.url.includes("subscription")) {
197
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
198
+ }
199
+ throw new Error("ECONNREFUSED")
200
+ })
201
+
202
+ const plugin = await loadPlugin()
203
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.")
204
+ })
205
+
206
+ it("throws on invalid JSON from quota endpoint", async () => {
207
+ const ctx = makeCtx()
208
+ mockEnvWithKey(ctx, "test-key")
209
+ ctx.host.http.request.mockImplementation((opts) => {
210
+ if (opts.url.includes("subscription")) {
211
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
212
+ }
213
+ return { status: 200, bodyText: "not-json" }
214
+ })
215
+
216
+ const plugin = await loadPlugin()
217
+ expect(() => plugin.probe(ctx)).toThrow("Usage response invalid")
218
+ })
219
+
220
+ it("shows badge when limits array is empty", async () => {
221
+ const ctx = makeCtx()
222
+ mockEnvWithKey(ctx, "test-key")
223
+ ctx.host.http.request.mockImplementation((opts) => {
224
+ if (opts.url.includes("subscription")) {
225
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
226
+ }
227
+ return { status: 200, bodyText: JSON.stringify({ data: { limits: [] } }) }
228
+ })
229
+
230
+ const plugin = await loadPlugin()
231
+ const result = plugin.probe(ctx)
232
+ expect(result.lines[0].text).toBe("No usage data")
233
+ })
234
+
235
+ it("passes resetsAt from nextResetTime (epoch ms to ISO)", async () => {
236
+ const ctx = makeCtx()
237
+ mockEnvWithKey(ctx, "test-key")
238
+ mockHttp(ctx)
239
+
240
+ const plugin = await loadPlugin()
241
+ const result = plugin.probe(ctx)
242
+ const line = result.lines.find((l) => l.label === "Session")
243
+ expect(line.resetsAt).toBe(new Date(1738368000000).toISOString())
244
+ })
245
+
246
+ it("renders Web Searches line with count format and 1st-of-month reset", async () => {
247
+ const ctx = makeCtx()
248
+ mockEnvWithKey(ctx, "test-key")
249
+ mockHttp(ctx)
250
+
251
+ const plugin = await loadPlugin()
252
+ const result = plugin.probe(ctx)
253
+ const line = result.lines.find((l) => l.label === "Web Searches")
254
+ expect(line).toBeTruthy()
255
+ expect(line.type).toBe("progress")
256
+ expect(line.used).toBe(1095)
257
+ expect(line.limit).toBe(4000)
258
+ expect(line.format).toEqual({ kind: "count", suffix: "/ 4000" })
259
+ expect(line.periodDurationMs).toBe(30 * 24 * 60 * 60 * 1000)
260
+ const now = new Date()
261
+ const expected1st = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))
262
+ expect(line.resetsAt).toBe(expected1st.toISOString())
263
+ })
264
+
265
+ it("skips Web Searches when TIME_LIMIT is absent", async () => {
266
+ const ctx = makeCtx()
267
+ mockEnvWithKey(ctx, "test-key")
268
+ ctx.host.http.request.mockImplementation((opts) => {
269
+ if (opts.url.includes("subscription")) {
270
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
271
+ }
272
+ return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE_NO_TIME_LIMIT) }
273
+ })
274
+
275
+ const plugin = await loadPlugin()
276
+ const result = plugin.probe(ctx)
277
+ expect(result.lines.find((l) => l.label === "Web Searches")).toBeUndefined()
278
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
279
+ })
280
+
281
+ it("Web Searches still has resetsAt (1st of month) even when subscription fails", async () => {
282
+ const ctx = makeCtx()
283
+ mockEnvWithKey(ctx, "test-key")
284
+ ctx.host.http.request.mockImplementation((opts) => {
285
+ if (opts.url.includes("subscription")) {
286
+ return { status: 500, bodyText: "" }
287
+ }
288
+ return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE) }
289
+ })
290
+
291
+ const plugin = await loadPlugin()
292
+ const result = plugin.probe(ctx)
293
+ const line = result.lines.find((l) => l.label === "Web Searches")
294
+ expect(line).toBeTruthy()
295
+ const now = new Date()
296
+ const expected1st = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))
297
+ expect(line.resetsAt).toBe(expected1st.toISOString())
298
+ })
299
+
300
+ it("handles missing nextResetTime gracefully", async () => {
301
+ const ctx = makeCtx()
302
+ mockEnvWithKey(ctx, "test-key")
303
+ const quotaNoReset = {
304
+ data: {
305
+ limits: [
306
+ { type: "TOKENS_LIMIT", percentage: 10 },
307
+ ],
308
+ },
309
+ }
310
+ ctx.host.http.request.mockImplementation((opts) => {
311
+ if (opts.url.includes("subscription")) {
312
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
313
+ }
314
+ return { status: 200, bodyText: JSON.stringify(quotaNoReset) }
315
+ })
316
+
317
+ const plugin = await loadPlugin()
318
+ const result = plugin.probe(ctx)
319
+ const line = result.lines.find((l) => l.label === "Session")
320
+ expect(line).toBeTruthy()
321
+ expect(line.resetsAt).toBeUndefined()
322
+ })
323
+
324
+ it("handles invalid subscription JSON without failing quota rendering", async () => {
325
+ const ctx = makeCtx()
326
+ mockEnvWithKey(ctx, "test-key")
327
+ ctx.host.http.request.mockImplementation((opts) => {
328
+ if (opts.url.includes("subscription")) {
329
+ return { status: 200, bodyText: "not-json" }
330
+ }
331
+ return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE) }
332
+ })
333
+
334
+ const plugin = await loadPlugin()
335
+ const result = plugin.probe(ctx)
336
+ expect(result.plan).toBeNull()
337
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
338
+ })
339
+
340
+ it("handles subscription payload with empty list", async () => {
341
+ const ctx = makeCtx()
342
+ mockEnvWithKey(ctx, "test-key")
343
+ ctx.host.http.request.mockImplementation((opts) => {
344
+ if (opts.url.includes("subscription")) {
345
+ return { status: 200, bodyText: JSON.stringify({ data: [] }) }
346
+ }
347
+ return { status: 200, bodyText: JSON.stringify(QUOTA_RESPONSE) }
348
+ })
349
+
350
+ const plugin = await loadPlugin()
351
+ const result = plugin.probe(ctx)
352
+ expect(result.plan).toBeNull()
353
+ expect(result.lines.find((l) => l.label === "Session")).toBeTruthy()
354
+ })
355
+
356
+ it("supports quota payloads where limits are top-level and optional fields are non-numeric", async () => {
357
+ const ctx = makeCtx()
358
+ mockEnvWithKey(ctx, "test-key")
359
+ ctx.host.http.request.mockImplementation((opts) => {
360
+ if (opts.url.includes("subscription")) {
361
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
362
+ }
363
+ return {
364
+ status: 200,
365
+ bodyText: JSON.stringify([
366
+ { type: "TOKENS_LIMIT", percentage: "10", nextResetTime: 1738368000000 },
367
+ { type: "TIME_LIMIT", currentValue: "1095", usage: "4000" },
368
+ ]),
369
+ }
370
+ })
371
+
372
+ const plugin = await loadPlugin()
373
+ const result = plugin.probe(ctx)
374
+ const session = result.lines.find((l) => l.label === "Session")
375
+ const web = result.lines.find((l) => l.label === "Web Searches")
376
+ expect(session.used).toBe(0)
377
+ expect(web.used).toBe(0)
378
+ expect(web.limit).toBe(0)
379
+ })
380
+
381
+ it("shows no-usage badge when token limit entry is missing", async () => {
382
+ const ctx = makeCtx()
383
+ mockEnvWithKey(ctx, "test-key")
384
+ ctx.host.http.request.mockImplementation((opts) => {
385
+ if (opts.url.includes("subscription")) {
386
+ return { status: 200, bodyText: JSON.stringify(SUBSCRIPTION_RESPONSE) }
387
+ }
388
+ return { status: 200, bodyText: JSON.stringify({ data: { limits: [{ type: "TIME_LIMIT", usage: 10 }] } }) }
389
+ })
390
+
391
+ const plugin = await loadPlugin()
392
+ const result = plugin.probe(ctx)
393
+ expect(result.lines).toHaveLength(1)
394
+ expect(result.lines[0].text).toBe("No usage data")
395
+ })
396
+ })