@bitkyc08/opencodex 2.7.23 → 2.7.24
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.ko.md +37 -7
- package/README.md +52 -11
- package/README.zh-CN.md +36 -7
- package/bin/ocx.mjs +5 -3
- package/gui/dist/assets/index-BzhyTAco.js +40 -0
- package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opencode.svg +1 -1
- package/package.json +5 -2
- package/src/adapters/anthropic-image-normalize.ts +70 -29
- package/src/adapters/cursor/transport-retry.ts +20 -1
- package/src/adapters/run-turn-queue.ts +40 -0
- package/src/codex/auth-api.ts +10 -1
- package/src/codex/auth-context.ts +33 -7
- package/src/codex/catalog.ts +357 -23
- package/src/codex/routing.ts +10 -4
- package/src/combos/failover.ts +102 -0
- package/src/combos/index.ts +37 -0
- package/src/combos/request.ts +31 -0
- package/src/combos/resolve.ts +171 -0
- package/src/combos/types.ts +203 -0
- package/src/config.ts +280 -11
- package/src/lib/errors.ts +86 -24
- package/src/lib/upstream-retry.ts +8 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/key-providers.ts +2 -32
- package/src/oauth/login-cli.ts +4 -3
- package/src/oauth/token-guardian.ts +38 -3
- package/src/providers/derive.ts +27 -2
- package/src/providers/kiro-models.ts +8 -3
- package/src/providers/label.ts +3 -1
- package/src/providers/openai-sidecar.ts +94 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +283 -0
- package/src/providers/openai-virtual-models.ts +82 -0
- package/src/providers/quota.ts +344 -24
- package/src/providers/registry.ts +112 -20
- package/src/reasoning-effort.ts +12 -11
- package/src/router.ts +80 -36
- package/src/server/auth-cors.ts +85 -9
- package/src/server/images.ts +31 -75
- package/src/server/index.ts +45 -86
- package/src/server/management-api.ts +273 -21
- package/src/server/request-log.ts +221 -20
- package/src/server/responses.ts +594 -75
- package/src/server/search.ts +22 -37
- package/src/types.ts +49 -1
- package/src/update/index.ts +50 -6
- package/src/update/job.ts +21 -4
- package/src/usage/log.ts +124 -1
- package/src/usage/summary.ts +147 -56
- package/src/vision/index.ts +20 -19
- package/src/web-search/index.ts +15 -17
- package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
- package/gui/dist/assets/index-DQjt6Hly.js +0 -40
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { PROVIDER_REGISTRY } from "./registry";
|
|
2
|
+
import { OPENAI_API_PROVIDER_ID } from "./openai-tiers";
|
|
3
|
+
import type { OcxParsedRequest } from "../types";
|
|
4
|
+
import type { RouteResult } from "../router";
|
|
5
|
+
import type { RequestLogContext } from "../server/request-log";
|
|
6
|
+
|
|
7
|
+
export interface OpenAiVirtualModelResolution {
|
|
8
|
+
selectedModelId: string;
|
|
9
|
+
wireModelId: string;
|
|
10
|
+
reasoningMode: "pro";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class InvalidOpenAiVirtualModelRegistryError extends Error {
|
|
14
|
+
constructor(selectedModelId: string) {
|
|
15
|
+
super(`Invalid OpenAI virtual model registry definition: ${selectedModelId}`);
|
|
16
|
+
this.name = "InvalidOpenAiVirtualModelRegistryError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateOpenAiVirtualModelDefinition(
|
|
21
|
+
selectedModelId: string,
|
|
22
|
+
definition: unknown,
|
|
23
|
+
): OpenAiVirtualModelResolution {
|
|
24
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
|
|
25
|
+
throw new InvalidOpenAiVirtualModelRegistryError(selectedModelId);
|
|
26
|
+
}
|
|
27
|
+
const raw = definition as { wireModelId?: unknown; reasoningMode?: unknown };
|
|
28
|
+
if (
|
|
29
|
+
typeof raw.wireModelId !== "string"
|
|
30
|
+
|| raw.wireModelId.trim() !== raw.wireModelId
|
|
31
|
+
|| raw.wireModelId.length === 0
|
|
32
|
+
|| raw.wireModelId.includes("/")
|
|
33
|
+
|| raw.wireModelId === selectedModelId
|
|
34
|
+
|| raw.reasoningMode !== "pro"
|
|
35
|
+
) {
|
|
36
|
+
throw new InvalidOpenAiVirtualModelRegistryError(selectedModelId);
|
|
37
|
+
}
|
|
38
|
+
return { selectedModelId, wireModelId: raw.wireModelId, reasoningMode: "pro" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveOpenAiVirtualModel(
|
|
42
|
+
providerName: string,
|
|
43
|
+
selectedModelId: string,
|
|
44
|
+
): OpenAiVirtualModelResolution | undefined {
|
|
45
|
+
if (providerName !== OPENAI_API_PROVIDER_ID) return undefined;
|
|
46
|
+
const entry = PROVIDER_REGISTRY.find(row => row.id === OPENAI_API_PROVIDER_ID);
|
|
47
|
+
if (!entry?.virtualModels || !Object.hasOwn(entry.virtualModels, selectedModelId)) return undefined;
|
|
48
|
+
const definition = entry.virtualModels[selectedModelId];
|
|
49
|
+
return validateOpenAiVirtualModelDefinition(selectedModelId, definition);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function applyOpenAiVirtualModel(
|
|
53
|
+
parsed: OcxParsedRequest,
|
|
54
|
+
route: RouteResult,
|
|
55
|
+
logCtx: RequestLogContext,
|
|
56
|
+
): OpenAiVirtualModelResolution | undefined {
|
|
57
|
+
const selectedModelId = logCtx.model && logCtx.model !== route.modelId ? logCtx.model : route.modelId;
|
|
58
|
+
const resolution = resolveOpenAiVirtualModel(route.providerName, selectedModelId);
|
|
59
|
+
if (!resolution) return undefined;
|
|
60
|
+
|
|
61
|
+
logCtx.model = resolution.selectedModelId;
|
|
62
|
+
logCtx.resolvedModel = resolution.wireModelId;
|
|
63
|
+
route.modelId = resolution.wireModelId;
|
|
64
|
+
parsed.modelId = resolution.wireModelId;
|
|
65
|
+
|
|
66
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object" && !Array.isArray(parsed._rawBody)) {
|
|
67
|
+
const raw = parsed._rawBody as Record<string, unknown>;
|
|
68
|
+
raw.model = resolution.wireModelId;
|
|
69
|
+
const existing = raw.reasoning;
|
|
70
|
+
raw.reasoning = existing && typeof existing === "object" && !Array.isArray(existing)
|
|
71
|
+
? { ...(existing as Record<string, unknown>), mode: resolution.reasoningMode }
|
|
72
|
+
: { mode: resolution.reasoningMode };
|
|
73
|
+
}
|
|
74
|
+
return resolution;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolveOpenAiCompactModel(
|
|
78
|
+
providerName: string,
|
|
79
|
+
selectedModelId: string,
|
|
80
|
+
): OpenAiVirtualModelResolution | undefined {
|
|
81
|
+
return resolveOpenAiVirtualModel(providerName, selectedModelId);
|
|
82
|
+
}
|
package/src/providers/quota.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { listCodexAuthAccounts } from "../codex/auth-api";
|
|
1
|
+
import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api";
|
|
2
2
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
3
3
|
import { getValidAccessToken } from "../oauth";
|
|
4
4
|
import { getCredential } from "../oauth/store";
|
|
5
5
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
6
|
-
import { getProviderRegistryEntry } from "./registry";
|
|
6
|
+
import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
|
|
7
7
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
8
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
|
|
8
9
|
|
|
9
10
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
10
11
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
11
|
-
const
|
|
12
|
+
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
|
13
|
+
const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
|
|
14
|
+
/** Keep a failed probe's previous row at most this long before dropping it. */
|
|
15
|
+
const LAST_GOOD_MAX_AGE_MS = 30 * 60_000;
|
|
12
16
|
|
|
13
17
|
export interface ProviderQuotaWindow {
|
|
14
18
|
label: string;
|
|
@@ -17,6 +21,8 @@ export interface ProviderQuotaWindow {
|
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface ProviderQuota {
|
|
24
|
+
fiveHourPercent?: number;
|
|
25
|
+
fiveHourResetAt?: number;
|
|
20
26
|
weeklyPercent?: number;
|
|
21
27
|
weeklyResetAt?: number;
|
|
22
28
|
monthlyPercent?: number;
|
|
@@ -40,15 +46,19 @@ export interface ProviderQuotaResponse {
|
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
let cache: { key: string; ts: number; response: ProviderQuotaResponse } | null = null;
|
|
49
|
+
const inflight = new Map<string, { epoch: number; promise: Promise<ProviderQuotaResponse> }>();
|
|
50
|
+
/** Bumped on cache clear and on force-refresh start; stale-epoch probes lose commit authority. */
|
|
51
|
+
let invalidationEpoch = 0;
|
|
43
52
|
|
|
44
53
|
/** Invalidate the report cache (e.g. after switching a provider's active account). */
|
|
45
54
|
export function clearProviderQuotaCache(): void {
|
|
46
55
|
cache = null;
|
|
56
|
+
invalidationEpoch += 1;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
function cacheKey(config: OcxConfig): string {
|
|
50
60
|
const providers = Object.entries(config.providers)
|
|
51
|
-
.map(([name, provider]) => `${name}:${provider.authMode ?? "key"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}`)
|
|
61
|
+
.map(([name, provider]) => `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}`)
|
|
52
62
|
.sort()
|
|
53
63
|
.join("|");
|
|
54
64
|
return `${config.defaultProvider}|${config.activeCodexAccountId ?? ""}|${providers}`;
|
|
@@ -56,7 +66,8 @@ function cacheKey(config: OcxConfig): string {
|
|
|
56
66
|
|
|
57
67
|
function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota {
|
|
58
68
|
if (!quota) return false;
|
|
59
|
-
return typeof quota.
|
|
69
|
+
return typeof quota.fiveHourPercent === "number"
|
|
70
|
+
|| typeof quota.weeklyPercent === "number"
|
|
60
71
|
|| typeof quota.monthlyPercent === "number"
|
|
61
72
|
|| !!quota.customWindows?.some(window => typeof window.percent === "number");
|
|
62
73
|
}
|
|
@@ -93,12 +104,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean {
|
|
96
|
-
|
|
97
|
-
const normalizedName = name.toLowerCase();
|
|
98
|
-
return (normalizedName === "openai" || normalizedName === "chatgpt")
|
|
99
|
-
&& provider.adapter === "openai-responses"
|
|
100
|
-
&& provider.authMode === "forward"
|
|
101
|
-
&& base === "https://chatgpt.com/backend-api/codex";
|
|
107
|
+
return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider);
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
function report(provider: string, source: string, quota: ProviderQuota): ProviderQuotaReport | null {
|
|
@@ -112,7 +118,17 @@ function report(provider: string, source: string, quota: ProviderQuota): Provide
|
|
|
112
118
|
};
|
|
113
119
|
}
|
|
114
120
|
|
|
115
|
-
async function fetchChatGptForwardQuota(
|
|
121
|
+
async function fetchChatGptForwardQuota(
|
|
122
|
+
config: OcxConfig,
|
|
123
|
+
provider: string,
|
|
124
|
+
providerConfig: OcxProviderConfig,
|
|
125
|
+
forceRefresh: boolean,
|
|
126
|
+
): Promise<ProviderQuotaReport | null> {
|
|
127
|
+
if (providerCodexAccountMode(provider, providerConfig) === "direct") {
|
|
128
|
+
const main = await fetchMainAccountInfo(forceRefresh);
|
|
129
|
+
const quota = main.quota ? { ...main.quota, updatedAt: Date.now() } as ProviderQuota : null;
|
|
130
|
+
return quota ? report(provider, "chatgpt:wham", quota) : null;
|
|
131
|
+
}
|
|
116
132
|
const accounts = await listCodexAuthAccounts(config, forceRefresh);
|
|
117
133
|
const activeId = config.activeCodexAccountId || MAIN_CODEX_ACCOUNT_ID;
|
|
118
134
|
const active = accounts.find(account => account.id === activeId)
|
|
@@ -165,15 +181,19 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number
|
|
|
165
181
|
}
|
|
166
182
|
|
|
167
183
|
async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
168
|
-
|
|
169
|
-
|
|
184
|
+
let accessToken: string;
|
|
185
|
+
try {
|
|
186
|
+
accessToken = await getValidAccessToken("anthropic");
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
170
190
|
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
171
191
|
headers: {
|
|
172
192
|
Accept: "application/json, text/plain, */*",
|
|
173
193
|
"Content-Type": "application/json",
|
|
174
194
|
"User-Agent": "claude-cli/2.1.63 (external, cli)",
|
|
175
195
|
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
|
176
|
-
Authorization: `Bearer ${
|
|
196
|
+
Authorization: `Bearer ${accessToken}`,
|
|
177
197
|
},
|
|
178
198
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
179
199
|
});
|
|
@@ -197,6 +217,270 @@ async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaRepor
|
|
|
197
217
|
return report(provider, "anthropic:oauth-usage", quota);
|
|
198
218
|
}
|
|
199
219
|
|
|
220
|
+
function normalizedBaseUrl(value: string): string | null {
|
|
221
|
+
try {
|
|
222
|
+
const url = new URL(value);
|
|
223
|
+
if (url.search || url.hash) return null;
|
|
224
|
+
return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function quotaResetAt(row: Record<string, unknown>): number | undefined {
|
|
231
|
+
return normalizeResetAt(row.resetTime ?? row.resetAt ?? row.reset_time ?? row.reset_at);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseKimiQuotaRow(value: unknown, resetFallback?: Record<string, unknown>): { percent: number; resetAt?: number } | null {
|
|
235
|
+
const row = asRecord(value);
|
|
236
|
+
if (!row) return null;
|
|
237
|
+
const limit = toFiniteNumber(row.limit);
|
|
238
|
+
if (limit === undefined || limit <= 0) return null;
|
|
239
|
+
let used = toFiniteNumber(row.used);
|
|
240
|
+
if (used === undefined) {
|
|
241
|
+
const remaining = toFiniteNumber(row.remaining);
|
|
242
|
+
if (remaining === undefined) return null;
|
|
243
|
+
used = limit - remaining;
|
|
244
|
+
}
|
|
245
|
+
const percent = normalizePercent((used / limit) * 100);
|
|
246
|
+
if (percent === undefined) return null;
|
|
247
|
+
const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined);
|
|
248
|
+
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isKimiFiveHourLimit(item: Record<string, unknown>, detail: Record<string, unknown>, window: Record<string, unknown>): boolean {
|
|
252
|
+
const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration);
|
|
253
|
+
const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase();
|
|
254
|
+
if ((unit.includes("MINUTE") && duration === 300) || (unit.includes("HOUR") && duration === 5)) return true;
|
|
255
|
+
const label = [item.name, item.title, item.scope, detail.name, detail.title]
|
|
256
|
+
.filter((value): value is string => typeof value === "string")
|
|
257
|
+
.join(" ")
|
|
258
|
+
.toLowerCase();
|
|
259
|
+
return /(^|\b)5\s*(?:h|hour)/.test(label);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
|
|
263
|
+
const body = asRecord(value);
|
|
264
|
+
if (!body) return null;
|
|
265
|
+
const weekly = parseKimiQuotaRow(body.usage);
|
|
266
|
+
const total = parseKimiQuotaRow(body.totalQuota);
|
|
267
|
+
let fiveHour: { percent: number; resetAt?: number } | null = null;
|
|
268
|
+
if (Array.isArray(body.limits)) {
|
|
269
|
+
for (const rawItem of body.limits) {
|
|
270
|
+
const item = asRecord(rawItem);
|
|
271
|
+
if (!item) continue;
|
|
272
|
+
const detail = asRecord(item.detail) ?? item;
|
|
273
|
+
const window = asRecord(item.window) ?? {};
|
|
274
|
+
if (!isKimiFiveHourLimit(item, detail, window)) continue;
|
|
275
|
+
fiveHour = parseKimiQuotaRow(detail, window);
|
|
276
|
+
if (fiveHour) break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const quota: ProviderQuota = {
|
|
280
|
+
...(fiveHour ? {
|
|
281
|
+
fiveHourPercent: fiveHour.percent,
|
|
282
|
+
...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
|
|
283
|
+
} : {}),
|
|
284
|
+
...(weekly ? {
|
|
285
|
+
weeklyPercent: weekly.percent,
|
|
286
|
+
...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
|
|
287
|
+
} : {}),
|
|
288
|
+
...(total ? { customWindows: [{ label: "Total subscription credits", percent: total.percent, ...(total.resetAt !== undefined ? { resetAt: total.resetAt } : {}) }] } : {}),
|
|
289
|
+
updatedAt: Date.now(),
|
|
290
|
+
};
|
|
291
|
+
return hasQuotaRows(quota) ? quota : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
|
|
295
|
+
// Never release an OAuth token to a user-edited or lookalike provider host.
|
|
296
|
+
if (normalizedBaseUrl(config.baseUrl) !== KIMI_CODE_BASE_URL) return null;
|
|
297
|
+
let accessToken: string;
|
|
298
|
+
try {
|
|
299
|
+
accessToken = await getValidAccessToken("kimi");
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const response = await fetch(KIMI_CODE_USAGE_URL, {
|
|
304
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
|
|
305
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
306
|
+
});
|
|
307
|
+
if (!response.ok) return null;
|
|
308
|
+
const quota = parseKimiQuotaPayload(await response.json().catch(() => null));
|
|
309
|
+
return quota ? report(provider, "kimi:usages", quota) : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
|
|
313
|
+
async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
314
|
+
let accessToken: string;
|
|
315
|
+
try {
|
|
316
|
+
accessToken = await getValidAccessToken("cursor");
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const authHeaders = {
|
|
322
|
+
Accept: "application/json",
|
|
323
|
+
Authorization: `Bearer ${accessToken}`,
|
|
324
|
+
"User-Agent": "opencodex-quota",
|
|
325
|
+
} as const;
|
|
326
|
+
|
|
327
|
+
// Prefer dashboard period usage (Pro/Team/Ultra spend allowance in USD cents).
|
|
328
|
+
// Field names follow Cursor's Connect RPC shape (limit/remaining/includedSpend), not usedCents.
|
|
329
|
+
try {
|
|
330
|
+
const periodRes = await fetch("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: {
|
|
333
|
+
...authHeaders,
|
|
334
|
+
"Content-Type": "application/json",
|
|
335
|
+
"Connect-Protocol-Version": "1",
|
|
336
|
+
},
|
|
337
|
+
body: "{}",
|
|
338
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
339
|
+
});
|
|
340
|
+
if (periodRes.ok) {
|
|
341
|
+
const body = asRecord(await periodRes.json().catch(() => null));
|
|
342
|
+
const planUsage = asRecord(body?.planUsage);
|
|
343
|
+
if (planUsage) {
|
|
344
|
+
const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
|
|
345
|
+
// Cursor tracks two linked pools: First-party models (Auto/Composer/Grok) and API usage.
|
|
346
|
+
const autoPercent = normalizePercent(planUsage.autoPercentUsed);
|
|
347
|
+
const apiPercent = normalizePercent(planUsage.apiPercentUsed);
|
|
348
|
+
const customWindows: ProviderQuotaWindow[] = [];
|
|
349
|
+
if (autoPercent !== undefined) {
|
|
350
|
+
customWindows.push({
|
|
351
|
+
label: "First-party models",
|
|
352
|
+
percent: autoPercent,
|
|
353
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (apiPercent !== undefined) {
|
|
357
|
+
customWindows.push({
|
|
358
|
+
label: "API usage",
|
|
359
|
+
percent: apiPercent,
|
|
360
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (customWindows.length > 0) {
|
|
364
|
+
const built = report(provider, "cursor:period-usage", {
|
|
365
|
+
customWindows,
|
|
366
|
+
updatedAt: Date.now(),
|
|
367
|
+
});
|
|
368
|
+
if (built) return { ...built, reverseEngineered: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents);
|
|
372
|
+
const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents);
|
|
373
|
+
const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used);
|
|
374
|
+
const totalSpend = toFiniteNumber(planUsage.totalSpend);
|
|
375
|
+
let used: number | undefined;
|
|
376
|
+
if (includedSpend !== undefined) used = includedSpend;
|
|
377
|
+
else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining);
|
|
378
|
+
else if (totalSpend !== undefined) used = totalSpend;
|
|
379
|
+
const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed);
|
|
380
|
+
if (limit !== undefined && limit > 0 && used !== undefined) {
|
|
381
|
+
const percent = totalPercent ?? normalizePercent((used / limit) * 100);
|
|
382
|
+
if (percent !== undefined) {
|
|
383
|
+
const built = report(provider, "cursor:period-usage", {
|
|
384
|
+
monthlyPercent: percent,
|
|
385
|
+
...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
|
|
386
|
+
updatedAt: Date.now(),
|
|
387
|
+
});
|
|
388
|
+
if (built) return { ...built, reverseEngineered: true };
|
|
389
|
+
}
|
|
390
|
+
} else if (totalPercent !== undefined) {
|
|
391
|
+
const built = report(provider, "cursor:period-usage", {
|
|
392
|
+
monthlyPercent: totalPercent,
|
|
393
|
+
...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
|
|
394
|
+
updatedAt: Date.now(),
|
|
395
|
+
});
|
|
396
|
+
if (built) return { ...built, reverseEngineered: true };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
/* fall through */
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// /api/usage/summary — same host, sometimes richer than /auth/usage for Team plans.
|
|
405
|
+
try {
|
|
406
|
+
const summaryRes = await fetch("https://api2.cursor.sh/api/usage/summary", {
|
|
407
|
+
headers: authHeaders,
|
|
408
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
409
|
+
});
|
|
410
|
+
if (summaryRes.ok) {
|
|
411
|
+
const body = asRecord(await summaryRes.json().catch(() => null));
|
|
412
|
+
const individual = asRecord(body?.individualUsage);
|
|
413
|
+
const plan = asRecord(individual?.plan);
|
|
414
|
+
if (plan) {
|
|
415
|
+
const used = toFiniteNumber(plan.used);
|
|
416
|
+
const limit = toFiniteNumber(plan.limit);
|
|
417
|
+
const percent = normalizePercent(plan.totalPercentUsed)
|
|
418
|
+
?? (used !== undefined && limit !== undefined && limit > 0
|
|
419
|
+
? normalizePercent((used / limit) * 100)
|
|
420
|
+
: undefined);
|
|
421
|
+
if (percent !== undefined) {
|
|
422
|
+
const built = report(provider, "cursor:usage-summary", {
|
|
423
|
+
monthlyPercent: percent,
|
|
424
|
+
monthlyResetAt: normalizeResetAt(body?.billingCycleEnd),
|
|
425
|
+
updatedAt: Date.now(),
|
|
426
|
+
});
|
|
427
|
+
if (built) return { ...built, reverseEngineered: true };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
/* fall through to /auth/usage */
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const response = await fetch("https://api2.cursor.sh/auth/usage", {
|
|
436
|
+
headers: authHeaders,
|
|
437
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
438
|
+
});
|
|
439
|
+
if (!response.ok) return null;
|
|
440
|
+
const body = asRecord(await response.json().catch(() => null));
|
|
441
|
+
if (!body) return null;
|
|
442
|
+
|
|
443
|
+
// Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit.
|
|
444
|
+
let used: number | undefined;
|
|
445
|
+
let limit: number | undefined;
|
|
446
|
+
const gpt4 = asRecord(body["gpt-4"]);
|
|
447
|
+
if (gpt4) {
|
|
448
|
+
used = toFiniteNumber(gpt4.numRequests ?? gpt4.used);
|
|
449
|
+
limit = toFiniteNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests);
|
|
450
|
+
}
|
|
451
|
+
if (used === undefined || limit === undefined || limit <= 0) {
|
|
452
|
+
for (const [key, value] of Object.entries(body)) {
|
|
453
|
+
if (key === "startOfMonth" || key === "billingCycleStart") continue;
|
|
454
|
+
const bucket = asRecord(value);
|
|
455
|
+
if (!bucket) continue;
|
|
456
|
+
const bucketUsed = toFiniteNumber(bucket.numRequests ?? bucket.used);
|
|
457
|
+
const bucketLimit = toFiniteNumber(bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests);
|
|
458
|
+
if (bucketUsed !== undefined && bucketLimit !== undefined && bucketLimit > 0) {
|
|
459
|
+
used = bucketUsed;
|
|
460
|
+
limit = bucketLimit;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (used === undefined || limit === undefined || limit <= 0) return null;
|
|
466
|
+
const percent = normalizePercent((used / limit) * 100);
|
|
467
|
+
if (percent === undefined) return null;
|
|
468
|
+
const startOfMonth = normalizeResetAt(body.startOfMonth ?? body.billingCycleStart);
|
|
469
|
+
// Next reset = same day next month, computed in UTC to avoid timezone-shifted rollover.
|
|
470
|
+
const monthlyResetAt = startOfMonth !== undefined
|
|
471
|
+
? (() => {
|
|
472
|
+
const start = new Date(startOfMonth);
|
|
473
|
+
return Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate());
|
|
474
|
+
})()
|
|
475
|
+
: undefined;
|
|
476
|
+
const built = report(provider, "cursor:auth-usage", {
|
|
477
|
+
monthlyPercent: percent,
|
|
478
|
+
...(monthlyResetAt !== undefined ? { monthlyResetAt } : {}),
|
|
479
|
+
updatedAt: Date.now(),
|
|
480
|
+
});
|
|
481
|
+
return built ? { ...built, reverseEngineered: true } : null;
|
|
482
|
+
}
|
|
483
|
+
|
|
200
484
|
function quotaInfoEntries(modelInfo: Record<string, unknown>): Record<string, unknown>[] {
|
|
201
485
|
const entries: Record<string, unknown>[] = [];
|
|
202
486
|
const add = (value: unknown, tier?: string) => {
|
|
@@ -307,10 +591,12 @@ async function maybeFetchProviderQuota(
|
|
|
307
591
|
): Promise<ProviderQuotaReport | null> {
|
|
308
592
|
if (provider.disabled === true) return null;
|
|
309
593
|
try {
|
|
310
|
-
if (isBuiltInChatGptForwardProvider(name, provider)) return fetchChatGptForwardQuota(config, name, forceRefresh);
|
|
594
|
+
if (isBuiltInChatGptForwardProvider(name, provider)) return fetchChatGptForwardQuota(config, name, provider, forceRefresh);
|
|
311
595
|
if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name);
|
|
312
596
|
if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
|
|
597
|
+
if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
|
|
313
598
|
if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
|
|
599
|
+
if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
|
|
314
600
|
return null;
|
|
315
601
|
} catch {
|
|
316
602
|
return null;
|
|
@@ -320,12 +606,46 @@ async function maybeFetchProviderQuota(
|
|
|
320
606
|
export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise<ProviderQuotaResponse> {
|
|
321
607
|
const key = cacheKey(config);
|
|
322
608
|
const now = Date.now();
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
609
|
+
// The cache fast path must not extend a preserved last-good row past its 30-minute bound:
|
|
610
|
+
// a row preserved at age 29:59 plus a full 5-minute TTL would otherwise serve until ~35min.
|
|
611
|
+
const cacheFresh = cache && cache.key === key && now - cache.ts < CACHE_TTL_MS
|
|
612
|
+
&& cache.response.reports.every(item => now - item.updatedAt < LAST_GOOD_MAX_AGE_MS);
|
|
613
|
+
if (!forceRefresh && cacheFresh) return cache!.response;
|
|
614
|
+
const joinable = inflight.get(key);
|
|
615
|
+
if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) return joinable.promise;
|
|
616
|
+
// A forced probe takes commit authority: older in-flight probes must not overwrite its result.
|
|
617
|
+
if (forceRefresh) invalidationEpoch += 1;
|
|
618
|
+
const epoch = invalidationEpoch;
|
|
619
|
+
|
|
620
|
+
const promise = (async (): Promise<ProviderQuotaResponse> => {
|
|
621
|
+
const previous = cache && cache.key === key ? cache.response.reports : [];
|
|
622
|
+
const fresh = (await Promise.all(
|
|
623
|
+
Object.entries(config.providers).map(([name, provider]) => maybeFetchProviderQuota(name, provider, config, forceRefresh)),
|
|
624
|
+
)).filter((item): item is ProviderQuotaReport => item !== null);
|
|
625
|
+
|
|
626
|
+
// Keep bounded last-good rows when a probe fails (e.g. transient upstream flake); never
|
|
627
|
+
// re-stamp their timestamps, and drop rows older than LAST_GOOD_MAX_AGE_MS.
|
|
628
|
+
// Note: the cache key encodes the provider set (name/adapter/authMode/disabled/baseUrl),
|
|
629
|
+
// so previous rows always correspond to currently configured, enabled providers — a
|
|
630
|
+
// disabled or removed provider changes the key and starts from an empty previous set.
|
|
631
|
+
const cutoff = Date.now() - LAST_GOOD_MAX_AGE_MS;
|
|
632
|
+
const byProvider = new Map<string, ProviderQuotaReport>();
|
|
633
|
+
for (const item of previous) {
|
|
634
|
+
if (item.updatedAt >= cutoff) byProvider.set(item.provider, item);
|
|
635
|
+
}
|
|
636
|
+
for (const item of fresh) byProvider.set(item.provider, item);
|
|
637
|
+
|
|
638
|
+
const response = { generatedAt: Date.now(), reports: [...byProvider.values()] };
|
|
639
|
+
// Commit only when this probe still holds authority (no clear/force superseded it).
|
|
640
|
+
if (epoch === invalidationEpoch) cache = { key, ts: Date.now(), response };
|
|
641
|
+
return response;
|
|
642
|
+
})();
|
|
643
|
+
|
|
644
|
+
const entry = { epoch, promise };
|
|
645
|
+
inflight.set(key, entry);
|
|
646
|
+
try {
|
|
647
|
+
return await promise;
|
|
648
|
+
} finally {
|
|
649
|
+
if (inflight.get(key) === entry) inflight.delete(key);
|
|
650
|
+
}
|
|
331
651
|
}
|