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,602 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { makeCtx } from "../test-helpers.js"
|
|
3
|
+
|
|
4
|
+
const PRIMARY_CACHE_DB_PATH =
|
|
5
|
+
"~/Library/Containers/ai.perplexity.mac/Data/Library/Caches/ai.perplexity.mac/Cache.db"
|
|
6
|
+
const FALLBACK_CACHE_DB_PATH = "~/Library/Caches/ai.perplexity.mac/Cache.db"
|
|
7
|
+
|
|
8
|
+
const GROUP_ID = "test-group-id"
|
|
9
|
+
|
|
10
|
+
const loadPlugin = async () => {
|
|
11
|
+
await import("./plugin.js")
|
|
12
|
+
return globalThis.__openusage_plugin
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeJwtLikeToken() {
|
|
16
|
+
return "eyJ" + "a".repeat(80) + "." + "b".repeat(80) + "." + "c".repeat(80)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeRequestHexWithBearer(token, extraBytes) {
|
|
20
|
+
const base = Buffer.from("Bearer " + token, "utf8")
|
|
21
|
+
const out = extraBytes ? Buffer.concat([base, extraBytes]) : base
|
|
22
|
+
return out.toString("hex").toUpperCase()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeRequestHexWithSessionFields(token, userAgent, deviceId) {
|
|
26
|
+
const chunks = [Buffer.from("Bearer " + token + " ", "utf8")]
|
|
27
|
+
if (userAgent) {
|
|
28
|
+
chunks.push(Buffer.from(userAgent, "utf8"))
|
|
29
|
+
chunks.push(Buffer.from([0x00]))
|
|
30
|
+
}
|
|
31
|
+
if (deviceId) {
|
|
32
|
+
chunks.push(Buffer.from(deviceId, "utf8"))
|
|
33
|
+
chunks.push(Buffer.from([0x00]))
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks).toString("hex").toUpperCase()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mockCacheSession(ctx, options = {}) {
|
|
39
|
+
const selectedDbPath = options.dbPath || PRIMARY_CACHE_DB_PATH
|
|
40
|
+
const requestHex = options.requestHex || null
|
|
41
|
+
|
|
42
|
+
const originalExists = ctx.host.fs.exists
|
|
43
|
+
ctx.host.fs.exists = (path) => path === selectedDbPath || originalExists(path)
|
|
44
|
+
|
|
45
|
+
ctx.host.sqlite.query.mockImplementation((dbPath, sql) => {
|
|
46
|
+
if (dbPath === selectedDbPath && String(sql).includes("https://www.perplexity.ai/api/user")) {
|
|
47
|
+
return JSON.stringify([{ requestHex }])
|
|
48
|
+
}
|
|
49
|
+
return "[]"
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mockRestApi(ctx, options = {}) {
|
|
54
|
+
const balance = options.balance ?? 4.99
|
|
55
|
+
const isPro = options.isPro ?? true
|
|
56
|
+
const usageAnalytics = options.usageAnalytics ?? [
|
|
57
|
+
{ meter_event_summaries: [{ usage: 1, cost: 0.04 }] },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
61
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
62
|
+
return {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: {},
|
|
65
|
+
bodyText: JSON.stringify({ orgs: [{ api_org_id: GROUP_ID, is_default_org: true }] }),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}`) {
|
|
70
|
+
return {
|
|
71
|
+
status: 200,
|
|
72
|
+
headers: {},
|
|
73
|
+
bodyText: JSON.stringify({ customerInfo: { balance, is_pro: isPro } }),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}/usage-analytics`) {
|
|
78
|
+
return {
|
|
79
|
+
status: 200,
|
|
80
|
+
headers: {},
|
|
81
|
+
bodyText: JSON.stringify(usageAnalytics),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("perplexity plugin", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
delete globalThis.__openusage_plugin
|
|
92
|
+
vi.resetModules()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
vi.restoreAllMocks()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("throws when no local session is available", async () => {
|
|
100
|
+
const ctx = makeCtx()
|
|
101
|
+
const plugin = await loadPlugin()
|
|
102
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("returns only a Usage progress bar (primary cache path)", async () => {
|
|
106
|
+
const ctx = makeCtx()
|
|
107
|
+
const token = makeJwtLikeToken()
|
|
108
|
+
mockCacheSession(ctx, { dbPath: PRIMARY_CACHE_DB_PATH, requestHex: makeRequestHexWithBearer(token) })
|
|
109
|
+
mockRestApi(ctx, { balance: 4.99, usageAnalytics: [{ meter_event_summaries: [{ cost: 0.04 }] }] })
|
|
110
|
+
|
|
111
|
+
const plugin = await loadPlugin()
|
|
112
|
+
const result = plugin.probe(ctx)
|
|
113
|
+
|
|
114
|
+
expect(result.plan).toBe("Pro")
|
|
115
|
+
expect(Array.isArray(result.lines)).toBe(true)
|
|
116
|
+
expect(result.lines.length).toBe(1)
|
|
117
|
+
|
|
118
|
+
const line = result.lines[0]
|
|
119
|
+
expect(line.type).toBe("progress")
|
|
120
|
+
expect(line.label).toBe("Usage")
|
|
121
|
+
expect(line.format.kind).toBe("dollars")
|
|
122
|
+
expect(line.used).toBe(0.04)
|
|
123
|
+
expect(line.limit).toBe(4.99) // limit = balance only
|
|
124
|
+
expect(line.resetsAt).toBeUndefined()
|
|
125
|
+
expect(line.periodDurationMs).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("falls back to secondary cache path", async () => {
|
|
129
|
+
const ctx = makeCtx()
|
|
130
|
+
const token = makeJwtLikeToken()
|
|
131
|
+
mockCacheSession(ctx, { dbPath: FALLBACK_CACHE_DB_PATH, requestHex: makeRequestHexWithBearer(token) })
|
|
132
|
+
mockRestApi(ctx, { balance: 10, isPro: false, usageAnalytics: [{ meter_event_summaries: [{ cost: 0 }] }] })
|
|
133
|
+
|
|
134
|
+
const plugin = await loadPlugin()
|
|
135
|
+
const result = plugin.probe(ctx)
|
|
136
|
+
|
|
137
|
+
expect(result.plan).toBeUndefined()
|
|
138
|
+
expect(result.lines.length).toBe(1)
|
|
139
|
+
expect(result.lines[0].label).toBe("Usage")
|
|
140
|
+
expect(result.lines[0].limit).toBe(10)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("does not read env", async () => {
|
|
144
|
+
const ctx = makeCtx()
|
|
145
|
+
const token = makeJwtLikeToken()
|
|
146
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
147
|
+
mockRestApi(ctx)
|
|
148
|
+
|
|
149
|
+
const plugin = await loadPlugin()
|
|
150
|
+
plugin.probe(ctx)
|
|
151
|
+
expect(ctx.host.env.get).not.toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("treats cache row without bearer token as not logged in", async () => {
|
|
155
|
+
const ctx = makeCtx()
|
|
156
|
+
mockCacheSession(ctx, { requestHex: "00DEADBEEF00" })
|
|
157
|
+
|
|
158
|
+
const plugin = await loadPlugin()
|
|
159
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("strips trailing bplist marker after bearer token", async () => {
|
|
163
|
+
const ctx = makeCtx()
|
|
164
|
+
const token = makeJwtLikeToken()
|
|
165
|
+
const markerBytes = Buffer.from([0x5f, 0x10, 0xb5]) // '_' then bplist int marker
|
|
166
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token, markerBytes) })
|
|
167
|
+
mockRestApi(ctx)
|
|
168
|
+
|
|
169
|
+
const plugin = await loadPlugin()
|
|
170
|
+
plugin.probe(ctx)
|
|
171
|
+
|
|
172
|
+
const call = ctx.host.http.request.mock.calls[0]?.[0]
|
|
173
|
+
expect(call.headers.Authorization).toBe("Bearer " + token)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("throws when usage analytics is unavailable (avoid false $0 used)", async () => {
|
|
177
|
+
const ctx = makeCtx()
|
|
178
|
+
const token = makeJwtLikeToken()
|
|
179
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
180
|
+
|
|
181
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
182
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
183
|
+
return {
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: {},
|
|
186
|
+
bodyText: JSON.stringify({ orgs: [{ api_org_id: GROUP_ID, is_default_org: true }] }),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}`) {
|
|
190
|
+
return {
|
|
191
|
+
status: 200,
|
|
192
|
+
headers: {},
|
|
193
|
+
bodyText: JSON.stringify({ customerInfo: { balance: 4.99, is_pro: true } }),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}/usage-analytics`) {
|
|
197
|
+
return { status: 403, headers: {}, bodyText: "<html>Just a moment...</html>" }
|
|
198
|
+
}
|
|
199
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const plugin = await loadPlugin()
|
|
203
|
+
expect(() => plugin.probe(ctx)).toThrow("Usage unavailable")
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("supports trailing-slash REST fallbacks and nested money fields", async () => {
|
|
207
|
+
const ctx = makeCtx()
|
|
208
|
+
const token = makeJwtLikeToken()
|
|
209
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
210
|
+
|
|
211
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
212
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
213
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
214
|
+
}
|
|
215
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups/") {
|
|
216
|
+
return {
|
|
217
|
+
status: 200,
|
|
218
|
+
headers: {},
|
|
219
|
+
bodyText: JSON.stringify({ data: [{ id: 123, isDefaultOrg: true }] }),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups/123") {
|
|
223
|
+
return { status: 503, headers: {}, bodyText: "{}" }
|
|
224
|
+
}
|
|
225
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups/123/") {
|
|
226
|
+
return {
|
|
227
|
+
status: 200,
|
|
228
|
+
headers: {},
|
|
229
|
+
bodyText: JSON.stringify({
|
|
230
|
+
customerInfo: { is_pro: false },
|
|
231
|
+
organization: { balance: { amount_cents: 250 } },
|
|
232
|
+
}),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups/123/usage-analytics") {
|
|
236
|
+
return { status: 502, headers: {}, bodyText: "{}" }
|
|
237
|
+
}
|
|
238
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups/123/usage-analytics/") {
|
|
239
|
+
return {
|
|
240
|
+
status: 200,
|
|
241
|
+
headers: {},
|
|
242
|
+
bodyText: JSON.stringify([{ meterEventSummaries: [{ cost: 0.25 }] }]),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const plugin = await loadPlugin()
|
|
249
|
+
const result = plugin.probe(ctx)
|
|
250
|
+
|
|
251
|
+
expect(result.plan).toBeUndefined()
|
|
252
|
+
expect(result.lines[0].label).toBe("Usage")
|
|
253
|
+
expect(result.lines[0].used).toBe(0.25)
|
|
254
|
+
expect(result.lines[0].limit).toBe(2.5)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("extracts app version and device id from cached request and forwards as headers", async () => {
|
|
258
|
+
const ctx = makeCtx()
|
|
259
|
+
const token = makeJwtLikeToken()
|
|
260
|
+
mockCacheSession(ctx, {
|
|
261
|
+
requestHex: makeRequestHexWithSessionFields(token, "Ask/9.9.9", "macos:device-123"),
|
|
262
|
+
})
|
|
263
|
+
mockRestApi(ctx)
|
|
264
|
+
|
|
265
|
+
const plugin = await loadPlugin()
|
|
266
|
+
plugin.probe(ctx)
|
|
267
|
+
|
|
268
|
+
const firstRestCall = ctx.host.http.request.mock.calls.find((call) =>
|
|
269
|
+
String(call[0]?.url).includes("/rest/pplx-api/v2/groups")
|
|
270
|
+
)?.[0]
|
|
271
|
+
|
|
272
|
+
expect(firstRestCall).toBeTruthy()
|
|
273
|
+
expect(firstRestCall.headers["X-App-Version"]).toBe("9.9.9")
|
|
274
|
+
expect(firstRestCall.headers["X-Device-ID"]).toBe("macos:device-123")
|
|
275
|
+
expect(firstRestCall.headers["User-Agent"]).toBe("Ask/9.9.9")
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("throws balance unavailable when groups request is unauthorized", async () => {
|
|
279
|
+
const ctx = makeCtx()
|
|
280
|
+
const token = makeJwtLikeToken()
|
|
281
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
282
|
+
ctx.host.http.request.mockReturnValue({ status: 401, headers: {}, bodyText: "" })
|
|
283
|
+
|
|
284
|
+
const plugin = await loadPlugin()
|
|
285
|
+
expect(() => plugin.probe(ctx)).toThrow("Balance unavailable")
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("throws usage unavailable when analytics payload has no numeric cost", async () => {
|
|
289
|
+
const ctx = makeCtx()
|
|
290
|
+
const token = makeJwtLikeToken()
|
|
291
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
292
|
+
|
|
293
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
294
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
295
|
+
return {
|
|
296
|
+
status: 200,
|
|
297
|
+
headers: {},
|
|
298
|
+
bodyText: JSON.stringify({ orgs: [{ api_org_id: GROUP_ID, is_default_org: true }] }),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}`) {
|
|
302
|
+
return {
|
|
303
|
+
status: 200,
|
|
304
|
+
headers: {},
|
|
305
|
+
bodyText: JSON.stringify({ customerInfo: { balance: "$4.99", is_pro: true } }),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}/usage-analytics`) {
|
|
309
|
+
return {
|
|
310
|
+
status: 200,
|
|
311
|
+
headers: {},
|
|
312
|
+
bodyText: JSON.stringify([{ meter_event_summaries: [{ cost: "NaN" }] }]),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const plugin = await loadPlugin()
|
|
319
|
+
expect(() => plugin.probe(ctx)).toThrow("Usage unavailable")
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it("recovers when primary cache sqlite read fails and fallback cache is valid", async () => {
|
|
323
|
+
const ctx = makeCtx()
|
|
324
|
+
const token = makeJwtLikeToken()
|
|
325
|
+
const requestHex = makeRequestHexWithBearer(token)
|
|
326
|
+
const originalExists = ctx.host.fs.exists
|
|
327
|
+
|
|
328
|
+
ctx.host.fs.exists = (path) =>
|
|
329
|
+
path === PRIMARY_CACHE_DB_PATH || path === FALLBACK_CACHE_DB_PATH || originalExists(path)
|
|
330
|
+
|
|
331
|
+
ctx.host.sqlite.query.mockImplementation((dbPath, sql) => {
|
|
332
|
+
if (!String(sql).includes("https://www.perplexity.ai/api/user")) return "[]"
|
|
333
|
+
if (dbPath === PRIMARY_CACHE_DB_PATH) throw new Error("primary db locked")
|
|
334
|
+
if (dbPath === FALLBACK_CACHE_DB_PATH) return JSON.stringify([{ requestHex }])
|
|
335
|
+
return "[]"
|
|
336
|
+
})
|
|
337
|
+
mockRestApi(ctx)
|
|
338
|
+
|
|
339
|
+
const plugin = await loadPlugin()
|
|
340
|
+
const result = plugin.probe(ctx)
|
|
341
|
+
expect(result.lines[0].label).toBe("Usage")
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it("continues when primary cache exists-check throws and fallback has a session", async () => {
|
|
345
|
+
const ctx = makeCtx()
|
|
346
|
+
const token = makeJwtLikeToken()
|
|
347
|
+
const requestHex = makeRequestHexWithBearer(token)
|
|
348
|
+
const originalExists = ctx.host.fs.exists
|
|
349
|
+
|
|
350
|
+
ctx.host.fs.exists = (path) => {
|
|
351
|
+
if (path === PRIMARY_CACHE_DB_PATH) throw new Error("permission denied")
|
|
352
|
+
if (path === FALLBACK_CACHE_DB_PATH) return true
|
|
353
|
+
return originalExists(path)
|
|
354
|
+
}
|
|
355
|
+
ctx.host.sqlite.query.mockImplementation((dbPath, sql) => {
|
|
356
|
+
if (dbPath === FALLBACK_CACHE_DB_PATH && String(sql).includes("https://www.perplexity.ai/api/user")) {
|
|
357
|
+
return JSON.stringify([{ requestHex }])
|
|
358
|
+
}
|
|
359
|
+
return "[]"
|
|
360
|
+
})
|
|
361
|
+
mockRestApi(ctx)
|
|
362
|
+
|
|
363
|
+
const plugin = await loadPlugin()
|
|
364
|
+
const result = plugin.probe(ctx)
|
|
365
|
+
expect(result.lines[0].label).toBe("Usage")
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("parses balance from regex-matched credit key path", async () => {
|
|
369
|
+
const ctx = makeCtx()
|
|
370
|
+
const token = makeJwtLikeToken()
|
|
371
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
372
|
+
|
|
373
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
374
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
375
|
+
return {
|
|
376
|
+
status: 200,
|
|
377
|
+
headers: {},
|
|
378
|
+
bodyText: JSON.stringify({ orgs: [{ api_org_id: GROUP_ID, is_default_org: true }] }),
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}`) {
|
|
382
|
+
return {
|
|
383
|
+
status: 200,
|
|
384
|
+
headers: {},
|
|
385
|
+
bodyText: JSON.stringify({
|
|
386
|
+
customerInfo: { is_pro: true },
|
|
387
|
+
available_credit: "$7.25",
|
|
388
|
+
}),
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}/usage-analytics`) {
|
|
392
|
+
return {
|
|
393
|
+
status: 200,
|
|
394
|
+
headers: {},
|
|
395
|
+
bodyText: JSON.stringify([{ meter_event_summaries: [{ cost: 0.25 }] }]),
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const plugin = await loadPlugin()
|
|
402
|
+
const result = plugin.probe(ctx)
|
|
403
|
+
expect(result.plan).toBe("Pro")
|
|
404
|
+
expect(result.lines[0].limit).toBe(7.25)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it("uses first group id when groups payload is an array without default flag", async () => {
|
|
408
|
+
const ctx = makeCtx()
|
|
409
|
+
const token = makeJwtLikeToken()
|
|
410
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
411
|
+
|
|
412
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
413
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
414
|
+
return {
|
|
415
|
+
status: 200,
|
|
416
|
+
headers: {},
|
|
417
|
+
bodyText: JSON.stringify([{ id: "grp-a" }, { id: "grp-b" }]),
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups/grp-a") {
|
|
421
|
+
return {
|
|
422
|
+
status: 200,
|
|
423
|
+
headers: {},
|
|
424
|
+
bodyText: JSON.stringify([
|
|
425
|
+
{ note: "first element has no balance" },
|
|
426
|
+
{ balance_usd: 6.5, customerInfo: { is_pro: false } },
|
|
427
|
+
]),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups/grp-a/usage-analytics") {
|
|
431
|
+
return {
|
|
432
|
+
status: 200,
|
|
433
|
+
headers: {},
|
|
434
|
+
bodyText: JSON.stringify([{ meter_event_summaries: [{ cost: 0.5 }] }]),
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
const plugin = await loadPlugin()
|
|
441
|
+
const result = plugin.probe(ctx)
|
|
442
|
+
expect(result.lines[0].limit).toBe(6.5)
|
|
443
|
+
expect(result.lines[0].used).toBe(0.5)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it("throws balance unavailable when groups payload contains no readable ids", async () => {
|
|
447
|
+
const ctx = makeCtx()
|
|
448
|
+
const token = makeJwtLikeToken()
|
|
449
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
450
|
+
|
|
451
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
452
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
453
|
+
return { status: 200, headers: {}, bodyText: JSON.stringify({ orgs: [{ name: "missing-id" }] }) }
|
|
454
|
+
}
|
|
455
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
const plugin = await loadPlugin()
|
|
459
|
+
expect(() => plugin.probe(ctx)).toThrow("Balance unavailable")
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it("treats empty cache query rows as not logged in", async () => {
|
|
463
|
+
const ctx = makeCtx()
|
|
464
|
+
const originalExists = ctx.host.fs.exists
|
|
465
|
+
ctx.host.fs.exists = (path) => path === PRIMARY_CACHE_DB_PATH || originalExists(path)
|
|
466
|
+
ctx.host.sqlite.query.mockImplementation((dbPath, sql) => {
|
|
467
|
+
if (dbPath === PRIMARY_CACHE_DB_PATH && String(sql).includes("https://www.perplexity.ai/api/user")) {
|
|
468
|
+
return JSON.stringify([])
|
|
469
|
+
}
|
|
470
|
+
return "[]"
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const plugin = await loadPlugin()
|
|
474
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it("treats cache rows without requestHex as not logged in", async () => {
|
|
478
|
+
const ctx = makeCtx()
|
|
479
|
+
const originalExists = ctx.host.fs.exists
|
|
480
|
+
ctx.host.fs.exists = (path) => path === PRIMARY_CACHE_DB_PATH || originalExists(path)
|
|
481
|
+
ctx.host.sqlite.query.mockImplementation((dbPath, sql) => {
|
|
482
|
+
if (dbPath === PRIMARY_CACHE_DB_PATH && String(sql).includes("https://www.perplexity.ai/api/user")) {
|
|
483
|
+
return JSON.stringify([{}])
|
|
484
|
+
}
|
|
485
|
+
return "[]"
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
const plugin = await loadPlugin()
|
|
489
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it("treats non-string requestHex as not logged in", async () => {
|
|
493
|
+
const ctx = makeCtx()
|
|
494
|
+
const originalExists = ctx.host.fs.exists
|
|
495
|
+
ctx.host.fs.exists = (path) => path === PRIMARY_CACHE_DB_PATH || originalExists(path)
|
|
496
|
+
ctx.host.sqlite.query.mockImplementation((dbPath, sql) => {
|
|
497
|
+
if (dbPath === PRIMARY_CACHE_DB_PATH && String(sql).includes("https://www.perplexity.ai/api/user")) {
|
|
498
|
+
return JSON.stringify([{ requestHex: 12345 }])
|
|
499
|
+
}
|
|
500
|
+
return "[]"
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
const plugin = await loadPlugin()
|
|
504
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it("rejects malformed bearer token payloads from cache rows", async () => {
|
|
508
|
+
const ctx = makeCtx()
|
|
509
|
+
// Contains "Bearer " prefix but token has no JWT dots and invalid hex bytes later.
|
|
510
|
+
const malformed = "426561726572204142435A5A"
|
|
511
|
+
mockCacheSession(ctx, { requestHex: malformed })
|
|
512
|
+
|
|
513
|
+
const plugin = await loadPlugin()
|
|
514
|
+
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it("throws balance unavailable when groups endpoint returns invalid JSON", async () => {
|
|
518
|
+
const ctx = makeCtx()
|
|
519
|
+
const token = makeJwtLikeToken()
|
|
520
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
521
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
522
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
523
|
+
return { status: 200, headers: {}, bodyText: "not-json" }
|
|
524
|
+
}
|
|
525
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
const plugin = await loadPlugin()
|
|
529
|
+
expect(() => plugin.probe(ctx)).toThrow("Balance unavailable")
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it("throws balance unavailable when balance object only contains non-finite cents", async () => {
|
|
533
|
+
const ctx = makeCtx()
|
|
534
|
+
const token = makeJwtLikeToken()
|
|
535
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
536
|
+
|
|
537
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
538
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
539
|
+
return {
|
|
540
|
+
status: 200,
|
|
541
|
+
headers: {},
|
|
542
|
+
bodyText: JSON.stringify({ orgs: [{ api_org_id: GROUP_ID, is_default_org: true }] }),
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}`) {
|
|
546
|
+
return {
|
|
547
|
+
status: 200,
|
|
548
|
+
headers: {},
|
|
549
|
+
bodyText: JSON.stringify({
|
|
550
|
+
customerInfo: { is_pro: false },
|
|
551
|
+
wallet: { amount_cents: "1e309" },
|
|
552
|
+
}),
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}/usage-analytics`) {
|
|
556
|
+
return {
|
|
557
|
+
status: 200,
|
|
558
|
+
headers: {},
|
|
559
|
+
bodyText: JSON.stringify([{ meter_event_summaries: [{ cost: 1.0 }] }]),
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
const plugin = await loadPlugin()
|
|
566
|
+
expect(() => plugin.probe(ctx)).toThrow("Balance unavailable")
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it("throws balance unavailable when computed limit is zero", async () => {
|
|
570
|
+
const ctx = makeCtx()
|
|
571
|
+
const token = makeJwtLikeToken()
|
|
572
|
+
mockCacheSession(ctx, { requestHex: makeRequestHexWithBearer(token) })
|
|
573
|
+
|
|
574
|
+
ctx.host.http.request.mockImplementation((req) => {
|
|
575
|
+
if (req.url === "https://www.perplexity.ai/rest/pplx-api/v2/groups") {
|
|
576
|
+
return {
|
|
577
|
+
status: 200,
|
|
578
|
+
headers: {},
|
|
579
|
+
bodyText: JSON.stringify({ orgs: [{ api_org_id: GROUP_ID, is_default_org: true }] }),
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}`) {
|
|
583
|
+
return {
|
|
584
|
+
status: 200,
|
|
585
|
+
headers: {},
|
|
586
|
+
bodyText: JSON.stringify({ customerInfo: { is_pro: true, balance: 0 } }),
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (req.url === `https://www.perplexity.ai/rest/pplx-api/v2/groups/${GROUP_ID}/usage-analytics`) {
|
|
590
|
+
return {
|
|
591
|
+
status: 200,
|
|
592
|
+
headers: {},
|
|
593
|
+
bodyText: JSON.stringify([{ meter_event_summaries: [{ cost: 0.1 }] }]),
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { status: 404, headers: {}, bodyText: "{}" }
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
const plugin = await loadPlugin()
|
|
600
|
+
expect(() => plugin.probe(ctx)).toThrow("Balance unavailable")
|
|
601
|
+
})
|
|
602
|
+
})
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg width="100" height="100" viewBox="-10 -40 532 380" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M507.28 0.142623H502.4C476.721 0.10263 455.882 20.899 455.882 46.5745V150.416C455.882 171.153 438.743 187.95 418.344 187.95C406.224 187.95 394.125 181.851 386.945 171.613L280.889 20.1391C272.089 7.56133 257.77 0.0626373 242.271 0.0626373C218.091 0.0626373 196.332 20.6191 196.332 45.9946V150.436C196.332 171.173 179.333 187.97 158.794 187.97C146.634 187.97 134.555 181.871 127.375 171.633L8.69966 2.12228C6.01976 -1.71705 0 0.182617 0 4.8618V95.426C0 100.005 1.39995 104.444 4.01984 108.204L120.815 274.995C127.715 284.853 137.895 292.172 149.634 294.831C179.013 301.51 206.052 278.894 206.052 250.079V145.697C206.052 124.961 222.851 108.164 243.59 108.164H243.65C256.15 108.164 267.87 114.263 275.049 124.501L381.125 275.955C389.945 288.552 403.524 296.031 419.724 296.031C444.443 296.031 465.622 275.455 465.622 250.099V145.677C465.622 124.941 482.421 108.144 503.16 108.144H507.3C509.9 108.144 512 106.044 512 103.445V4.8418C512 2.24226 509.9 0.142623 507.3 0.142623H507.28Z" fill="currentColor"/>
|
|
3
|
+
</svg>
|