@slkiser/opencode-quota 2.7.1 → 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 (42) hide show
  1. package/README.md +192 -213
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/config.d.ts.map +1 -1
  6. package/dist/lib/config.js +14 -0
  7. package/dist/lib/config.js.map +1 -1
  8. package/dist/lib/cursor-detection.d.ts.map +1 -1
  9. package/dist/lib/cursor-detection.js +49 -5
  10. package/dist/lib/cursor-detection.js.map +1 -1
  11. package/dist/lib/cursor-pricing.d.ts +1 -0
  12. package/dist/lib/cursor-pricing.d.ts.map +1 -1
  13. package/dist/lib/cursor-pricing.js +5 -3
  14. package/dist/lib/cursor-pricing.js.map +1 -1
  15. package/dist/lib/entries.d.ts +1 -0
  16. package/dist/lib/entries.d.ts.map +1 -1
  17. package/dist/lib/jsonc.d.ts +8 -1
  18. package/dist/lib/jsonc.d.ts.map +1 -1
  19. package/dist/lib/jsonc.js +12 -2
  20. package/dist/lib/jsonc.js.map +1 -1
  21. package/dist/lib/modelsdev-pricing.d.ts +8 -2
  22. package/dist/lib/modelsdev-pricing.d.ts.map +1 -1
  23. package/dist/lib/modelsdev-pricing.js +84 -29
  24. package/dist/lib/modelsdev-pricing.js.map +1 -1
  25. package/dist/lib/quota-stats-format.d.ts.map +1 -1
  26. package/dist/lib/quota-stats-format.js +30 -38
  27. package/dist/lib/quota-stats-format.js.map +1 -1
  28. package/dist/lib/quota-status.d.ts +2 -1
  29. package/dist/lib/quota-status.d.ts.map +1 -1
  30. package/dist/lib/quota-status.js +10 -1
  31. package/dist/lib/quota-status.js.map +1 -1
  32. package/dist/lib/types.d.ts +14 -0
  33. package/dist/lib/types.d.ts.map +1 -1
  34. package/dist/lib/types.js +4 -0
  35. package/dist/lib/types.js.map +1 -1
  36. package/dist/plugin.d.ts.map +1 -1
  37. package/dist/plugin.js +300 -115
  38. package/dist/plugin.js.map +1 -1
  39. package/dist/providers/cursor.d.ts.map +1 -1
  40. package/dist/providers/cursor.js +3 -1
  41. package/dist/providers/cursor.js.map +1 -1
  42. package/package.json +4 -1
package/dist/plugin.js CHANGED
@@ -16,13 +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 } from "./lib/cursor-pricing.js";
25
+ import { isCursorModelId, isCursorProviderId } from "./lib/cursor-pricing.js";
26
26
  import { parseOptionalJsonArgs, parseQuotaBetweenArgs, startOfLocalDayMs, startOfNextLocalDayMs, formatYmd, } from "./lib/command-parsing.js";
27
27
  import { handled } from "./lib/command-handled.js";
28
28
  import { renderCommandHeading } from "./lib/format-utils.js";
@@ -153,20 +153,45 @@ export const QuotaToastPlugin = async ({ client }) => {
153
153
  let lastSessionTokenError;
154
154
  const providerFetchCache = new Map();
155
155
  function getQuotaCommandCache() {
156
- let quotaCache = globalThis.__opencodeQuotaCommandCache;
157
- if (!quotaCache) {
158
- quotaCache = { body: "", timestamp: 0 };
159
- globalThis.__opencodeQuotaCommandCache = quotaCache;
156
+ const existing = globalThis.__opencodeQuotaCommandCache;
157
+ if (existing instanceof Map) {
158
+ return existing;
160
159
  }
160
+ const quotaCache = new Map();
161
+ globalThis.__opencodeQuotaCommandCache = quotaCache;
161
162
  return quotaCache;
162
163
  }
163
164
  function clearQuotaCommandCache() {
164
- const quotaCache = globalThis.__opencodeQuotaCommandCache;
165
- if (!quotaCache)
166
- return;
167
- quotaCache.body = "";
168
- quotaCache.timestamp = 0;
169
- 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
+ }
170
195
  }
171
196
  function asRecord(value) {
172
197
  return value && typeof value === "object" ? value : null;
@@ -216,7 +241,8 @@ export const QuotaToastPlugin = async ({ client }) => {
216
241
  const cursorBillingCycleStartDay = ctx.config.cursorBillingCycleStartDay ?? "";
217
242
  const onlyCurrentModel = ctx.config.onlyCurrentModel ? "yes" : "no";
218
243
  const currentModel = ctx.config.currentModel ?? "";
219
- return `${providerId}|style=${style}|googleModels=${googleModels}|alibabaTier=${alibabaCodingPlanTier}|cursorPlan=${cursorPlan}|cursorIncludedApiUsd=${cursorIncludedApiUsd}|cursorBillingCycleStartDay=${cursorBillingCycleStartDay}|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}`;
220
246
  }
221
247
  async function fetchProviderWithCache(params) {
222
248
  const { provider, ctx, ttlMs } = params;
@@ -290,10 +316,12 @@ export const QuotaToastPlugin = async ({ client }) => {
290
316
  function isProviderEnabled(providerId) {
291
317
  return config.enabledProviders === "auto" || config.enabledProviders.includes(providerId);
292
318
  }
293
- async function shouldBypassToastCacheForLiveLocalUsage(trigger, sessionID) {
319
+ async function shouldBypassToastCacheForLiveLocalUsage(params) {
320
+ const { trigger, sessionID } = params;
294
321
  if (trigger !== "question")
295
322
  return false;
296
- const currentModel = await getCurrentModel(sessionID);
323
+ const currentSession = params.sessionMeta ?? (await getSessionModelMeta(sessionID));
324
+ const currentModel = currentSession.modelID;
297
325
  if (isQwenCodeModelId(currentModel)) {
298
326
  const plan = await resolveQwenLocalPlanCached();
299
327
  return plan.state === "qwen_free" && isProviderEnabled("qwen-code");
@@ -305,15 +333,19 @@ export const QuotaToastPlugin = async ({ client }) => {
305
333
  });
306
334
  return plan.state === "configured" && isProviderEnabled("alibaba-coding-plan");
307
335
  }
308
- if (isCursorModelId(currentModel)) {
336
+ if (isCursorProviderId(currentSession.providerID) || isCursorModelId(currentModel)) {
309
337
  return isProviderEnabled("cursor");
310
338
  }
311
339
  return false;
312
340
  }
313
- async function shouldBypassQuotaCommandCache(sessionID) {
341
+ async function shouldBypassQuotaCommandCache(sessionID, sessionMeta) {
314
342
  if (config.debug || !sessionID)
315
343
  return config.debug;
316
- return await shouldBypassToastCacheForLiveLocalUsage("question", sessionID);
344
+ return await shouldBypassToastCacheForLiveLocalUsage({
345
+ trigger: "question",
346
+ sessionID,
347
+ sessionMeta,
348
+ });
317
349
  }
318
350
  async function refreshConfig() {
319
351
  if (configInFlight)
@@ -322,11 +354,15 @@ export const QuotaToastPlugin = async ({ client }) => {
322
354
  try {
323
355
  configMeta = createLoadConfigMeta();
324
356
  config = await loadConfig(typedClient, configMeta);
357
+ setPricingSnapshotAutoRefresh(config.pricingSnapshot.autoRefresh);
358
+ setPricingSnapshotSelection(config.pricingSnapshot.source);
325
359
  configLoaded = true;
326
360
  }
327
361
  catch {
328
362
  // Leave configLoaded=false so we can retry on next trigger.
329
363
  config = DEFAULT_CONFIG;
364
+ setPricingSnapshotAutoRefresh(DEFAULT_CONFIG.pricingSnapshot.autoRefresh);
365
+ setPricingSnapshotSelection(DEFAULT_CONFIG.pricingSnapshot.source);
330
366
  }
331
367
  finally {
332
368
  configInFlight = null;
@@ -336,7 +372,10 @@ export const QuotaToastPlugin = async ({ client }) => {
336
372
  }
337
373
  async function kickPricingRefresh(params) {
338
374
  try {
339
- const refreshPromise = maybeRefreshPricingSnapshot({ reason: params.reason });
375
+ const refreshPromise = maybeRefreshPricingSnapshot({
376
+ reason: params.reason,
377
+ snapshotSelection: config.pricingSnapshot.source,
378
+ });
340
379
  const guardedRefreshPromise = refreshPromise.catch(() => undefined);
341
380
  if (!params.maxWaitMs || params.maxWaitMs <= 0) {
342
381
  void guardedRefreshPromise;
@@ -378,6 +417,8 @@ export const QuotaToastPlugin = async ({ client }) => {
378
417
  cursorPlan: config.cursorPlan,
379
418
  cursorIncludedApiUsd: config.cursorIncludedApiUsd,
380
419
  cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
420
+ pricingSnapshotSource: config.pricingSnapshot.source,
421
+ pricingSnapshotAutoRefresh: config.pricingSnapshot.autoRefresh,
381
422
  showOnIdle: config.showOnIdle,
382
423
  showOnQuestion: config.showOnQuestion,
383
424
  showOnCompact: config.showOnCompact,
@@ -425,22 +466,38 @@ export const QuotaToastPlugin = async ({ client }) => {
425
466
  }
426
467
  }
427
468
  /**
428
- * Get the current model from the active session.
469
+ * Get the current model metadata from the active session.
429
470
  *
430
471
  * Only uses session-scoped model lookup. Does NOT fall back to
431
472
  * client.config.get() because that returns the global/default model
432
473
  * which can be stale across sessions.
433
474
  */
434
- async function getCurrentModel(sessionID) {
475
+ async function getSessionModelMeta(sessionID) {
435
476
  if (!sessionID)
436
- return undefined;
477
+ return {};
437
478
  try {
438
479
  const sessionResp = await typedClient.session.get({ path: { id: sessionID } });
439
- return sessionResp.data?.modelID;
480
+ return {
481
+ modelID: sessionResp.data?.modelID,
482
+ providerID: sessionResp.data?.providerID,
483
+ };
440
484
  }
441
485
  catch {
486
+ return {};
487
+ }
488
+ }
489
+ async function getCurrentModel(sessionID) {
490
+ if (!sessionID)
442
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;
443
497
  }
498
+ if (!params.currentModel)
499
+ return false;
500
+ return params.provider.matchesCurrentModel ? params.provider.matchesCurrentModel(params.currentModel) : true;
444
501
  }
445
502
  function formatDebugInfo(params) {
446
503
  const availability = params.availability
@@ -461,6 +518,99 @@ export const QuotaToastPlugin = async ({ client }) => {
461
518
  `available=${availability}`,
462
519
  ].join("\n");
463
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
+ }
464
614
  async function fetchQuotaMessage(trigger, sessionID) {
465
615
  // Ensure we have loaded config at least once. If load fails, we keep trying
466
616
  // on subsequent triggers.
@@ -487,8 +637,11 @@ export const QuotaToastPlugin = async ({ client }) => {
487
637
  : null;
488
638
  }
489
639
  let currentModel;
640
+ let currentProviderID;
490
641
  if (config.onlyCurrentModel) {
491
- currentModel = await getCurrentModel(sessionID);
642
+ const currentSession = await getSessionModelMeta(sessionID);
643
+ currentModel = currentSession.modelID;
644
+ currentProviderID = currentSession.providerID;
492
645
  }
493
646
  const ctx = {
494
647
  client: typedClient,
@@ -501,10 +654,11 @@ export const QuotaToastPlugin = async ({ client }) => {
501
654
  toastStyle: config.toastStyle,
502
655
  onlyCurrentModel: config.onlyCurrentModel,
503
656
  currentModel,
657
+ currentProviderID,
504
658
  },
505
659
  };
506
- const filtered = config.onlyCurrentModel && currentModel
507
- ? 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 }))
508
662
  : providers;
509
663
  // availability checks are cheap, do them in parallel
510
664
  const avail = await Promise.all(filtered.map(async (p) => ({ p, ok: await p.isAvailable(ctx) })));
@@ -650,7 +804,7 @@ export const QuotaToastPlugin = async ({ client }) => {
650
804
  }
651
805
  const bypassMessageCache = config.debug
652
806
  ? true
653
- : await shouldBypassToastCacheForLiveLocalUsage(trigger, sessionID);
807
+ : await shouldBypassToastCacheForLiveLocalUsage({ trigger, sessionID });
654
808
  const message = bypassMessageCache
655
809
  ? await fetchQuotaMessage(trigger, sessionID)
656
810
  : await getOrFetchWithCacheControl(async () => {
@@ -683,37 +837,12 @@ export const QuotaToastPlugin = async ({ client }) => {
683
837
  });
684
838
  }
685
839
  }
686
- async function fetchQuotaCommandBody(trigger, sessionID) {
687
- if (!configLoaded)
688
- await refreshConfig();
689
- if (!config.enabled)
690
- return null;
691
- const allProviders = getProviders();
692
- const isAutoMode = config.enabledProviders === "auto";
693
- const providers = isAutoMode
694
- ? allProviders
695
- : allProviders.filter((p) => config.enabledProviders.includes(p.id));
696
- if (!isAutoMode && providers.length === 0)
840
+ async function fetchQuotaCommandBody(trigger, params = {}) {
841
+ const selection = await resolveQuotaCommandSelection(params);
842
+ if (!selection)
697
843
  return null;
698
- let currentModel;
699
- if (config.onlyCurrentModel && sessionID) {
700
- currentModel = await getCurrentModel(sessionID);
701
- }
702
- const ctx = {
703
- client: typedClient,
704
- config: {
705
- googleModels: config.googleModels,
706
- alibabaCodingPlanTier: config.alibabaCodingPlanTier,
707
- cursorPlan: config.cursorPlan,
708
- cursorIncludedApiUsd: config.cursorIncludedApiUsd,
709
- cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
710
- // Always format /quota in grouped mode for a more dashboard-like look.
711
- toastStyle: "grouped",
712
- onlyCurrentModel: config.onlyCurrentModel,
713
- currentModel,
714
- },
715
- };
716
- 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) })));
717
846
  const active = avail.filter((x) => x.ok).map((x) => x.p);
718
847
  if (active.length === 0)
719
848
  return null;
@@ -738,10 +867,10 @@ export const QuotaToastPlugin = async ({ client }) => {
738
867
  }
739
868
  // Fetch session tokens if enabled and sessionID is available
740
869
  let sessionTokens;
741
- if (config.showSessionTokens && sessionID) {
870
+ if (config.showSessionTokens && params.sessionID) {
742
871
  const stResult = await fetchSessionTokensForDisplay({
743
872
  enabled: config.showSessionTokens,
744
- sessionID,
873
+ sessionID: params.sessionID,
745
874
  });
746
875
  sessionTokens = stResult.sessionTokens;
747
876
  // Update diagnostics state: clear on success (no error returned), set on failure
@@ -775,7 +904,9 @@ export const QuotaToastPlugin = async ({ client }) => {
775
904
  if (!config.enabled)
776
905
  return null;
777
906
  await kickPricingRefresh({ reason: "status", maxWaitMs: 750 });
778
- const currentModel = await getCurrentModel(params.sessionID);
907
+ const currentSession = await getSessionModelMeta(params.sessionID);
908
+ const currentModel = currentSession.modelID;
909
+ const currentProviderID = currentSession.providerID;
779
910
  const sessionModelLookup = !params.sessionID
780
911
  ? "no_session"
781
912
  : currentModel
@@ -795,6 +926,7 @@ export const QuotaToastPlugin = async ({ client }) => {
795
926
  cursorIncludedApiUsd: config.cursorIncludedApiUsd,
796
927
  cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
797
928
  currentModel,
929
+ currentProviderID,
798
930
  },
799
931
  });
800
932
  }
@@ -806,8 +938,8 @@ export const QuotaToastPlugin = async ({ client }) => {
806
938
  // In auto mode, a provider is effectively "enabled" if it's available.
807
939
  enabled: isAutoMode ? ok : config.enabledProviders.includes(p.id),
808
940
  available: ok,
809
- matchesCurrentModel: typeof p.matchesCurrentModel === "function" && currentModel
810
- ? p.matchesCurrentModel(currentModel)
941
+ matchesCurrentModel: currentModel || isCursorProviderId(currentProviderID)
942
+ ? matchesProviderCurrentSelection({ provider: p, currentModel, currentProviderID })
811
943
  : undefined,
812
944
  };
813
945
  }));
@@ -822,6 +954,7 @@ export const QuotaToastPlugin = async ({ client }) => {
822
954
  cursorPlan: config.cursorPlan,
823
955
  cursorIncludedApiUsd: config.cursorIncludedApiUsd,
824
956
  cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
957
+ pricingSnapshotSource: config.pricingSnapshot.source,
825
958
  onlyCurrentModel: config.onlyCurrentModel,
826
959
  currentModel,
827
960
  sessionModelLookup,
@@ -838,6 +971,42 @@ export const QuotaToastPlugin = async ({ client }) => {
838
971
  generatedAtMs: params.generatedAtMs,
839
972
  });
840
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
+ }
841
1010
  // Return hook implementations
842
1011
  return {
843
1012
  // Register built-in slash commands (in addition to /tool quota_*)
@@ -853,6 +1022,10 @@ export const QuotaToastPlugin = async ({ client }) => {
853
1022
  template: "/quota_status",
854
1023
  description: "Diagnostics for toast + pricing + local storage (includes unknown pricing report).",
855
1024
  };
1025
+ cfg.command["pricing_refresh"] = {
1026
+ template: "/pricing_refresh",
1027
+ description: "Refresh the local runtime pricing snapshot from models.dev.",
1028
+ };
856
1029
  // Register token report commands (/tokens_*)
857
1030
  for (const spec of TOKEN_REPORT_COMMANDS) {
858
1031
  cfg.command[spec.id] = {
@@ -865,7 +1038,10 @@ export const QuotaToastPlugin = async ({ client }) => {
865
1038
  try {
866
1039
  const cmd = input.command;
867
1040
  const sessionID = input.sessionID;
868
- 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);
869
1045
  if (isQuotaCommand && !configLoaded) {
870
1046
  await refreshConfig();
871
1047
  }
@@ -874,30 +1050,45 @@ export const QuotaToastPlugin = async ({ client }) => {
874
1050
  }
875
1051
  if (cmd === "quota") {
876
1052
  const generatedAtMs = Date.now();
877
- // Separate cache for /quota so it doesn't pollute the toast cache.
878
- const quotaCache = getQuotaCommandCache();
879
1053
  const now = generatedAtMs;
880
- const bypassCommandCache = await shouldBypassQuotaCommandCache(sessionID);
881
- const cached = !bypassCommandCache &&
882
- quotaCache.timestamp &&
883
- now - quotaCache.timestamp < config.minIntervalMs
884
- ? quotaCache.body
885
- : null;
886
- const body = cached
887
- ? cached
888
- : await (quotaCache.inFlight ??
889
- (quotaCache.inFlight = (async () => {
890
- try {
891
- return await fetchQuotaCommandBody("command:/quota", sessionID);
892
- }
893
- finally {
894
- quotaCache.inFlight = undefined;
895
- }
896
- })()));
897
- if (body) {
898
- quotaCache.body = body;
899
- quotaCache.timestamp = Date.now();
900
- }
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
+ })();
901
1092
  if (!body) {
902
1093
  // Provide an actionable message instead of a generic "unavailable".
903
1094
  if (!configLoaded) {
@@ -907,33 +1098,7 @@ export const QuotaToastPlugin = async ({ client }) => {
907
1098
  await injectRawOutput(sessionID, "Quota disabled in config (enabled: false)");
908
1099
  }
909
1100
  else {
910
- // Check what providers are available for a more specific hint.
911
- const allProvs = getProviders();
912
- const ctx = {
913
- client: typedClient,
914
- config: {
915
- googleModels: config.googleModels,
916
- alibabaCodingPlanTier: config.alibabaCodingPlanTier,
917
- cursorPlan: config.cursorPlan,
918
- cursorIncludedApiUsd: config.cursorIncludedApiUsd,
919
- cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
920
- },
921
- };
922
- const avail = await Promise.all(allProvs.map(async (p) => {
923
- try {
924
- return { id: p.id, ok: await p.isAvailable(ctx) };
925
- }
926
- catch {
927
- return { id: p.id, ok: false };
928
- }
929
- }));
930
- const availableIds = avail.filter((x) => x.ok).map((x) => x.id);
931
- if (availableIds.length === 0) {
932
- 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.");
933
- }
934
- else {
935
- 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.`);
936
- }
1101
+ await injectRawOutput(sessionID, await buildQuotaCommandUnavailableMessage(quotaRequestContext));
937
1102
  }
938
1103
  handled();
939
1104
  }
@@ -944,6 +1109,24 @@ export const QuotaToastPlugin = async ({ client }) => {
944
1109
  await injectRawOutput(sessionID, `${heading}\n\n${body}`);
945
1110
  handled();
946
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
+ }
947
1130
  const untilMs = Date.now();
948
1131
  // Handle token report commands (/tokens_*)
949
1132
  if (isTokenReportCommand(cmd)) {
@@ -1095,7 +1278,8 @@ export const QuotaToastPlugin = async ({ client }) => {
1095
1278
  if (!config.enabled)
1096
1279
  return;
1097
1280
  if (isSuccessfulQuestionExecution(output)) {
1098
- const model = await getCurrentModel(input.sessionID);
1281
+ const sessionMeta = await getSessionModelMeta(input.sessionID);
1282
+ const model = sessionMeta.modelID;
1099
1283
  try {
1100
1284
  if (isQwenCodeModelId(model)) {
1101
1285
  const plan = await resolveQwenLocalPlanCached();
@@ -1114,7 +1298,7 @@ export const QuotaToastPlugin = async ({ client }) => {
1114
1298
  clearQuotaCommandCache();
1115
1299
  }
1116
1300
  }
1117
- else if (isCursorModelId(model)) {
1301
+ else if (isCursorProviderId(sessionMeta.providerID) || isCursorModelId(model)) {
1118
1302
  clearQuotaCommandCache();
1119
1303
  }
1120
1304
  }
@@ -1122,6 +1306,7 @@ export const QuotaToastPlugin = async ({ client }) => {
1122
1306
  await log("Failed to record local request-plan quota completion", {
1123
1307
  error: err instanceof Error ? err.message : String(err),
1124
1308
  model,
1309
+ providerID: sessionMeta.providerID,
1125
1310
  });
1126
1311
  }
1127
1312
  }