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,338 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { makeCtx } from "../test-helpers.js"
|
|
3
|
+
|
|
4
|
+
const DARWIN_PATH = "~/Library/Application Support/JetBrains/WebStorm2025.3/options/AIAssistantQuotaManager2.xml"
|
|
5
|
+
const LINUX_PATH = "~/.config/JetBrains/IntelliJIdea2025.3/options/AIAssistantQuotaManager2.xml"
|
|
6
|
+
|
|
7
|
+
const loadPlugin = async () => {
|
|
8
|
+
await import("./plugin.js")
|
|
9
|
+
return globalThis.__openusage_plugin
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodeXmlValue(value) {
|
|
13
|
+
return JSON.stringify(value, null, 4)
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/"/g, """)
|
|
16
|
+
.replace(/\n/g, " ")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeQuotaXml({ quotaInfo, nextRefill }) {
|
|
20
|
+
return [
|
|
21
|
+
"<application>",
|
|
22
|
+
' <component name="AIAssistantQuotaManager2">',
|
|
23
|
+
` <option name="nextRefill" value="${encodeXmlValue(nextRefill)}" />`,
|
|
24
|
+
` <option name="quotaInfo" value="${encodeXmlValue(quotaInfo)}" />`,
|
|
25
|
+
" </component>",
|
|
26
|
+
"</application>",
|
|
27
|
+
].join("\n")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("jetbrains-ai-assistant plugin", () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
delete globalThis.__openusage_plugin
|
|
33
|
+
vi.resetModules()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("parses quota xml and emits quota + remaining lines", async () => {
|
|
37
|
+
const ctx = makeCtx()
|
|
38
|
+
ctx.host.fs.writeText(
|
|
39
|
+
DARWIN_PATH,
|
|
40
|
+
makeQuotaXml({
|
|
41
|
+
quotaInfo: {
|
|
42
|
+
type: "Available",
|
|
43
|
+
current: "75",
|
|
44
|
+
maximum: "100",
|
|
45
|
+
available: "25",
|
|
46
|
+
until: "2099-01-31T00:00:00Z",
|
|
47
|
+
},
|
|
48
|
+
nextRefill: {
|
|
49
|
+
type: "Known",
|
|
50
|
+
next: "2099-01-01T00:00:00Z",
|
|
51
|
+
tariff: { amount: "100", duration: "PT720H" },
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const plugin = await loadPlugin()
|
|
57
|
+
const result = plugin.probe(ctx)
|
|
58
|
+
|
|
59
|
+
const quota = result.lines.find((line) => line.label === "Quota")
|
|
60
|
+
const used = result.lines.find((line) => line.label === "Used")
|
|
61
|
+
const remaining = result.lines.find((line) => line.label === "Remaining")
|
|
62
|
+
|
|
63
|
+
expect(quota && quota.used).toBe(75)
|
|
64
|
+
expect(quota && quota.limit).toBe(100)
|
|
65
|
+
expect(quota && quota.resetsAt).toBe("2099-01-01T00:00:00.000Z")
|
|
66
|
+
expect(quota && quota.periodDurationMs).toBe(2592000000)
|
|
67
|
+
expect(used && used.value).toBe("75")
|
|
68
|
+
expect(remaining && remaining.value).toBe("25")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("falls back to quota until when nextRefill is missing", async () => {
|
|
72
|
+
const ctx = makeCtx()
|
|
73
|
+
ctx.host.fs.writeText(
|
|
74
|
+
DARWIN_PATH,
|
|
75
|
+
makeQuotaXml({
|
|
76
|
+
quotaInfo: {
|
|
77
|
+
type: "Available",
|
|
78
|
+
current: "50",
|
|
79
|
+
maximum: "100",
|
|
80
|
+
available: "50",
|
|
81
|
+
until: "2099-01-31T00:00:00Z",
|
|
82
|
+
},
|
|
83
|
+
nextRefill: null,
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const plugin = await loadPlugin()
|
|
88
|
+
const result = plugin.probe(ctx)
|
|
89
|
+
const quota = result.lines.find((line) => line.label === "Quota")
|
|
90
|
+
expect(quota && quota.resetsAt).toBe("2099-01-31T00:00:00.000Z")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("prefers the quota state with the latest until window", async () => {
|
|
94
|
+
const ctx = makeCtx()
|
|
95
|
+
ctx.app.platform = "macos"
|
|
96
|
+
|
|
97
|
+
ctx.host.fs.writeText(
|
|
98
|
+
DARWIN_PATH,
|
|
99
|
+
makeQuotaXml({
|
|
100
|
+
quotaInfo: {
|
|
101
|
+
type: "Available",
|
|
102
|
+
current: "10",
|
|
103
|
+
maximum: "100",
|
|
104
|
+
available: "90",
|
|
105
|
+
until: "2099-01-01T00:00:00Z",
|
|
106
|
+
},
|
|
107
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "0", duration: "PT720H" } },
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// Unknown platforms probe all base paths. Write second valid file in Linux path to verify latest-until selection.
|
|
112
|
+
ctx.app.platform = "unknown"
|
|
113
|
+
ctx.host.fs.writeText(
|
|
114
|
+
LINUX_PATH,
|
|
115
|
+
makeQuotaXml({
|
|
116
|
+
quotaInfo: {
|
|
117
|
+
type: "Available",
|
|
118
|
+
current: "20",
|
|
119
|
+
maximum: "100",
|
|
120
|
+
available: "80",
|
|
121
|
+
until: "2099-02-01T00:00:00Z",
|
|
122
|
+
},
|
|
123
|
+
nextRefill: { type: "Known", next: "2099-02-01T00:00:00Z", tariff: { amount: "0", duration: "PT720H" } },
|
|
124
|
+
})
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const plugin = await loadPlugin()
|
|
128
|
+
const result = plugin.probe(ctx)
|
|
129
|
+
const quota = result.lines.find((line) => line.label === "Quota")
|
|
130
|
+
expect(quota && quota.used).toBe(20)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("computes remaining when available is missing", async () => {
|
|
134
|
+
const ctx = makeCtx()
|
|
135
|
+
ctx.host.fs.writeText(
|
|
136
|
+
DARWIN_PATH,
|
|
137
|
+
makeQuotaXml({
|
|
138
|
+
quotaInfo: {
|
|
139
|
+
type: "Available",
|
|
140
|
+
current: "60",
|
|
141
|
+
maximum: "100",
|
|
142
|
+
until: "2099-01-31T00:00:00Z",
|
|
143
|
+
},
|
|
144
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "0", duration: "P30D" } },
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const plugin = await loadPlugin()
|
|
149
|
+
const result = plugin.probe(ctx)
|
|
150
|
+
const remaining = result.lines.find((line) => line.label === "Remaining")
|
|
151
|
+
expect(remaining && remaining.value).toBe("40")
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("prefers explicit tariff/topUp available over maximum-current math", async () => {
|
|
155
|
+
const ctx = makeCtx()
|
|
156
|
+
ctx.host.fs.writeText(
|
|
157
|
+
DARWIN_PATH,
|
|
158
|
+
makeQuotaXml({
|
|
159
|
+
quotaInfo: {
|
|
160
|
+
type: "Available",
|
|
161
|
+
current: "90",
|
|
162
|
+
maximum: "100",
|
|
163
|
+
// Intentionally inconsistent to verify explicit available is preferred.
|
|
164
|
+
tariffQuota: { current: "90", maximum: "100", available: "12.5" },
|
|
165
|
+
topUpQuota: { current: "0", maximum: "0", available: "3.5" },
|
|
166
|
+
until: "2099-01-31T00:00:00Z",
|
|
167
|
+
},
|
|
168
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "0", duration: "P30D" } },
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const plugin = await loadPlugin()
|
|
173
|
+
const result = plugin.probe(ctx)
|
|
174
|
+
const remaining = result.lines.find((line) => line.label === "Remaining")
|
|
175
|
+
expect(remaining && remaining.value).toBe("16")
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("breaks equal-until ties by higher used ratio", async () => {
|
|
179
|
+
const ctx = makeCtx()
|
|
180
|
+
ctx.app.platform = "unknown"
|
|
181
|
+
|
|
182
|
+
ctx.host.fs.writeText(
|
|
183
|
+
DARWIN_PATH,
|
|
184
|
+
makeQuotaXml({
|
|
185
|
+
quotaInfo: {
|
|
186
|
+
type: "Available",
|
|
187
|
+
current: "80",
|
|
188
|
+
maximum: "100",
|
|
189
|
+
available: "20",
|
|
190
|
+
until: "2099-01-31T00:00:00Z",
|
|
191
|
+
},
|
|
192
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "0", duration: "PT720H" } },
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
ctx.host.fs.writeText(
|
|
196
|
+
LINUX_PATH,
|
|
197
|
+
makeQuotaXml({
|
|
198
|
+
quotaInfo: {
|
|
199
|
+
type: "Available",
|
|
200
|
+
current: "30",
|
|
201
|
+
maximum: "100",
|
|
202
|
+
available: "70",
|
|
203
|
+
until: "2099-01-31T00:00:00Z",
|
|
204
|
+
},
|
|
205
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "0", duration: "PT720H" } },
|
|
206
|
+
})
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const plugin = await loadPlugin()
|
|
210
|
+
const result = plugin.probe(ctx)
|
|
211
|
+
const used = result.lines.find((line) => line.label === "Used")
|
|
212
|
+
expect(used && used.value).toBe("80")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("converts JetBrains raw quota units to credits for display", async () => {
|
|
216
|
+
const ctx = makeCtx()
|
|
217
|
+
ctx.host.fs.writeText(
|
|
218
|
+
DARWIN_PATH,
|
|
219
|
+
makeQuotaXml({
|
|
220
|
+
quotaInfo: {
|
|
221
|
+
type: "Available",
|
|
222
|
+
current: "1981684.92",
|
|
223
|
+
maximum: "2367648.941",
|
|
224
|
+
tariffQuota: { current: "1981684.92", maximum: "2367648.941", available: "385964.21" },
|
|
225
|
+
topUpQuota: { current: "0", maximum: "0", available: "0" },
|
|
226
|
+
until: "2026-04-30T21:00:00Z",
|
|
227
|
+
},
|
|
228
|
+
nextRefill: {
|
|
229
|
+
type: "Known",
|
|
230
|
+
next: "2026-03-14T06:00:54.020Z",
|
|
231
|
+
tariff: { amount: "2000000", duration: "PT720H" },
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const plugin = await loadPlugin()
|
|
237
|
+
const result = plugin.probe(ctx)
|
|
238
|
+
const used = result.lines.find((line) => line.label === "Used")
|
|
239
|
+
const remaining = result.lines.find((line) => line.label === "Remaining")
|
|
240
|
+
|
|
241
|
+
expect(used && used.value).toBe("19.82 / 23.68 credits")
|
|
242
|
+
expect(remaining && remaining.value).toBe("3.86 credits")
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("throws when no quota file is detected", async () => {
|
|
246
|
+
const ctx = makeCtx()
|
|
247
|
+
const plugin = await loadPlugin()
|
|
248
|
+
expect(() => plugin.probe(ctx)).toThrow("JetBrains AI Assistant not detected")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("throws when quota payload is present but invalid", async () => {
|
|
252
|
+
const ctx = makeCtx()
|
|
253
|
+
ctx.host.fs.writeText(
|
|
254
|
+
DARWIN_PATH,
|
|
255
|
+
makeQuotaXml({
|
|
256
|
+
quotaInfo: {
|
|
257
|
+
type: "Available",
|
|
258
|
+
current: "0",
|
|
259
|
+
maximum: "0",
|
|
260
|
+
until: "2099-01-31T00:00:00Z",
|
|
261
|
+
},
|
|
262
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "0", duration: "PT720H" } },
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const plugin = await loadPlugin()
|
|
267
|
+
expect(() => plugin.probe(ctx)).toThrow("quota data unavailable")
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("discovers quota on windows", async () => {
|
|
271
|
+
const ctx = makeCtx()
|
|
272
|
+
ctx.app.platform = "windows"
|
|
273
|
+
ctx.host.fs.writeText(
|
|
274
|
+
"~/AppData/Roaming/JetBrains/WebStorm2025.3/options/AIAssistantQuotaManager2.xml",
|
|
275
|
+
makeQuotaXml({
|
|
276
|
+
quotaInfo: { current: "50", maximum: "100", available: "50", until: "2099-01-31T00:00:00Z" },
|
|
277
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "100", duration: "PT720H" } },
|
|
278
|
+
})
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
const plugin = await loadPlugin()
|
|
282
|
+
const result = plugin.probe(ctx)
|
|
283
|
+
const quota = result.lines.find((line) => line.label === "Quota")
|
|
284
|
+
expect(quota && quota.used).toBe(50)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("continues gracefully when listDir throws", async () => {
|
|
288
|
+
const ctx = makeCtx()
|
|
289
|
+
ctx.host.fs.writeText(DARWIN_PATH, makeQuotaXml({
|
|
290
|
+
quotaInfo: { current: "50", maximum: "100", available: "50", until: "2099-01-31T00:00:00Z" },
|
|
291
|
+
nextRefill: null,
|
|
292
|
+
}))
|
|
293
|
+
ctx.host.fs.listDir = () => { throw new Error("permission denied") }
|
|
294
|
+
|
|
295
|
+
const plugin = await loadPlugin()
|
|
296
|
+
expect(() => plugin.probe(ctx)).toThrow("JetBrains AI Assistant not detected")
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it("throws when quota file exists but quotaInfo element is absent", async () => {
|
|
300
|
+
const ctx = makeCtx()
|
|
301
|
+
ctx.host.fs.writeText(
|
|
302
|
+
DARWIN_PATH,
|
|
303
|
+
'<application><component name="AIAssistantQuotaManager2"></component></application>'
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
const plugin = await loadPlugin()
|
|
307
|
+
expect(() => plugin.probe(ctx)).toThrow("quota data unavailable")
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it("parses P30D and P4W durations", async () => {
|
|
311
|
+
const ctx = makeCtx()
|
|
312
|
+
ctx.host.fs.writeText(
|
|
313
|
+
DARWIN_PATH,
|
|
314
|
+
makeQuotaXml({
|
|
315
|
+
quotaInfo: { current: "50", maximum: "100", available: "50", until: "2099-01-31T00:00:00Z" },
|
|
316
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "100", duration: "P30D" } },
|
|
317
|
+
})
|
|
318
|
+
)
|
|
319
|
+
const plugin = await loadPlugin()
|
|
320
|
+
const result = plugin.probe(ctx)
|
|
321
|
+
const quota = result.lines.find((line) => line.label === "Quota")
|
|
322
|
+
expect(quota && quota.periodDurationMs).toBe(30 * 24 * 60 * 60 * 1000)
|
|
323
|
+
|
|
324
|
+
delete globalThis.__openusage_plugin
|
|
325
|
+
vi.resetModules()
|
|
326
|
+
ctx.host.fs.writeText(
|
|
327
|
+
DARWIN_PATH,
|
|
328
|
+
makeQuotaXml({
|
|
329
|
+
quotaInfo: { current: "50", maximum: "100", available: "50", until: "2099-01-31T00:00:00Z" },
|
|
330
|
+
nextRefill: { type: "Known", next: "2099-01-01T00:00:00Z", tariff: { amount: "100", duration: "P4W" } },
|
|
331
|
+
})
|
|
332
|
+
)
|
|
333
|
+
const plugin2 = await loadPlugin()
|
|
334
|
+
const result2 = plugin2.probe(ctx)
|
|
335
|
+
const quota2 = result2.lines.find((line) => line.label === "Quota")
|
|
336
|
+
expect(quota2 && quota2.periodDurationMs).toBe(4 * 7 * 24 * 60 * 60 * 1000)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
2
|
+
<path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 0 0-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 0 1 .205-.023l6.484 4.772a7.677 7.677 0 0 0 3.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 0 1-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" />
|
|
3
|
+
</svg>
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const CRED_PATH = "~/.kimi/credentials/kimi-code.json"
|
|
3
|
+
const USAGE_URL = "https://api.kimi.com/coding/v1/usages"
|
|
4
|
+
const REFRESH_URL = "https://auth.kimi.com/api/oauth/token"
|
|
5
|
+
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
|
6
|
+
const REFRESH_BUFFER_SEC = 5 * 60
|
|
7
|
+
|
|
8
|
+
function readNumber(value) {
|
|
9
|
+
const n = Number(value)
|
|
10
|
+
return Number.isFinite(n) ? n : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function titleCaseWords(value) {
|
|
14
|
+
return String(value)
|
|
15
|
+
.trim()
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/\b[a-z]/g, function (c) {
|
|
18
|
+
return c.toUpperCase()
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parsePlanLabel(data) {
|
|
23
|
+
const level =
|
|
24
|
+
data &&
|
|
25
|
+
data.user &&
|
|
26
|
+
data.user.membership &&
|
|
27
|
+
typeof data.user.membership.level === "string"
|
|
28
|
+
? data.user.membership.level
|
|
29
|
+
: null
|
|
30
|
+
if (!level) return null
|
|
31
|
+
|
|
32
|
+
const cleaned = level.replace(/^LEVEL_/, "").replace(/_/g, " ")
|
|
33
|
+
const label = titleCaseWords(cleaned)
|
|
34
|
+
return label || null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadCredentials(ctx) {
|
|
38
|
+
if (!ctx.host.fs.exists(CRED_PATH)) {
|
|
39
|
+
ctx.host.log.warn("credentials file not found: " + CRED_PATH)
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const text = ctx.host.fs.readText(CRED_PATH)
|
|
45
|
+
const parsed = ctx.util.tryParseJson(text)
|
|
46
|
+
if (!parsed || typeof parsed !== "object") {
|
|
47
|
+
ctx.host.log.warn("credentials file is not valid json")
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
if (!parsed.access_token && !parsed.refresh_token) {
|
|
51
|
+
ctx.host.log.warn("credentials missing access_token and refresh_token")
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
return parsed
|
|
55
|
+
} catch (e) {
|
|
56
|
+
ctx.host.log.warn("credentials read failed: " + String(e))
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function saveCredentials(ctx, creds) {
|
|
62
|
+
try {
|
|
63
|
+
ctx.host.fs.writeText(CRED_PATH, JSON.stringify(creds))
|
|
64
|
+
} catch (e) {
|
|
65
|
+
ctx.host.log.warn("failed to persist credentials: " + String(e))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function needsRefresh(creds, nowSec) {
|
|
70
|
+
if (!creds.access_token) return true
|
|
71
|
+
const expiresAt = readNumber(creds.expires_at)
|
|
72
|
+
if (expiresAt === null) return true
|
|
73
|
+
return nowSec + REFRESH_BUFFER_SEC >= expiresAt
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function refreshToken(ctx, creds) {
|
|
77
|
+
if (!creds.refresh_token) {
|
|
78
|
+
ctx.host.log.warn("refresh skipped: no refresh token")
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ctx.host.log.info("attempting token refresh")
|
|
83
|
+
let resp
|
|
84
|
+
try {
|
|
85
|
+
resp = ctx.util.request({
|
|
86
|
+
method: "POST",
|
|
87
|
+
url: REFRESH_URL,
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
90
|
+
Accept: "application/json",
|
|
91
|
+
},
|
|
92
|
+
bodyText:
|
|
93
|
+
"client_id=" +
|
|
94
|
+
encodeURIComponent(CLIENT_ID) +
|
|
95
|
+
"&grant_type=refresh_token" +
|
|
96
|
+
"&refresh_token=" +
|
|
97
|
+
encodeURIComponent(creds.refresh_token),
|
|
98
|
+
timeoutMs: 15000,
|
|
99
|
+
})
|
|
100
|
+
} catch (e) {
|
|
101
|
+
ctx.host.log.error("refresh exception: " + String(e))
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (ctx.util.isAuthStatus(resp.status)) {
|
|
106
|
+
throw "Session expired. Run `kimi login` to authenticate."
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
110
|
+
ctx.host.log.warn("refresh returned unexpected status: " + resp.status)
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const body = ctx.util.tryParseJson(resp.bodyText)
|
|
115
|
+
if (!body || !body.access_token) {
|
|
116
|
+
ctx.host.log.warn("refresh response missing access_token")
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
creds.access_token = body.access_token
|
|
121
|
+
if (body.refresh_token) creds.refresh_token = body.refresh_token
|
|
122
|
+
if (typeof body.expires_in === "number") {
|
|
123
|
+
creds.expires_at = Date.now() / 1000 + body.expires_in
|
|
124
|
+
}
|
|
125
|
+
if (typeof body.scope === "string") creds.scope = body.scope
|
|
126
|
+
if (typeof body.token_type === "string") creds.token_type = body.token_type
|
|
127
|
+
|
|
128
|
+
saveCredentials(ctx, creds)
|
|
129
|
+
return creds.access_token
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function fetchUsage(ctx, accessToken) {
|
|
133
|
+
return ctx.util.request({
|
|
134
|
+
method: "GET",
|
|
135
|
+
url: USAGE_URL,
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: "Bearer " + accessToken,
|
|
138
|
+
Accept: "application/json",
|
|
139
|
+
"User-Agent": "OpenUsage",
|
|
140
|
+
},
|
|
141
|
+
timeoutMs: 10000,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseWindowPeriodMs(window) {
|
|
146
|
+
if (!window || typeof window !== "object") return null
|
|
147
|
+
const duration = readNumber(window.duration)
|
|
148
|
+
if (duration === null || duration <= 0) return null
|
|
149
|
+
|
|
150
|
+
const unit = String(window.timeUnit || window.time_unit || "").toUpperCase()
|
|
151
|
+
if (unit.indexOf("MINUTE") !== -1) return duration * 60 * 1000
|
|
152
|
+
if (unit.indexOf("HOUR") !== -1) return duration * 60 * 60 * 1000
|
|
153
|
+
if (unit.indexOf("DAY") !== -1) return duration * 24 * 60 * 60 * 1000
|
|
154
|
+
if (unit.indexOf("SECOND") !== -1) return duration * 1000
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseQuota(row, ctx) {
|
|
159
|
+
if (!row || typeof row !== "object") return null
|
|
160
|
+
|
|
161
|
+
const limit = readNumber(row.limit)
|
|
162
|
+
if (limit === null || limit <= 0) return null
|
|
163
|
+
|
|
164
|
+
let used = readNumber(row.used)
|
|
165
|
+
if (used === null) {
|
|
166
|
+
const remaining = readNumber(row.remaining)
|
|
167
|
+
if (remaining !== null) {
|
|
168
|
+
used = limit - remaining
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (used === null) return null
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
used,
|
|
175
|
+
limit,
|
|
176
|
+
resetsAt: ctx.util.toIso(row.resetTime || row.reset_at || row.resetAt || row.reset_time),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function toPercentUsage(quota) {
|
|
181
|
+
if (!quota || quota.limit <= 0) return null
|
|
182
|
+
const usedPercent = (quota.used / quota.limit) * 100
|
|
183
|
+
if (!Number.isFinite(usedPercent)) return null
|
|
184
|
+
return {
|
|
185
|
+
used: Math.round(Math.max(0, usedPercent) * 10) / 10,
|
|
186
|
+
limit: 100,
|
|
187
|
+
resetsAt: quota.resetsAt,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function collectLimitCandidates(ctx, data) {
|
|
192
|
+
const limits = Array.isArray(data && data.limits) ? data.limits : []
|
|
193
|
+
const out = []
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < limits.length; i += 1) {
|
|
196
|
+
const item = limits[i]
|
|
197
|
+
const detail = item && typeof item.detail === "object" ? item.detail : item
|
|
198
|
+
const quota = parseQuota(detail, ctx)
|
|
199
|
+
if (!quota) continue
|
|
200
|
+
|
|
201
|
+
const periodMs = parseWindowPeriodMs(item && item.window)
|
|
202
|
+
out.push({ quota, periodMs })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pickSessionCandidate(candidates) {
|
|
209
|
+
if (!candidates.length) return null
|
|
210
|
+
const sorted = candidates.slice().sort(function (a, b) {
|
|
211
|
+
const aKnown = typeof a.periodMs === "number"
|
|
212
|
+
const bKnown = typeof b.periodMs === "number"
|
|
213
|
+
if (aKnown && bKnown) return a.periodMs - b.periodMs
|
|
214
|
+
if (aKnown) return -1
|
|
215
|
+
if (bKnown) return 1
|
|
216
|
+
return 0
|
|
217
|
+
})
|
|
218
|
+
return sorted[0]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pickLargestByPeriod(candidates) {
|
|
222
|
+
if (!candidates.length) return null
|
|
223
|
+
let best = candidates[0]
|
|
224
|
+
for (let i = 1; i < candidates.length; i += 1) {
|
|
225
|
+
const cur = candidates[i]
|
|
226
|
+
const curMs = typeof cur.periodMs === "number" ? cur.periodMs : -1
|
|
227
|
+
const bestMs = typeof best.periodMs === "number" ? best.periodMs : -1
|
|
228
|
+
if (curMs > bestMs) best = cur
|
|
229
|
+
}
|
|
230
|
+
return best
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sameQuota(a, b) {
|
|
234
|
+
if (!a || !b) return false
|
|
235
|
+
return (
|
|
236
|
+
a.quota.used === b.quota.used &&
|
|
237
|
+
a.quota.limit === b.quota.limit &&
|
|
238
|
+
(a.quota.resetsAt || null) === (b.quota.resetsAt || null)
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function probe(ctx) {
|
|
243
|
+
const creds = loadCredentials(ctx)
|
|
244
|
+
if (!creds) {
|
|
245
|
+
throw "Not logged in. Run `kimi login` to authenticate."
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const nowSec = Date.now() / 1000
|
|
249
|
+
let accessToken = creds.access_token || ""
|
|
250
|
+
|
|
251
|
+
if (needsRefresh(creds, nowSec)) {
|
|
252
|
+
const refreshed = refreshToken(ctx, creds)
|
|
253
|
+
if (refreshed) {
|
|
254
|
+
accessToken = refreshed
|
|
255
|
+
} else if (!accessToken) {
|
|
256
|
+
throw "Not logged in. Run `kimi login` to authenticate."
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let didRefresh = false
|
|
261
|
+
let resp
|
|
262
|
+
try {
|
|
263
|
+
resp = ctx.util.retryOnceOnAuth({
|
|
264
|
+
request: function (token) {
|
|
265
|
+
return fetchUsage(ctx, token || accessToken)
|
|
266
|
+
},
|
|
267
|
+
refresh: function () {
|
|
268
|
+
didRefresh = true
|
|
269
|
+
const refreshed = refreshToken(ctx, creds)
|
|
270
|
+
if (refreshed) accessToken = refreshed
|
|
271
|
+
return refreshed
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (typeof e === "string") throw e
|
|
276
|
+
if (didRefresh) {
|
|
277
|
+
throw "Usage request failed after refresh. Try again."
|
|
278
|
+
}
|
|
279
|
+
throw "Usage request failed. Check your connection."
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (ctx.util.isAuthStatus(resp.status)) {
|
|
283
|
+
throw "Token expired. Run `kimi login` to authenticate."
|
|
284
|
+
}
|
|
285
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
286
|
+
throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later."
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const data = ctx.util.tryParseJson(resp.bodyText)
|
|
290
|
+
if (!data || typeof data !== "object") {
|
|
291
|
+
throw "Usage response invalid. Try again later."
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const lines = []
|
|
295
|
+
const candidates = collectLimitCandidates(ctx, data)
|
|
296
|
+
const sessionCandidate = pickSessionCandidate(candidates)
|
|
297
|
+
|
|
298
|
+
let weeklyCandidate = null
|
|
299
|
+
const usageQuota = parseQuota(data.usage, ctx)
|
|
300
|
+
if (usageQuota) {
|
|
301
|
+
weeklyCandidate = { quota: usageQuota, periodMs: null }
|
|
302
|
+
} else {
|
|
303
|
+
const withoutSession = candidates.filter(function (candidate) {
|
|
304
|
+
return candidate !== sessionCandidate
|
|
305
|
+
})
|
|
306
|
+
weeklyCandidate = pickLargestByPeriod(withoutSession)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (sessionCandidate) {
|
|
310
|
+
const sessionPercent = toPercentUsage(sessionCandidate.quota)
|
|
311
|
+
if (sessionPercent) {
|
|
312
|
+
lines.push(
|
|
313
|
+
ctx.line.progress({
|
|
314
|
+
label: "Session",
|
|
315
|
+
used: sessionPercent.used,
|
|
316
|
+
limit: sessionPercent.limit,
|
|
317
|
+
format: { kind: "percent" },
|
|
318
|
+
resetsAt: sessionPercent.resetsAt || undefined,
|
|
319
|
+
periodDurationMs:
|
|
320
|
+
typeof sessionCandidate.periodMs === "number"
|
|
321
|
+
? sessionCandidate.periodMs
|
|
322
|
+
: undefined,
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (weeklyCandidate && !sameQuota(weeklyCandidate, sessionCandidate)) {
|
|
329
|
+
const weeklyPercent = toPercentUsage(weeklyCandidate.quota)
|
|
330
|
+
if (weeklyPercent) {
|
|
331
|
+
lines.push(
|
|
332
|
+
ctx.line.progress({
|
|
333
|
+
label: "Weekly",
|
|
334
|
+
used: weeklyPercent.used,
|
|
335
|
+
limit: weeklyPercent.limit,
|
|
336
|
+
format: { kind: "percent" },
|
|
337
|
+
resetsAt: weeklyPercent.resetsAt || undefined,
|
|
338
|
+
periodDurationMs:
|
|
339
|
+
typeof weeklyCandidate.periodMs === "number"
|
|
340
|
+
? weeklyCandidate.periodMs
|
|
341
|
+
: undefined,
|
|
342
|
+
})
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (lines.length === 0) {
|
|
348
|
+
lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" }))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
plan: parsePlanLabel(data),
|
|
353
|
+
lines,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
globalThis.__openusage_plugin = { id: "kimi", probe }
|
|
358
|
+
})()
|