@slkiser/opencode-quota 4.8.2 → 4.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 (227) hide show
  1. package/README.md +17 -18
  2. package/dist/bin/opencode-quota.d.ts.map +1 -1
  3. package/dist/bin/opencode-quota.js +5 -3
  4. package/dist/bin/opencode-quota.js.map +1 -1
  5. package/dist/lib/accounting-format.d.ts +54 -0
  6. package/dist/lib/accounting-format.d.ts.map +1 -0
  7. package/dist/lib/accounting-format.js +271 -0
  8. package/dist/lib/accounting-format.js.map +1 -0
  9. package/dist/lib/anthropic.d.ts +2 -0
  10. package/dist/lib/anthropic.d.ts.map +1 -1
  11. package/dist/lib/anthropic.js +79 -19
  12. package/dist/lib/anthropic.js.map +1 -1
  13. package/dist/lib/cli-show.d.ts.map +1 -1
  14. package/dist/lib/cli-show.js +1 -0
  15. package/dist/lib/cli-show.js.map +1 -1
  16. package/dist/lib/config.d.ts +10 -1
  17. package/dist/lib/config.d.ts.map +1 -1
  18. package/dist/lib/config.js +35 -3
  19. package/dist/lib/config.js.map +1 -1
  20. package/dist/lib/copilot.d.ts +2 -0
  21. package/dist/lib/copilot.d.ts.map +1 -1
  22. package/dist/lib/copilot.js +38 -0
  23. package/dist/lib/copilot.js.map +1 -1
  24. package/dist/lib/deepseek.d.ts +12 -12
  25. package/dist/lib/deepseek.d.ts.map +1 -1
  26. package/dist/lib/deepseek.js +33 -29
  27. package/dist/lib/deepseek.js.map +1 -1
  28. package/dist/lib/display-sanitize.d.ts.map +1 -1
  29. package/dist/lib/display-sanitize.js +75 -22
  30. package/dist/lib/display-sanitize.js.map +1 -1
  31. package/dist/lib/entries.d.ts +105 -23
  32. package/dist/lib/entries.d.ts.map +1 -1
  33. package/dist/lib/entries.js +37 -1
  34. package/dist/lib/entries.js.map +1 -1
  35. package/dist/lib/format.d.ts +1 -0
  36. package/dist/lib/format.d.ts.map +1 -1
  37. package/dist/lib/format.js +84 -19
  38. package/dist/lib/format.js.map +1 -1
  39. package/dist/lib/google-agy.d.ts +2 -0
  40. package/dist/lib/google-agy.d.ts.map +1 -1
  41. package/dist/lib/google-agy.js +29 -14
  42. package/dist/lib/google-agy.js.map +1 -1
  43. package/dist/lib/google-gemini-cli.d.ts +2 -0
  44. package/dist/lib/google-gemini-cli.d.ts.map +1 -1
  45. package/dist/lib/google-gemini-cli.js +29 -3
  46. package/dist/lib/google-gemini-cli.js.map +1 -1
  47. package/dist/lib/google-token-cache.d.ts +5 -8
  48. package/dist/lib/google-token-cache.d.ts.map +1 -1
  49. package/dist/lib/google-token-cache.js +30 -118
  50. package/dist/lib/google-token-cache.js.map +1 -1
  51. package/dist/lib/google.d.ts +2 -0
  52. package/dist/lib/google.d.ts.map +1 -1
  53. package/dist/lib/google.js +31 -6
  54. package/dist/lib/google.js.map +1 -1
  55. package/dist/lib/grouped-entry-normalization.d.ts.map +1 -1
  56. package/dist/lib/grouped-entry-normalization.js +53 -20
  57. package/dist/lib/grouped-entry-normalization.js.map +1 -1
  58. package/dist/lib/minimax-auth.d.ts +1 -1
  59. package/dist/lib/minimax-auth.js +1 -1
  60. package/dist/lib/nanogpt.d.ts +5 -5
  61. package/dist/lib/nanogpt.d.ts.map +1 -1
  62. package/dist/lib/nanogpt.js +59 -23
  63. package/dist/lib/nanogpt.js.map +1 -1
  64. package/dist/lib/openai.d.ts +4 -0
  65. package/dist/lib/openai.d.ts.map +1 -1
  66. package/dist/lib/openai.js +22 -3
  67. package/dist/lib/openai.js.map +1 -1
  68. package/dist/lib/opencode-go-auth.js +1 -1
  69. package/dist/lib/opencode-go-auth.js.map +1 -1
  70. package/dist/lib/opencode-go.d.ts.map +1 -1
  71. package/dist/lib/opencode-go.js +6 -1
  72. package/dist/lib/opencode-go.js.map +1 -1
  73. package/dist/lib/opencode-zen-config.d.ts +0 -1
  74. package/dist/lib/opencode-zen-config.d.ts.map +1 -1
  75. package/dist/lib/opencode-zen-config.js +0 -22
  76. package/dist/lib/opencode-zen-config.js.map +1 -1
  77. package/dist/lib/opencode-zen.d.ts +3 -0
  78. package/dist/lib/opencode-zen.d.ts.map +1 -1
  79. package/dist/lib/opencode-zen.js +18 -1
  80. package/dist/lib/opencode-zen.js.map +1 -1
  81. package/dist/lib/openrouter.d.ts +2 -0
  82. package/dist/lib/openrouter.d.ts.map +1 -1
  83. package/dist/lib/openrouter.js +10 -0
  84. package/dist/lib/openrouter.js.map +1 -1
  85. package/dist/lib/provider-metadata.d.ts +2 -307
  86. package/dist/lib/provider-metadata.d.ts.map +1 -1
  87. package/dist/lib/provider-metadata.js +14 -316
  88. package/dist/lib/provider-metadata.js.map +1 -1
  89. package/dist/lib/provider-registration.d.ts +325 -0
  90. package/dist/lib/provider-registration.d.ts.map +1 -0
  91. package/dist/lib/provider-registration.js +338 -0
  92. package/dist/lib/provider-registration.js.map +1 -0
  93. package/dist/lib/quota-accounting-projection.d.ts +7 -0
  94. package/dist/lib/quota-accounting-projection.d.ts.map +1 -0
  95. package/dist/lib/quota-accounting-projection.js +239 -0
  96. package/dist/lib/quota-accounting-projection.js.map +1 -0
  97. package/dist/lib/quota-command-format.d.ts +3 -2
  98. package/dist/lib/quota-command-format.d.ts.map +1 -1
  99. package/dist/lib/quota-command-format.js +35 -9
  100. package/dist/lib/quota-command-format.js.map +1 -1
  101. package/dist/lib/quota-dialog-commands.d.ts.map +1 -1
  102. package/dist/lib/quota-dialog-commands.js +1 -0
  103. package/dist/lib/quota-dialog-commands.js.map +1 -1
  104. package/dist/lib/quota-export.d.ts +2 -1
  105. package/dist/lib/quota-export.d.ts.map +1 -1
  106. package/dist/lib/quota-export.js +36 -10
  107. package/dist/lib/quota-export.js.map +1 -1
  108. package/dist/lib/quota-render-data.d.ts +0 -1
  109. package/dist/lib/quota-render-data.d.ts.map +1 -1
  110. package/dist/lib/quota-render-data.js +9 -138
  111. package/dist/lib/quota-render-data.js.map +1 -1
  112. package/dist/lib/quota-reset-notifications.d.ts.map +1 -1
  113. package/dist/lib/quota-reset-notifications.js +3 -0
  114. package/dist/lib/quota-reset-notifications.js.map +1 -1
  115. package/dist/lib/quota-state-codec.d.ts +16 -0
  116. package/dist/lib/quota-state-codec.d.ts.map +1 -0
  117. package/dist/lib/quota-state-codec.js +409 -0
  118. package/dist/lib/quota-state-codec.js.map +1 -0
  119. package/dist/lib/quota-state.d.ts +2 -11
  120. package/dist/lib/quota-state.d.ts.map +1 -1
  121. package/dist/lib/quota-state.js +165 -305
  122. package/dist/lib/quota-state.js.map +1 -1
  123. package/dist/lib/quota-status.d.ts.map +1 -1
  124. package/dist/lib/quota-status.js +14 -7
  125. package/dist/lib/quota-status.js.map +1 -1
  126. package/dist/lib/quota-telemetry.d.ts.map +1 -1
  127. package/dist/lib/quota-telemetry.js +7 -3
  128. package/dist/lib/quota-telemetry.js.map +1 -1
  129. package/dist/lib/quota-toast-runtime.d.ts +60 -0
  130. package/dist/lib/quota-toast-runtime.d.ts.map +1 -0
  131. package/dist/lib/quota-toast-runtime.js +765 -0
  132. package/dist/lib/quota-toast-runtime.js.map +1 -0
  133. package/dist/lib/resolved-auth-identity.d.ts +36 -0
  134. package/dist/lib/resolved-auth-identity.d.ts.map +1 -0
  135. package/dist/lib/resolved-auth-identity.js +193 -0
  136. package/dist/lib/resolved-auth-identity.js.map +1 -0
  137. package/dist/lib/scoped-update-migration.d.ts +101 -0
  138. package/dist/lib/scoped-update-migration.d.ts.map +1 -0
  139. package/dist/lib/scoped-update-migration.js +348 -0
  140. package/dist/lib/scoped-update-migration.js.map +1 -0
  141. package/dist/lib/scoped-update.d.ts +8 -0
  142. package/dist/lib/scoped-update.d.ts.map +1 -1
  143. package/dist/lib/scoped-update.js +263 -50
  144. package/dist/lib/scoped-update.js.map +1 -1
  145. package/dist/lib/toast-format-grouped.d.ts +1 -0
  146. package/dist/lib/toast-format-grouped.d.ts.map +1 -1
  147. package/dist/lib/toast-format-grouped.js +111 -24
  148. package/dist/lib/toast-format-grouped.js.map +1 -1
  149. package/dist/lib/tui-compact-format.d.ts +1 -0
  150. package/dist/lib/tui-compact-format.d.ts.map +1 -1
  151. package/dist/lib/tui-compact-format.js +133 -22
  152. package/dist/lib/tui-compact-format.js.map +1 -1
  153. package/dist/lib/tui-panel-state.d.ts +2 -0
  154. package/dist/lib/tui-panel-state.d.ts.map +1 -1
  155. package/dist/lib/tui-panel-state.js.map +1 -1
  156. package/dist/lib/tui-runtime.d.ts.map +1 -1
  157. package/dist/lib/tui-runtime.js +84 -10
  158. package/dist/lib/tui-runtime.js.map +1 -1
  159. package/dist/lib/tui-sidebar-format.d.ts +1 -1
  160. package/dist/lib/tui-sidebar-format.d.ts.map +1 -1
  161. package/dist/lib/tui-sidebar-format.js +1 -0
  162. package/dist/lib/tui-sidebar-format.js.map +1 -1
  163. package/dist/lib/types.d.ts +7 -1
  164. package/dist/lib/types.d.ts.map +1 -1
  165. package/dist/lib/types.js +1 -0
  166. package/dist/lib/types.js.map +1 -1
  167. package/dist/lib/xai.d.ts +2 -0
  168. package/dist/lib/xai.d.ts.map +1 -1
  169. package/dist/lib/xai.js +10 -0
  170. package/dist/lib/xai.js.map +1 -1
  171. package/dist/plugin.d.ts.map +1 -1
  172. package/dist/plugin.js +27 -720
  173. package/dist/plugin.js.map +1 -1
  174. package/dist/providers/accounting-decimal.d.ts +2 -0
  175. package/dist/providers/accounting-decimal.d.ts.map +1 -0
  176. package/dist/providers/accounting-decimal.js +23 -0
  177. package/dist/providers/accounting-decimal.js.map +1 -0
  178. package/dist/providers/cache-policies.d.ts +64 -0
  179. package/dist/providers/cache-policies.d.ts.map +1 -0
  180. package/dist/providers/cache-policies.js +197 -0
  181. package/dist/providers/cache-policies.js.map +1 -0
  182. package/dist/providers/cursor.d.ts.map +1 -1
  183. package/dist/providers/cursor.js +73 -58
  184. package/dist/providers/cursor.js.map +1 -1
  185. package/dist/providers/deepseek.d.ts +2 -2
  186. package/dist/providers/deepseek.d.ts.map +1 -1
  187. package/dist/providers/deepseek.js +64 -33
  188. package/dist/providers/deepseek.js.map +1 -1
  189. package/dist/providers/google-agy.js +2 -2
  190. package/dist/providers/google-agy.js.map +1 -1
  191. package/dist/providers/kilo.d.ts.map +1 -1
  192. package/dist/providers/kilo.js +56 -29
  193. package/dist/providers/kilo.js.map +1 -1
  194. package/dist/providers/mimo.d.ts.map +1 -1
  195. package/dist/providers/mimo.js +37 -15
  196. package/dist/providers/mimo.js.map +1 -1
  197. package/dist/providers/minimax-coding-plan.d.ts +8 -7
  198. package/dist/providers/minimax-coding-plan.d.ts.map +1 -1
  199. package/dist/providers/minimax-coding-plan.js +97 -83
  200. package/dist/providers/minimax-coding-plan.js.map +1 -1
  201. package/dist/providers/nanogpt.d.ts.map +1 -1
  202. package/dist/providers/nanogpt.js +72 -51
  203. package/dist/providers/nanogpt.js.map +1 -1
  204. package/dist/providers/openai.d.ts.map +1 -1
  205. package/dist/providers/openai.js +0 -3
  206. package/dist/providers/openai.js.map +1 -1
  207. package/dist/providers/opencode-go.d.ts.map +1 -1
  208. package/dist/providers/opencode-go.js +3 -4
  209. package/dist/providers/opencode-go.js.map +1 -1
  210. package/dist/providers/opencode-zen.d.ts.map +1 -1
  211. package/dist/providers/opencode-zen.js +78 -17
  212. package/dist/providers/opencode-zen.js.map +1 -1
  213. package/dist/providers/quota-providers.d.ts.map +1 -1
  214. package/dist/providers/quota-providers.js +31 -23
  215. package/dist/providers/quota-providers.js.map +1 -1
  216. package/dist/providers/registry.d.ts +3 -2
  217. package/dist/providers/registry.d.ts.map +1 -1
  218. package/dist/providers/registry.js +41 -31
  219. package/dist/providers/registry.js.map +1 -1
  220. package/dist/providers/result-helpers.d.ts +10 -5
  221. package/dist/providers/result-helpers.d.ts.map +1 -1
  222. package/dist/providers/result-helpers.js +3 -3
  223. package/dist/providers/result-helpers.js.map +1 -1
  224. package/dist/tui.d.ts.map +1 -1
  225. package/dist/tui.js +11 -3
  226. package/dist/tui.tsx +16 -7
  227. package/package.json +1 -1
package/dist/plugin.js CHANGED
@@ -7,29 +7,14 @@
7
7
  */
8
8
  import { isMainThread } from "node:worker_threads";
9
9
  import { tool } from "@opencode-ai/plugin";
10
- import { DEFAULT_ALIBABA_AUTH_CACHE_MAX_AGE_MS, isAlibabaModelId, resolveAlibabaCodingPlanAuthCached, } from "./lib/alibaba-auth.js";
11
- import { getOrFetchWithCacheControl } from "./lib/cache.js";
12
10
  import { handled } from "./lib/command-handled.js";
13
11
  import { shouldRegisterServerSlashCommands } from "./lib/command-surfaces.js";
14
- import { createLoadConfigMeta } from "./lib/config.js";
15
12
  import { findGitWorktreeRoot, getEffectiveConfigRoot } from "./lib/config-file-utils.js";
16
- import { isCursorModelId, isCursorProviderId } from "./lib/cursor-pricing.js";
17
13
  import { sanitizeDisplayText } from "./lib/display-sanitize.js";
18
- import { formatQuotaRows } from "./lib/format.js";
19
- import { BUNDLED_MAINTAINER_ANNOUNCEMENTS, formatMaintainerAnnouncementHomeCountLine, getMaintainerAnnouncementsSummary, } from "./lib/maintainer-announcements.js";
20
- import { maybeRefreshPricingSnapshot, setPricingSnapshotAutoRefresh, setPricingSnapshotSelection, } from "./lib/modelsdev-pricing.js";
21
14
  import { reconcileDetectedProvidersInGlobalConfig } from "./lib/opencode-config-providers.js";
22
15
  import { buildQuotaDialogCommandOutput, isQuotaDialogCommand, QUOTA_DIALOG_COMMANDS, } from "./lib/quota-dialog-commands.js";
23
- import { resolveQuotaFormatStyle } from "./lib/quota-format-style.js";
24
- import { customQuotaProviderDefinitions, QUOTA_PROVIDERS_AGGREGATE_ID, } from "./lib/quota-providers.js";
25
- import { collectQuotaRenderData } from "./lib/quota-render-data.js";
26
- import { formatQuotaResetNotification, observeQuotaResetNotifications, } from "./lib/quota-reset-notifications.js";
27
- import { createQuotaRuntimeRequestContext, resolveQuotaRuntimeContext, } from "./lib/quota-runtime-context.js";
28
16
  import { disposeQuotaTelemetryOwner } from "./lib/quota-telemetry.js";
29
- import { isQwenCodeModelId, resolveQwenLocalPlanCached } from "./lib/qwen-auth.js";
30
- import { inspectTuiConfig } from "./lib/tui-config-diagnostics.js";
31
- import { DEFAULT_CONFIG } from "./lib/types.js";
32
- import { getProviders } from "./providers/registry.js";
17
+ import { createQuotaToastRuntime } from "./lib/quota-toast-runtime.js";
33
18
  function normalizeDefaultAgent(cfg) {
34
19
  if (!cfg?.default_agent || !cfg.agent || cfg.default_agent in cfg.agent)
35
20
  return;
@@ -40,7 +25,6 @@ function normalizeDefaultAgent(cfg) {
40
25
  cfg.default_agent = matches[0];
41
26
  }
42
27
  }
43
- const DEFERRED_QUOTA_REFRESH_DELAYS_MS = [3_000, 15_000, 60_000, 300_000];
44
28
  // =============================================================================
45
29
  // Plugin Implementation
46
30
  // =============================================================================
@@ -82,105 +66,9 @@ export const QuotaToastPlugin = async ({ client, directory }) => {
82
66
  }
83
67
  }
84
68
  }
85
- // Keep init fast/non-blocking so TUI never hangs. We still want the first
86
- // toast trigger to work reliably, so we refresh config on-demand.
87
- let config = DEFAULT_CONFIG;
88
- let configLoaded = false;
89
- let configInFlight = null;
90
69
  let providerConfigReconcileQueue = Promise.resolve();
91
- let configMeta = createLoadConfigMeta();
92
- let runtimeProviders = getProviders();
93
70
  // Track last session token error for /quota_status diagnostics
94
71
  let lastSessionTokenError;
95
- const deferredQuotaRefreshes = new Map();
96
- const detectedProviderIdsByToastCacheKey = new Map();
97
- const maintainerAnnouncementToastFallback = {
98
- pending: true,
99
- inFlight: false,
100
- };
101
- function getDeferredQuotaRefreshDelayMs(attempts) {
102
- const index = Math.min(Math.max(0, attempts), DEFERRED_QUOTA_REFRESH_DELAYS_MS.length - 1);
103
- return DEFERRED_QUOTA_REFRESH_DELAYS_MS[index];
104
- }
105
- function clearDeferredQuotaRefresh(sessionID) {
106
- const state = deferredQuotaRefreshes.get(sessionID);
107
- if (state?.timer) {
108
- clearTimeout(state.timer);
109
- }
110
- deferredQuotaRefreshes.delete(sessionID);
111
- }
112
- function clearDeferredQuotaRefreshTimer(state) {
113
- if (!state.timer)
114
- return;
115
- clearTimeout(state.timer);
116
- state.timer = null;
117
- }
118
- function scheduleDeferredQuotaRefresh(params) {
119
- let state = deferredQuotaRefreshes.get(params.sessionID);
120
- if (!state) {
121
- state = {
122
- sessionID: params.sessionID,
123
- attempts: 0,
124
- reason: params.reason,
125
- queuedAtMs: Date.now(),
126
- timer: null,
127
- inFlight: false,
128
- };
129
- deferredQuotaRefreshes.set(params.sessionID, state);
130
- }
131
- else {
132
- if (params.incrementAttempts) {
133
- state.attempts += 1;
134
- }
135
- state.reason = params.reason;
136
- clearDeferredQuotaRefreshTimer(state);
137
- }
138
- const delayMs = getDeferredQuotaRefreshDelayMs(state.attempts);
139
- state.timer = setTimeout(() => {
140
- void runDeferredQuotaRefresh(params.sessionID);
141
- }, delayMs);
142
- state.timer.unref?.();
143
- void log("Deferred quota refresh scheduled", {
144
- sessionID: params.sessionID,
145
- reason: params.reason,
146
- attempts: state.attempts,
147
- delayMs,
148
- });
149
- }
150
- async function runDeferredQuotaRefresh(sessionID) {
151
- const state = deferredQuotaRefreshes.get(sessionID);
152
- if (!state || state.inFlight)
153
- return;
154
- await showQuotaToast(sessionID, "deferred.retry", { deferredRetry: true });
155
- }
156
- function isProviderEnabled(providerId) {
157
- return config.enabledProviders === "auto" || config.enabledProviders.includes(providerId);
158
- }
159
- async function shouldBypassToastCacheForLiveLocalUsage(params) {
160
- if (isProviderEnabled(QUOTA_PROVIDERS_AGGREGATE_ID) &&
161
- customQuotaProviderDefinitions(config.quotaProviders).some((definition) => definition.mode === "local-estimate")) {
162
- return true;
163
- }
164
- const currentSession = params.sessionMeta ?? (await getSessionModelMeta(params.sessionID));
165
- const currentModel = currentSession.modelID;
166
- if (currentSession.providerID === "qwen-code" || isQwenCodeModelId(currentModel)) {
167
- const plan = await resolveQwenLocalPlanCached();
168
- return plan.state === "qwen_free" && isProviderEnabled("qwen-code");
169
- }
170
- if (currentSession.providerID === "alibaba-coding-plan" ||
171
- currentSession.providerID === "alibaba" ||
172
- isAlibabaModelId(currentModel)) {
173
- const plan = await resolveAlibabaCodingPlanAuthCached({
174
- maxAgeMs: DEFAULT_ALIBABA_AUTH_CACHE_MAX_AGE_MS,
175
- fallbackTier: "lite",
176
- });
177
- return plan.state === "configured" && isProviderEnabled("alibaba-coding-plan");
178
- }
179
- if (isCursorProviderId(currentSession.providerID) || isCursorModelId(currentModel)) {
180
- return isProviderEnabled("cursor");
181
- }
182
- return false;
183
- }
184
72
  function getPluginRuntimeRootHints() {
185
73
  const cwd = directory || process.cwd();
186
74
  const workspaceRoot = findGitWorktreeRoot(cwd) ?? cwd;
@@ -220,174 +108,6 @@ export const QuotaToastPlugin = async ({ client, directory }) => {
220
108
  }
221
109
  handled();
222
110
  }
223
- function triggerMaintainerAnnouncementToastFallback(trigger, detectedProviderIds) {
224
- if (!maintainerAnnouncementToastFallback.pending ||
225
- maintainerAnnouncementToastFallback.inFlight) {
226
- return;
227
- }
228
- if (!config.enabled || !config.enableToast) {
229
- maintainerAnnouncementToastFallback.pending = false;
230
- return;
231
- }
232
- if (!config.maintainerAnnouncements.enabled || !config.maintainerAnnouncements.home) {
233
- maintainerAnnouncementToastFallback.pending = false;
234
- return;
235
- }
236
- maintainerAnnouncementToastFallback.inFlight = true;
237
- void (async () => {
238
- try {
239
- const summary = getMaintainerAnnouncementsSummary({
240
- announcements: BUNDLED_MAINTAINER_ANNOUNCEMENTS,
241
- enabledProviders: detectedProviderIds,
242
- });
243
- if (summary.activeCount <= 0) {
244
- if (summary.futureCount <= 0) {
245
- maintainerAnnouncementToastFallback.pending = false;
246
- }
247
- return;
248
- }
249
- const tuiDiagnostics = await inspectTuiConfig({ roots: getPluginRuntimeRootHints() });
250
- if (tuiDiagnostics.quotaPluginConfigured) {
251
- maintainerAnnouncementToastFallback.pending = false;
252
- return;
253
- }
254
- const message = formatMaintainerAnnouncementHomeCountLine(summary.activeCount);
255
- if (!message) {
256
- return;
257
- }
258
- await typedClient.tui.showToast({
259
- body: {
260
- message: sanitizeDisplayText(message),
261
- variant: "info",
262
- duration: config.toastDurationMs,
263
- },
264
- });
265
- maintainerAnnouncementToastFallback.pending = false;
266
- await log("Displayed maintainer announcement fallback toast", { trigger });
267
- }
268
- catch (err) {
269
- await log("Failed to show maintainer announcement fallback toast", {
270
- trigger,
271
- error: err instanceof Error ? err.message : String(err),
272
- });
273
- }
274
- finally {
275
- maintainerAnnouncementToastFallback.inFlight = false;
276
- }
277
- })();
278
- }
279
- async function resolvePluginRuntimeContext(params = {}) {
280
- if (!configLoaded) {
281
- await refreshConfig();
282
- }
283
- return resolveQuotaRuntimeContext({
284
- client: typedClient,
285
- roots: getPluginRuntimeRootHints(),
286
- config,
287
- configMeta,
288
- providers: runtimeProviders,
289
- sessionID: params.sessionID,
290
- sessionMeta: params.sessionMeta,
291
- resolveSessionMeta: (sessionID) => getSessionModelMeta(sessionID),
292
- includeSessionMeta: params.includeSessionMeta,
293
- });
294
- }
295
- async function refreshConfig() {
296
- if (configInFlight)
297
- return configInFlight;
298
- configInFlight = (async () => {
299
- try {
300
- const runtime = await resolveQuotaRuntimeContext({
301
- client: typedClient,
302
- roots: getPluginRuntimeRootHints(),
303
- });
304
- configMeta = runtime.configMeta;
305
- config = runtime.config;
306
- runtimeProviders = runtime.providers;
307
- setPricingSnapshotAutoRefresh(config.pricingSnapshot.autoRefresh);
308
- setPricingSnapshotSelection(config.pricingSnapshot.source);
309
- configLoaded = true;
310
- onFirstConfigLoaded();
311
- }
312
- catch {
313
- // Leave configLoaded=false so we can retry on next trigger.
314
- config = DEFAULT_CONFIG;
315
- configMeta = createLoadConfigMeta();
316
- runtimeProviders = getProviders();
317
- setPricingSnapshotAutoRefresh(DEFAULT_CONFIG.pricingSnapshot.autoRefresh);
318
- setPricingSnapshotSelection(DEFAULT_CONFIG.pricingSnapshot.source);
319
- }
320
- finally {
321
- configInFlight = null;
322
- }
323
- })();
324
- return configInFlight;
325
- }
326
- async function kickPricingRefresh(params) {
327
- try {
328
- const refreshPromise = maybeRefreshPricingSnapshot({
329
- reason: params.reason,
330
- snapshotSelection: config.pricingSnapshot.source,
331
- });
332
- const guardedRefreshPromise = refreshPromise.catch(() => undefined);
333
- if (!params.maxWaitMs || params.maxWaitMs <= 0) {
334
- void guardedRefreshPromise;
335
- return;
336
- }
337
- await Promise.race([
338
- guardedRefreshPromise,
339
- new Promise((resolve) => {
340
- setTimeout(resolve, params.maxWaitMs);
341
- }),
342
- ]);
343
- }
344
- catch (error) {
345
- await log("Pricing refresh failed", {
346
- reason: params.reason,
347
- error: error instanceof Error ? error.message : String(error),
348
- });
349
- }
350
- }
351
- // Deferred init: runs once after the first successful config load.
352
- // Avoids HTTP calls during plugin construction, which can interfere with
353
- // other plugins that are still being loaded (see #39).
354
- let initDone = false;
355
- function onFirstConfigLoaded() {
356
- if (initDone)
357
- return;
358
- initDone = true;
359
- if (config.enabled) {
360
- void kickPricingRefresh({ reason: "init" });
361
- }
362
- void typedClient.app
363
- .log({
364
- body: {
365
- service: "quota-toast",
366
- level: "info",
367
- message: "plugin initialized",
368
- extra: {
369
- configLoaded,
370
- configSource: configMeta.source,
371
- configPaths: configMeta.paths,
372
- enabledProviders: config.enabledProviders,
373
- minIntervalMs: config.minIntervalMs,
374
- googleModels: config.googleModels,
375
- cursorPlan: config.cursorPlan,
376
- cursorIncludedApiUsd: config.cursorIncludedApiUsd,
377
- cursorBillingCycleStartDay: config.cursorBillingCycleStartDay,
378
- pricingSnapshotSource: config.pricingSnapshot.source,
379
- pricingSnapshotAutoRefresh: config.pricingSnapshot.autoRefresh,
380
- showOnIdle: config.showOnIdle,
381
- showOnQuestion: config.showOnQuestion,
382
- showOnCompact: config.showOnCompact,
383
- showOnBothFail: config.showOnBothFail,
384
- },
385
- },
386
- })
387
- .catch(() => { });
388
- }
389
- // If disabled in config, it'll be picked up on first trigger; we can't
390
- // reliably read config synchronously without risking TUI startup.
391
111
  /**
392
112
  * Log a message (debug level)
393
113
  */
@@ -477,422 +197,30 @@ export const QuotaToastPlugin = async ({ client, directory }) => {
477
197
  return {};
478
198
  }
479
199
  }
480
- function formatDebugInfo(params) {
481
- const availability = params.availability
482
- ? params.availability.map((x) => `${x.id}=${x.ok ? "ok" : "no"}`).join(" ")
483
- : "unknown";
484
- const providers = params.enabledProviders === "auto"
485
- ? "(auto)"
486
- : params.enabledProviders.length > 0
487
- ? params.enabledProviders.join(",")
488
- : "(none)";
489
- const modelPart = params.currentModel ? ` model=${params.currentModel}` : "";
490
- const paths = configMeta.paths.length > 0 ? configMeta.paths.join(" | ") : "(none)";
491
- return [
492
- `Quota Toast Debug (opencode-quota)`,
493
- `trigger=${params.trigger} reason=${params.reason}`,
494
- `configSource=${configMeta.source} paths=${paths}`,
495
- `enabled=${config.enabled} providers=${providers}${modelPart}`,
496
- `available=${availability}`,
497
- ].join("\n");
498
- }
499
- function buildToastCacheKey(params) {
500
- const formatStyle = resolveQuotaFormatStyle(config.formatStyle);
501
- const enabledProviders = config.enabledProviders === "auto" ? "auto" : config.enabledProviders.join(",");
502
- const googleModels = config.googleModels.join(",");
503
- const currentModel = config.onlyCurrentModel && params.sessionID ? (params.sessionMeta?.modelID ?? "") : "";
504
- const currentProviderID = config.onlyCurrentModel && params.sessionID ? (params.sessionMeta?.providerID ?? "") : "";
505
- return [
506
- `sessionID=${params.sessionID}`,
507
- `enabledProviders=${enabledProviders}`,
508
- `formatStyle=${formatStyle}`,
509
- `percentDisplayMode=${config.percentDisplayMode}`,
510
- `layout=${JSON.stringify(config.layout)}`,
511
- `showSessionTokens=${config.showSessionTokens ? "yes" : "no"}`,
512
- `onlyCurrentModel=${config.onlyCurrentModel ? "yes" : "no"}`,
513
- `currentModel=${currentModel}`,
514
- `currentProviderID=${currentProviderID}`,
515
- `anthropicBinaryPath=${config.anthropicBinaryPath}`,
516
- `googleModels=${googleModels}`,
517
- `cursorPlan=${config.cursorPlan}`,
518
- `cursorIncludedApiUsd=${config.cursorIncludedApiUsd ?? ""}`,
519
- `cursorBillingCycleStartDay=${config.cursorBillingCycleStartDay ?? ""}`,
520
- ].join("|");
521
- }
522
- function isProviderFetchFailureOnly(errors) {
523
- return (errors.length > 0 && errors.every((error) => error.message === "Failed to read quota data"));
524
- }
525
- async function fetchQuotaMessageResult(params) {
526
- // Ensure we have loaded config at least once. If load fails, we keep trying
527
- // on subsequent triggers and queue a deferred retry for toast paths.
528
- if (!configLoaded) {
529
- await refreshConfig();
530
- }
531
- if (!configLoaded) {
532
- return {
533
- message: config.debug
534
- ? formatDebugInfo({
535
- trigger: params.trigger,
536
- reason: "config load failed",
537
- enabledProviders: config.enabledProviders,
538
- })
539
- : null,
540
- cacheRenderedMessage: false,
541
- retryable: true,
542
- retryReason: "config_load_failed",
543
- hasQuotaRows: false,
544
- detectedProviderIds: [],
545
- };
546
- }
547
- if (!config.enabled) {
548
- return {
549
- message: config.debug
550
- ? formatDebugInfo({ trigger: params.trigger, reason: "disabled", enabledProviders: [] })
551
- : null,
552
- cacheRenderedMessage: false,
553
- retryable: false,
554
- hasQuotaRows: false,
555
- detectedProviderIds: [],
556
- };
557
- }
558
- if (config.enabledProviders !== "auto" && config.enabledProviders.length === 0) {
559
- return {
560
- message: config.debug
561
- ? formatDebugInfo({
562
- trigger: params.trigger,
563
- reason: "enabledProviders empty",
564
- enabledProviders: [],
565
- })
566
- : null,
567
- cacheRenderedMessage: false,
568
- retryable: false,
569
- hasQuotaRows: false,
570
- detectedProviderIds: [],
571
- };
572
- }
573
- const runtime = await resolvePluginRuntimeContext({
574
- sessionID: params.sessionID,
575
- sessionMeta: params.sessionMeta,
576
- includeSessionMeta: (config) => config.onlyCurrentModel,
577
- });
578
- const runtimeConfig = runtime.config;
579
- const quotaRequestContext = createQuotaRuntimeRequestContext(runtime);
580
- const quotaResult = await collectQuotaRenderData({
581
- client: runtime.client,
582
- resolveRuntimeProviderIds: runtime.resolveRuntimeProviderIds,
583
- config: runtimeConfig,
584
- configMeta: runtime.configMeta,
585
- request: quotaRequestContext,
586
- surfaceExplicitProviderIssues: true,
587
- formatStyle: resolveQuotaFormatStyle(runtimeConfig.formatStyle),
588
- bypassProviderCache: params.bypassProviderCache,
589
- providers: runtime.providers,
590
- });
591
- const { selection, availability, active, providerResults, attemptedAny, hasExplicitProviderIssues, data, } = quotaResult;
592
- let resetNotification;
593
- if (runtimeConfig.enableToast &&
594
- runtimeConfig.resetNotifications.enabled &&
595
- providerResults.length > 0) {
596
- try {
597
- const notices = await observeQuotaResetNotifications({
598
- providers: providerResults,
599
- windows: runtimeConfig.resetNotifications.windows,
600
- });
601
- resetNotification = formatQuotaResetNotification(notices) ?? undefined;
602
- }
603
- catch (error) {
604
- await log("Failed to observe quota reset transitions", {
605
- error: error instanceof Error ? error.message : String(error),
606
- });
607
- }
608
- }
609
- if (selection?.isAutoMode) {
610
- await reconcileDetectedProviderConfig(active.map((provider) => provider.id));
611
- }
612
- const detectedProviderIds = active.map((provider) => provider.id);
613
- if (runtimeConfig.showSessionTokens && params.sessionID) {
614
- lastSessionTokenError = quotaResult.sessionTokenError;
615
- }
616
- const currentModel = selection?.currentModel;
617
- const errors = data?.errors ?? [];
618
- const hasProviderQuotaRows = Boolean(data?.entries.length);
619
- const hasQuotaRows = Boolean(hasProviderQuotaRows || data?.sessionTokens);
620
- const providerFetchFailureOnly = attemptedAny && isProviderFetchFailureOnly(errors);
621
- const retryableAvailabilityFailure = active.length === 0 && availability.some((item) => !item.ok && item.error === true);
622
- if (active.length === 0 && !(hasExplicitProviderIssues && errors.length > 0)) {
623
- const message = runtimeConfig.debug
624
- ? formatDebugInfo({
625
- trigger: params.trigger,
626
- reason: "no enabled providers available",
627
- currentModel,
628
- enabledProviders: runtimeConfig.enabledProviders,
629
- availability: availability.map((item) => ({
630
- id: item.provider.id,
631
- ok: item.ok,
632
- })),
633
- })
634
- : null;
635
- const retryableNoProviders = selection?.isAutoMode === true || retryableAvailabilityFailure;
636
- return {
637
- message,
638
- cacheRenderedMessage: false,
639
- retryable: retryableNoProviders,
640
- retryReason: retryableNoProviders ? "no_available_providers" : undefined,
641
- hasQuotaRows: false,
642
- detectedProviderIds,
643
- resetNotification,
644
- };
645
- }
646
- if (hasQuotaRows) {
647
- const formatted = formatQuotaRows({
648
- version: "1.0.0",
649
- layout: runtimeConfig.layout,
650
- entries: data?.entries ?? [],
651
- errors: data?.errors ?? [],
652
- style: resolveQuotaFormatStyle(runtimeConfig.formatStyle),
653
- percentDisplayMode: runtimeConfig.percentDisplayMode,
654
- resetTimeDecimals: runtimeConfig.resetTimeDecimals,
655
- sessionTokens: data?.sessionTokens,
656
- });
657
- const retryableMaskedProviderFailure = !hasProviderQuotaRows && providerFetchFailureOnly;
658
- if (!runtimeConfig.debug) {
659
- return {
660
- message: formatted,
661
- cacheRenderedMessage: true,
662
- retryable: retryableMaskedProviderFailure,
663
- retryReason: retryableMaskedProviderFailure ? "provider_fetch_failed" : undefined,
664
- hasQuotaRows: true,
665
- detectedProviderIds,
666
- resetNotification,
667
- };
668
- }
669
- const debugFooter = `\n\n[debug] src=${configMeta.source} providers=${runtimeConfig.enabledProviders === "auto" ? "(auto)" : runtimeConfig.enabledProviders.join(",") || "(none)"} avail=${availability
670
- .map((item) => `${item.provider.id}:${item.ok ? "ok" : "no"}`)
671
- .join(" ")}`;
672
- return {
673
- message: formatted + debugFooter,
674
- cacheRenderedMessage: false,
675
- retryable: retryableMaskedProviderFailure,
676
- retryReason: retryableMaskedProviderFailure ? "provider_fetch_failed" : undefined,
677
- hasQuotaRows: true,
678
- detectedProviderIds,
679
- resetNotification,
680
- };
681
- }
682
- // Show errors even without entries when:
683
- // 1. showOnBothFail is enabled and at least one provider attempted (existing behavior)
684
- // 2. OR we're in explicit mode and have "Not configured"/"Unavailable" errors (new behavior)
685
- if ((runtimeConfig.showOnBothFail && attemptedAny && errors.length > 0) ||
686
- hasExplicitProviderIssues) {
687
- const errorLines = errors.map((error) => `${error.label}: ${error.message}`).join("\n");
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
- detectedProviderIds,
718
- resetNotification,
719
- };
720
- }
721
- const retryableNoData = providerFetchFailureOnly ||
722
- (selection?.isAutoMode === true && active.length > 0 && errors.length === 0);
723
- return {
724
- message: runtimeConfig.debug
725
- ? formatDebugInfo({
726
- trigger: params.trigger,
727
- reason: "no entries",
728
- currentModel,
729
- enabledProviders: runtimeConfig.enabledProviders,
730
- availability: availability.map((item) => ({
731
- id: item.provider.id,
732
- ok: item.ok,
733
- })),
734
- })
735
- : null,
736
- cacheRenderedMessage: false,
737
- retryable: retryableNoData,
738
- retryReason: providerFetchFailureOnly
739
- ? "provider_fetch_failed"
740
- : retryableNoData
741
- ? "no_reportable_data"
742
- : undefined,
743
- hasQuotaRows: false,
744
- detectedProviderIds,
745
- resetNotification,
746
- };
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;
760
- }
761
- if (!params.result.retryReason) {
762
- return;
763
- }
764
- scheduleDeferredQuotaRefresh({
765
- sessionID: params.sessionID,
766
- reason: params.result.retryReason,
767
- incrementAttempts: params.consumedDeferredRetry,
768
- });
769
- }
770
- /**
771
- * Show quota toast for a session
772
- */
773
- async function showQuotaToast(sessionID, trigger, options = {}) {
774
- if (!configLoaded) {
775
- await refreshConfig();
776
- }
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);
786
- }
787
- try {
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
- const sessionMeta = await getSessionModelMeta(sessionID);
799
- const bypassForLiveLocalUsage = await shouldBypassToastCacheForLiveLocalUsage({
800
- sessionID,
801
- sessionMeta,
802
- });
803
- const bypassMessageCache = config.debug || consumedDeferredRetry || bypassForLiveLocalUsage;
804
- const bypassProviderCache = consumedDeferredRetry || bypassForLiveLocalUsage;
805
- const toastCacheKey = buildToastCacheKey({ sessionID, sessionMeta });
806
- let fetchResult;
807
- const fetchForToast = () => fetchQuotaMessageResult({
808
- trigger,
809
- sessionID,
810
- sessionMeta,
811
- bypassProviderCache,
812
- });
813
- const message = bypassMessageCache
814
- ? await (async () => {
815
- fetchResult = await fetchForToast();
816
- return fetchResult.message;
817
- })()
818
- : await (async () => {
819
- const fetched = {};
820
- const cachedMessage = await getOrFetchWithCacheControl(toastCacheKey, async () => {
821
- const result = await fetchForToast();
822
- fetched.result = result;
823
- const cache = Boolean(result.message && result.cacheRenderedMessage && result.hasQuotaRows);
824
- return { message: result.message, cache };
825
- }, config.minIntervalMs);
826
- fetchResult = fetched.result;
827
- return cachedMessage;
828
- })();
829
- if (fetchResult) {
830
- detectedProviderIdsByToastCacheKey.set(toastCacheKey, [...fetchResult.detectedProviderIds]);
831
- await reconcileDeferredQuotaRefresh({
832
- sessionID,
833
- result: fetchResult,
834
- consumedDeferredRetry,
835
- trigger,
836
- });
837
- }
838
- if (options.deferredRetry && fetchResult && !fetchResult.hasQuotaRows) {
839
- await log("Deferred quota refresh did not produce reportable data", {
840
- sessionID,
841
- trigger,
842
- retryable: fetchResult.retryable,
843
- retryReason: fetchResult.retryReason,
844
- });
845
- return;
846
- }
847
- if (!message) {
848
- await log("No quota message to display", { trigger });
849
- return;
850
- }
851
- if (!config.enableToast) {
852
- await log("Toast disabled (enableToast=false)", { trigger });
853
- return;
854
- }
855
- // Show toast
856
- try {
857
- await typedClient.tui.showToast({
858
- body: {
859
- message: sanitizeDisplayText(message),
860
- variant: "info",
861
- duration: config.toastDurationMs,
862
- },
863
- });
864
- triggerMaintainerAnnouncementToastFallback(trigger, fetchResult?.detectedProviderIds ??
865
- detectedProviderIdsByToastCacheKey.get(toastCacheKey) ??
866
- []);
867
- await log("Displayed quota toast", { message, trigger });
868
- if (fetchResult?.resetNotification) {
869
- await typedClient.tui.showToast({
870
- body: {
871
- title: "Quota available",
872
- message: sanitizeDisplayText(fetchResult.resetNotification),
873
- variant: "success",
874
- duration: config.toastDurationMs,
875
- },
876
- });
877
- await log("Displayed quota reset notification", {
878
- message: fetchResult.resetNotification,
879
- trigger,
880
- });
881
- }
882
- }
883
- catch (err) {
884
- await log("Failed to show toast", {
885
- error: err instanceof Error ? err.message : String(err),
886
- });
887
- }
888
- }
889
- finally {
890
- const state = deferredQuotaRefreshes.get(sessionID);
891
- if (state && state === pendingDeferred) {
892
- state.inFlight = false;
893
- }
894
- }
895
- }
200
+ const quotaToastRuntime = createQuotaToastRuntime({
201
+ client: typedClient,
202
+ roots: getPluginRuntimeRootHints,
203
+ resolveSessionMeta: (sessionID) => getSessionModelMeta(sessionID),
204
+ isSubagentSession,
205
+ reconcileDetectedProviders: reconcileDetectedProviderConfig,
206
+ setSessionTokenError: (error) => {
207
+ lastSessionTokenError = error;
208
+ },
209
+ showToast: (body) => typedClient.tui.showToast({ body }),
210
+ log,
211
+ onInitialized: (extra) => {
212
+ void typedClient.app
213
+ .log({
214
+ body: {
215
+ service: "quota-toast",
216
+ level: "info",
217
+ message: "plugin initialized",
218
+ extra,
219
+ },
220
+ })
221
+ .catch(() => { });
222
+ },
223
+ });
896
224
  // Return hook implementations
897
225
  return {
898
226
  dispose: async () => {
@@ -964,34 +292,13 @@ export const QuotaToastPlugin = async ({ client, directory }) => {
964
292
  if (event.type !== "session.idle" && event.type !== "session.compacted") {
965
293
  return;
966
294
  }
967
- if (!configLoaded) {
968
- await refreshConfig();
969
- }
970
- if (!config.enabled) {
971
- clearDeferredQuotaRefresh(sessionID);
972
- return;
973
- }
974
- if (event.type === "session.idle" && config.showOnIdle) {
975
- await showQuotaToast(sessionID, "session.idle");
976
- }
977
- else if (event.type === "session.compacted" && config.showOnCompact) {
978
- await showQuotaToast(sessionID, "session.compacted");
979
- }
295
+ await quotaToastRuntime.handleTrigger({ sessionID, trigger: event.type });
980
296
  },
981
297
  // Tool execute hook for question tool
982
298
  "tool.execute.after": async (input, _output) => {
983
299
  if (input.tool !== "question")
984
300
  return;
985
- if (!configLoaded) {
986
- await refreshConfig();
987
- }
988
- if (!config.enabled) {
989
- clearDeferredQuotaRefresh(input.sessionID);
990
- return;
991
- }
992
- if (config.showOnQuestion) {
993
- await showQuotaToast(input.sessionID, "question");
994
- }
301
+ await quotaToastRuntime.handleTrigger({ sessionID: input.sessionID, trigger: "question" });
995
302
  },
996
303
  };
997
304
  };