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,943 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { makeCtx } from "../test-helpers.js"
3
+
4
+ const PRIMARY_USAGE_URL = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains"
5
+ const FALLBACK_USAGE_URL = "https://api.minimax.io/v1/coding_plan/remains"
6
+ const LEGACY_WWW_USAGE_URL = "https://www.minimax.io/v1/api/openplatform/coding_plan/remains"
7
+ const CN_PRIMARY_USAGE_URL = "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains"
8
+ const CN_FALLBACK_USAGE_URL = "https://api.minimaxi.com/v1/coding_plan/remains"
9
+
10
+ const loadPlugin = async () => {
11
+ await import("./plugin.js")
12
+ return globalThis.__openusage_plugin
13
+ }
14
+
15
+ function setEnv(ctx, envValues) {
16
+ ctx.host.env.get.mockImplementation((name) =>
17
+ Object.prototype.hasOwnProperty.call(envValues, name) ? envValues[name] : null
18
+ )
19
+ }
20
+
21
+ function successPayload(overrides) {
22
+ const base = {
23
+ base_resp: { status_code: 0 },
24
+ plan_name: "Plus",
25
+ model_remains: [
26
+ {
27
+ model_name: "MiniMax-M2",
28
+ current_interval_total_count: 300,
29
+ current_interval_usage_count: 180,
30
+ start_time: 1700000000000,
31
+ end_time: 1700018000000,
32
+ },
33
+ ],
34
+ }
35
+ if (!overrides) return base
36
+ return Object.assign(base, overrides)
37
+ }
38
+
39
+ describe("minimax plugin", () => {
40
+ beforeEach(() => {
41
+ delete globalThis.__openusage_plugin
42
+ vi.resetModules()
43
+ })
44
+
45
+ afterEach(() => {
46
+ vi.restoreAllMocks()
47
+ })
48
+
49
+ it("throws when API key is missing", async () => {
50
+ const ctx = makeCtx()
51
+ setEnv(ctx, {})
52
+ const plugin = await loadPlugin()
53
+ expect(() => plugin.probe(ctx)).toThrow(
54
+ "MiniMax API key missing. Set MINIMAX_API_KEY or MINIMAX_CN_API_KEY."
55
+ )
56
+ })
57
+
58
+ it("uses MINIMAX_API_KEY for auth header", async () => {
59
+ const ctx = makeCtx()
60
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
61
+ ctx.host.http.request.mockReturnValue({
62
+ status: 200,
63
+ headers: {},
64
+ bodyText: JSON.stringify(successPayload()),
65
+ })
66
+
67
+ const plugin = await loadPlugin()
68
+ plugin.probe(ctx)
69
+
70
+ const call = ctx.host.http.request.mock.calls[0][0]
71
+ expect(call.url).toBe(PRIMARY_USAGE_URL)
72
+ expect(call.headers.Authorization).toBe("Bearer mini-key")
73
+ expect(call.headers["Content-Type"]).toBe("application/json")
74
+ expect(call.headers.Accept).toBe("application/json")
75
+ })
76
+
77
+ it("falls back to MINIMAX_API_TOKEN", async () => {
78
+ const ctx = makeCtx()
79
+ setEnv(ctx, {
80
+ MINIMAX_API_KEY: "",
81
+ MINIMAX_API_TOKEN: "token-fallback",
82
+ })
83
+ ctx.host.http.request.mockReturnValue({
84
+ status: 200,
85
+ headers: {},
86
+ bodyText: JSON.stringify(successPayload()),
87
+ })
88
+
89
+ const plugin = await loadPlugin()
90
+ plugin.probe(ctx)
91
+
92
+ const call = ctx.host.http.request.mock.calls[0][0]
93
+ expect(call.headers.Authorization).toBe("Bearer token-fallback")
94
+ })
95
+
96
+ it("auto-selects CN endpoint when MINIMAX_CN_API_KEY exists", async () => {
97
+ const ctx = makeCtx()
98
+ setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key", MINIMAX_API_KEY: "global-key" })
99
+ ctx.host.http.request.mockReturnValue({
100
+ status: 200,
101
+ headers: {},
102
+ bodyText: JSON.stringify(successPayload()),
103
+ })
104
+
105
+ const plugin = await loadPlugin()
106
+ const result = plugin.probe(ctx)
107
+
108
+ const call = ctx.host.http.request.mock.calls[0][0]
109
+ expect(call.url).toBe(CN_PRIMARY_USAGE_URL)
110
+ expect(call.headers.Authorization).toBe("Bearer cn-key")
111
+ expect(result.plan).toBe("Plus (CN)")
112
+ })
113
+
114
+ it("prefers MINIMAX_CN_API_KEY in AUTO mode when both keys exist", async () => {
115
+ const ctx = makeCtx()
116
+ setEnv(ctx, {
117
+ MINIMAX_CN_API_KEY: "cn-key",
118
+ MINIMAX_API_KEY: "global-key",
119
+ })
120
+ ctx.host.http.request.mockReturnValue({
121
+ status: 200,
122
+ headers: {},
123
+ bodyText: JSON.stringify(successPayload()),
124
+ })
125
+
126
+ const plugin = await loadPlugin()
127
+ const result = plugin.probe(ctx)
128
+
129
+ const call = ctx.host.http.request.mock.calls[0][0]
130
+ expect(call.url).toBe(CN_PRIMARY_USAGE_URL)
131
+ expect(call.headers.Authorization).toBe("Bearer cn-key")
132
+ expect(result.plan).toBe("Plus (CN)")
133
+ })
134
+
135
+ it("uses MINIMAX_API_KEY when CN key is missing", async () => {
136
+ const ctx = makeCtx()
137
+ setEnv(ctx, {
138
+ MINIMAX_API_KEY: "global-key",
139
+ })
140
+ ctx.host.http.request.mockReturnValue({
141
+ status: 200,
142
+ headers: {},
143
+ bodyText: JSON.stringify(successPayload()),
144
+ })
145
+
146
+ const plugin = await loadPlugin()
147
+ plugin.probe(ctx)
148
+
149
+ const call = ctx.host.http.request.mock.calls[0][0]
150
+ expect(call.url).toBe(PRIMARY_USAGE_URL)
151
+ expect(call.headers.Authorization).toBe("Bearer global-key")
152
+ })
153
+
154
+ it("uses GLOBAL first in AUTO mode when CN key is missing", async () => {
155
+ const ctx = makeCtx()
156
+ setEnv(ctx, { MINIMAX_API_KEY: "global-key" })
157
+ ctx.host.http.request.mockReturnValue({
158
+ status: 200,
159
+ headers: {},
160
+ bodyText: JSON.stringify(successPayload()),
161
+ })
162
+
163
+ const plugin = await loadPlugin()
164
+ plugin.probe(ctx)
165
+
166
+ const call = ctx.host.http.request.mock.calls[0][0]
167
+ expect(call.url).toBe(PRIMARY_USAGE_URL)
168
+ })
169
+
170
+ it("falls back to CN in AUTO mode when GLOBAL auth fails", async () => {
171
+ const ctx = makeCtx()
172
+ setEnv(ctx, { MINIMAX_API_KEY: "global-key" })
173
+ ctx.host.http.request.mockImplementation((req) => {
174
+ if (req.url === PRIMARY_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
175
+ if (req.url === FALLBACK_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
176
+ if (req.url === LEGACY_WWW_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
177
+ if (req.url === CN_PRIMARY_USAGE_URL) {
178
+ return {
179
+ status: 200,
180
+ headers: {},
181
+ bodyText: JSON.stringify(successPayload({
182
+ model_remains: [
183
+ {
184
+ model_name: "MiniMax-M2",
185
+ current_interval_total_count: 1500, // CN Plus: 100 prompts × 15
186
+ current_interval_usage_count: 1200, // Remaining
187
+ start_time: 1700000000000,
188
+ end_time: 1700018000000,
189
+ },
190
+ ],
191
+ })),
192
+ }
193
+ }
194
+ return { status: 404, headers: {}, bodyText: "{}" }
195
+ })
196
+
197
+ const plugin = await loadPlugin()
198
+ const result = plugin.probe(ctx)
199
+
200
+ expect(result.lines[0].used).toBe(20) // (1500-1200) / 15 = 20
201
+ expect(result.plan).toBe("Plus (CN)")
202
+ const first = ctx.host.http.request.mock.calls[0][0].url
203
+ const last = ctx.host.http.request.mock.calls[ctx.host.http.request.mock.calls.length - 1][0].url
204
+ expect(first).toBe(PRIMARY_USAGE_URL)
205
+ expect(last).toBe(CN_PRIMARY_USAGE_URL)
206
+ })
207
+
208
+ it("preserves first non-auth error in AUTO mode when later CN retry is auth", async () => {
209
+ const ctx = makeCtx()
210
+ setEnv(ctx, { MINIMAX_API_KEY: "global-key" })
211
+ ctx.host.http.request.mockImplementation((req) => {
212
+ if (req.url === PRIMARY_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" }
213
+ if (req.url === FALLBACK_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" }
214
+ if (req.url === LEGACY_WWW_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" }
215
+ if (req.url === CN_PRIMARY_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
216
+ if (req.url === CN_FALLBACK_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
217
+ return { status: 404, headers: {}, bodyText: "{}" }
218
+ })
219
+
220
+ const plugin = await loadPlugin()
221
+ expect(() => plugin.probe(ctx)).toThrow("Request failed (HTTP 500)")
222
+ })
223
+
224
+ it("preserves first auth error in AUTO mode when later CN retry is non-auth", async () => {
225
+ const ctx = makeCtx()
226
+ setEnv(ctx, { MINIMAX_API_KEY: "global-key" })
227
+ ctx.host.http.request.mockImplementation((req) => {
228
+ if (req.url === PRIMARY_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
229
+ if (req.url === FALLBACK_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
230
+ if (req.url === LEGACY_WWW_USAGE_URL) return { status: 401, headers: {}, bodyText: "" }
231
+ if (req.url === CN_PRIMARY_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" }
232
+ if (req.url === CN_FALLBACK_USAGE_URL) return { status: 500, headers: {}, bodyText: "{}" }
233
+ return { status: 404, headers: {}, bodyText: "{}" }
234
+ })
235
+
236
+ const plugin = await loadPlugin()
237
+ expect(() => plugin.probe(ctx)).toThrow("Session expired. Check your MiniMax API key.")
238
+ })
239
+
240
+ it("parses usage, plan, reset timestamp, and period duration", async () => {
241
+ const ctx = makeCtx()
242
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
243
+ ctx.host.http.request.mockReturnValue({
244
+ status: 200,
245
+ headers: {},
246
+ bodyText: JSON.stringify(successPayload()),
247
+ })
248
+
249
+ const plugin = await loadPlugin()
250
+ const result = plugin.probe(ctx)
251
+
252
+ expect(result.plan).toBe("Plus (GLOBAL)")
253
+ expect(result.lines.length).toBe(1)
254
+ const line = result.lines[0]
255
+ expect(line.label).toBe("Session")
256
+ expect(line.type).toBe("progress")
257
+ expect(line.used).toBe(120) // current_interval_usage_count is remaining
258
+ expect(line.limit).toBe(300)
259
+ expect(line.format.kind).toBe("count")
260
+ expect(line.format.suffix).toBe("prompts")
261
+ expect(line.resetsAt).toBe("2023-11-15T03:13:20.000Z")
262
+ expect(line.periodDurationMs).toBe(18000000)
263
+ })
264
+
265
+ it("treats current_interval_usage_count as remaining prompts", async () => {
266
+ const ctx = makeCtx()
267
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
268
+ ctx.host.http.request.mockReturnValue({
269
+ status: 200,
270
+ headers: {},
271
+ bodyText: JSON.stringify({
272
+ base_resp: { status_code: 0 },
273
+ model_remains: [
274
+ {
275
+ current_interval_total_count: 1500,
276
+ current_interval_usage_count: 1500,
277
+ remains_time: 3600000,
278
+ },
279
+ ],
280
+ }),
281
+ })
282
+
283
+ const plugin = await loadPlugin()
284
+ const result = plugin.probe(ctx)
285
+
286
+ expect(result.lines[0].used).toBe(0)
287
+ expect(result.lines[0].limit).toBe(1500)
288
+ })
289
+
290
+ it("infers Starter plan from 1500 model-call limit", async () => {
291
+ const ctx = makeCtx()
292
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
293
+ ctx.host.http.request.mockReturnValue({
294
+ status: 200,
295
+ headers: {},
296
+ bodyText: JSON.stringify({
297
+ base_resp: { status_code: 0 },
298
+ model_remains: [
299
+ {
300
+ current_interval_total_count: 1500,
301
+ current_interval_usage_count: 1200,
302
+ model_name: "MiniMax-M2",
303
+ },
304
+ ],
305
+ }),
306
+ })
307
+
308
+ const plugin = await loadPlugin()
309
+ const result = plugin.probe(ctx)
310
+
311
+ expect(result.plan).toBe("Starter (GLOBAL)")
312
+ expect(result.lines[0].used).toBe(300)
313
+ expect(result.lines[0].limit).toBe(1500)
314
+ })
315
+
316
+ it("does not fallback to model name when plan cannot be inferred", async () => {
317
+ const ctx = makeCtx()
318
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
319
+ ctx.host.http.request.mockReturnValue({
320
+ status: 200,
321
+ headers: {},
322
+ bodyText: JSON.stringify({
323
+ base_resp: { status_code: 0 },
324
+ model_remains: [
325
+ {
326
+ current_interval_total_count: 1337,
327
+ current_interval_usage_count: 1000,
328
+ model_name: "MiniMax-M2.5",
329
+ },
330
+ ],
331
+ }),
332
+ })
333
+
334
+ const plugin = await loadPlugin()
335
+ const result = plugin.probe(ctx)
336
+
337
+ expect(result.plan).toBeUndefined()
338
+ expect(result.lines[0].used).toBe(337)
339
+ })
340
+
341
+ it("supports nested payload and remains_time reset fallback", async () => {
342
+ const ctx = makeCtx()
343
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
344
+ vi.spyOn(Date, "now").mockReturnValue(1700000000000)
345
+ ctx.host.http.request.mockReturnValue({
346
+ status: 200,
347
+ headers: {},
348
+ bodyText: JSON.stringify({
349
+ data: {
350
+ base_resp: { status_code: 0 },
351
+ current_subscribe_title: "Max",
352
+ model_remains: [
353
+ {
354
+ current_interval_total_count: 100,
355
+ current_interval_usage_count: 40,
356
+ remains_time: 7200,
357
+ },
358
+ ],
359
+ },
360
+ }),
361
+ })
362
+
363
+ const plugin = await loadPlugin()
364
+ const result = plugin.probe(ctx)
365
+ const line = result.lines[0]
366
+ const expectedReset = new Date(1700000000000 + 7200 * 1000).toISOString()
367
+
368
+ expect(result.plan).toBe("Max (GLOBAL)")
369
+ expect(line.used).toBe(60)
370
+ expect(line.limit).toBe(100)
371
+ expect(line.resetsAt).toBe(expectedReset)
372
+ })
373
+
374
+ it("treats small remains_time values as milliseconds when seconds exceed window", async () => {
375
+ const ctx = makeCtx()
376
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
377
+ vi.spyOn(Date, "now").mockReturnValue(1700000000000)
378
+ ctx.host.http.request.mockReturnValue({
379
+ status: 200,
380
+ headers: {},
381
+ bodyText: JSON.stringify({
382
+ data: {
383
+ base_resp: { status_code: 0 },
384
+ model_remains: [
385
+ {
386
+ current_interval_total_count: 100,
387
+ current_interval_usage_count: 55,
388
+ remains_time: 300000,
389
+ },
390
+ ],
391
+ },
392
+ }),
393
+ })
394
+
395
+ const plugin = await loadPlugin()
396
+ const result = plugin.probe(ctx)
397
+ const line = result.lines[0]
398
+
399
+ expect(line.used).toBe(45)
400
+ expect(line.limit).toBe(100)
401
+ expect(line.resetsAt).toBe(new Date(1700000000000 + 300000).toISOString())
402
+ })
403
+
404
+ it("supports remaining-count payload variants", async () => {
405
+ const ctx = makeCtx()
406
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
407
+ ctx.host.http.request.mockReturnValue({
408
+ status: 200,
409
+ headers: {},
410
+ bodyText: JSON.stringify({
411
+ base_resp: { status_code: 0 },
412
+ plan_name: "MiniMax Coding Plan Pro",
413
+ model_remains: [
414
+ {
415
+ current_interval_total_count: 300,
416
+ current_interval_remaining_count: 120,
417
+ end_time: 1700018000000,
418
+ },
419
+ ],
420
+ }),
421
+ })
422
+
423
+ const plugin = await loadPlugin()
424
+ const result = plugin.probe(ctx)
425
+ const line = result.lines[0]
426
+
427
+ expect(result.plan).toBe("Pro (GLOBAL)")
428
+ expect(line.used).toBe(180)
429
+ expect(line.limit).toBe(300)
430
+ })
431
+
432
+ it("throws on HTTP auth status", async () => {
433
+ const ctx = makeCtx()
434
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
435
+ ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" })
436
+ const plugin = await loadPlugin()
437
+ let message = ""
438
+ try {
439
+ plugin.probe(ctx)
440
+ } catch (e) {
441
+ message = String(e)
442
+ }
443
+ expect(message).toContain("Session expired")
444
+ expect(ctx.host.http.request.mock.calls.length).toBe(5)
445
+ })
446
+
447
+ it("falls back to secondary endpoint when primary fails", async () => {
448
+ const ctx = makeCtx()
449
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
450
+ ctx.host.http.request.mockImplementation((req) => {
451
+ if (req.url === PRIMARY_USAGE_URL) return { status: 503, headers: {}, bodyText: "{}" }
452
+ if (req.url === FALLBACK_USAGE_URL) {
453
+ return {
454
+ status: 200,
455
+ headers: {},
456
+ bodyText: JSON.stringify(successPayload()),
457
+ }
458
+ }
459
+ return { status: 404, headers: {}, bodyText: "{}" }
460
+ })
461
+
462
+ const plugin = await loadPlugin()
463
+ const result = plugin.probe(ctx)
464
+
465
+ expect(result.lines[0].used).toBe(120)
466
+ expect(ctx.host.http.request.mock.calls.length).toBe(2)
467
+ })
468
+
469
+ it("uses CN fallback endpoint when CN primary fails", async () => {
470
+ const ctx = makeCtx()
471
+ setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" })
472
+ ctx.host.http.request.mockImplementation((req) => {
473
+ if (req.url === CN_PRIMARY_USAGE_URL) return { status: 503, headers: {}, bodyText: "{}" }
474
+ if (req.url === CN_FALLBACK_USAGE_URL) {
475
+ return {
476
+ status: 200,
477
+ headers: {},
478
+ bodyText: JSON.stringify(successPayload({
479
+ model_remains: [
480
+ {
481
+ model_name: "MiniMax-M2",
482
+ current_interval_total_count: 1500, // CN Plus: 100 prompts × 15
483
+ current_interval_usage_count: 1200, // Remaining
484
+ start_time: 1700000000000,
485
+ end_time: 1700018000000,
486
+ },
487
+ ],
488
+ })),
489
+ }
490
+ }
491
+ return { status: 404, headers: {}, bodyText: "{}" }
492
+ })
493
+
494
+ const plugin = await loadPlugin()
495
+ const result = plugin.probe(ctx)
496
+
497
+ expect(result.lines[0].used).toBe(20) // (1500-1200) / 15 = 20
498
+ expect(ctx.host.http.request.mock.calls.length).toBe(2)
499
+ expect(ctx.host.http.request.mock.calls[0][0].url).toBe(CN_PRIMARY_USAGE_URL)
500
+ expect(ctx.host.http.request.mock.calls[1][0].url).toBe(CN_FALLBACK_USAGE_URL)
501
+ })
502
+
503
+ it("infers CN Starter plan from 600 model-call limit", async () => {
504
+ const ctx = makeCtx()
505
+ setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" })
506
+ ctx.host.http.request.mockReturnValue({
507
+ status: 200,
508
+ headers: {},
509
+ bodyText: JSON.stringify(
510
+ successPayload({
511
+ plan_name: undefined, // Force inference
512
+ model_remains: [
513
+ {
514
+ model_name: "MiniMax-M2",
515
+ current_interval_total_count: 600, // 40 prompts × 15
516
+ current_interval_usage_count: 500, // Remaining (not used!)
517
+ start_time: 1700000000000,
518
+ end_time: 1700018000000,
519
+ },
520
+ ],
521
+ })
522
+ ),
523
+ })
524
+
525
+ const plugin = await loadPlugin()
526
+ const result = plugin.probe(ctx)
527
+
528
+ expect(result.plan).toBe("Starter (CN)")
529
+ expect(result.lines[0].limit).toBe(40) // 600 / 15 = 40 prompts
530
+ expect(result.lines[0].used).toBe(7) // (600-500) / 15 = 6.67 ≈ 7
531
+ })
532
+
533
+ it("infers CN Plus plan from 1500 model-call limit", async () => {
534
+ const ctx = makeCtx()
535
+ setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" })
536
+ ctx.host.http.request.mockReturnValue({
537
+ status: 200,
538
+ headers: {},
539
+ bodyText: JSON.stringify(
540
+ successPayload({
541
+ plan_name: undefined, // Force inference
542
+ model_remains: [
543
+ {
544
+ model_name: "MiniMax-M2",
545
+ current_interval_total_count: 1500, // 100 prompts × 15
546
+ current_interval_usage_count: 1200, // Remaining
547
+ start_time: 1700000000000,
548
+ end_time: 1700018000000,
549
+ },
550
+ ],
551
+ })
552
+ ),
553
+ })
554
+
555
+ const plugin = await loadPlugin()
556
+ const result = plugin.probe(ctx)
557
+
558
+ expect(result.plan).toBe("Plus (CN)")
559
+ expect(result.lines[0].limit).toBe(100) // 1500 / 15 = 100 prompts
560
+ expect(result.lines[0].used).toBe(20) // (1500-1200) / 15 = 20
561
+ })
562
+
563
+ it("infers CN Max plan from 4500 model-call limit", async () => {
564
+ const ctx = makeCtx()
565
+ setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" })
566
+ ctx.host.http.request.mockReturnValue({
567
+ status: 200,
568
+ headers: {},
569
+ bodyText: JSON.stringify(
570
+ successPayload({
571
+ plan_name: undefined, // Force inference
572
+ model_remains: [
573
+ {
574
+ model_name: "MiniMax-M2",
575
+ current_interval_total_count: 4500, // 300 prompts × 15
576
+ current_interval_usage_count: 2700, // Remaining
577
+ start_time: 1700000000000,
578
+ end_time: 1700018000000,
579
+ },
580
+ ],
581
+ })
582
+ ),
583
+ })
584
+
585
+ const plugin = await loadPlugin()
586
+ const result = plugin.probe(ctx)
587
+
588
+ expect(result.plan).toBe("Max (CN)")
589
+ expect(result.lines[0].limit).toBe(300) // 4500 / 15 = 300 prompts
590
+ expect(result.lines[0].used).toBe(120) // (4500-2700) / 15 = 120
591
+ })
592
+
593
+ it("does not infer CN plan for unknown CN model-call limits", async () => {
594
+ const ctx = makeCtx()
595
+ setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" })
596
+ ctx.host.http.request.mockReturnValue({
597
+ status: 200,
598
+ headers: {},
599
+ bodyText: JSON.stringify(
600
+ successPayload({
601
+ plan_name: undefined, // Force inference
602
+ model_remains: [
603
+ {
604
+ model_name: "MiniMax-M2",
605
+ current_interval_total_count: 9000, // Unknown CN tier
606
+ current_interval_usage_count: 6000, // Remaining
607
+ start_time: 1700000000000,
608
+ end_time: 1700018000000,
609
+ },
610
+ ],
611
+ })
612
+ ),
613
+ })
614
+
615
+ const plugin = await loadPlugin()
616
+ const result = plugin.probe(ctx)
617
+
618
+ expect(result.plan).toBeUndefined()
619
+ expect(result.lines[0].limit).toBe(600) // 9000 / 15 = 600 prompts
620
+ expect(result.lines[0].used).toBe(200) // (9000-6000) / 15 = 200 prompts
621
+ })
622
+
623
+ it("falls back when primary returns auth-like status", async () => {
624
+ const ctx = makeCtx()
625
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
626
+ ctx.host.http.request.mockImplementation((req) => {
627
+ if (req.url === PRIMARY_USAGE_URL) return { status: 403, headers: {}, bodyText: "<html>cf</html>" }
628
+ if (req.url === FALLBACK_USAGE_URL) {
629
+ return {
630
+ status: 200,
631
+ headers: {},
632
+ bodyText: JSON.stringify(successPayload()),
633
+ }
634
+ }
635
+ if (req.url === LEGACY_WWW_USAGE_URL) return { status: 403, headers: {}, bodyText: "<html>cf</html>" }
636
+ return { status: 404, headers: {}, bodyText: "{}" }
637
+ })
638
+
639
+ const plugin = await loadPlugin()
640
+ const result = plugin.probe(ctx)
641
+
642
+ expect(result.lines[0].used).toBe(120)
643
+ expect(ctx.host.http.request.mock.calls.length).toBe(2)
644
+ })
645
+
646
+ it("throws when API returns non-zero base_resp status", async () => {
647
+ const ctx = makeCtx()
648
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
649
+ ctx.host.http.request.mockReturnValue({
650
+ status: 200,
651
+ headers: {},
652
+ bodyText: JSON.stringify({
653
+ base_resp: { status_code: 1004, status_msg: "cookie is missing, log in again" },
654
+ model_remains: [],
655
+ }),
656
+ })
657
+ const plugin = await loadPlugin()
658
+ expect(() => plugin.probe(ctx)).toThrow("Session expired")
659
+ })
660
+
661
+ it("uses same generic auth error text for CN path", async () => {
662
+ const ctx = makeCtx()
663
+ setEnv(ctx, { MINIMAX_CN_API_KEY: "cn-key" })
664
+ ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" })
665
+ const plugin = await loadPlugin()
666
+ expect(() => plugin.probe(ctx)).toThrow("Session expired. Check your MiniMax API key.")
667
+ })
668
+
669
+ it("throws when payload has no usable usage data", async () => {
670
+ const ctx = makeCtx()
671
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
672
+ ctx.host.http.request.mockReturnValue({
673
+ status: 200,
674
+ headers: {},
675
+ bodyText: JSON.stringify({ base_resp: { status_code: 0 }, model_remains: [] }),
676
+ })
677
+ const plugin = await loadPlugin()
678
+ expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data")
679
+ })
680
+
681
+ it("continues when env getter throws and still uses fallback env var", async () => {
682
+ const ctx = makeCtx()
683
+ ctx.host.env.get.mockImplementation((name) => {
684
+ if (name === "MINIMAX_API_KEY") throw new Error("env unavailable")
685
+ if (name === "MINIMAX_API_TOKEN") return "fallback-token"
686
+ return null
687
+ })
688
+ ctx.host.http.request.mockReturnValue({
689
+ status: 200,
690
+ headers: {},
691
+ bodyText: JSON.stringify(successPayload()),
692
+ })
693
+
694
+ const plugin = await loadPlugin()
695
+ const result = plugin.probe(ctx)
696
+ expect(result.lines[0].used).toBe(120)
697
+ })
698
+
699
+ it("supports camelCase modelRemains and explicit used count fields", async () => {
700
+ const ctx = makeCtx()
701
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
702
+ vi.spyOn(Date, "now").mockReturnValue(1700000000000)
703
+ ctx.host.http.request.mockReturnValue({
704
+ status: 200,
705
+ headers: {},
706
+ bodyText: JSON.stringify({
707
+ base_resp: { status_code: 0 },
708
+ modelRemains: [
709
+ null,
710
+ {
711
+ currentIntervalTotalCount: "500",
712
+ currentIntervalUsedCount: "123",
713
+ remainsTime: 7200000,
714
+ },
715
+ ],
716
+ }),
717
+ })
718
+
719
+ const plugin = await loadPlugin()
720
+ const result = plugin.probe(ctx)
721
+ const line = result.lines[0]
722
+ expect(line.used).toBe(123)
723
+ expect(line.limit).toBe(500)
724
+ expect(line.resetsAt).toBe(new Date(1700000000000 + 7200000).toISOString())
725
+ expect(line.periodDurationMs).toBeUndefined()
726
+ })
727
+
728
+ it("throws generic MiniMax API error when status message is absent", async () => {
729
+ const ctx = makeCtx()
730
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
731
+ ctx.host.http.request.mockReturnValue({
732
+ status: 200,
733
+ headers: {},
734
+ bodyText: JSON.stringify({
735
+ base_resp: { status_code: 429 },
736
+ model_remains: [],
737
+ }),
738
+ })
739
+ const plugin = await loadPlugin()
740
+ expect(() => plugin.probe(ctx)).toThrow("MiniMax API error (status 429)")
741
+ })
742
+
743
+ it("throws HTTP error when all endpoints return non-2xx", async () => {
744
+ const ctx = makeCtx()
745
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
746
+ ctx.host.http.request.mockReturnValue({ status: 500, headers: {}, bodyText: "{}" })
747
+ const plugin = await loadPlugin()
748
+ expect(() => plugin.probe(ctx)).toThrow("Request failed (HTTP 500)")
749
+ })
750
+
751
+ it("throws network error when all endpoints fail with exceptions", async () => {
752
+ const ctx = makeCtx()
753
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
754
+ ctx.host.http.request.mockImplementation(() => {
755
+ throw new Error("ECONNRESET")
756
+ })
757
+ const plugin = await loadPlugin()
758
+ expect(() => plugin.probe(ctx)).toThrow("Request failed. Check your connection.")
759
+ })
760
+
761
+ it("throws parse error when all endpoints return invalid JSON with 2xx status", async () => {
762
+ const ctx = makeCtx()
763
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
764
+ ctx.host.http.request.mockReturnValue({ status: 200, headers: {}, bodyText: "not-json" })
765
+ const plugin = await loadPlugin()
766
+ expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data.")
767
+ })
768
+
769
+ it("normalizes bare 'MiniMax Coding Plan' to 'Coding Plan'", async () => {
770
+ const ctx = makeCtx()
771
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
772
+ ctx.host.http.request.mockReturnValue({
773
+ status: 200,
774
+ headers: {},
775
+ bodyText: JSON.stringify({
776
+ base_resp: { status_code: 0 },
777
+ plan_name: "MiniMax Coding Plan",
778
+ model_remains: [
779
+ {
780
+ current_interval_total_count: 100,
781
+ current_interval_usage_count: 20,
782
+ },
783
+ ],
784
+ }),
785
+ })
786
+
787
+ const plugin = await loadPlugin()
788
+ const result = plugin.probe(ctx)
789
+ expect(result.plan).toBe("Coding Plan (GLOBAL)")
790
+ })
791
+
792
+ it("supports payload.modelRemains and remains-count aliases", async () => {
793
+ const ctx = makeCtx()
794
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
795
+ ctx.host.http.request.mockReturnValue({
796
+ status: 200,
797
+ headers: {},
798
+ bodyText: JSON.stringify({
799
+ base_resp: { status_code: 0 },
800
+ plan: "MiniMax Coding Plan: Team",
801
+ modelRemains: [
802
+ {
803
+ currentIntervalTotalCount: "300",
804
+ remainsCount: "120",
805
+ endTime: 1700018000000,
806
+ },
807
+ ],
808
+ }),
809
+ })
810
+
811
+ const plugin = await loadPlugin()
812
+ const result = plugin.probe(ctx)
813
+ expect(result.plan).toBe("Team (GLOBAL)")
814
+ expect(result.lines[0].used).toBe(180)
815
+ expect(result.lines[0].limit).toBe(300)
816
+ })
817
+
818
+ it("clamps negative used counts to zero", async () => {
819
+ const ctx = makeCtx()
820
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
821
+ ctx.host.http.request.mockReturnValue({
822
+ status: 200,
823
+ headers: {},
824
+ bodyText: JSON.stringify({
825
+ base_resp: { status_code: 0 },
826
+ model_remains: [
827
+ {
828
+ current_interval_total_count: 100,
829
+ current_interval_used_count: -5,
830
+ },
831
+ ],
832
+ }),
833
+ })
834
+
835
+ const plugin = await loadPlugin()
836
+ const result = plugin.probe(ctx)
837
+ expect(result.lines[0].used).toBe(0)
838
+ })
839
+
840
+ it("clamps used counts above total", async () => {
841
+ const ctx = makeCtx()
842
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
843
+ ctx.host.http.request.mockReturnValue({
844
+ status: 200,
845
+ headers: {},
846
+ bodyText: JSON.stringify({
847
+ base_resp: { status_code: 0 },
848
+ model_remains: [
849
+ {
850
+ current_interval_total_count: 100,
851
+ current_interval_used_count: 500,
852
+ },
853
+ ],
854
+ }),
855
+ })
856
+
857
+ const plugin = await loadPlugin()
858
+ const result = plugin.probe(ctx)
859
+ expect(result.lines[0].used).toBe(100)
860
+ })
861
+
862
+ it("supports epoch seconds for start/end timestamps", async () => {
863
+ const ctx = makeCtx()
864
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
865
+ ctx.host.http.request.mockReturnValue({
866
+ status: 200,
867
+ headers: {},
868
+ bodyText: JSON.stringify({
869
+ base_resp: { status_code: 0 },
870
+ model_remains: [
871
+ {
872
+ current_interval_total_count: 100,
873
+ current_interval_usage_count: 25,
874
+ start_time: 1700000000,
875
+ end_time: 1700018000,
876
+ },
877
+ ],
878
+ }),
879
+ })
880
+
881
+ const plugin = await loadPlugin()
882
+ const result = plugin.probe(ctx)
883
+ const line = result.lines[0]
884
+ expect(line.periodDurationMs).toBe(18000000)
885
+ expect(line.resetsAt).toBe(new Date(1700018000 * 1000).toISOString())
886
+ })
887
+
888
+ it("infers remains_time as milliseconds when value is plausible", async () => {
889
+ const ctx = makeCtx()
890
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
891
+ vi.spyOn(Date, "now").mockReturnValue(1700000000000)
892
+ ctx.host.http.request.mockReturnValue({
893
+ status: 200,
894
+ headers: {},
895
+ bodyText: JSON.stringify({
896
+ base_resp: { status_code: 0 },
897
+ model_remains: [
898
+ {
899
+ current_interval_total_count: 100,
900
+ current_interval_usage_count: 40,
901
+ remains_time: 300000,
902
+ },
903
+ ],
904
+ }),
905
+ })
906
+
907
+ const plugin = await loadPlugin()
908
+ const result = plugin.probe(ctx)
909
+ expect(result.lines[0].resetsAt).toBe(new Date(1700000000000 + 300000).toISOString())
910
+ })
911
+
912
+ it("throws parse error when model_remains entries are unusable", async () => {
913
+ const ctx = makeCtx()
914
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
915
+ ctx.host.http.request.mockReturnValue({
916
+ status: 200,
917
+ headers: {},
918
+ bodyText: JSON.stringify({
919
+ base_resp: { status_code: 0 },
920
+ model_remains: [null, { current_interval_total_count: 0, current_interval_usage_count: 1 }],
921
+ }),
922
+ })
923
+
924
+ const plugin = await loadPlugin()
925
+ expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data")
926
+ })
927
+
928
+ it("throws parse error when both used and remaining counts are missing", async () => {
929
+ const ctx = makeCtx()
930
+ setEnv(ctx, { MINIMAX_API_KEY: "mini-key" })
931
+ ctx.host.http.request.mockReturnValue({
932
+ status: 200,
933
+ headers: {},
934
+ bodyText: JSON.stringify({
935
+ base_resp: { status_code: 0 },
936
+ model_remains: [{ current_interval_total_count: 100 }],
937
+ }),
938
+ })
939
+
940
+ const plugin = await loadPlugin()
941
+ expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data")
942
+ })
943
+ })