@slkiser/opencode-quota 2.6.2 → 2.9.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 (65) hide show
  1. package/README.md +225 -168
  2. package/dist/data/modelsdev-pricing.min.json +51 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/config.js +32 -0
  8. package/dist/lib/config.js.map +1 -1
  9. package/dist/lib/cursor-detection.d.ts +17 -0
  10. package/dist/lib/cursor-detection.d.ts.map +1 -0
  11. package/dist/lib/cursor-detection.js +169 -0
  12. package/dist/lib/cursor-detection.js.map +1 -0
  13. package/dist/lib/cursor-pricing.d.ts +34 -0
  14. package/dist/lib/cursor-pricing.d.ts.map +1 -0
  15. package/dist/lib/cursor-pricing.js +133 -0
  16. package/dist/lib/cursor-pricing.js.map +1 -0
  17. package/dist/lib/cursor-usage.d.ts +32 -0
  18. package/dist/lib/cursor-usage.d.ts.map +1 -0
  19. package/dist/lib/cursor-usage.js +127 -0
  20. package/dist/lib/cursor-usage.js.map +1 -0
  21. package/dist/lib/entries.d.ts +5 -0
  22. package/dist/lib/entries.d.ts.map +1 -1
  23. package/dist/lib/entries.js +0 -6
  24. package/dist/lib/entries.js.map +1 -1
  25. package/dist/lib/jsonc.d.ts +8 -1
  26. package/dist/lib/jsonc.d.ts.map +1 -1
  27. package/dist/lib/jsonc.js +12 -2
  28. package/dist/lib/jsonc.js.map +1 -1
  29. package/dist/lib/modelsdev-pricing.d.ts +8 -2
  30. package/dist/lib/modelsdev-pricing.d.ts.map +1 -1
  31. package/dist/lib/modelsdev-pricing.js +84 -29
  32. package/dist/lib/modelsdev-pricing.js.map +1 -1
  33. package/dist/lib/provider-metadata.d.ts.map +1 -1
  34. package/dist/lib/provider-metadata.js +4 -0
  35. package/dist/lib/provider-metadata.js.map +1 -1
  36. package/dist/lib/quota-stats-format.d.ts.map +1 -1
  37. package/dist/lib/quota-stats-format.js +30 -38
  38. package/dist/lib/quota-stats-format.js.map +1 -1
  39. package/dist/lib/quota-stats.d.ts +1 -1
  40. package/dist/lib/quota-stats.d.ts.map +1 -1
  41. package/dist/lib/quota-stats.js +26 -14
  42. package/dist/lib/quota-stats.js.map +1 -1
  43. package/dist/lib/quota-status.d.ts +5 -0
  44. package/dist/lib/quota-status.d.ts.map +1 -1
  45. package/dist/lib/quota-status.js +59 -2
  46. package/dist/lib/quota-status.js.map +1 -1
  47. package/dist/lib/token-cost.d.ts +10 -0
  48. package/dist/lib/token-cost.d.ts.map +1 -0
  49. package/dist/lib/token-cost.js +16 -0
  50. package/dist/lib/token-cost.js.map +1 -0
  51. package/dist/lib/types.d.ts +18 -0
  52. package/dist/lib/types.d.ts.map +1 -1
  53. package/dist/lib/types.js +5 -0
  54. package/dist/lib/types.js.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/dist/plugin.js +382 -128
  57. package/dist/plugin.js.map +1 -1
  58. package/dist/providers/cursor.d.ts +3 -0
  59. package/dist/providers/cursor.d.ts.map +1 -0
  60. package/dist/providers/cursor.js +138 -0
  61. package/dist/providers/cursor.js.map +1 -0
  62. package/dist/providers/registry.d.ts.map +1 -1
  63. package/dist/providers/registry.js +2 -0
  64. package/dist/providers/registry.js.map +1 -1
  65. package/package.json +4 -1
package/dist/plugin.js CHANGED
@@ -16,12 +16,13 @@ import { aggregateUsage } from "./lib/quota-stats.js";
16
16
  import { fetchSessionTokensForDisplay } from "./lib/session-tokens.js";
17
17
  import { formatQuotaStatsReport } from "./lib/quota-stats-format.js";
18
18
  import { buildQuotaStatusReport } from "./lib/quota-status.js";
19
- import { maybeRefreshPricingSnapshot } from "./lib/modelsdev-pricing.js";
19
+ import { getPricingSnapshotMeta, getPricingSnapshotSource, getRuntimePricingRefreshStatePath, getRuntimePricingSnapshotPath, maybeRefreshPricingSnapshot, setPricingSnapshotAutoRefresh, setPricingSnapshotSelection, } from "./lib/modelsdev-pricing.js";
20
20
  import { refreshGoogleTokensForAllAccounts } from "./lib/google.js";
21
21
  import { getQuotaProviderDisplayLabel } from "./lib/provider-metadata.js";
22
22
  import { DEFAULT_ALIBABA_AUTH_CACHE_MAX_AGE_MS, isAlibabaModelId, resolveAlibabaCodingPlanAuthCached, } from "./lib/alibaba-auth.js";
23
23
  import { isQwenCodeModelId, resolveQwenLocalPlanCached, } from "./lib/qwen-auth.js";
24
24
  import { recordAlibabaCodingPlanCompletion, recordQwenCompletion, } from "./lib/qwen-local-quota.js";
25
+ import { isCursorModelId, isCursorProviderId } from "./lib/cursor-pricing.js";
25
26
  import { parseOptionalJsonArgs, parseQuotaBetweenArgs, startOfLocalDayMs, startOfNextLocalDayMs, formatYmd, } from "./lib/command-parsing.js";
26
27
  import { handled } from "./lib/command-handled.js";
27
28
  import { renderCommandHeading } from "./lib/format-utils.js";
@@ -30,7 +31,7 @@ const TOKEN_REPORT_COMMANDS = [
30
31
  {
31
32
  id: "tokens_today",
32
33
  template: "/tokens_today",
33
- description: "Token + official API cost summary for today (calendar day, local timezone).",
34
+ description: "Token + deterministic cost summary for today (calendar day, local timezone).",
34
35
  title: "Tokens used (Today) (/tokens_today)",
35
36
  metadataTitle: "Tokens used (Today)",
36
37
  kind: "today",
@@ -38,7 +39,7 @@ const TOKEN_REPORT_COMMANDS = [
38
39
  {
39
40
  id: "tokens_daily",
40
41
  template: "/tokens_daily",
41
- description: "Token + official API cost summary for the last 24 hours (rolling).",
42
+ description: "Token + deterministic cost summary for the last 24 hours (rolling).",
42
43
  title: "Tokens used (Last 24 Hours) (/tokens_daily)",
43
44
  metadataTitle: "Tokens used (Last 24 Hours)",
44
45
  kind: "rolling",
@@ -47,7 +48,7 @@ const TOKEN_REPORT_COMMANDS = [
47
48
  {
48
49
  id: "tokens_weekly",
49
50
  template: "/tokens_weekly",
50
- description: "Token + official API cost summary for the last 7 days (rolling).",
51
+ description: "Token + deterministic cost summary for the last 7 days (rolling).",
51
52
  title: "Tokens used (Last 7 Days) (/tokens_weekly)",
52
53
  metadataTitle: "Tokens used (Last 7 Days)",
53
54
  kind: "rolling",
@@ -56,7 +57,7 @@ const TOKEN_REPORT_COMMANDS = [
56
57
  {
57
58
  id: "tokens_monthly",
58
59
  template: "/tokens_monthly",
59
- description: "Token + official API cost summary for the last 30 days (rolling).",
60
+ description: "Token + deterministic cost summary for the last 30 days (rolling).",
60
61
  title: "Tokens used (Last 30 Days) (/tokens_monthly)",
61
62
  metadataTitle: "Tokens used (Last 30 Days)",
62
63
  kind: "rolling",
@@ -65,7 +66,7 @@ const TOKEN_REPORT_COMMANDS = [
65
66
  {
66
67
  id: "tokens_all",
67
68
  template: "/tokens_all",
68
- description: "Token + official API cost summary for all locally saved OpenCode history.",
69
+ description: "Token + deterministic cost summary for all locally saved OpenCode history.",
69
70
  title: "Tokens used (All Time) (/tokens_all)",
70
71
  metadataTitle: "Tokens used (All Time)",
71
72
  kind: "all",
@@ -75,7 +76,7 @@ const TOKEN_REPORT_COMMANDS = [
75
76
  {
76
77
  id: "tokens_session",
77
78
  template: "/tokens_session",
78
- description: "Token + official API cost summary for current session only.",
79
+ description: "Token + deterministic cost summary for current session only.",
79
80
  title: "Tokens used (Current Session) (/tokens_session)",
80
81
  metadataTitle: "Tokens used (Current Session)",
81
82
  kind: "session",
@@ -83,7 +84,7 @@ const TOKEN_REPORT_COMMANDS = [
83
84
  {
84
85
  id: "tokens_between",
85
86
  template: "/tokens_between",
86
- description: "Token + cost report between two YYYY-MM-DD dates (local timezone, inclusive).",
87
+ description: "Token + deterministic cost report between two YYYY-MM-DD dates (local timezone, inclusive).",
87
88
  titleForRange: (startYmd, endYmd) => {
88
89
  return `Tokens used (${formatYmd(startYmd)} .. ${formatYmd(endYmd)}) (/tokens_between)`;
89
90
  },
@@ -106,7 +107,7 @@ function isTokenReportCommand(cmd) {
106
107
  // =============================================================================
107
108
  // Plugin Implementation
108
109
  // =============================================================================
109
- const LOCAL_REQUEST_PLAN_PROVIDER_IDS = new Set(["qwen-code", "alibaba-coding-plan"]);
110
+ const LIVE_LOCAL_USAGE_PROVIDER_IDS = new Set(["qwen-code", "alibaba-coding-plan", "cursor"]);
110
111
  /**
111
112
  * Main plugin export
112
113
  */
@@ -152,20 +153,45 @@ export const QuotaToastPlugin = async ({ client }) => {
152
153
  let lastSessionTokenError;
153
154
  const providerFetchCache = new Map();
154
155
  function getQuotaCommandCache() {
155
- let quotaCache = globalThis.__opencodeQuotaCommandCache;
156
- if (!quotaCache) {
157
- quotaCache = { body: "", timestamp: 0 };
158
- globalThis.__opencodeQuotaCommandCache = quotaCache;
156
+ const existing = globalThis.__opencodeQuotaCommandCache;
157
+ if (existing instanceof Map) {
158
+ return existing;
159
159
  }
160
+ const quotaCache = new Map();
161
+ globalThis.__opencodeQuotaCommandCache = quotaCache;
160
162
  return quotaCache;
161
163
  }
162
164
  function clearQuotaCommandCache() {
163
- const quotaCache = globalThis.__opencodeQuotaCommandCache;
164
- if (!quotaCache)
165
- return;
166
- quotaCache.body = "";
167
- quotaCache.timestamp = 0;
168
- quotaCache.inFlight = undefined;
165
+ getQuotaCommandCache().clear();
166
+ }
167
+ function buildQuotaCommandCacheKey(params) {
168
+ const enabledProviders = config.enabledProviders === "auto" ? "auto" : config.enabledProviders.join(",");
169
+ const googleModels = config.googleModels.join(",");
170
+ const currentModel = config.onlyCurrentModel && params.sessionID ? params.sessionMeta?.modelID ?? "" : "";
171
+ const currentProviderID = config.onlyCurrentModel && params.sessionID ? params.sessionMeta?.providerID ?? "" : "";
172
+ return [
173
+ `sessionID=${params.sessionID ?? ""}`,
174
+ `showSessionTokens=${config.showSessionTokens ? "yes" : "no"}`,
175
+ `onlyCurrentModel=${config.onlyCurrentModel ? "yes" : "no"}`,
176
+ `enabledProviders=${enabledProviders}`,
177
+ `googleModels=${googleModels}`,
178
+ `alibabaTier=${config.alibabaCodingPlanTier}`,
179
+ `cursorPlan=${config.cursorPlan}`,
180
+ `cursorIncludedApiUsd=${config.cursorIncludedApiUsd ?? ""}`,
181
+ `cursorBillingCycleStartDay=${config.cursorBillingCycleStartDay ?? ""}`,
182
+ `currentModel=${currentModel}`,
183
+ `currentProviderID=${currentProviderID}`,
184
+ ].join("|");
185
+ }
186
+ function pruneQuotaCommandCache(ttlMs, nowMs) {
187
+ const quotaCache = getQuotaCommandCache();
188
+ for (const [cacheKey, entry] of quotaCache.entries()) {
189
+ if (entry.inFlight)
190
+ continue;
191
+ if (entry.timestamp <= 0 || ttlMs <= 0 || nowMs - entry.timestamp >= ttlMs) {
192
+ quotaCache.delete(cacheKey);
193
+ }
194
+ }
169
195
  }
170
196
  function asRecord(value) {
171
197
  return value && typeof value === "object" ? value : null;
@@ -210,14 +236,18 @@ export const QuotaToastPlugin = async ({ client }) => {
210
236
  const style = ctx.config.toastStyle ?? "classic";
211
237
  const googleModels = ctx.config.googleModels.join(",");
212
238
  const alibabaCodingPlanTier = ctx.config.alibabaCodingPlanTier;
239
+ const cursorPlan = ctx.config.cursorPlan;
240
+ const cursorIncludedApiUsd = ctx.config.cursorIncludedApiUsd ?? "";
241
+ const cursorBillingCycleStartDay = ctx.config.cursorBillingCycleStartDay ?? "";
213
242
  const onlyCurrentModel = ctx.config.onlyCurrentModel ? "yes" : "no";
214
243
  const currentModel = ctx.config.currentModel ?? "";
215
- return `${providerId}|style=${style}|googleModels=${googleModels}|alibabaTier=${alibabaCodingPlanTier}|onlyCurrentModel=${onlyCurrentModel}|currentModel=${currentModel}`;
244
+ const currentProviderID = ctx.config.currentProviderID ?? "";
245
+ return `${providerId}|style=${style}|googleModels=${googleModels}|alibabaTier=${alibabaCodingPlanTier}|cursorPlan=${cursorPlan}|cursorIncludedApiUsd=${cursorIncludedApiUsd}|cursorBillingCycleStartDay=${cursorBillingCycleStartDay}|onlyCurrentModel=${onlyCurrentModel}|currentModel=${currentModel}|currentProviderID=${currentProviderID}`;
216
246
  }
217
247
  async function fetchProviderWithCache(params) {
218
248
  const { provider, ctx, ttlMs } = params;
219
- // Local request-plan providers should update per completion for accurate rolling counters.
220
- if (LOCAL_REQUEST_PLAN_PROVIDER_IDS.has(provider.id)) {
249
+ // Live local-usage providers should update per completion for accurate local reports.
250
+ if (LIVE_LOCAL_USAGE_PROVIDER_IDS.has(provider.id)) {
221
251
  return await provider.fetch(ctx);
222
252
  }
223
253
  const cacheKey = makeProviderFetchCacheKey(provider.id, ctx);
@@ -255,30 +285,67 @@ export const QuotaToastPlugin = async ({ client }) => {
255
285
  });
256
286
  return promise;
257
287
  }
258
- async function shouldBypassToastCacheForLocalRequestPlan(trigger, sessionID) {
288
+ function makeProviderFetchFailure(provider) {
289
+ return {
290
+ attempted: true,
291
+ entries: [],
292
+ errors: [
293
+ {
294
+ label: getQuotaProviderDisplayLabel(provider.id),
295
+ message: "Failed to read quota data",
296
+ },
297
+ ],
298
+ };
299
+ }
300
+ async function fetchProviderResults(params) {
301
+ const settled = await Promise.allSettled(params.providers.map((provider) => fetchProviderWithCache({
302
+ provider,
303
+ ctx: params.ctx,
304
+ ttlMs: params.ttlMs,
305
+ })));
306
+ return settled.map((result, index) => result.status === "fulfilled"
307
+ ? result.value
308
+ : makeProviderFetchFailure(params.providers[index]));
309
+ }
310
+ function getExplicitNoDataMessage(provider) {
311
+ if (provider.id === "cursor") {
312
+ return "No local usage yet";
313
+ }
314
+ return "Not configured";
315
+ }
316
+ function isProviderEnabled(providerId) {
317
+ return config.enabledProviders === "auto" || config.enabledProviders.includes(providerId);
318
+ }
319
+ async function shouldBypassToastCacheForLiveLocalUsage(params) {
320
+ const { trigger, sessionID } = params;
259
321
  if (trigger !== "question")
260
322
  return false;
261
- const currentModel = await getCurrentModel(sessionID);
323
+ const currentSession = params.sessionMeta ?? (await getSessionModelMeta(sessionID));
324
+ const currentModel = currentSession.modelID;
262
325
  if (isQwenCodeModelId(currentModel)) {
263
326
  const plan = await resolveQwenLocalPlanCached();
264
- return (plan.state === "qwen_free" &&
265
- (config.enabledProviders === "auto" || config.enabledProviders.includes("qwen-code")));
327
+ return plan.state === "qwen_free" && isProviderEnabled("qwen-code");
266
328
  }
267
329
  if (isAlibabaModelId(currentModel)) {
268
330
  const plan = await resolveAlibabaCodingPlanAuthCached({
269
331
  maxAgeMs: DEFAULT_ALIBABA_AUTH_CACHE_MAX_AGE_MS,
270
332
  fallbackTier: config.alibabaCodingPlanTier,
271
333
  });
272
- return (plan.state === "configured" &&
273
- (config.enabledProviders === "auto" ||
274
- config.enabledProviders.includes("alibaba-coding-plan")));
334
+ return plan.state === "configured" && isProviderEnabled("alibaba-coding-plan");
335
+ }
336
+ if (isCursorProviderId(currentSession.providerID) || isCursorModelId(currentModel)) {
337
+ return isProviderEnabled("cursor");
275
338
  }
276
339
  return false;
277
340
  }
278
- async function shouldBypassQuotaCommandCache(sessionID) {
341
+ async function shouldBypassQuotaCommandCache(sessionID, sessionMeta) {
279
342
  if (config.debug || !sessionID)
280
343
  return config.debug;
281
- return await shouldBypassToastCacheForLocalRequestPlan("question", sessionID);
344
+ return await shouldBypassToastCacheForLiveLocalUsage({
345
+ trigger: "question",
346
+ sessionID,
347
+ sessionMeta,
348
+ });
282
349
  }
283
350
  async function refreshConfig() {
284
351
  if (configInFlight)
@@ -287,11 +354,15 @@ export const QuotaToastPlugin = async ({ client }) => {
287
354
  try {
288
355
  configMeta = createLoadConfigMeta();
289
356
  config = await loadConfig(typedClient, configMeta);
357
+ setPricingSnapshotAutoRefresh(config.pricingSnapshot.autoRefresh);
358
+ setPricingSnapshotSelection(config.pricingSnapshot.source);
290
359
  configLoaded = true;
291
360
  }
292
361
  catch {
293
362
  // Leave configLoaded=false so we can retry on next trigger.
294
363
  config = DEFAULT_CONFIG;
364
+ setPricingSnapshotAutoRefresh(DEFAULT_CONFIG.pricingSnapshot.autoRefresh);
365
+ setPricingSnapshotSelection(DEFAULT_CONFIG.pricingSnapshot.source);
295
366
  }
296
367
  finally {
297
368
  configInFlight = null;
@@ -301,7 +372,10 @@ export const QuotaToastPlugin = async ({ client }) => {
301
372
  }
302
373
  async function kickPricingRefresh(params) {
303
374
  try {
304
- const refreshPromise = maybeRefreshPricingSnapshot({ reason: params.reason });
375
+ const refreshPromise = maybeRefreshPricingSnapshot({
376
+ reason: params.reason,
377
+ snapshotSelection: config.pricingSnapshot.source,
378
+ });
305
379
  const guardedRefreshPromise = refreshPromise.catch(() => undefined);
306
380
  if (!params.maxWaitMs || params.maxWaitMs <= 0) {
307
381
  void guardedRefreshPromise;
@@ -340,6 +414,11 @@ export const QuotaToastPlugin = async ({ client }) => {
340
414
  enabledProviders: config.enabledProviders,
341
415
  minIntervalMs: config.minIntervalMs,
342
416
  googleModels: config.googleModels,
417
+ cursorPlan: config.cursorPlan,
418
+ cursorIncludedApiUsd: config.cursorIncludedApiUsd,
419
+ cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
420
+ pricingSnapshotSource: config.pricingSnapshot.source,
421
+ pricingSnapshotAutoRefresh: config.pricingSnapshot.autoRefresh,
343
422
  showOnIdle: config.showOnIdle,
344
423
  showOnQuestion: config.showOnQuestion,
345
424
  showOnCompact: config.showOnCompact,
@@ -387,22 +466,38 @@ export const QuotaToastPlugin = async ({ client }) => {
387
466
  }
388
467
  }
389
468
  /**
390
- * Get the current model from the active session.
469
+ * Get the current model metadata from the active session.
391
470
  *
392
471
  * Only uses session-scoped model lookup. Does NOT fall back to
393
472
  * client.config.get() because that returns the global/default model
394
473
  * which can be stale across sessions.
395
474
  */
396
- async function getCurrentModel(sessionID) {
475
+ async function getSessionModelMeta(sessionID) {
397
476
  if (!sessionID)
398
- return undefined;
477
+ return {};
399
478
  try {
400
479
  const sessionResp = await typedClient.session.get({ path: { id: sessionID } });
401
- return sessionResp.data?.modelID;
480
+ return {
481
+ modelID: sessionResp.data?.modelID,
482
+ providerID: sessionResp.data?.providerID,
483
+ };
402
484
  }
403
485
  catch {
486
+ return {};
487
+ }
488
+ }
489
+ async function getCurrentModel(sessionID) {
490
+ if (!sessionID)
404
491
  return undefined;
492
+ return (await getSessionModelMeta(sessionID)).modelID;
493
+ }
494
+ function matchesProviderCurrentSelection(params) {
495
+ if (params.provider.id === "cursor" && isCursorProviderId(params.currentProviderID)) {
496
+ return true;
405
497
  }
498
+ if (!params.currentModel)
499
+ return false;
500
+ return params.provider.matchesCurrentModel ? params.provider.matchesCurrentModel(params.currentModel) : true;
406
501
  }
407
502
  function formatDebugInfo(params) {
408
503
  const availability = params.availability
@@ -423,6 +518,99 @@ export const QuotaToastPlugin = async ({ client }) => {
423
518
  `available=${availability}`,
424
519
  ].join("\n");
425
520
  }
521
+ async function resolveQuotaCommandSelection(params = {}) {
522
+ if (!configLoaded)
523
+ await refreshConfig();
524
+ if (!config.enabled)
525
+ return null;
526
+ const allProviders = getProviders();
527
+ const isAutoMode = config.enabledProviders === "auto";
528
+ const providers = isAutoMode
529
+ ? allProviders
530
+ : allProviders.filter((p) => config.enabledProviders.includes(p.id));
531
+ if (!isAutoMode && providers.length === 0)
532
+ return null;
533
+ let currentModel;
534
+ let currentProviderID;
535
+ if (config.onlyCurrentModel && params.sessionID) {
536
+ const currentSession = params.sessionMeta ?? (await getSessionModelMeta(params.sessionID));
537
+ currentModel = currentSession.modelID;
538
+ currentProviderID = currentSession.providerID;
539
+ }
540
+ const ctx = {
541
+ client: typedClient,
542
+ config: {
543
+ googleModels: config.googleModels,
544
+ alibabaCodingPlanTier: config.alibabaCodingPlanTier,
545
+ cursorPlan: config.cursorPlan,
546
+ cursorIncludedApiUsd: config.cursorIncludedApiUsd,
547
+ cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
548
+ // Always format /quota in grouped mode for a more dashboard-like look.
549
+ toastStyle: "grouped",
550
+ onlyCurrentModel: config.onlyCurrentModel,
551
+ currentModel,
552
+ currentProviderID,
553
+ },
554
+ };
555
+ const filteringByCurrentSelection = config.onlyCurrentModel && Boolean(currentModel || isCursorProviderId(currentProviderID));
556
+ const filtered = filteringByCurrentSelection
557
+ ? providers.filter((p) => matchesProviderCurrentSelection({ provider: p, currentModel, currentProviderID }))
558
+ : providers;
559
+ return {
560
+ isAutoMode,
561
+ providers,
562
+ filtered,
563
+ ctx,
564
+ currentModel,
565
+ currentProviderID,
566
+ filteringByCurrentSelection,
567
+ };
568
+ }
569
+ function describeQuotaCommandCurrentSelection(params) {
570
+ if (isCursorProviderId(params.currentProviderID)) {
571
+ return `current provider: ${params.currentProviderID}`;
572
+ }
573
+ if (params.currentModel) {
574
+ return `current model: ${params.currentModel}`;
575
+ }
576
+ return "current session";
577
+ }
578
+ async function buildQuotaCommandUnavailableMessage(params = {}) {
579
+ const selection = await resolveQuotaCommandSelection(params);
580
+ if (!selection) {
581
+ return "Quota unavailable\n\nNo enabled quota providers are configured.\n\nRun /quota_status for diagnostics.";
582
+ }
583
+ if (selection.filteringByCurrentSelection && selection.filtered.length === 0) {
584
+ const detail = describeQuotaCommandCurrentSelection({
585
+ currentModel: selection.currentModel,
586
+ currentProviderID: selection.currentProviderID,
587
+ });
588
+ return `Quota unavailable\n\nNo enabled quota providers matched the ${detail}.\n\nRun /quota_status for diagnostics.`;
589
+ }
590
+ const avail = await Promise.all(selection.filtered.map(async (p) => {
591
+ try {
592
+ return { id: p.id, ok: await p.isAvailable(selection.ctx) };
593
+ }
594
+ catch {
595
+ return { id: p.id, ok: false };
596
+ }
597
+ }));
598
+ const availableIds = avail.filter((x) => x.ok).map((x) => x.id);
599
+ if (availableIds.length === 0) {
600
+ const scopedDetail = selection.filteringByCurrentSelection
601
+ ? ` for the ${describeQuotaCommandCurrentSelection({
602
+ currentModel: selection.currentModel,
603
+ currentProviderID: selection.currentProviderID,
604
+ })}`
605
+ : "";
606
+ return (`Quota unavailable\n\nNo quota providers detected${scopedDetail}. ` +
607
+ "Make sure you are logged in to a supported provider (Copilot, OpenAI, etc.).\n\n" +
608
+ "Run /quota_status for diagnostics.");
609
+ }
610
+ return (`Quota unavailable\n\nProviders detected (${availableIds.join(", ")}) but returned no data. ` +
611
+ "This may be a temporary API error.\n\n" +
612
+ "Run /quota_status for diagnostics.");
613
+ }
426
614
  async function fetchQuotaMessage(trigger, sessionID) {
427
615
  // Ensure we have loaded config at least once. If load fails, we keep trying
428
616
  // on subsequent triggers.
@@ -449,21 +637,28 @@ export const QuotaToastPlugin = async ({ client }) => {
449
637
  : null;
450
638
  }
451
639
  let currentModel;
640
+ let currentProviderID;
452
641
  if (config.onlyCurrentModel) {
453
- currentModel = await getCurrentModel(sessionID);
642
+ const currentSession = await getSessionModelMeta(sessionID);
643
+ currentModel = currentSession.modelID;
644
+ currentProviderID = currentSession.providerID;
454
645
  }
455
646
  const ctx = {
456
647
  client: typedClient,
457
648
  config: {
458
649
  googleModels: config.googleModels,
459
650
  alibabaCodingPlanTier: config.alibabaCodingPlanTier,
651
+ cursorPlan: config.cursorPlan,
652
+ cursorIncludedApiUsd: config.cursorIncludedApiUsd,
653
+ cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
460
654
  toastStyle: config.toastStyle,
461
655
  onlyCurrentModel: config.onlyCurrentModel,
462
656
  currentModel,
657
+ currentProviderID,
463
658
  },
464
659
  };
465
- const filtered = config.onlyCurrentModel && currentModel
466
- ? providers.filter((p) => p.matchesCurrentModel ? p.matchesCurrentModel(currentModel) : true)
660
+ const filtered = config.onlyCurrentModel && (currentModel || isCursorProviderId(currentProviderID))
661
+ ? providers.filter((p) => matchesProviderCurrentSelection({ provider: p, currentModel, currentProviderID }))
467
662
  : providers;
468
663
  // availability checks are cheap, do them in parallel
469
664
  const avail = await Promise.all(filtered.map(async (p) => ({ p, ok: await p.isAvailable(ctx) })));
@@ -479,11 +674,11 @@ export const QuotaToastPlugin = async ({ client }) => {
479
674
  })
480
675
  : null;
481
676
  }
482
- const results = await Promise.all(active.map((p) => fetchProviderWithCache({
483
- provider: p,
677
+ const results = await fetchProviderResults({
678
+ providers: active,
484
679
  ctx,
485
680
  ttlMs: config.minIntervalMs,
486
- })));
681
+ });
487
682
  const entries = results.flatMap((r) => r.entries);
488
683
  const errors = results.flatMap((r) => r.errors);
489
684
  const attemptedAny = results.some((r) => r.attempted);
@@ -498,7 +693,7 @@ export const QuotaToastPlugin = async ({ client }) => {
498
693
  if (!result.attempted && result.entries.length === 0 && result.errors.length === 0) {
499
694
  errors.push({
500
695
  label: getQuotaProviderDisplayLabel(provider.id),
501
- message: "Not configured",
696
+ message: getExplicitNoDataMessage(provider),
502
697
  });
503
698
  hasExplicitProviderIssues = true;
504
699
  }
@@ -609,7 +804,7 @@ export const QuotaToastPlugin = async ({ client }) => {
609
804
  }
610
805
  const bypassMessageCache = config.debug
611
806
  ? true
612
- : await shouldBypassToastCacheForLocalRequestPlan(trigger, sessionID);
807
+ : await shouldBypassToastCacheForLiveLocalUsage({ trigger, sessionID });
613
808
  const message = bypassMessageCache
614
809
  ? await fetchQuotaMessage(trigger, sessionID)
615
810
  : await getOrFetchWithCacheControl(async () => {
@@ -642,50 +837,40 @@ export const QuotaToastPlugin = async ({ client }) => {
642
837
  });
643
838
  }
644
839
  }
645
- async function fetchQuotaCommandBody(trigger, sessionID) {
646
- if (!configLoaded)
647
- await refreshConfig();
648
- if (!config.enabled)
649
- return null;
650
- const allProviders = getProviders();
651
- const isAutoMode = config.enabledProviders === "auto";
652
- const providers = isAutoMode
653
- ? allProviders
654
- : allProviders.filter((p) => config.enabledProviders.includes(p.id));
655
- if (!isAutoMode && providers.length === 0)
840
+ async function fetchQuotaCommandBody(trigger, params = {}) {
841
+ const selection = await resolveQuotaCommandSelection(params);
842
+ if (!selection)
656
843
  return null;
657
- let currentModel;
658
- if (config.onlyCurrentModel && sessionID) {
659
- currentModel = await getCurrentModel(sessionID);
660
- }
661
- const ctx = {
662
- client: typedClient,
663
- config: {
664
- googleModels: config.googleModels,
665
- alibabaCodingPlanTier: config.alibabaCodingPlanTier,
666
- // Always format /quota in grouped mode for a more dashboard-like look.
667
- toastStyle: "grouped",
668
- onlyCurrentModel: config.onlyCurrentModel,
669
- currentModel,
670
- },
671
- };
672
- const avail = await Promise.all(providers.map(async (p) => ({ p, ok: await p.isAvailable(ctx) })));
844
+ const { isAutoMode, ctx } = selection;
845
+ const avail = await Promise.all(selection.filtered.map(async (p) => ({ p, ok: await p.isAvailable(ctx) })));
673
846
  const active = avail.filter((x) => x.ok).map((x) => x.p);
674
847
  if (active.length === 0)
675
848
  return null;
676
- const results = await Promise.all(active.map((p) => fetchProviderWithCache({
677
- provider: p,
849
+ const results = await fetchProviderResults({
850
+ providers: active,
678
851
  ctx,
679
852
  ttlMs: config.minIntervalMs,
680
- })));
853
+ });
681
854
  const entries = results.flatMap((r) => r.entries);
682
855
  const errors = results.flatMap((r) => r.errors);
856
+ if (!isAutoMode) {
857
+ for (let i = 0; i < active.length; i++) {
858
+ const provider = active[i];
859
+ const result = results[i];
860
+ if (!result.attempted && result.entries.length === 0 && result.errors.length === 0) {
861
+ errors.push({
862
+ label: getQuotaProviderDisplayLabel(provider.id),
863
+ message: getExplicitNoDataMessage(provider),
864
+ });
865
+ }
866
+ }
867
+ }
683
868
  // Fetch session tokens if enabled and sessionID is available
684
869
  let sessionTokens;
685
- if (config.showSessionTokens && sessionID) {
870
+ if (config.showSessionTokens && params.sessionID) {
686
871
  const stResult = await fetchSessionTokensForDisplay({
687
872
  enabled: config.showSessionTokens,
688
- sessionID,
873
+ sessionID: params.sessionID,
689
874
  });
690
875
  sessionTokens = stResult.sessionTokens;
691
876
  // Update diagnostics state: clear on success (no error returned), set on failure
@@ -719,7 +904,9 @@ export const QuotaToastPlugin = async ({ client }) => {
719
904
  if (!config.enabled)
720
905
  return null;
721
906
  await kickPricingRefresh({ reason: "status", maxWaitMs: 750 });
722
- const currentModel = await getCurrentModel(params.sessionID);
907
+ const currentSession = await getSessionModelMeta(params.sessionID);
908
+ const currentModel = currentSession.modelID;
909
+ const currentProviderID = currentSession.providerID;
723
910
  const sessionModelLookup = !params.sessionID
724
911
  ? "no_session"
725
912
  : currentModel
@@ -735,6 +922,11 @@ export const QuotaToastPlugin = async ({ client }) => {
735
922
  config: {
736
923
  googleModels: config.googleModels,
737
924
  alibabaCodingPlanTier: config.alibabaCodingPlanTier,
925
+ cursorPlan: config.cursorPlan,
926
+ cursorIncludedApiUsd: config.cursorIncludedApiUsd,
927
+ cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
928
+ currentModel,
929
+ currentProviderID,
738
930
  },
739
931
  });
740
932
  }
@@ -746,8 +938,8 @@ export const QuotaToastPlugin = async ({ client }) => {
746
938
  // In auto mode, a provider is effectively "enabled" if it's available.
747
939
  enabled: isAutoMode ? ok : config.enabledProviders.includes(p.id),
748
940
  available: ok,
749
- matchesCurrentModel: typeof p.matchesCurrentModel === "function" && currentModel
750
- ? p.matchesCurrentModel(currentModel)
941
+ matchesCurrentModel: currentModel || isCursorProviderId(currentProviderID)
942
+ ? matchesProviderCurrentSelection({ provider: p, currentModel, currentProviderID })
751
943
  : undefined,
752
944
  };
753
945
  }));
@@ -759,6 +951,10 @@ export const QuotaToastPlugin = async ({ client }) => {
759
951
  configPaths: configMeta.paths,
760
952
  enabledProviders: config.enabledProviders,
761
953
  alibabaCodingPlanTier: config.alibabaCodingPlanTier,
954
+ cursorPlan: config.cursorPlan,
955
+ cursorIncludedApiUsd: config.cursorIncludedApiUsd,
956
+ cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
957
+ pricingSnapshotSource: config.pricingSnapshot.source,
762
958
  onlyCurrentModel: config.onlyCurrentModel,
763
959
  currentModel,
764
960
  sessionModelLookup,
@@ -775,6 +971,42 @@ export const QuotaToastPlugin = async ({ client }) => {
775
971
  generatedAtMs: params.generatedAtMs,
776
972
  });
777
973
  }
974
+ function formatIsoTimestamp(timestampMs) {
975
+ return typeof timestampMs === "number" && Number.isFinite(timestampMs) && timestampMs > 0
976
+ ? new Date(timestampMs).toISOString()
977
+ : "(none)";
978
+ }
979
+ function buildPricingRefreshCommandOutput(params) {
980
+ const meta = getPricingSnapshotMeta();
981
+ const activeSource = getPricingSnapshotSource();
982
+ const configuredSelection = config.pricingSnapshot.source;
983
+ const resultLabel = params.result.reason ??
984
+ params.result.state.lastResult ??
985
+ (params.result.updated ? "success" : "unknown");
986
+ const lines = [
987
+ renderCommandHeading({
988
+ title: "Pricing Refresh (/pricing_refresh)",
989
+ generatedAtMs: params.generatedAtMs,
990
+ }),
991
+ "",
992
+ "refresh:",
993
+ `- attempted: ${params.result.attempted ? "true" : "false"}`,
994
+ `- result: ${resultLabel}`,
995
+ `- runtime_snapshot_persisted: ${params.result.updated ? "true" : "false"}`,
996
+ ];
997
+ if (params.result.error) {
998
+ lines.push(`- error: ${params.result.error}`);
999
+ }
1000
+ lines.push("");
1001
+ lines.push("pricing_snapshot:");
1002
+ lines.push(`- selection: configured=${configuredSelection} active=${activeSource}`);
1003
+ lines.push(`- active_snapshot: source=${meta.source} generated_at=${formatIsoTimestamp(meta.generatedAt)} units=${meta.units}`);
1004
+ lines.push(`- runtime_paths: snapshot=${getRuntimePricingSnapshotPath()} refresh_state=${getRuntimePricingRefreshStatePath()}`);
1005
+ if (configuredSelection === "bundled" && params.result.updated) {
1006
+ lines.push("- selection_note: runtime snapshot refreshed locally, but active reports remain pinned to bundled pricing");
1007
+ }
1008
+ return lines.join("\n");
1009
+ }
778
1010
  // Return hook implementations
779
1011
  return {
780
1012
  // Register built-in slash commands (in addition to /tool quota_*)
@@ -790,6 +1022,10 @@ export const QuotaToastPlugin = async ({ client }) => {
790
1022
  template: "/quota_status",
791
1023
  description: "Diagnostics for toast + pricing + local storage (includes unknown pricing report).",
792
1024
  };
1025
+ cfg.command["pricing_refresh"] = {
1026
+ template: "/pricing_refresh",
1027
+ description: "Refresh the local runtime pricing snapshot from models.dev.",
1028
+ };
793
1029
  // Register token report commands (/tokens_*)
794
1030
  for (const spec of TOKEN_REPORT_COMMANDS) {
795
1031
  cfg.command[spec.id] = {
@@ -802,7 +1038,10 @@ export const QuotaToastPlugin = async ({ client }) => {
802
1038
  try {
803
1039
  const cmd = input.command;
804
1040
  const sessionID = input.sessionID;
805
- const isQuotaCommand = cmd === "quota" || cmd === "quota_status" || isTokenReportCommand(cmd);
1041
+ const isQuotaCommand = cmd === "quota" ||
1042
+ cmd === "quota_status" ||
1043
+ cmd === "pricing_refresh" ||
1044
+ isTokenReportCommand(cmd);
806
1045
  if (isQuotaCommand && !configLoaded) {
807
1046
  await refreshConfig();
808
1047
  }
@@ -811,30 +1050,45 @@ export const QuotaToastPlugin = async ({ client }) => {
811
1050
  }
812
1051
  if (cmd === "quota") {
813
1052
  const generatedAtMs = Date.now();
814
- // Separate cache for /quota so it doesn't pollute the toast cache.
815
- const quotaCache = getQuotaCommandCache();
816
1053
  const now = generatedAtMs;
817
- const bypassCommandCache = await shouldBypassQuotaCommandCache(sessionID);
818
- const cached = !bypassCommandCache &&
819
- quotaCache.timestamp &&
820
- now - quotaCache.timestamp < config.minIntervalMs
821
- ? quotaCache.body
822
- : null;
823
- const body = cached
824
- ? cached
825
- : await (quotaCache.inFlight ??
826
- (quotaCache.inFlight = (async () => {
827
- try {
828
- return await fetchQuotaCommandBody("command:/quota", sessionID);
829
- }
830
- finally {
831
- quotaCache.inFlight = undefined;
832
- }
833
- })()));
834
- if (body) {
835
- quotaCache.body = body;
836
- quotaCache.timestamp = Date.now();
837
- }
1054
+ const quotaRequestContext = {
1055
+ sessionID,
1056
+ sessionMeta: sessionID ? await getSessionModelMeta(sessionID) : undefined,
1057
+ };
1058
+ const bypassCommandCache = await shouldBypassQuotaCommandCache(sessionID, quotaRequestContext.sessionMeta);
1059
+ const body = bypassCommandCache
1060
+ ? await fetchQuotaCommandBody("command:/quota", quotaRequestContext)
1061
+ : await (async () => {
1062
+ const quotaCache = getQuotaCommandCache();
1063
+ pruneQuotaCommandCache(config.minIntervalMs, now);
1064
+ const cacheKey = buildQuotaCommandCacheKey(quotaRequestContext);
1065
+ const cachedEntry = quotaCache.get(cacheKey);
1066
+ if (cachedEntry?.timestamp &&
1067
+ now - cachedEntry.timestamp < config.minIntervalMs) {
1068
+ return cachedEntry.body;
1069
+ }
1070
+ const cacheEntry = cachedEntry ?? { body: "", timestamp: 0 };
1071
+ if (!cachedEntry) {
1072
+ quotaCache.set(cacheKey, cacheEntry);
1073
+ }
1074
+ return await (cacheEntry.inFlight ??
1075
+ (cacheEntry.inFlight = (async () => {
1076
+ try {
1077
+ const freshBody = await fetchQuotaCommandBody("command:/quota", quotaRequestContext);
1078
+ if (freshBody) {
1079
+ cacheEntry.body = freshBody;
1080
+ cacheEntry.timestamp = Date.now();
1081
+ }
1082
+ return freshBody;
1083
+ }
1084
+ finally {
1085
+ cacheEntry.inFlight = undefined;
1086
+ if (!cacheEntry.body && cacheEntry.timestamp <= 0) {
1087
+ quotaCache.delete(cacheKey);
1088
+ }
1089
+ }
1090
+ })()));
1091
+ })();
838
1092
  if (!body) {
839
1093
  // Provide an actionable message instead of a generic "unavailable".
840
1094
  if (!configLoaded) {
@@ -844,30 +1098,7 @@ export const QuotaToastPlugin = async ({ client }) => {
844
1098
  await injectRawOutput(sessionID, "Quota disabled in config (enabled: false)");
845
1099
  }
846
1100
  else {
847
- // Check what providers are available for a more specific hint.
848
- const allProvs = getProviders();
849
- const ctx = {
850
- client: typedClient,
851
- config: {
852
- googleModels: config.googleModels,
853
- alibabaCodingPlanTier: config.alibabaCodingPlanTier,
854
- },
855
- };
856
- const avail = await Promise.all(allProvs.map(async (p) => {
857
- try {
858
- return { id: p.id, ok: await p.isAvailable(ctx) };
859
- }
860
- catch {
861
- return { id: p.id, ok: false };
862
- }
863
- }));
864
- const availableIds = avail.filter((x) => x.ok).map((x) => x.id);
865
- if (availableIds.length === 0) {
866
- await injectRawOutput(sessionID, "Quota unavailable\n\nNo quota providers detected. Make sure you are logged in to a supported provider (Copilot, OpenAI, etc.).\n\nRun /quota_status for diagnostics.");
867
- }
868
- else {
869
- await injectRawOutput(sessionID, `Quota unavailable\n\nProviders detected (${availableIds.join(", ")}) but returned no data. This may be a temporary API error.\n\nRun /quota_status for diagnostics.`);
870
- }
1101
+ await injectRawOutput(sessionID, await buildQuotaCommandUnavailableMessage(quotaRequestContext));
871
1102
  }
872
1103
  handled();
873
1104
  }
@@ -878,6 +1109,24 @@ export const QuotaToastPlugin = async ({ client }) => {
878
1109
  await injectRawOutput(sessionID, `${heading}\n\n${body}`);
879
1110
  handled();
880
1111
  }
1112
+ if (cmd === "pricing_refresh") {
1113
+ const generatedAtMs = Date.now();
1114
+ if ((input.arguments ?? "").trim()) {
1115
+ await injectRawOutput(sessionID, "Invalid arguments for /pricing_refresh\n\nThis command does not accept arguments.\n\nUsage:\n/pricing_refresh");
1116
+ handled();
1117
+ }
1118
+ const result = await maybeRefreshPricingSnapshot({
1119
+ reason: "manual",
1120
+ force: true,
1121
+ snapshotSelection: config.pricingSnapshot.source,
1122
+ allowRefreshWhenSelectionBundled: true,
1123
+ });
1124
+ await injectRawOutput(sessionID, buildPricingRefreshCommandOutput({
1125
+ result,
1126
+ generatedAtMs,
1127
+ }));
1128
+ handled();
1129
+ }
881
1130
  const untilMs = Date.now();
882
1131
  // Handle token report commands (/tokens_*)
883
1132
  if (isTokenReportCommand(cmd)) {
@@ -1029,7 +1278,8 @@ export const QuotaToastPlugin = async ({ client }) => {
1029
1278
  if (!config.enabled)
1030
1279
  return;
1031
1280
  if (isSuccessfulQuestionExecution(output)) {
1032
- const model = await getCurrentModel(input.sessionID);
1281
+ const sessionMeta = await getSessionModelMeta(input.sessionID);
1282
+ const model = sessionMeta.modelID;
1033
1283
  try {
1034
1284
  if (isQwenCodeModelId(model)) {
1035
1285
  const plan = await resolveQwenLocalPlanCached();
@@ -1048,11 +1298,15 @@ export const QuotaToastPlugin = async ({ client }) => {
1048
1298
  clearQuotaCommandCache();
1049
1299
  }
1050
1300
  }
1301
+ else if (isCursorProviderId(sessionMeta.providerID) || isCursorModelId(model)) {
1302
+ clearQuotaCommandCache();
1303
+ }
1051
1304
  }
1052
1305
  catch (err) {
1053
1306
  await log("Failed to record local request-plan quota completion", {
1054
1307
  error: err instanceof Error ? err.message : String(err),
1055
1308
  model,
1309
+ providerID: sessionMeta.providerID,
1056
1310
  });
1057
1311
  }
1058
1312
  }