opencode-quotas 0.0.1

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 (136) hide show
  1. package/README.md +344 -0
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +3 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/cli.d.ts +3 -0
  7. package/dist/src/cli.d.ts.map +1 -0
  8. package/dist/src/cli.js +42 -0
  9. package/dist/src/cli.js.map +1 -0
  10. package/dist/src/constants.d.ts +9 -0
  11. package/dist/src/constants.d.ts.map +1 -0
  12. package/dist/src/constants.js +15 -0
  13. package/dist/src/constants.js.map +1 -0
  14. package/dist/src/defaults.d.ts +3 -0
  15. package/dist/src/defaults.d.ts.map +1 -0
  16. package/dist/src/defaults.js +52 -0
  17. package/dist/src/defaults.js.map +1 -0
  18. package/dist/src/index.d.ts +6 -0
  19. package/dist/src/index.d.ts.map +1 -0
  20. package/dist/src/index.js +265 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/interfaces.d.ts +197 -0
  23. package/dist/src/interfaces.d.ts.map +1 -0
  24. package/dist/src/interfaces.js +2 -0
  25. package/dist/src/interfaces.js.map +1 -0
  26. package/dist/src/logger.d.ts +14 -0
  27. package/dist/src/logger.d.ts.map +1 -0
  28. package/dist/src/logger.js +44 -0
  29. package/dist/src/logger.js.map +1 -0
  30. package/dist/src/plugin-state.d.ts +20 -0
  31. package/dist/src/plugin-state.d.ts.map +1 -0
  32. package/dist/src/plugin-state.js +53 -0
  33. package/dist/src/plugin-state.js.map +1 -0
  34. package/dist/src/providers/antigravity/auth.d.ts +12 -0
  35. package/dist/src/providers/antigravity/auth.d.ts.map +1 -0
  36. package/dist/src/providers/antigravity/auth.js +109 -0
  37. package/dist/src/providers/antigravity/auth.js.map +1 -0
  38. package/dist/src/providers/antigravity/index.d.ts +2 -0
  39. package/dist/src/providers/antigravity/index.d.ts.map +1 -0
  40. package/dist/src/providers/antigravity/index.js +2 -0
  41. package/dist/src/providers/antigravity/index.js.map +1 -0
  42. package/dist/src/providers/antigravity/provider.d.ts +34 -0
  43. package/dist/src/providers/antigravity/provider.d.ts.map +1 -0
  44. package/dist/src/providers/antigravity/provider.js +216 -0
  45. package/dist/src/providers/antigravity/provider.js.map +1 -0
  46. package/dist/src/providers/codex.d.ts +4 -0
  47. package/dist/src/providers/codex.d.ts.map +1 -0
  48. package/dist/src/providers/codex.js +242 -0
  49. package/dist/src/providers/codex.js.map +1 -0
  50. package/dist/src/providers/github.d.ts +4 -0
  51. package/dist/src/providers/github.d.ts.map +1 -0
  52. package/dist/src/providers/github.js +139 -0
  53. package/dist/src/providers/github.js.map +1 -0
  54. package/dist/src/quota-cache.d.ts +26 -0
  55. package/dist/src/quota-cache.d.ts.map +1 -0
  56. package/dist/src/quota-cache.js +107 -0
  57. package/dist/src/quota-cache.js.map +1 -0
  58. package/dist/src/registry.d.ts +3 -0
  59. package/dist/src/registry.d.ts.map +1 -0
  60. package/dist/src/registry.js +23 -0
  61. package/dist/src/registry.js.map +1 -0
  62. package/dist/src/services/aggregation-service.d.ts +34 -0
  63. package/dist/src/services/aggregation-service.d.ts.map +1 -0
  64. package/dist/src/services/aggregation-service.js +89 -0
  65. package/dist/src/services/aggregation-service.js.map +1 -0
  66. package/dist/src/services/config-loader.d.ts +25 -0
  67. package/dist/src/services/config-loader.d.ts.map +1 -0
  68. package/dist/src/services/config-loader.js +105 -0
  69. package/dist/src/services/config-loader.js.map +1 -0
  70. package/dist/src/services/history-service.d.ts +15 -0
  71. package/dist/src/services/history-service.d.ts.map +1 -0
  72. package/dist/src/services/history-service.js +99 -0
  73. package/dist/src/services/history-service.js.map +1 -0
  74. package/dist/src/services/prediction-engine.d.ts +47 -0
  75. package/dist/src/services/prediction-engine.d.ts.map +1 -0
  76. package/dist/src/services/prediction-engine.js +94 -0
  77. package/dist/src/services/prediction-engine.js.map +1 -0
  78. package/dist/src/services/quota-service.d.ts +41 -0
  79. package/dist/src/services/quota-service.d.ts.map +1 -0
  80. package/dist/src/services/quota-service.js +257 -0
  81. package/dist/src/services/quota-service.js.map +1 -0
  82. package/dist/src/tools/quotas.d.ts +11 -0
  83. package/dist/src/tools/quotas.d.ts.map +1 -0
  84. package/dist/src/tools/quotas.js +62 -0
  85. package/dist/src/tools/quotas.js.map +1 -0
  86. package/dist/src/ui/progress-bar.d.ts +20 -0
  87. package/dist/src/ui/progress-bar.d.ts.map +1 -0
  88. package/dist/src/ui/progress-bar.js +150 -0
  89. package/dist/src/ui/progress-bar.js.map +1 -0
  90. package/dist/src/ui/quota-table.d.ts +15 -0
  91. package/dist/src/ui/quota-table.d.ts.map +1 -0
  92. package/dist/src/ui/quota-table.js +136 -0
  93. package/dist/src/ui/quota-table.js.map +1 -0
  94. package/dist/src/utils/debug.d.ts +1 -0
  95. package/dist/src/utils/debug.d.ts.map +1 -0
  96. package/dist/src/utils/debug.js +3 -0
  97. package/dist/src/utils/debug.js.map +1 -0
  98. package/dist/src/utils/paths.d.ts +7 -0
  99. package/dist/src/utils/paths.d.ts.map +1 -0
  100. package/dist/src/utils/paths.js +37 -0
  101. package/dist/src/utils/paths.js.map +1 -0
  102. package/dist/src/utils/time.d.ts +3 -0
  103. package/dist/src/utils/time.d.ts.map +1 -0
  104. package/dist/src/utils/time.js +38 -0
  105. package/dist/src/utils/time.js.map +1 -0
  106. package/dist/src/utils/validation.d.ts +6 -0
  107. package/dist/src/utils/validation.d.ts.map +1 -0
  108. package/dist/src/utils/validation.js +66 -0
  109. package/dist/src/utils/validation.js.map +1 -0
  110. package/package.json +42 -0
  111. package/src/cli.ts +53 -0
  112. package/src/constants.ts +17 -0
  113. package/src/defaults.ts +53 -0
  114. package/src/index.ts +338 -0
  115. package/src/interfaces.ts +258 -0
  116. package/src/logger.ts +55 -0
  117. package/src/plugin-state.ts +65 -0
  118. package/src/providers/antigravity/auth.ts +163 -0
  119. package/src/providers/antigravity/index.ts +1 -0
  120. package/src/providers/antigravity/provider.ts +337 -0
  121. package/src/providers/codex.ts +327 -0
  122. package/src/providers/github.ts +161 -0
  123. package/src/quota-cache.ts +157 -0
  124. package/src/registry.ts +30 -0
  125. package/src/services/aggregation-service.ts +116 -0
  126. package/src/services/config-loader.ts +124 -0
  127. package/src/services/history-service.ts +116 -0
  128. package/src/services/prediction-engine.ts +133 -0
  129. package/src/services/quota-service.ts +343 -0
  130. package/src/tools/quotas.ts +78 -0
  131. package/src/ui/progress-bar.ts +204 -0
  132. package/src/ui/quota-table.ts +173 -0
  133. package/src/utils/debug.ts +1 -0
  134. package/src/utils/paths.ts +41 -0
  135. package/src/utils/time.ts +40 -0
  136. package/src/utils/validation.ts +63 -0
@@ -0,0 +1,133 @@
1
+ import { type IPredictionEngine, type IHistoryService, type HistoryPoint } from "../interfaces";
2
+
3
+ /**
4
+ * Configuration options for the prediction engine.
5
+ */
6
+ export interface PredictionEngineConfig {
7
+ /**
8
+ * Default short time window for regression to capture spikes (minutes).
9
+ * Defaults to 5.
10
+ */
11
+ predictionShortWindowMinutes?: number;
12
+
13
+ /**
14
+ * Idle timeout in milliseconds. If the last data point is older than this,
15
+ * the prediction returns Infinity. Defaults to 5 minutes.
16
+ */
17
+ idleTimeoutMs?: number;
18
+ }
19
+
20
+ /**
21
+ * Prediction engine using dual-window linear regression.
22
+ *
23
+ * This implementation uses two time windows to calculate usage slopes:
24
+ * - Long window: Captures overall trend (default: 60 minutes)
25
+ * - Short window: Captures recent spikes (default: 5 minutes)
26
+ *
27
+ * The maximum of the two slopes is used for conservative estimation.
28
+ */
29
+ export class LinearRegressionPredictionEngine implements IPredictionEngine {
30
+ private readonly historyService: IHistoryService;
31
+ private readonly config: Required<PredictionEngineConfig>;
32
+
33
+ constructor(historyService: IHistoryService, config?: PredictionEngineConfig) {
34
+ this.historyService = historyService;
35
+ this.config = {
36
+ predictionShortWindowMinutes: config?.predictionShortWindowMinutes ?? 5,
37
+ idleTimeoutMs: config?.idleTimeoutMs ?? 5 * 60 * 1000,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Predicts time to limit in milliseconds using a dual-window linear regression approach.
43
+ * Returns Infinity if usage is decreasing, stable, or idle.
44
+ */
45
+ predictTimeToLimit(
46
+ quotaId: string,
47
+ windowMinutes: number = 60,
48
+ shortWindowMinutes?: number
49
+ ): number {
50
+ const longWindowMs = windowMinutes * 60 * 1000;
51
+ const shortWindowMin = shortWindowMinutes ?? this.config.predictionShortWindowMinutes;
52
+ const shortWindowMs = shortWindowMin * 60 * 1000;
53
+
54
+ const history = this.historyService.getHistory(quotaId, longWindowMs);
55
+ if (history.length < 2) return Infinity;
56
+
57
+ // Idle Handling: If the last history point is older than the idle timeout,
58
+ // assume usage has stopped.
59
+ const lastPoint = history[history.length - 1];
60
+ const now = Date.now();
61
+ if (now - lastPoint.timestamp > this.config.idleTimeoutMs) {
62
+ return Infinity;
63
+ }
64
+
65
+ // Long Slope
66
+ const mLong = this.calculateSlope(history);
67
+
68
+ // Short Slope: most recent data in short window or last 15% of points
69
+ const shortHistory = history.filter(p => p.timestamp > now - shortWindowMs);
70
+
71
+ // Ensure we have enough points in short history, or take the last 15%
72
+ let effectiveShortHistory = shortHistory;
73
+ if (effectiveShortHistory.length < 2) {
74
+ const fifteenPercentCount = Math.max(2, Math.ceil(history.length * 0.15));
75
+ effectiveShortHistory = history.slice(-fifteenPercentCount);
76
+ }
77
+
78
+ const mShort = this.calculateSlope(effectiveShortHistory);
79
+
80
+ // Conservative Estimation: use the maximum slope
81
+ const m = Math.max(mLong, mShort);
82
+
83
+ if (m <= 0) return Infinity;
84
+ if (lastPoint.limit === null) return Infinity;
85
+
86
+ const remaining = lastPoint.limit - lastPoint.used;
87
+ if (remaining <= 0) return 0;
88
+
89
+ const msFromLastPoint = remaining / m;
90
+ const elapsedSinceLastPoint = now - lastPoint.timestamp;
91
+
92
+ return Math.max(0, msFromLastPoint - elapsedSinceLastPoint);
93
+ }
94
+
95
+ /**
96
+ * Calculates the slope (usage per ms) using linear regression for the given history points.
97
+ */
98
+ calculateSlope(history: HistoryPoint[]): number {
99
+ if (history.length < 2) return 0;
100
+
101
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
102
+ const n = history.length;
103
+ const firstTimestamp = history[0].timestamp;
104
+
105
+ for (const p of history) {
106
+ const x = p.timestamp - firstTimestamp;
107
+ const y = p.used;
108
+ sumX += x;
109
+ sumY += y;
110
+ sumXY += x * y;
111
+ sumX2 += x * x;
112
+ }
113
+
114
+ const denominator = (n * sumX2 - sumX * sumX);
115
+ if (denominator === 0) return 0;
116
+
117
+ return (n * sumXY - sumX * sumY) / denominator;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * A null prediction engine that always returns Infinity.
123
+ * Used when no history service is available.
124
+ */
125
+ export class NullPredictionEngine implements IPredictionEngine {
126
+ predictTimeToLimit(
127
+ _quotaId: string,
128
+ _windowMinutes: number = 60,
129
+ _shortWindowMinutes?: number
130
+ ): number {
131
+ return Infinity;
132
+ }
133
+ }
@@ -0,0 +1,343 @@
1
+ import { DEFAULT_CONFIG } from "../defaults";
2
+ import {
3
+ type QuotaConfig,
4
+ type QuotaData,
5
+ type IQuotaProvider,
6
+ type IHistoryService,
7
+ type IPredictionEngine,
8
+ type IAggregationService,
9
+ type AggregatedGroup
10
+ } from "../interfaces";
11
+ import { getQuotaRegistry } from "../registry";
12
+ import { createAntigravityProvider } from "../providers/antigravity";
13
+ import { createCodexProvider } from "../providers/codex";
14
+ import { formatDurationMs } from "../utils/time";
15
+ import { logger } from "../logger";
16
+ import { LinearRegressionPredictionEngine, NullPredictionEngine } from "./prediction-engine";
17
+ import { AggregationService } from "./aggregation-service";
18
+ import { ConfigLoader } from "./config-loader";
19
+
20
+ export class QuotaService {
21
+ private config: QuotaConfig;
22
+ private initialized: boolean = false;
23
+ private initPromise: Promise<void> | null = null;
24
+ private historyService?: IHistoryService;
25
+ private predictionEngine: IPredictionEngine;
26
+ private aggregationService: IAggregationService;
27
+
28
+ constructor(initialConfig?: Partial<QuotaConfig>) {
29
+ this.config = ConfigLoader.createConfig(initialConfig);
30
+
31
+ // Initialize with null prediction engine until init() is called
32
+ this.predictionEngine = new NullPredictionEngine();
33
+ this.aggregationService = new AggregationService(this.predictionEngine);
34
+ }
35
+
36
+ async init(directory: string, historyService?: IHistoryService): Promise<void> {
37
+ if (this.initialized) return;
38
+ if (this.initPromise) return this.initPromise;
39
+
40
+ this.initPromise = (async () => {
41
+ this.historyService = historyService;
42
+
43
+ // Load config from disk
44
+ this.config = await ConfigLoader.loadFromDisk(directory, this.config);
45
+
46
+ if (this.historyService && this.config.historyMaxAgeHours !== undefined) {
47
+ this.historyService.setMaxAge(this.config.historyMaxAgeHours);
48
+ }
49
+
50
+ // Initialize prediction engine with history service
51
+ if (this.historyService) {
52
+ this.predictionEngine = new LinearRegressionPredictionEngine(
53
+ this.historyService,
54
+ { predictionShortWindowMinutes: this.config.predictionShortWindowMinutes }
55
+ );
56
+ }
57
+ // Re-initialize aggregation service with the new prediction engine
58
+ this.aggregationService = new AggregationService(this.predictionEngine);
59
+
60
+ // Register providers
61
+ await this.registerProviders();
62
+
63
+ this.initialized = true;
64
+ })();
65
+
66
+ return this.initPromise;
67
+ }
68
+
69
+ private async registerProviders(): Promise<void> {
70
+ const registry = getQuotaRegistry();
71
+
72
+ // Register Antigravity
73
+ try {
74
+ registry.register(
75
+ createAntigravityProvider({
76
+ debug: !!this.config.debug,
77
+ }),
78
+ );
79
+ logger.debug("init:provider_registered", { id: "antigravity" });
80
+ } catch (e) {
81
+ logger.error("init:provider_failed", { id: "antigravity", error: e });
82
+ console.warn("[QuotaService] Failed to initialize Antigravity provider:", e);
83
+ }
84
+
85
+ // Register Codex
86
+ try {
87
+ registry.register(createCodexProvider());
88
+ logger.debug("init:provider_registered", { id: "codex" });
89
+ } catch (e) {
90
+ logger.error("init:provider_failed", { id: "codex", error: e });
91
+ console.warn("[QuotaService] Failed to initialize Codex provider:", e);
92
+ }
93
+ }
94
+
95
+ getConfig(): QuotaConfig {
96
+ return this.config;
97
+ }
98
+
99
+ getProviders(): IQuotaProvider[] {
100
+ return getQuotaRegistry().getAll();
101
+ }
102
+
103
+ /**
104
+ * Returns the prediction engine used by this service.
105
+ * Useful for testing or for other services that need prediction capabilities.
106
+ */
107
+ getPredictionEngine(): IPredictionEngine {
108
+ return this.predictionEngine;
109
+ }
110
+
111
+ /**
112
+ * Returns the aggregation service used by this service.
113
+ * Useful for testing or for other services that need aggregation capabilities.
114
+ */
115
+ getAggregationService(): IAggregationService {
116
+ return this.aggregationService;
117
+ }
118
+
119
+ async getQuotas(context?: { providerId?: string; modelId?: string }): Promise<QuotaData[]> {
120
+ const providers = this.getProviders();
121
+
122
+ logger.debug(
123
+ "quota_service:get_quotas_start",
124
+ { providerCount: providers.length, ids: providers.map((p) => p.id) },
125
+ );
126
+
127
+ if (providers.length === 0) return [];
128
+
129
+ const results = await Promise.all(
130
+ providers.map(async (p: IQuotaProvider) => {
131
+ const startedAt = Date.now();
132
+ try {
133
+ logger.debug(
134
+ "quota_service:provider_fetch_start",
135
+ { id: p.id },
136
+ );
137
+ const result = await p.fetchQuota();
138
+ logger.debug(
139
+ "quota_service:provider_fetch_ok",
140
+ { id: p.id, count: result.length, durationMs: Date.now() - startedAt },
141
+ );
142
+ return result;
143
+ } catch (e) {
144
+ logger.error(
145
+ "quota_service:provider_fetch_error",
146
+ { id: p.id, durationMs: Date.now() - startedAt, error: e },
147
+ );
148
+ console.error(`Provider ${p.id} failed:`, e);
149
+ return [];
150
+ }
151
+ })
152
+ );
153
+
154
+ const processed = this.processQuotas(results.flat(), context);
155
+ logger.debug(
156
+ "quota_service:get_quotas_end",
157
+ { totalCount: processed.length },
158
+ );
159
+ return processed;
160
+ }
161
+
162
+ processQuotas(data: QuotaData[], context?: { providerId?: string; modelId?: string }): QuotaData[] {
163
+ let results = [...data];
164
+
165
+ // 1. Enrich with predictions (before aggregation so sources have it too)
166
+ results = results.map(q => {
167
+ const time = this.predictionEngine.predictTimeToLimit(q.id, 60);
168
+ if (time !== Infinity) {
169
+ return {
170
+ ...q,
171
+ predictedReset: `${formatDurationMs(time)} (predicted)`
172
+ };
173
+ }
174
+ return q;
175
+ });
176
+
177
+ // 2. Apply Aggregation
178
+ results = this.applyAggregation(results);
179
+
180
+ // 3. Filter (Disabled & Model Mapping). If requested, perform model-strict filtering.
181
+ results = this.filterQuotas(results, context);
182
+
183
+ // 4. Sort
184
+ results = this.sortQuotas(results);
185
+
186
+ return results;
187
+ }
188
+
189
+ private applyAggregation(quotas: QuotaData[]): QuotaData[] {
190
+ if (!this.config.aggregatedGroups || this.config.aggregatedGroups.length === 0) {
191
+ return quotas;
192
+ }
193
+
194
+ const aggregatedResults: QuotaData[] = [];
195
+ let remainingQuotas = [...quotas];
196
+
197
+ for (const group of this.config.aggregatedGroups) {
198
+ // Resolve source quotas from explicit sources and patterns
199
+ const sourceQuotas = this.resolveGroupSources(remainingQuotas, group);
200
+ if (sourceQuotas.length === 0) continue;
201
+
202
+ const strategy = group.strategy || "most_critical";
203
+ let representative: QuotaData | null = null;
204
+
205
+ if (strategy === "most_critical") {
206
+ representative = this.aggregationService.aggregateMostCritical(
207
+ sourceQuotas,
208
+ group.predictionWindowMinutes,
209
+ group.predictionShortWindowMinutes
210
+ );
211
+ } else if (strategy === "max") {
212
+ representative = this.aggregationService.aggregateMax(sourceQuotas);
213
+ } else if (strategy === "min") {
214
+ representative = this.aggregationService.aggregateMin(sourceQuotas);
215
+ } else if (strategy === "mean" || strategy === "median") {
216
+ representative = this.aggregationService.aggregateAverage(
217
+ sourceQuotas,
218
+ group.name,
219
+ group.id,
220
+ strategy
221
+ );
222
+ }
223
+
224
+ if (representative) {
225
+ // Create a copy for display
226
+ const displayQuota = {
227
+ ...representative,
228
+ id: group.id,
229
+ providerName: group.name
230
+ };
231
+
232
+ // Remove matched sources from pool to avoid double aggregation
233
+ const sourceIds = new Set(sourceQuotas.map(q => q.id));
234
+ remainingQuotas = remainingQuotas.filter(q => !sourceIds.has(q.id));
235
+
236
+ aggregatedResults.push(displayQuota);
237
+ }
238
+ }
239
+
240
+ // Return aggregated results.
241
+ // If showUnaggregated is false, only return what matched a group.
242
+ if (this.config.showUnaggregated === false) {
243
+ return aggregatedResults;
244
+ }
245
+
246
+ return [...remainingQuotas, ...aggregatedResults];
247
+ }
248
+
249
+ /**
250
+ * Resolves which quotas belong to an AggregatedGroup using explicit sources and patterns.
251
+ */
252
+ private resolveGroupSources(quotas: QuotaData[], group: AggregatedGroup): QuotaData[] {
253
+ const matched: QuotaData[] = [];
254
+ const matchedIds = new Set<string>();
255
+
256
+ // 1. Explicit sources (highest priority)
257
+ if (group.sources && group.sources.length > 0) {
258
+ for (const quota of quotas) {
259
+ if (group.sources.includes(quota.id)) {
260
+ matched.push(quota);
261
+ matchedIds.add(quota.id);
262
+ }
263
+ }
264
+ }
265
+
266
+ // 2. Pattern matching
267
+ if (group.patterns && group.patterns.length > 0) {
268
+ for (const quota of quotas) {
269
+ // Skip if already matched by explicit source
270
+ if (matchedIds.has(quota.id)) continue;
271
+
272
+ // Filter by providerId if specified
273
+ if (group.providerId) {
274
+ const providerMatch =
275
+ quota.providerName.toLowerCase().includes(group.providerId.toLowerCase()) ||
276
+ quota.id.toLowerCase().startsWith(group.providerId.toLowerCase());
277
+ if (!providerMatch) continue;
278
+ }
279
+
280
+ // Check if any pattern matches
281
+ const matchTarget = `${quota.id} ${quota.providerName}`.toLowerCase();
282
+ const patternMatches = group.patterns.some(pattern => {
283
+ const lowerPattern = pattern.toLowerCase();
284
+ return matchTarget.includes(lowerPattern);
285
+ });
286
+
287
+ if (patternMatches) {
288
+ matched.push(quota);
289
+ matchedIds.add(quota.id);
290
+ }
291
+ }
292
+ }
293
+
294
+ return matched;
295
+ }
296
+
297
+ private filterQuotas(quotas: QuotaData[], context?: { providerId?: string; modelId?: string }): QuotaData[] {
298
+ let results = [...quotas];
299
+
300
+ // Filter out disabled quotas
301
+ const disabledIds = new Set(this.config.disabled || []);
302
+ results = results.filter((data) => !disabledIds.has(data.id));
303
+
304
+ // If requested, apply model-aware filtering
305
+ if (this.config.filterByCurrentModel && context && context.providerId && context.modelId) {
306
+ return this.filterByModel(results, context.providerId, context.modelId);
307
+ }
308
+
309
+ return results;
310
+ }
311
+
312
+ private filterByModel(quotas: QuotaData[], providerId: string, modelId: string): QuotaData[] {
313
+ const providerLower = providerId.toLowerCase();
314
+ const modelIdLower = modelId.toLowerCase();
315
+
316
+ // Fuzzy token match with scoring
317
+ const tokens = modelIdLower.split(/[^a-z0-9]+/).filter(Boolean);
318
+ const scoredMatches = quotas
319
+ .map(q => {
320
+ const id = q.id.toLowerCase();
321
+ const name = q.providerName.toLowerCase();
322
+ const score = tokens.reduce((acc, t) => acc + (id.includes(t) || name.includes(t) ? 1 : 0), 0);
323
+ return { q, score };
324
+ })
325
+ .filter(m => m.score > 0)
326
+ .sort((a, b) => b.score - a.score);
327
+
328
+ if (scoredMatches.length > 0) {
329
+ const maxScore = scoredMatches[0].score;
330
+ return scoredMatches.filter(m => m.score === maxScore).map(m => m.q);
331
+ }
332
+
333
+ // 3) Provider fallback
334
+ const matchesProvider = quotas.filter(q =>
335
+ q.providerName.toLowerCase().includes(providerLower)
336
+ );
337
+ return matchesProvider.length > 0 ? matchesProvider : [];
338
+ }
339
+
340
+ private sortQuotas(quotas: QuotaData[]): QuotaData[] {
341
+ return quotas.sort((a, b) => a.providerName.localeCompare(b.providerName));
342
+ }
343
+ }
@@ -0,0 +1,78 @@
1
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin";
2
+ import { type QuotaService } from "../services/quota-service";
3
+ import { type QuotaConfig } from "../interfaces";
4
+ import { renderQuotaTable } from "../ui/quota-table";
5
+ import { logger } from "../logger";
6
+
7
+ /**
8
+ * Creates a tool definition for fetching and displaying quota information.
9
+ * This tool always performs a fresh fetch of quotas from all providers.
10
+ *
11
+ * @returns A ToolDefinition that can be registered under Hooks.tool
12
+ */
13
+ export function createQuotaTool(
14
+ quotaService: QuotaService,
15
+ getConfig: () => QuotaConfig
16
+ ): ToolDefinition {
17
+ return tool({
18
+ description:
19
+ "Fetch and display the current API quota usage across all configured providers (Antigravity, Codex, etc.). " +
20
+ "Always returns fresh, up-to-date quota information. " +
21
+ "Use this tool when you need to check remaining API capacity, quota limits, or usage statistics.",
22
+ args: {
23
+ providerId: tool.schema
24
+ .string()
25
+ .optional()
26
+ .describe(
27
+ "Optional: Filter results to show only quotas from a specific provider (e.g., 'antigravity', 'codex')"
28
+ ),
29
+ modelId: tool.schema
30
+ .string()
31
+ .optional()
32
+ .describe(
33
+ "Optional: Filter results to show only quotas relevant to a specific model"
34
+ ),
35
+ },
36
+ async execute(args) {
37
+ const config = getConfig();
38
+
39
+ logger.debug("tool:quotas:execute", {
40
+ providerId: args.providerId,
41
+ modelId: args.modelId,
42
+ });
43
+
44
+ try {
45
+ // Always fetch fresh quotas
46
+ const quotas = await quotaService.getQuotas({
47
+ providerId: args.providerId,
48
+ modelId: args.modelId,
49
+ });
50
+
51
+ logger.debug("tool:quotas:fetched", {
52
+ count: quotas.length,
53
+ });
54
+
55
+ if (quotas.length === 0) {
56
+ return "No quota information available. This could mean:\n" +
57
+ "- No quota providers are configured\n" +
58
+ "- The filter criteria matched no quotas\n" +
59
+ "- There was an error fetching quota data";
60
+ }
61
+
62
+ // Render the quota table
63
+ const lines = renderQuotaTable(quotas, {
64
+ progressBarConfig: config.progressBar,
65
+ tableConfig: config.table,
66
+ }).map((l) => l.line);
67
+
68
+ const showMode = config.progressBar?.show ?? "used";
69
+ const modeLabel = showMode === "available" ? "(Remaining)" : "(Used)";
70
+
71
+ return `**Quota Status ${modeLabel}**\n\n` + lines.join("\n");
72
+ } catch (error) {
73
+ logger.error("tool:quotas:error", { error });
74
+ return `Error fetching quotas: ${error instanceof Error ? error.message : String(error)}`;
75
+ }
76
+ },
77
+ });
78
+ }