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.
- package/README.md +344 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +42 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/constants.d.ts +9 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +15 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/defaults.d.ts +3 -0
- package/dist/src/defaults.d.ts.map +1 -0
- package/dist/src/defaults.js +52 -0
- package/dist/src/defaults.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +265 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/interfaces.d.ts +197 -0
- package/dist/src/interfaces.d.ts.map +1 -0
- package/dist/src/interfaces.js +2 -0
- package/dist/src/interfaces.js.map +1 -0
- package/dist/src/logger.d.ts +14 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +44 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/plugin-state.d.ts +20 -0
- package/dist/src/plugin-state.d.ts.map +1 -0
- package/dist/src/plugin-state.js +53 -0
- package/dist/src/plugin-state.js.map +1 -0
- package/dist/src/providers/antigravity/auth.d.ts +12 -0
- package/dist/src/providers/antigravity/auth.d.ts.map +1 -0
- package/dist/src/providers/antigravity/auth.js +109 -0
- package/dist/src/providers/antigravity/auth.js.map +1 -0
- package/dist/src/providers/antigravity/index.d.ts +2 -0
- package/dist/src/providers/antigravity/index.d.ts.map +1 -0
- package/dist/src/providers/antigravity/index.js +2 -0
- package/dist/src/providers/antigravity/index.js.map +1 -0
- package/dist/src/providers/antigravity/provider.d.ts +34 -0
- package/dist/src/providers/antigravity/provider.d.ts.map +1 -0
- package/dist/src/providers/antigravity/provider.js +216 -0
- package/dist/src/providers/antigravity/provider.js.map +1 -0
- package/dist/src/providers/codex.d.ts +4 -0
- package/dist/src/providers/codex.d.ts.map +1 -0
- package/dist/src/providers/codex.js +242 -0
- package/dist/src/providers/codex.js.map +1 -0
- package/dist/src/providers/github.d.ts +4 -0
- package/dist/src/providers/github.d.ts.map +1 -0
- package/dist/src/providers/github.js +139 -0
- package/dist/src/providers/github.js.map +1 -0
- package/dist/src/quota-cache.d.ts +26 -0
- package/dist/src/quota-cache.d.ts.map +1 -0
- package/dist/src/quota-cache.js +107 -0
- package/dist/src/quota-cache.js.map +1 -0
- package/dist/src/registry.d.ts +3 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +23 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/services/aggregation-service.d.ts +34 -0
- package/dist/src/services/aggregation-service.d.ts.map +1 -0
- package/dist/src/services/aggregation-service.js +89 -0
- package/dist/src/services/aggregation-service.js.map +1 -0
- package/dist/src/services/config-loader.d.ts +25 -0
- package/dist/src/services/config-loader.d.ts.map +1 -0
- package/dist/src/services/config-loader.js +105 -0
- package/dist/src/services/config-loader.js.map +1 -0
- package/dist/src/services/history-service.d.ts +15 -0
- package/dist/src/services/history-service.d.ts.map +1 -0
- package/dist/src/services/history-service.js +99 -0
- package/dist/src/services/history-service.js.map +1 -0
- package/dist/src/services/prediction-engine.d.ts +47 -0
- package/dist/src/services/prediction-engine.d.ts.map +1 -0
- package/dist/src/services/prediction-engine.js +94 -0
- package/dist/src/services/prediction-engine.js.map +1 -0
- package/dist/src/services/quota-service.d.ts +41 -0
- package/dist/src/services/quota-service.d.ts.map +1 -0
- package/dist/src/services/quota-service.js +257 -0
- package/dist/src/services/quota-service.js.map +1 -0
- package/dist/src/tools/quotas.d.ts +11 -0
- package/dist/src/tools/quotas.d.ts.map +1 -0
- package/dist/src/tools/quotas.js +62 -0
- package/dist/src/tools/quotas.js.map +1 -0
- package/dist/src/ui/progress-bar.d.ts +20 -0
- package/dist/src/ui/progress-bar.d.ts.map +1 -0
- package/dist/src/ui/progress-bar.js +150 -0
- package/dist/src/ui/progress-bar.js.map +1 -0
- package/dist/src/ui/quota-table.d.ts +15 -0
- package/dist/src/ui/quota-table.d.ts.map +1 -0
- package/dist/src/ui/quota-table.js +136 -0
- package/dist/src/ui/quota-table.js.map +1 -0
- package/dist/src/utils/debug.d.ts +1 -0
- package/dist/src/utils/debug.d.ts.map +1 -0
- package/dist/src/utils/debug.js +3 -0
- package/dist/src/utils/debug.js.map +1 -0
- package/dist/src/utils/paths.d.ts +7 -0
- package/dist/src/utils/paths.d.ts.map +1 -0
- package/dist/src/utils/paths.js +37 -0
- package/dist/src/utils/paths.js.map +1 -0
- package/dist/src/utils/time.d.ts +3 -0
- package/dist/src/utils/time.d.ts.map +1 -0
- package/dist/src/utils/time.js +38 -0
- package/dist/src/utils/time.js.map +1 -0
- package/dist/src/utils/validation.d.ts +6 -0
- package/dist/src/utils/validation.d.ts.map +1 -0
- package/dist/src/utils/validation.js +66 -0
- package/dist/src/utils/validation.js.map +1 -0
- package/package.json +42 -0
- package/src/cli.ts +53 -0
- package/src/constants.ts +17 -0
- package/src/defaults.ts +53 -0
- package/src/index.ts +338 -0
- package/src/interfaces.ts +258 -0
- package/src/logger.ts +55 -0
- package/src/plugin-state.ts +65 -0
- package/src/providers/antigravity/auth.ts +163 -0
- package/src/providers/antigravity/index.ts +1 -0
- package/src/providers/antigravity/provider.ts +337 -0
- package/src/providers/codex.ts +327 -0
- package/src/providers/github.ts +161 -0
- package/src/quota-cache.ts +157 -0
- package/src/registry.ts +30 -0
- package/src/services/aggregation-service.ts +116 -0
- package/src/services/config-loader.ts +124 -0
- package/src/services/history-service.ts +116 -0
- package/src/services/prediction-engine.ts +133 -0
- package/src/services/quota-service.ts +343 -0
- package/src/tools/quotas.ts +78 -0
- package/src/ui/progress-bar.ts +204 -0
- package/src/ui/quota-table.ts +173 -0
- package/src/utils/debug.ts +1 -0
- package/src/utils/paths.ts +41 -0
- package/src/utils/time.ts +40 -0
- 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
|
+
}
|