@slkiser/opencode-quota 3.2.0 → 3.4.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 (127) hide show
  1. package/README.md +278 -557
  2. package/dist/bin/opencode-quota.d.ts.map +1 -1
  3. package/dist/bin/opencode-quota.js +6 -0
  4. package/dist/bin/opencode-quota.js.map +1 -1
  5. package/dist/lib/anthropic.js +1 -1
  6. package/dist/lib/anthropic.js.map +1 -1
  7. package/dist/lib/cli-show.d.ts +8 -0
  8. package/dist/lib/cli-show.d.ts.map +1 -0
  9. package/dist/lib/cli-show.js +178 -0
  10. package/dist/lib/cli-show.js.map +1 -0
  11. package/dist/lib/config-file-utils.d.ts +13 -0
  12. package/dist/lib/config-file-utils.d.ts.map +1 -1
  13. package/dist/lib/config-file-utils.js +33 -0
  14. package/dist/lib/config-file-utils.js.map +1 -1
  15. package/dist/lib/config.d.ts +16 -3
  16. package/dist/lib/config.d.ts.map +1 -1
  17. package/dist/lib/config.js +452 -216
  18. package/dist/lib/config.js.map +1 -1
  19. package/dist/lib/copilot.d.ts.map +1 -1
  20. package/dist/lib/copilot.js +3 -2
  21. package/dist/lib/copilot.js.map +1 -1
  22. package/dist/lib/entries.d.ts +3 -2
  23. package/dist/lib/entries.d.ts.map +1 -1
  24. package/dist/lib/format-utils.d.ts.map +1 -1
  25. package/dist/lib/format-utils.js +3 -2
  26. package/dist/lib/format-utils.js.map +1 -1
  27. package/dist/lib/format.d.ts.map +1 -1
  28. package/dist/lib/format.js +4 -2
  29. package/dist/lib/format.js.map +1 -1
  30. package/dist/lib/google-gemini-cli-companion.d.ts +29 -0
  31. package/dist/lib/google-gemini-cli-companion.d.ts.map +1 -0
  32. package/dist/lib/google-gemini-cli-companion.js +166 -0
  33. package/dist/lib/google-gemini-cli-companion.js.map +1 -0
  34. package/dist/lib/google-gemini-cli.d.ts +48 -0
  35. package/dist/lib/google-gemini-cli.d.ts.map +1 -0
  36. package/dist/lib/google-gemini-cli.js +447 -0
  37. package/dist/lib/google-gemini-cli.js.map +1 -0
  38. package/dist/lib/grouped-entry-normalization.js +1 -1
  39. package/dist/lib/grouped-entry-normalization.js.map +1 -1
  40. package/dist/lib/init-installer.js +2 -2
  41. package/dist/lib/opencode-config-providers.d.ts +6 -0
  42. package/dist/lib/opencode-config-providers.d.ts.map +1 -0
  43. package/dist/lib/opencode-config-providers.js +79 -0
  44. package/dist/lib/opencode-config-providers.js.map +1 -0
  45. package/dist/lib/opencode-go.d.ts +5 -4
  46. package/dist/lib/opencode-go.d.ts.map +1 -1
  47. package/dist/lib/opencode-go.js +32 -18
  48. package/dist/lib/opencode-go.js.map +1 -1
  49. package/dist/lib/provider-metadata.d.ts +1 -1
  50. package/dist/lib/provider-metadata.d.ts.map +1 -1
  51. package/dist/lib/provider-metadata.js +19 -0
  52. package/dist/lib/provider-metadata.js.map +1 -1
  53. package/dist/lib/provider-model-matching.d.ts +12 -0
  54. package/dist/lib/provider-model-matching.d.ts.map +1 -0
  55. package/dist/lib/provider-model-matching.js +23 -0
  56. package/dist/lib/provider-model-matching.js.map +1 -0
  57. package/dist/lib/quota-render-data.d.ts +4 -0
  58. package/dist/lib/quota-render-data.d.ts.map +1 -1
  59. package/dist/lib/quota-render-data.js +4 -1
  60. package/dist/lib/quota-render-data.js.map +1 -1
  61. package/dist/lib/quota-runtime-context.d.ts +43 -0
  62. package/dist/lib/quota-runtime-context.d.ts.map +1 -0
  63. package/dist/lib/quota-runtime-context.js +62 -0
  64. package/dist/lib/quota-runtime-context.js.map +1 -0
  65. package/dist/lib/quota-state.d.ts +1 -0
  66. package/dist/lib/quota-state.d.ts.map +1 -1
  67. package/dist/lib/quota-state.js +18 -4
  68. package/dist/lib/quota-state.js.map +1 -1
  69. package/dist/lib/quota-status.d.ts +18 -1
  70. package/dist/lib/quota-status.d.ts.map +1 -1
  71. package/dist/lib/quota-status.js +106 -22
  72. package/dist/lib/quota-status.js.map +1 -1
  73. package/dist/lib/toast-format-grouped.d.ts.map +1 -1
  74. package/dist/lib/toast-format-grouped.js +5 -3
  75. package/dist/lib/toast-format-grouped.js.map +1 -1
  76. package/dist/lib/tui-config-diagnostics.d.ts +7 -2
  77. package/dist/lib/tui-config-diagnostics.d.ts.map +1 -1
  78. package/dist/lib/tui-config-diagnostics.js +27 -8
  79. package/dist/lib/tui-config-diagnostics.js.map +1 -1
  80. package/dist/lib/tui-runtime.d.ts.map +1 -1
  81. package/dist/lib/tui-runtime.js +25 -16
  82. package/dist/lib/tui-runtime.js.map +1 -1
  83. package/dist/lib/types.d.ts +55 -9
  84. package/dist/lib/types.d.ts.map +1 -1
  85. package/dist/lib/types.js +1 -0
  86. package/dist/lib/types.js.map +1 -1
  87. package/dist/plugin.d.ts.map +1 -1
  88. package/dist/plugin.js +423 -159
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/providers/chutes.d.ts.map +1 -1
  91. package/dist/providers/chutes.js +2 -4
  92. package/dist/providers/chutes.js.map +1 -1
  93. package/dist/providers/copilot.d.ts.map +1 -1
  94. package/dist/providers/copilot.js +5 -10
  95. package/dist/providers/copilot.js.map +1 -1
  96. package/dist/providers/cursor.js +2 -2
  97. package/dist/providers/cursor.js.map +1 -1
  98. package/dist/providers/google-account-format.d.ts +6 -0
  99. package/dist/providers/google-account-format.d.ts.map +1 -0
  100. package/dist/providers/google-account-format.js +21 -0
  101. package/dist/providers/google-account-format.js.map +1 -0
  102. package/dist/providers/google-antigravity.d.ts.map +1 -1
  103. package/dist/providers/google-antigravity.js +7 -29
  104. package/dist/providers/google-antigravity.js.map +1 -1
  105. package/dist/providers/google-gemini-cli.d.ts +3 -0
  106. package/dist/providers/google-gemini-cli.d.ts.map +1 -0
  107. package/dist/providers/google-gemini-cli.js +63 -0
  108. package/dist/providers/google-gemini-cli.js.map +1 -0
  109. package/dist/providers/minimax-coding-plan.js +2 -2
  110. package/dist/providers/minimax-coding-plan.js.map +1 -1
  111. package/dist/providers/nanogpt.d.ts.map +1 -1
  112. package/dist/providers/nanogpt.js +2 -2
  113. package/dist/providers/nanogpt.js.map +1 -1
  114. package/dist/providers/openai.d.ts.map +1 -1
  115. package/dist/providers/openai.js +2 -4
  116. package/dist/providers/openai.js.map +1 -1
  117. package/dist/providers/opencode-go.d.ts +2 -2
  118. package/dist/providers/opencode-go.d.ts.map +1 -1
  119. package/dist/providers/opencode-go.js +60 -11
  120. package/dist/providers/opencode-go.js.map +1 -1
  121. package/dist/providers/registry.d.ts.map +1 -1
  122. package/dist/providers/registry.js +2 -0
  123. package/dist/providers/registry.js.map +1 -1
  124. package/dist/providers/synthetic.d.ts.map +1 -1
  125. package/dist/providers/synthetic.js +2 -4
  126. package/dist/providers/synthetic.js.map +1 -1
  127. package/package.json +2 -1
package/dist/plugin.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Supports GitHub Copilot and Google (via opencode-antigravity-auth).
7
7
  */
8
8
  import { DEFAULT_CONFIG } from "./lib/types.js";
9
- import { loadConfig, createLoadConfigMeta } from "./lib/config.js";
9
+ import { createLoadConfigMeta } from "./lib/config.js";
10
10
  import { clearCache, getOrFetchWithCacheControl } from "./lib/cache.js";
11
11
  import { formatQuotaRows } from "./lib/format.js";
12
12
  import { formatQuotaCommand } from "./lib/quota-command-format.js";
@@ -28,6 +28,8 @@ import { renderCommandHeading } from "./lib/format-utils.js";
28
28
  import { sanitizeDisplayText } from "./lib/display-sanitize.js";
29
29
  import { ALL_WINDOWS_FORMAT_STYLE, SINGLE_WINDOW_PER_PROVIDER_FORMAT_STYLE, resolveQuotaFormatStyle, } from "./lib/quota-format-style.js";
30
30
  import { collectQuotaRenderData, collectQuotaStatusLiveProbes, matchesQuotaProviderCurrentSelection, resolveQuotaRenderSelection, } from "./lib/quota-render-data.js";
31
+ import { createQuotaProviderRuntimeContext, createQuotaRuntimeRequestContext, resolveQuotaRuntimeContext, } from "./lib/quota-runtime-context.js";
32
+ const DEFERRED_QUOTA_REFRESH_DELAYS_MS = [3_000, 15_000, 60_000, 300_000];
31
33
  /** All token report command specifications */
32
34
  const TOKEN_REPORT_COMMANDS = [
33
35
  {
@@ -158,8 +160,65 @@ export const QuotaToastPlugin = async ({ client }) => {
158
160
  let configLoaded = false;
159
161
  let configInFlight = null;
160
162
  let configMeta = createLoadConfigMeta();
163
+ let runtimeProviders = getProviders();
161
164
  // Track last session token error for /quota_status diagnostics
162
165
  let lastSessionTokenError;
166
+ const deferredQuotaRefreshes = new Map();
167
+ function getDeferredQuotaRefreshDelayMs(attempts) {
168
+ const index = Math.min(Math.max(0, attempts), DEFERRED_QUOTA_REFRESH_DELAYS_MS.length - 1);
169
+ return DEFERRED_QUOTA_REFRESH_DELAYS_MS[index];
170
+ }
171
+ function clearDeferredQuotaRefresh(sessionID) {
172
+ const state = deferredQuotaRefreshes.get(sessionID);
173
+ if (state?.timer) {
174
+ clearTimeout(state.timer);
175
+ }
176
+ deferredQuotaRefreshes.delete(sessionID);
177
+ }
178
+ function clearDeferredQuotaRefreshTimer(state) {
179
+ if (!state.timer)
180
+ return;
181
+ clearTimeout(state.timer);
182
+ state.timer = null;
183
+ }
184
+ function scheduleDeferredQuotaRefresh(params) {
185
+ let state = deferredQuotaRefreshes.get(params.sessionID);
186
+ if (!state) {
187
+ state = {
188
+ sessionID: params.sessionID,
189
+ attempts: 0,
190
+ reason: params.reason,
191
+ queuedAtMs: Date.now(),
192
+ timer: null,
193
+ inFlight: false,
194
+ };
195
+ deferredQuotaRefreshes.set(params.sessionID, state);
196
+ }
197
+ else {
198
+ if (params.incrementAttempts) {
199
+ state.attempts += 1;
200
+ }
201
+ state.reason = params.reason;
202
+ clearDeferredQuotaRefreshTimer(state);
203
+ }
204
+ const delayMs = getDeferredQuotaRefreshDelayMs(state.attempts);
205
+ state.timer = setTimeout(() => {
206
+ void runDeferredQuotaRefresh(params.sessionID);
207
+ }, delayMs);
208
+ state.timer.unref?.();
209
+ void log("Deferred quota refresh scheduled", {
210
+ sessionID: params.sessionID,
211
+ reason: params.reason,
212
+ attempts: state.attempts,
213
+ delayMs,
214
+ });
215
+ }
216
+ async function runDeferredQuotaRefresh(sessionID) {
217
+ const state = deferredQuotaRefreshes.get(sessionID);
218
+ if (!state || state.inFlight)
219
+ return;
220
+ await showQuotaToast(sessionID, "deferred.retry", { deferredRetry: true });
221
+ }
163
222
  function asRecord(value) {
164
223
  return value && typeof value === "object" ? value : null;
165
224
  }
@@ -224,13 +283,42 @@ export const QuotaToastPlugin = async ({ client }) => {
224
283
  }
225
284
  return false;
226
285
  }
286
+ function getPluginRuntimeRootHints() {
287
+ const cwd = process.cwd();
288
+ return {
289
+ workspaceRoot: cwd,
290
+ configRoot: cwd,
291
+ fallbackDirectory: cwd,
292
+ };
293
+ }
294
+ async function resolvePluginRuntimeContext(params = {}) {
295
+ if (!configLoaded) {
296
+ await refreshConfig();
297
+ }
298
+ return resolveQuotaRuntimeContext({
299
+ client: typedClient,
300
+ roots: getPluginRuntimeRootHints(),
301
+ config,
302
+ configMeta,
303
+ providers: runtimeProviders,
304
+ sessionID: params.sessionID,
305
+ sessionMeta: params.sessionMeta,
306
+ resolveSessionMeta: (sessionID) => getSessionModelMeta(sessionID),
307
+ includeSessionMeta: params.includeSessionMeta,
308
+ });
309
+ }
227
310
  async function refreshConfig() {
228
311
  if (configInFlight)
229
312
  return configInFlight;
230
313
  configInFlight = (async () => {
231
314
  try {
232
- configMeta = createLoadConfigMeta();
233
- config = await loadConfig(typedClient, configMeta);
315
+ const runtime = await resolveQuotaRuntimeContext({
316
+ client: typedClient,
317
+ roots: getPluginRuntimeRootHints(),
318
+ });
319
+ configMeta = runtime.configMeta;
320
+ config = runtime.config;
321
+ runtimeProviders = runtime.providers;
234
322
  setPricingSnapshotAutoRefresh(config.pricingSnapshot.autoRefresh);
235
323
  setPricingSnapshotSelection(config.pricingSnapshot.source);
236
324
  configLoaded = true;
@@ -239,6 +327,8 @@ export const QuotaToastPlugin = async ({ client }) => {
239
327
  catch {
240
328
  // Leave configLoaded=false so we can retry on next trigger.
241
329
  config = DEFAULT_CONFIG;
330
+ configMeta = createLoadConfigMeta();
331
+ runtimeProviders = getProviders();
242
332
  setPricingSnapshotAutoRefresh(DEFAULT_CONFIG.pricingSnapshot.autoRefresh);
243
333
  setPricingSnapshotSelection(DEFAULT_CONFIG.pricingSnapshot.source);
244
334
  }
@@ -394,11 +484,12 @@ export const QuotaToastPlugin = async ({ client }) => {
394
484
  }
395
485
  return "current session";
396
486
  }
397
- async function buildQuotaCommandUnavailableMessage(params = {}) {
487
+ async function buildQuotaCommandUnavailableMessage(runtime) {
398
488
  const selection = await resolveQuotaRenderSelection({
399
- client: typedClient,
400
- config,
401
- request: params,
489
+ client: runtime.client,
490
+ config: runtime.config,
491
+ request: createQuotaRuntimeRequestContext(runtime),
492
+ providers: runtime.providers,
402
493
  });
403
494
  if (!selection) {
404
495
  return "Quota unavailable\n\nNo enabled quota providers are configured.\n\nRun /quota_status for diagnostics.";
@@ -461,177 +552,349 @@ export const QuotaToastPlugin = async ({ client }) => {
461
552
  function clearToastCacheForSession(params) {
462
553
  clearCache(buildToastCacheKey(params));
463
554
  }
464
- async function fetchQuotaMessage(params) {
555
+ function isProviderFetchFailureOnly(errors) {
556
+ return (errors.length > 0 && errors.every((error) => error.message === "Failed to read quota data"));
557
+ }
558
+ async function fetchQuotaMessageResult(params) {
465
559
  // Ensure we have loaded config at least once. If load fails, we keep trying
466
- // on subsequent triggers.
560
+ // on subsequent triggers and queue a deferred retry for toast paths.
467
561
  if (!configLoaded) {
468
562
  await refreshConfig();
469
563
  }
564
+ if (!configLoaded) {
565
+ return {
566
+ message: config.debug
567
+ ? formatDebugInfo({
568
+ trigger: params.trigger,
569
+ reason: "config load failed",
570
+ enabledProviders: config.enabledProviders,
571
+ })
572
+ : null,
573
+ cacheRenderedMessage: false,
574
+ retryable: true,
575
+ retryReason: "config_load_failed",
576
+ hasQuotaRows: false,
577
+ };
578
+ }
470
579
  if (!config.enabled) {
471
- return config.debug
472
- ? formatDebugInfo({ trigger: params.trigger, reason: "disabled", enabledProviders: [] })
473
- : null;
580
+ return {
581
+ message: config.debug
582
+ ? formatDebugInfo({ trigger: params.trigger, reason: "disabled", enabledProviders: [] })
583
+ : null,
584
+ cacheRenderedMessage: false,
585
+ retryable: false,
586
+ hasQuotaRows: false,
587
+ };
474
588
  }
475
589
  if (config.enabledProviders !== "auto" && config.enabledProviders.length === 0) {
476
- return config.debug
477
- ? formatDebugInfo({
478
- trigger: params.trigger,
479
- reason: "enabledProviders empty",
480
- enabledProviders: [],
481
- })
482
- : null;
590
+ return {
591
+ message: config.debug
592
+ ? formatDebugInfo({
593
+ trigger: params.trigger,
594
+ reason: "enabledProviders empty",
595
+ enabledProviders: [],
596
+ })
597
+ : null,
598
+ cacheRenderedMessage: false,
599
+ retryable: false,
600
+ hasQuotaRows: false,
601
+ };
483
602
  }
484
- const quotaRequestContext = {
603
+ const runtime = await resolvePluginRuntimeContext({
485
604
  sessionID: params.sessionID,
486
- sessionMeta: config.onlyCurrentModel && params.sessionID
487
- ? (params.sessionMeta ?? (await getSessionModelMeta(params.sessionID)))
488
- : undefined,
489
- };
605
+ sessionMeta: params.sessionMeta,
606
+ includeSessionMeta: (config) => config.onlyCurrentModel,
607
+ });
608
+ const runtimeConfig = runtime.config;
609
+ const quotaRequestContext = createQuotaRuntimeRequestContext(runtime);
490
610
  const quotaResult = await collectQuotaRenderData({
491
- client: typedClient,
492
- config,
611
+ client: runtime.client,
612
+ config: runtimeConfig,
493
613
  request: quotaRequestContext,
494
614
  surfaceExplicitProviderIssues: true,
495
- formatStyle: resolveQuotaFormatStyle(config.formatStyle),
615
+ formatStyle: resolveQuotaFormatStyle(runtimeConfig.formatStyle),
616
+ bypassProviderCache: params.bypassProviderCache,
617
+ providers: runtime.providers,
496
618
  });
497
619
  const { selection, availability, active, attemptedAny, hasExplicitProviderIssues, data } = quotaResult;
498
- if (config.showSessionTokens && params.sessionID) {
620
+ if (runtimeConfig.showSessionTokens && params.sessionID) {
499
621
  lastSessionTokenError = quotaResult.sessionTokenError;
500
622
  }
501
623
  const currentModel = selection?.currentModel;
502
624
  const errors = data?.errors ?? [];
625
+ const hasProviderQuotaRows = Boolean(data?.entries.length);
626
+ const hasQuotaRows = Boolean(hasProviderQuotaRows || data?.sessionTokens);
627
+ const providerFetchFailureOnly = attemptedAny && isProviderFetchFailureOnly(errors);
628
+ const retryableAvailabilityFailure = active.length === 0 && availability.some((item) => !item.ok && item.error === true);
503
629
  if (active.length === 0 && !(hasExplicitProviderIssues && errors.length > 0)) {
504
- return config.debug
630
+ const message = runtimeConfig.debug
505
631
  ? formatDebugInfo({
506
632
  trigger: params.trigger,
507
633
  reason: "no enabled providers available",
508
634
  currentModel,
509
- enabledProviders: config.enabledProviders,
635
+ enabledProviders: runtimeConfig.enabledProviders,
510
636
  availability: availability.map((item) => ({
511
637
  id: item.provider.id,
512
638
  ok: item.ok,
513
639
  })),
514
640
  })
515
641
  : null;
642
+ const retryableNoProviders = selection?.isAutoMode === true || retryableAvailabilityFailure;
643
+ return {
644
+ message,
645
+ cacheRenderedMessage: false,
646
+ retryable: retryableNoProviders,
647
+ retryReason: retryableNoProviders ? "no_available_providers" : undefined,
648
+ hasQuotaRows: false,
649
+ };
516
650
  }
517
- if (data?.entries.length) {
651
+ if (hasQuotaRows) {
518
652
  const formatted = formatQuotaRows({
519
653
  version: "1.0.0",
520
- layout: config.layout,
521
- entries: data.entries,
522
- errors: data.errors,
523
- style: resolveQuotaFormatStyle(config.formatStyle),
524
- percentDisplayMode: config.percentDisplayMode,
525
- sessionTokens: data.sessionTokens,
654
+ layout: runtimeConfig.layout,
655
+ entries: data?.entries ?? [],
656
+ errors: data?.errors ?? [],
657
+ style: resolveQuotaFormatStyle(runtimeConfig.formatStyle),
658
+ percentDisplayMode: runtimeConfig.percentDisplayMode,
659
+ sessionTokens: data?.sessionTokens,
526
660
  });
527
- if (!config.debug)
528
- return formatted;
529
- const debugFooter = `\n\n[debug] src=${configMeta.source} providers=${config.enabledProviders === "auto" ? "(auto)" : config.enabledProviders.join(",") || "(none)"} avail=${availability
661
+ const retryableMaskedProviderFailure = !hasProviderQuotaRows && providerFetchFailureOnly;
662
+ if (!runtimeConfig.debug) {
663
+ return {
664
+ message: formatted,
665
+ cacheRenderedMessage: true,
666
+ retryable: retryableMaskedProviderFailure,
667
+ retryReason: retryableMaskedProviderFailure ? "provider_fetch_failed" : undefined,
668
+ hasQuotaRows: true,
669
+ };
670
+ }
671
+ const debugFooter = `\n\n[debug] src=${configMeta.source} providers=${runtimeConfig.enabledProviders === "auto" ? "(auto)" : runtimeConfig.enabledProviders.join(",") || "(none)"} avail=${availability
530
672
  .map((item) => `${item.provider.id}:${item.ok ? "ok" : "no"}`)
531
673
  .join(" ")}`;
532
- return formatted + debugFooter;
674
+ return {
675
+ message: formatted + debugFooter,
676
+ cacheRenderedMessage: false,
677
+ retryable: retryableMaskedProviderFailure,
678
+ retryReason: retryableMaskedProviderFailure ? "provider_fetch_failed" : undefined,
679
+ hasQuotaRows: true,
680
+ };
533
681
  }
534
682
  // Show errors even without entries when:
535
683
  // 1. showOnBothFail is enabled and at least one provider attempted (existing behavior)
536
684
  // 2. OR we're in explicit mode and have "Not configured"/"Unavailable" errors (new behavior)
537
- if ((config.showOnBothFail && attemptedAny && errors.length > 0) || hasExplicitProviderIssues) {
685
+ if ((runtimeConfig.showOnBothFail && attemptedAny && errors.length > 0) ||
686
+ hasExplicitProviderIssues) {
538
687
  const errorLines = errors.map((error) => `${error.label}: ${error.message}`).join("\n");
539
- if (!config.debug)
540
- return errorLines || "Quota unavailable";
541
- return ((errorLines || "Quota unavailable") +
542
- "\n\n" +
543
- formatDebugInfo({
688
+ const retryableFetchFailure = !hasExplicitProviderIssues && providerFetchFailureOnly;
689
+ const retryableFailure = retryableFetchFailure || retryableAvailabilityFailure;
690
+ const retryReason = retryableFetchFailure
691
+ ? "provider_fetch_failed"
692
+ : retryableAvailabilityFailure
693
+ ? "no_available_providers"
694
+ : undefined;
695
+ const message = !runtimeConfig.debug
696
+ ? errorLines || "Quota unavailable"
697
+ : (errorLines || "Quota unavailable") +
698
+ "\n\n" +
699
+ formatDebugInfo({
700
+ trigger: params.trigger,
701
+ reason: hasExplicitProviderIssues
702
+ ? "providers missing/unavailable"
703
+ : "all providers failed",
704
+ currentModel,
705
+ enabledProviders: runtimeConfig.enabledProviders,
706
+ availability: availability.map((item) => ({
707
+ id: item.provider.id,
708
+ ok: item.ok,
709
+ })),
710
+ });
711
+ return {
712
+ message,
713
+ cacheRenderedMessage: false,
714
+ retryable: retryableFailure,
715
+ retryReason,
716
+ hasQuotaRows: false,
717
+ };
718
+ }
719
+ const retryableNoData = providerFetchFailureOnly ||
720
+ (selection?.isAutoMode === true && active.length > 0 && errors.length === 0);
721
+ return {
722
+ message: runtimeConfig.debug
723
+ ? formatDebugInfo({
544
724
  trigger: params.trigger,
545
- reason: hasExplicitProviderIssues
546
- ? "providers missing/unavailable"
547
- : "all providers failed",
725
+ reason: "no entries",
548
726
  currentModel,
549
- enabledProviders: config.enabledProviders,
727
+ enabledProviders: runtimeConfig.enabledProviders,
550
728
  availability: availability.map((item) => ({
551
729
  id: item.provider.id,
552
730
  ok: item.ok,
553
731
  })),
554
- }));
732
+ })
733
+ : null,
734
+ cacheRenderedMessage: false,
735
+ retryable: retryableNoData,
736
+ retryReason: providerFetchFailureOnly
737
+ ? "provider_fetch_failed"
738
+ : retryableNoData
739
+ ? "no_reportable_data"
740
+ : undefined,
741
+ hasQuotaRows: false,
742
+ };
743
+ }
744
+ async function fetchQuotaMessage(params) {
745
+ const result = await fetchQuotaMessageResult(params);
746
+ return result.message;
747
+ }
748
+ async function reconcileDeferredQuotaRefresh(params) {
749
+ const existing = deferredQuotaRefreshes.get(params.sessionID);
750
+ if (!params.result.retryable) {
751
+ if (existing) {
752
+ clearDeferredQuotaRefresh(params.sessionID);
753
+ await log("Deferred quota refresh cleared", {
754
+ sessionID: params.sessionID,
755
+ trigger: params.trigger,
756
+ reason: params.result.hasQuotaRows ? "quota_rows_available" : "not_retryable",
757
+ });
758
+ }
759
+ return;
555
760
  }
556
- return config.debug
557
- ? formatDebugInfo({
558
- trigger: params.trigger,
559
- reason: "no entries",
560
- currentModel,
561
- enabledProviders: config.enabledProviders,
562
- availability: availability.map((item) => ({
563
- id: item.provider.id,
564
- ok: item.ok,
565
- })),
566
- })
567
- : null;
761
+ if (!params.result.retryReason) {
762
+ return;
763
+ }
764
+ scheduleDeferredQuotaRefresh({
765
+ sessionID: params.sessionID,
766
+ reason: params.result.retryReason,
767
+ incrementAttempts: params.consumedDeferredRetry,
768
+ });
568
769
  }
569
770
  /**
570
771
  * Show quota toast for a session
571
772
  */
572
- async function showQuotaToast(sessionID, trigger) {
773
+ async function showQuotaToast(sessionID, trigger, options = {}) {
573
774
  if (!configLoaded) {
574
775
  await refreshConfig();
575
776
  }
576
- // Check if subagent session
577
- if (await isSubagentSession(sessionID)) {
578
- await log("Skipping toast for subagent session", { sessionID, trigger });
579
- return;
580
- }
581
- // Get or fetch quota (with caching/throttling)
582
- // If debug is enabled, bypass caching so the toast reflects current state.
583
- function shouldCacheToastMessage(msg) {
584
- // Cache when we have any quota row (which always includes a "NN%" token).
585
- // Do not cache when output is only error rows (rendered as "label: message").
586
- const lines = msg.split("\n");
587
- return lines.some((l) => /\b\d{1,3}%\b/.test(l) && !/:\s/.test(l));
588
- }
589
- const sessionMeta = await getSessionModelMeta(sessionID);
590
- const bypassMessageCache = config.debug
591
- ? true
592
- : await shouldBypassToastCacheForLiveLocalUsage({ trigger, sessionID, sessionMeta });
593
- const toastCacheKey = buildToastCacheKey({ sessionID, sessionMeta });
594
- const message = bypassMessageCache
595
- ? await fetchQuotaMessage({ trigger, sessionID, sessionMeta })
596
- : await getOrFetchWithCacheControl(toastCacheKey, async () => {
597
- const msg = await fetchQuotaMessage({ trigger, sessionID, sessionMeta });
598
- const cache = msg ? shouldCacheToastMessage(msg) : true;
599
- return { message: msg, cache };
600
- }, config.minIntervalMs);
601
- if (!message) {
602
- await log("No quota message to display", { trigger });
603
- return;
604
- }
605
- if (!config.enableToast) {
606
- await log("Toast disabled (enableToast=false)", { trigger });
607
- return;
777
+ const pendingDeferred = deferredQuotaRefreshes.get(sessionID);
778
+ const consumedDeferredRetry = options.deferredRetry === true || Boolean(pendingDeferred);
779
+ if (pendingDeferred) {
780
+ if (pendingDeferred.inFlight && !options.deferredRetry) {
781
+ await log("Skipping duplicate deferred quota refresh", { sessionID, trigger });
782
+ return;
783
+ }
784
+ pendingDeferred.inFlight = true;
785
+ clearDeferredQuotaRefreshTimer(pendingDeferred);
608
786
  }
609
- // Show toast
610
787
  try {
611
- await typedClient.tui.showToast({
612
- body: {
613
- message: sanitizeDisplayText(message),
614
- variant: "info",
615
- duration: config.toastDurationMs,
616
- },
788
+ // Check if session is a subagent session
789
+ if (await isSubagentSession(sessionID)) {
790
+ if (consumedDeferredRetry) {
791
+ clearDeferredQuotaRefresh(sessionID);
792
+ }
793
+ await log("Skipping toast for subagent session", { sessionID, trigger });
794
+ return;
795
+ }
796
+ // Get or fetch quota (with caching/throttling)
797
+ // If debug is enabled, bypass caching so the toast reflects current state.
798
+ function shouldCacheToastMessage(msg) {
799
+ // Cache when we have any quota row (which always includes a "NN%" token).
800
+ // Do not cache when output is only error rows (rendered as "label: message").
801
+ const lines = msg.split("\n");
802
+ return lines.some((l) => /\b\d+%\b/.test(l) && !/:\s/.test(l));
803
+ }
804
+ const sessionMeta = await getSessionModelMeta(sessionID);
805
+ const bypassForLiveLocalUsage = await shouldBypassToastCacheForLiveLocalUsage({
806
+ trigger,
807
+ sessionID,
808
+ sessionMeta,
617
809
  });
618
- await log("Displayed quota toast", { message, trigger });
619
- }
620
- catch (err) {
621
- await log("Failed to show toast", {
622
- error: err instanceof Error ? err.message : String(err),
810
+ const bypassMessageCache = config.debug || consumedDeferredRetry || bypassForLiveLocalUsage;
811
+ const bypassProviderCache = consumedDeferredRetry || bypassForLiveLocalUsage;
812
+ const toastCacheKey = buildToastCacheKey({ sessionID, sessionMeta });
813
+ let fetchResult;
814
+ const fetchForToast = () => fetchQuotaMessageResult({
815
+ trigger,
816
+ sessionID,
817
+ sessionMeta,
818
+ bypassProviderCache,
623
819
  });
820
+ const message = bypassMessageCache
821
+ ? await (async () => {
822
+ fetchResult = await fetchForToast();
823
+ return fetchResult.message;
824
+ })()
825
+ : await (async () => {
826
+ const fetched = {};
827
+ const cachedMessage = await getOrFetchWithCacheControl(toastCacheKey, async () => {
828
+ const result = await fetchForToast();
829
+ fetched.result = result;
830
+ const cache = result.message
831
+ ? result.cacheRenderedMessage && shouldCacheToastMessage(result.message)
832
+ : result.cacheRenderedMessage;
833
+ return { message: result.message, cache };
834
+ }, config.minIntervalMs);
835
+ fetchResult = fetched.result;
836
+ return cachedMessage;
837
+ })();
838
+ if (fetchResult) {
839
+ await reconcileDeferredQuotaRefresh({
840
+ sessionID,
841
+ result: fetchResult,
842
+ consumedDeferredRetry,
843
+ trigger,
844
+ });
845
+ }
846
+ if (options.deferredRetry && fetchResult && !fetchResult.hasQuotaRows) {
847
+ await log("Deferred quota refresh did not produce reportable data", {
848
+ sessionID,
849
+ trigger,
850
+ retryable: fetchResult.retryable,
851
+ retryReason: fetchResult.retryReason,
852
+ });
853
+ return;
854
+ }
855
+ if (!message) {
856
+ await log("No quota message to display", { trigger });
857
+ return;
858
+ }
859
+ if (!config.enableToast) {
860
+ await log("Toast disabled (enableToast=false)", { trigger });
861
+ return;
862
+ }
863
+ // Show toast
864
+ try {
865
+ await typedClient.tui.showToast({
866
+ body: {
867
+ message: sanitizeDisplayText(message),
868
+ variant: "info",
869
+ duration: config.toastDurationMs,
870
+ },
871
+ });
872
+ await log("Displayed quota toast", { message, trigger });
873
+ }
874
+ catch (err) {
875
+ await log("Failed to show toast", {
876
+ error: err instanceof Error ? err.message : String(err),
877
+ });
878
+ }
879
+ }
880
+ finally {
881
+ const state = deferredQuotaRefreshes.get(sessionID);
882
+ if (state) {
883
+ state.inFlight = false;
884
+ }
624
885
  }
625
886
  }
626
- async function fetchQuotaCommandData(params = {}) {
887
+ async function fetchQuotaCommandData(runtime) {
888
+ const request = createQuotaRuntimeRequestContext(runtime);
627
889
  const quotaResult = await collectQuotaRenderData({
628
- client: typedClient,
629
- config,
630
- request: params,
890
+ client: runtime.client,
891
+ config: runtime.config,
892
+ request,
631
893
  surfaceExplicitProviderIssues: false,
632
894
  formatStyle: ALL_WINDOWS_FORMAT_STYLE,
895
+ providers: runtime.providers,
633
896
  });
634
- if (config.showSessionTokens && params.sessionID) {
897
+ if (runtime.config.showSessionTokens && request.sessionID) {
635
898
  lastSessionTokenError = quotaResult.sessionTokenError;
636
899
  }
637
900
  return quotaResult.data;
@@ -656,11 +919,15 @@ export const QuotaToastPlugin = async ({ client }) => {
656
919
  });
657
920
  }
658
921
  async function buildStatusReport(params) {
659
- await refreshConfig();
660
- if (!config.enabled)
922
+ const runtime = await resolvePluginRuntimeContext({
923
+ sessionID: params.sessionID,
924
+ includeSessionMeta: true,
925
+ });
926
+ const runtimeConfig = runtime.config;
927
+ if (!runtimeConfig.enabled)
661
928
  return null;
662
929
  await kickPricingRefresh({ reason: "status", maxWaitMs: 750 });
663
- const currentSession = await getSessionModelMeta(params.sessionID);
930
+ const currentSession = runtime.session.sessionMeta ?? {};
664
931
  const currentModel = currentSession.modelID;
665
932
  const currentProviderID = currentSession.providerID;
666
933
  const sessionModelLookup = !params.sessionID
@@ -668,24 +935,13 @@ export const QuotaToastPlugin = async ({ client }) => {
668
935
  : currentModel
669
936
  ? "ok"
670
937
  : "not_found";
671
- const isAutoMode = config.enabledProviders === "auto";
672
- const providers = getProviders();
938
+ const isAutoMode = runtimeConfig.enabledProviders === "auto";
939
+ const providers = runtime.providers;
940
+ const providerContext = createQuotaProviderRuntimeContext(runtime);
673
941
  const availability = await Promise.all(providers.map(async (p) => {
674
942
  let ok = false;
675
943
  try {
676
- ok = await p.isAvailable({
677
- client: typedClient,
678
- config: {
679
- googleModels: config.googleModels,
680
- anthropicBinaryPath: config.anthropicBinaryPath,
681
- alibabaCodingPlanTier: config.alibabaCodingPlanTier,
682
- cursorPlan: config.cursorPlan,
683
- cursorIncludedApiUsd: config.cursorIncludedApiUsd,
684
- cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
685
- currentModel,
686
- currentProviderID,
687
- },
688
- });
944
+ ok = await p.isAvailable(providerContext);
689
945
  }
690
946
  catch {
691
947
  ok = false;
@@ -693,7 +949,7 @@ export const QuotaToastPlugin = async ({ client }) => {
693
949
  return {
694
950
  id: p.id,
695
951
  // In auto mode, a provider is effectively "enabled" if it's available.
696
- enabled: isAutoMode ? ok : config.enabledProviders.includes(p.id),
952
+ enabled: isAutoMode ? ok : runtimeConfig.enabledProviders.includes(p.id),
697
953
  available: ok,
698
954
  matchesCurrentModel: currentModel || isCursorProviderId(currentProviderID)
699
955
  ? matchesQuotaProviderCurrentSelection({
@@ -716,12 +972,9 @@ export const QuotaToastPlugin = async ({ client }) => {
716
972
  if (liveProbeProviders.length > 0) {
717
973
  try {
718
974
  providerLiveProbes = await collectQuotaStatusLiveProbes({
719
- client: typedClient,
720
- config,
721
- request: {
722
- sessionID: params.sessionID,
723
- sessionMeta: currentSession,
724
- },
975
+ client: runtime.client,
976
+ config: runtimeConfig,
977
+ request: createQuotaRuntimeRequestContext(runtime),
725
978
  formatStyle: SINGLE_WINDOW_PER_PROVIDER_FORMAT_STYLE,
726
979
  providers: liveProbeProviders,
727
980
  });
@@ -743,20 +996,24 @@ export const QuotaToastPlugin = async ({ client }) => {
743
996
  const refresh = params.refreshGoogleTokens
744
997
  ? await refreshGoogleTokensForAllAccounts({ skewMs: params.skewMs, force: params.force })
745
998
  : null;
746
- const tuiDiagnostics = await inspectTuiConfig();
999
+ const tuiDiagnostics = await inspectTuiConfig({ roots: runtime.roots });
747
1000
  return await buildQuotaStatusReport({
748
1001
  tuiDiagnostics,
749
- configSource: configMeta.source,
750
- configPaths: configMeta.paths,
751
- networkSettingSources: configMeta.networkSettingSources,
752
- enabledProviders: config.enabledProviders,
753
- anthropicBinaryPath: config.anthropicBinaryPath,
754
- alibabaCodingPlanTier: config.alibabaCodingPlanTier,
755
- cursorPlan: config.cursorPlan,
756
- cursorIncludedApiUsd: config.cursorIncludedApiUsd,
757
- cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
758
- pricingSnapshotSource: config.pricingSnapshot.source,
759
- onlyCurrentModel: config.onlyCurrentModel,
1002
+ configSource: runtime.configMeta.source,
1003
+ configPaths: runtime.configMeta.paths,
1004
+ globalConfigPaths: runtime.configMeta.globalConfigPaths,
1005
+ workspaceConfigPaths: runtime.configMeta.workspaceConfigPaths,
1006
+ settingSources: runtime.configMeta.settingSources,
1007
+ configIssues: runtime.configMeta.configIssues,
1008
+ enabledProviders: runtimeConfig.enabledProviders,
1009
+ anthropicBinaryPath: runtimeConfig.anthropicBinaryPath,
1010
+ alibabaCodingPlanTier: runtimeConfig.alibabaCodingPlanTier,
1011
+ cursorPlan: runtimeConfig.cursorPlan,
1012
+ cursorIncludedApiUsd: runtimeConfig.cursorIncludedApiUsd,
1013
+ cursorBillingCycleStartDay: runtimeConfig.cursorBillingCycleStartDay,
1014
+ opencodeGoWindows: runtimeConfig.opencodeGoWindows,
1015
+ pricingSnapshotSource: runtimeConfig.pricingSnapshot.source,
1016
+ onlyCurrentModel: runtimeConfig.onlyCurrentModel,
760
1017
  currentModel,
761
1018
  sessionModelLookup,
762
1019
  providerAvailability: availability,
@@ -770,6 +1027,7 @@ export const QuotaToastPlugin = async ({ client }) => {
770
1027
  }
771
1028
  : { attempted: false },
772
1029
  sessionTokenError: lastSessionTokenError,
1030
+ geminiCliClient: typedClient,
773
1031
  generatedAtMs: params.generatedAtMs,
774
1032
  });
775
1033
  }
@@ -832,19 +1090,21 @@ export const QuotaToastPlugin = async ({ client }) => {
832
1090
  async function handleQuotaSlashCommand(input) {
833
1091
  const sessionID = input.sessionID;
834
1092
  const generatedAtMs = Date.now();
835
- const quotaRequestContext = {
1093
+ const sessionMeta = sessionID ? await getSessionModelMeta(sessionID) : undefined;
1094
+ const runtime = await resolvePluginRuntimeContext({
836
1095
  sessionID,
837
- sessionMeta: sessionID ? await getSessionModelMeta(sessionID) : undefined,
838
- };
839
- const reportData = await fetchQuotaCommandData(quotaRequestContext);
1096
+ sessionMeta,
1097
+ includeSessionMeta: (config) => config.onlyCurrentModel,
1098
+ });
1099
+ const reportData = await fetchQuotaCommandData(runtime);
840
1100
  if (!reportData) {
841
1101
  if (!configLoaded) {
842
1102
  return await injectCommandOutputAndHandle(sessionID, "Quota unavailable (config not loaded, try again)");
843
1103
  }
844
- if (!config.enabled) {
1104
+ if (!runtime.config.enabled) {
845
1105
  return await injectCommandOutputAndHandle(sessionID, "Quota disabled in config (enabled: false)");
846
1106
  }
847
- return await injectCommandOutputAndHandle(sessionID, await buildQuotaCommandUnavailableMessage(quotaRequestContext));
1107
+ return await injectCommandOutputAndHandle(sessionID, await buildQuotaCommandUnavailableMessage(runtime));
848
1108
  }
849
1109
  return await injectCommandOutputAndHandle(sessionID, formatQuotaCommand({
850
1110
  ...reportData,
@@ -1087,8 +1347,10 @@ export const QuotaToastPlugin = async ({ client }) => {
1087
1347
  if (!configLoaded) {
1088
1348
  await refreshConfig();
1089
1349
  }
1090
- if (!config.enabled)
1350
+ if (!config.enabled) {
1351
+ clearDeferredQuotaRefresh(sessionID);
1091
1352
  return;
1353
+ }
1092
1354
  if (event.type === "session.idle" && config.showOnIdle) {
1093
1355
  await showQuotaToast(sessionID, "session.idle");
1094
1356
  }
@@ -1103,8 +1365,10 @@ export const QuotaToastPlugin = async ({ client }) => {
1103
1365
  if (!configLoaded) {
1104
1366
  await refreshConfig();
1105
1367
  }
1106
- if (!config.enabled)
1368
+ if (!config.enabled) {
1369
+ clearDeferredQuotaRefresh(input.sessionID);
1107
1370
  return;
1371
+ }
1108
1372
  if (isSuccessfulQuestionExecution(output)) {
1109
1373
  const sessionMeta = await getSessionModelMeta(input.sessionID);
1110
1374
  const model = sessionMeta.modelID;