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,337 @@
|
|
|
1
|
+
import { formatRelativeTime } from "../../utils/time";
|
|
2
|
+
import { getCloudCredentials } from "./auth";
|
|
3
|
+
import { type IQuotaProvider, type QuotaData } from "../../interfaces";
|
|
4
|
+
import { logger } from "../../logger";
|
|
5
|
+
|
|
6
|
+
const CLOUDCODE_ENDPOINTS = [
|
|
7
|
+
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
8
|
+
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
|
9
|
+
"https://cloudcode-pa.googleapis.com",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
const CLOUDCODE_HEADERS = {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
"User-Agent": "antigravity/1.11.5 windows/amd64",
|
|
15
|
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
16
|
+
"Client-Metadata":
|
|
17
|
+
'{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export interface QuotaIndicator {
|
|
21
|
+
threshold: number;
|
|
22
|
+
symbol: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AntigravityConfig {
|
|
26
|
+
indicators?: QuotaIndicator[];
|
|
27
|
+
debug?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CloudQuotaInfo {
|
|
31
|
+
remainingFraction?: number;
|
|
32
|
+
resetTime?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CloudModelInfo {
|
|
36
|
+
displayName?: string;
|
|
37
|
+
model?: string;
|
|
38
|
+
quotaInfo?: CloudQuotaInfo;
|
|
39
|
+
supportsImages?: boolean;
|
|
40
|
+
supportsThinking?: boolean;
|
|
41
|
+
recommended?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface FetchModelsResponse {
|
|
45
|
+
models?: Record<string, CloudModelInfo>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface QuotaInfo {
|
|
49
|
+
remainingFraction: number;
|
|
50
|
+
resetTime?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ModelConfig {
|
|
54
|
+
modelName: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
quotaInfo?: QuotaInfo;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CloudAccountInfo {
|
|
60
|
+
email?: string;
|
|
61
|
+
projectId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CloudQuotaResult {
|
|
65
|
+
account: CloudAccountInfo;
|
|
66
|
+
models: ModelConfig[];
|
|
67
|
+
timestamp: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_INDICATORS: QuotaIndicator[] = [
|
|
71
|
+
{ threshold: 0.2, symbol: "!" },
|
|
72
|
+
{ threshold: 0.05, symbol: "!!" },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function getIndicatorSymbol(
|
|
76
|
+
fraction: number,
|
|
77
|
+
indicators: QuotaIndicator[] = DEFAULT_INDICATORS,
|
|
78
|
+
): string {
|
|
79
|
+
if (indicators.length === 0) return "";
|
|
80
|
+
const sorted = [...indicators].sort((a, b) => a.threshold - b.threshold);
|
|
81
|
+
for (const indicator of sorted) {
|
|
82
|
+
if (fraction <= indicator.threshold) {
|
|
83
|
+
return ` ${indicator.symbol}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fetchAvailableModels(
|
|
90
|
+
accessToken: string,
|
|
91
|
+
projectId: string | undefined,
|
|
92
|
+
debugEnabled: boolean,
|
|
93
|
+
): Promise<FetchModelsResponse> {
|
|
94
|
+
const payload = projectId ? { project: projectId } : {};
|
|
95
|
+
let lastError: Error | null = null;
|
|
96
|
+
|
|
97
|
+
const headers: Record<string, string> = {
|
|
98
|
+
...CLOUDCODE_HEADERS,
|
|
99
|
+
Authorization: `Bearer ${accessToken}`,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const endpoint of CLOUDCODE_ENDPOINTS) {
|
|
103
|
+
try {
|
|
104
|
+
const url = `${endpoint}/v1internal:fetchAvailableModels`;
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
if (debugEnabled) {
|
|
110
|
+
logger.debug("antigravity:request", {
|
|
111
|
+
endpoint,
|
|
112
|
+
url,
|
|
113
|
+
hasProjectId: !!projectId,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response = await fetch(url, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers,
|
|
120
|
+
body: JSON.stringify(payload),
|
|
121
|
+
signal: controller.signal,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (debugEnabled) {
|
|
125
|
+
logger.debug("antigravity:response_meta", {
|
|
126
|
+
endpoint,
|
|
127
|
+
status: response.status,
|
|
128
|
+
ok: response.ok,
|
|
129
|
+
contentType: response.headers.get("content-type"),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (response.status === 401) {
|
|
134
|
+
throw new Error("Authorization expired or invalid.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (response.status === 403) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"Access forbidden (403). Check your account permissions.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const text = await response.text();
|
|
145
|
+
if (debugEnabled) {
|
|
146
|
+
logger.debug("antigravity:error_body", {
|
|
147
|
+
endpoint,
|
|
148
|
+
status: response.status,
|
|
149
|
+
bodyPreview: text.slice(0, 2000),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Cloud Code API error ${response.status}: ${text.slice(0, 200)}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const json = (await response.json()) as FetchModelsResponse;
|
|
159
|
+
|
|
160
|
+
const modelCount = Object.keys(json.models || {}).length;
|
|
161
|
+
if (debugEnabled) {
|
|
162
|
+
logger.debug("antigravity:fetch_success", { modelCount });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (debugEnabled) {
|
|
166
|
+
const sampleKeys = Object.keys(json.models || {}).slice(
|
|
167
|
+
0,
|
|
168
|
+
8,
|
|
169
|
+
);
|
|
170
|
+
const sanitizedSample: Record<string, unknown> = {};
|
|
171
|
+
for (const key of sampleKeys) {
|
|
172
|
+
sanitizedSample[key] = json.models?.[key];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
logger.debug("antigravity:raw_response_sample", {
|
|
176
|
+
modelCount,
|
|
177
|
+
sampleKeys,
|
|
178
|
+
sample: sanitizedSample,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return json;
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(timeoutId);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
lastError =
|
|
188
|
+
error instanceof Error ? error : new Error(String(error));
|
|
189
|
+
if (
|
|
190
|
+
lastError.message.includes("Authorization") ||
|
|
191
|
+
lastError.message.includes("forbidden") ||
|
|
192
|
+
lastError.message.includes("invalid_grant")
|
|
193
|
+
) {
|
|
194
|
+
throw lastError;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw lastError || new Error("All Cloud Code API endpoints failed");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function fetchCloudQuota(
|
|
203
|
+
accessToken: string,
|
|
204
|
+
projectId?: string,
|
|
205
|
+
debugEnabled: boolean = false,
|
|
206
|
+
): Promise<CloudQuotaResult> {
|
|
207
|
+
if (!accessToken) {
|
|
208
|
+
throw new Error("Access token is required for cloud quota fetching");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const response = await fetchAvailableModels(
|
|
212
|
+
accessToken,
|
|
213
|
+
projectId,
|
|
214
|
+
debugEnabled,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const models: ModelConfig[] = [];
|
|
218
|
+
|
|
219
|
+
if (response.models) {
|
|
220
|
+
for (const [modelKey, info] of Object.entries(response.models)) {
|
|
221
|
+
if (!info.quotaInfo) continue;
|
|
222
|
+
|
|
223
|
+
models.push({
|
|
224
|
+
modelName: info.model || modelKey,
|
|
225
|
+
label: info.displayName || modelKey,
|
|
226
|
+
quotaInfo: {
|
|
227
|
+
remainingFraction: info.quotaInfo.remainingFraction ?? 0,
|
|
228
|
+
resetTime: info.quotaInfo.resetTime,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
account: {
|
|
236
|
+
projectId,
|
|
237
|
+
},
|
|
238
|
+
models,
|
|
239
|
+
timestamp: Date.now(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Creates the Antigravity provider that returns flat, raw quota data.
|
|
245
|
+
* Grouping and aggregation is handled by the service layer via AggregatedGroups.
|
|
246
|
+
*/
|
|
247
|
+
export function createAntigravityProvider(
|
|
248
|
+
config: AntigravityConfig = {},
|
|
249
|
+
): IQuotaProvider {
|
|
250
|
+
return {
|
|
251
|
+
id: "antigravity",
|
|
252
|
+
async fetchQuota(): Promise<QuotaData[]> {
|
|
253
|
+
const debugEnabled = !!config.debug;
|
|
254
|
+
logger.debug("provider:antigravity:fetch_start", {
|
|
255
|
+
configDebug: config.debug,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Fetch cloud credentials (Google OAuth)
|
|
259
|
+
let credentials;
|
|
260
|
+
try {
|
|
261
|
+
credentials = await getCloudCredentials();
|
|
262
|
+
if (debugEnabled) {
|
|
263
|
+
logger.debug("provider:antigravity:auth_ok", {
|
|
264
|
+
projectId: credentials.projectId,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
} catch (e) {
|
|
268
|
+
logger.error("provider:antigravity:auth_failed", e);
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Fetch live quota from Antigravity Cloud API
|
|
273
|
+
const cloudResult = await fetchCloudQuota(
|
|
274
|
+
credentials.accessToken,
|
|
275
|
+
credentials.projectId,
|
|
276
|
+
debugEnabled,
|
|
277
|
+
);
|
|
278
|
+
if (debugEnabled) {
|
|
279
|
+
logger.debug("provider:antigravity:cloud_ok", {
|
|
280
|
+
modelCount: cloudResult.models.length,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Return flat list of all models with quota info
|
|
285
|
+
const entries: QuotaData[] = [];
|
|
286
|
+
|
|
287
|
+
for (const model of cloudResult.models) {
|
|
288
|
+
if (
|
|
289
|
+
!model.quotaInfo ||
|
|
290
|
+
typeof model.quotaInfo.remainingFraction !== "number"
|
|
291
|
+
) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const label = model.label || model.modelName || "";
|
|
296
|
+
const remainingFraction = model.quotaInfo.remainingFraction;
|
|
297
|
+
const usedPercent = Math.max(
|
|
298
|
+
0,
|
|
299
|
+
Math.min(100, (1 - remainingFraction) * 100),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Generate stable raw ID from model name
|
|
303
|
+
const rawId = `ag-raw-${label
|
|
304
|
+
.toLowerCase()
|
|
305
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
306
|
+
.replace(/^-|-$/g, "")}`;
|
|
307
|
+
|
|
308
|
+
const indicator = getIndicatorSymbol(
|
|
309
|
+
remainingFraction,
|
|
310
|
+
config.indicators,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
let reset: string | undefined;
|
|
314
|
+
if (model.quotaInfo.resetTime) {
|
|
315
|
+
reset = `resets in ${formatRelativeTime(new Date(model.quotaInfo.resetTime))}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
entries.push({
|
|
319
|
+
id: rawId,
|
|
320
|
+
providerName: `Antigravity ${label}`,
|
|
321
|
+
used: usedPercent,
|
|
322
|
+
limit: 100,
|
|
323
|
+
unit: "%",
|
|
324
|
+
reset,
|
|
325
|
+
info: indicator.trim() || undefined,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (debugEnabled) {
|
|
330
|
+
logger.debug("provider:antigravity:fetch_ok", {
|
|
331
|
+
count: entries.length,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return entries;
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { AUTH_FILE } from "../utils/paths";
|
|
3
|
+
import { type IQuotaProvider, type QuotaData } from "../interfaces";
|
|
4
|
+
import { logger } from "../logger";
|
|
5
|
+
|
|
6
|
+
const AUTH_PATH = AUTH_FILE();
|
|
7
|
+
const DEFAULT_BASE_URL = "https://chatgpt.com/backend-api";
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
9
|
+
const MAX_ERROR_BODY_CHARS = 2_000;
|
|
10
|
+
|
|
11
|
+
type OauthAuth = {
|
|
12
|
+
type: "oauth";
|
|
13
|
+
access: string;
|
|
14
|
+
refresh: string;
|
|
15
|
+
expires: number;
|
|
16
|
+
enterpriseUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ApiAuth = {
|
|
20
|
+
type: "api";
|
|
21
|
+
key: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type WellKnownAuth = {
|
|
25
|
+
type: "wellknown";
|
|
26
|
+
key: string;
|
|
27
|
+
token: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type AuthInfo = OauthAuth | ApiAuth | WellKnownAuth;
|
|
31
|
+
|
|
32
|
+
type AuthFile = Record<string, AuthInfo>;
|
|
33
|
+
|
|
34
|
+
type OauthSelection = {
|
|
35
|
+
providerID: string;
|
|
36
|
+
access: string;
|
|
37
|
+
enterpriseUrl?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type RateLimitWindowSnapshot = {
|
|
41
|
+
used_percent?: number;
|
|
42
|
+
limit_window_seconds?: number;
|
|
43
|
+
reset_after_seconds?: number;
|
|
44
|
+
reset_at?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RateLimitStatusDetails = {
|
|
48
|
+
primary_window?: RateLimitWindowSnapshot | null;
|
|
49
|
+
secondary_window?: RateLimitWindowSnapshot | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type CreditStatusDetails = {
|
|
53
|
+
unlimited?: boolean;
|
|
54
|
+
balance?: string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type RateLimitStatusPayload = {
|
|
58
|
+
plan_type?: string;
|
|
59
|
+
rate_limit?: RateLimitStatusDetails | null;
|
|
60
|
+
credits?: CreditStatusDetails | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
async function readAuthFile(): Promise<AuthFile | null> {
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readFile(AUTH_PATH, "utf8");
|
|
66
|
+
const parsed = JSON.parse(raw) as AuthFile;
|
|
67
|
+
return parsed;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
logger.debug("provider:codex:auth_read_failed", { authPath: AUTH_PATH, error: e });
|
|
70
|
+
// Fallback: try the config directory location
|
|
71
|
+
try {
|
|
72
|
+
const configPath = AUTH_PATH.replace("auth.json", "antigravity-accounts.json");
|
|
73
|
+
// If the replacement is not valid, this will likely fail and we return null
|
|
74
|
+
const rawConfig = await readFile(configPath, "utf8");
|
|
75
|
+
return JSON.parse(rawConfig) as AuthFile;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickOauthAuth(auth: AuthFile): OauthSelection | null {
|
|
83
|
+
const preferred = ["opencode", "codex", "openai"];
|
|
84
|
+
for (const providerID of preferred) {
|
|
85
|
+
const info = auth[providerID];
|
|
86
|
+
if (info?.type === "oauth") {
|
|
87
|
+
return {
|
|
88
|
+
providerID,
|
|
89
|
+
access: info.access,
|
|
90
|
+
enterpriseUrl: info.enterpriseUrl,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [providerID, info] of Object.entries(auth)) {
|
|
96
|
+
if (info.type === "oauth") {
|
|
97
|
+
return {
|
|
98
|
+
providerID,
|
|
99
|
+
access: info.access,
|
|
100
|
+
enterpriseUrl: info.enterpriseUrl,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildUsageUrl(baseUrl: string): string {
|
|
109
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
110
|
+
if (trimmed.includes("/backend-api")) {
|
|
111
|
+
return `${trimmed}/wham/usage`;
|
|
112
|
+
}
|
|
113
|
+
return `${trimmed}/api/codex/usage`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function fetchQuotaPayload(
|
|
117
|
+
accessToken: string,
|
|
118
|
+
baseUrl: string,
|
|
119
|
+
): Promise<unknown> {
|
|
120
|
+
const url = buildUsageUrl(baseUrl);
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(url, {
|
|
125
|
+
headers: {
|
|
126
|
+
Authorization: `Bearer ${accessToken}`,
|
|
127
|
+
},
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const bodyText = await response.text();
|
|
132
|
+
let payload: unknown = null;
|
|
133
|
+
try {
|
|
134
|
+
payload = JSON.parse(bodyText);
|
|
135
|
+
} catch {
|
|
136
|
+
payload = bodyText;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const error = new Error(`quota request failed (${response.status})`);
|
|
141
|
+
error.cause = {
|
|
142
|
+
status: response.status,
|
|
143
|
+
bodyText: bodyText.slice(0, MAX_ERROR_BODY_CHARS),
|
|
144
|
+
};
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return payload;
|
|
149
|
+
} finally {
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
155
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toNumber(value: unknown): number | null {
|
|
159
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
if (typeof value === "string") {
|
|
163
|
+
const parsed = Number.parseFloat(value);
|
|
164
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function describeWindow(windowSeconds: number | null): string | null {
|
|
170
|
+
if (!windowSeconds || windowSeconds <= 0) return null;
|
|
171
|
+
const minutes = Math.round(windowSeconds / 60);
|
|
172
|
+
if (minutes >= 60 && minutes % 60 === 0) {
|
|
173
|
+
const hours = minutes / 60;
|
|
174
|
+
return `${hours}h window`;
|
|
175
|
+
}
|
|
176
|
+
return `${minutes}m window`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatRelativeSeconds(seconds: number): string {
|
|
180
|
+
if (seconds <= 0) return "now";
|
|
181
|
+
const minutes = Math.floor(seconds / 60);
|
|
182
|
+
const hours = Math.floor(minutes / 60);
|
|
183
|
+
const remainingMinutes = minutes % 60;
|
|
184
|
+
if (hours > 0) {
|
|
185
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
186
|
+
}
|
|
187
|
+
return `${minutes}m`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseRateLimitWindow(
|
|
191
|
+
id: string,
|
|
192
|
+
label: string,
|
|
193
|
+
snapshot: RateLimitWindowSnapshot,
|
|
194
|
+
): QuotaData | null {
|
|
195
|
+
const usedPercent = toNumber(snapshot.used_percent);
|
|
196
|
+
if (usedPercent === null) return null;
|
|
197
|
+
|
|
198
|
+
// Window info
|
|
199
|
+
let window: string | undefined;
|
|
200
|
+
const windowSeconds = toNumber(snapshot.limit_window_seconds);
|
|
201
|
+
const windowLabel = describeWindow(windowSeconds);
|
|
202
|
+
if (windowLabel) window = windowLabel;
|
|
203
|
+
|
|
204
|
+
// Reset info
|
|
205
|
+
let reset: string | undefined;
|
|
206
|
+
const resetAfter = toNumber(snapshot.reset_after_seconds);
|
|
207
|
+
const resetAt = toNumber(snapshot.reset_at);
|
|
208
|
+
if (resetAfter !== null) {
|
|
209
|
+
reset = `resets in ${formatRelativeSeconds(resetAfter)}`;
|
|
210
|
+
} else if (resetAt !== null) {
|
|
211
|
+
reset = `resets at ${new Date(resetAt * 1000).toLocaleTimeString()}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
id: `codex-${id}`,
|
|
216
|
+
providerName: `Codex ${label}`,
|
|
217
|
+
used: Math.max(0, Math.min(100, usedPercent)),
|
|
218
|
+
limit: 100,
|
|
219
|
+
unit: "%",
|
|
220
|
+
window,
|
|
221
|
+
reset,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseCredits(credits: CreditStatusDetails): QuotaData | null {
|
|
226
|
+
const base = {
|
|
227
|
+
id: "codex-credits",
|
|
228
|
+
providerName: "Codex Credits",
|
|
229
|
+
unit: "credits",
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (credits.unlimited) {
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
used: 0,
|
|
236
|
+
limit: null,
|
|
237
|
+
info: "unlimited",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const balance = toNumber(credits.balance ?? null);
|
|
242
|
+
if (balance === null) return null;
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
...base,
|
|
246
|
+
used: balance,
|
|
247
|
+
limit: null,
|
|
248
|
+
info: "balance",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function extractCodexQuota(payload: unknown): QuotaData[] {
|
|
253
|
+
if (!isObject(payload)) return [];
|
|
254
|
+
|
|
255
|
+
const rateLimitCandidate = (payload as Record<string, unknown>)["rate_limit"];
|
|
256
|
+
const rateLimit = isObject(rateLimitCandidate) ? rateLimitCandidate : null;
|
|
257
|
+
|
|
258
|
+
const entries: QuotaData[] = [];
|
|
259
|
+
|
|
260
|
+
if (rateLimit) {
|
|
261
|
+
const primary = isObject(rateLimit.primary_window)
|
|
262
|
+
? (rateLimit.primary_window as RateLimitWindowSnapshot)
|
|
263
|
+
: null;
|
|
264
|
+
const secondary = isObject(rateLimit.secondary_window)
|
|
265
|
+
? (rateLimit.secondary_window as RateLimitWindowSnapshot)
|
|
266
|
+
: null;
|
|
267
|
+
|
|
268
|
+
if (primary) {
|
|
269
|
+
const entry = parseRateLimitWindow("primary", "Primary", primary);
|
|
270
|
+
if (entry) entries.push(entry);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (secondary) {
|
|
274
|
+
const entry = parseRateLimitWindow("secondary", "Secondary", secondary);
|
|
275
|
+
if (entry) entries.push(entry);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const creditsCandidate = (payload as Record<string, unknown>)["credits"];
|
|
280
|
+
const credits = isObject(creditsCandidate) ? creditsCandidate : null;
|
|
281
|
+
|
|
282
|
+
if (credits) {
|
|
283
|
+
const creditEntry = parseCredits(credits as CreditStatusDetails);
|
|
284
|
+
if (creditEntry) entries.push(creditEntry);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return entries;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function createCodexProvider(): IQuotaProvider {
|
|
291
|
+
return {
|
|
292
|
+
id: "codex",
|
|
293
|
+
async fetchQuota(): Promise<QuotaData[]> {
|
|
294
|
+
logger.debug("provider:codex:fetch_start", { authPath: AUTH_PATH });
|
|
295
|
+
|
|
296
|
+
const auth = await readAuthFile();
|
|
297
|
+
if (!auth) {
|
|
298
|
+
logger.debug("provider:codex:no_auth", { authPath: AUTH_PATH });
|
|
299
|
+
throw new Error("Codex auth.json not found");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const oauth = pickOauthAuth(auth);
|
|
303
|
+
if (!oauth) {
|
|
304
|
+
logger.debug("provider:codex:no_oauth", { availableProviders: Object.keys(auth) });
|
|
305
|
+
throw new Error("Codex OAuth credentials missing");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const baseUrl =
|
|
309
|
+
process.env.OPENCODE_CODEX_BASE_URL ??
|
|
310
|
+
oauth.enterpriseUrl ??
|
|
311
|
+
DEFAULT_BASE_URL;
|
|
312
|
+
|
|
313
|
+
logger.debug("provider:codex:request", { providerID: oauth.providerID, baseUrl, url: buildUsageUrl(baseUrl) });
|
|
314
|
+
|
|
315
|
+
const payload = await fetchQuotaPayload(oauth.access, baseUrl);
|
|
316
|
+
const entries = extractCodexQuota(payload);
|
|
317
|
+
|
|
318
|
+
logger.debug("provider:codex:parse", { count: entries.length });
|
|
319
|
+
|
|
320
|
+
if (entries.length === 0) {
|
|
321
|
+
throw new Error("Codex quota payload did not include rate limits");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return entries;
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|