@bitkyc08/opencodex 2.55.0-preview.20260914 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,590 @@
1
+ import { effectiveCodexAuthAccountId, fetchMainAccountInfoSnapshot, listCodexAuthAccountsSnapshot } from "../../codex/auth-api";
2
+ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
3
+ import { getValidAccessToken } from "../../oauth";
4
+ import { getAccountCredential, getAccountSet } from "../../oauth/store";
5
+ import { fetchMuseKeyQuotaSnapshot } from "../muse-key-quota";
6
+ import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "../xai-transport";
7
+ import {
8
+ commitKiroAccountUsageState,
9
+ fetchKiroUsageSnapshot,
10
+ type KiroUsageSnapshot,
11
+ kiroUsageContextForAccount,
12
+ } from "../kiro-usage";
13
+ import { captureConfigGeneration } from "../../lib/state-store-sweeper";
14
+ import { aggregateCodexPoolCapacity, CODEX_CAPACITY_MAX_QUOTA_AGE_MS, type CodexCapacityQuota } from "../codex-capacity";
15
+ import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire";
16
+ import { providerCodexAccountMode } from "../registry";
17
+ import {
18
+ hasQuotaRows,
19
+ providerLabel,
20
+ providerQuotaFromCodexQuota,
21
+ publicCapacityAggregation,
22
+ report,
23
+ tagNativeMainReport,
24
+ type CodexAuthAccountsSnapshotPromise,
25
+ type ProviderQuotaReport,
26
+ } from "./report-cache";
27
+ import {
28
+ accountCacheKey,
29
+ accountQuotaCache,
30
+ hydrateAccountQuotaCache,
31
+ mayCommitAccountQuotaKey,
32
+ persistAccountQuotaCache,
33
+ } from "./account-cache";
34
+ import type { OcxConfig, OcxProviderConfig } from "../../types";
35
+ import type { ProviderQuota, ProviderQuotaWindow } from "../quota-types";
36
+
37
+ const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing";
38
+ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`;
39
+
40
+ export async function fetchChatGptForwardQuota(
41
+ config: OcxConfig,
42
+ provider: string,
43
+ providerConfig: OcxProviderConfig,
44
+ forceRefresh: boolean,
45
+ prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise,
46
+ ): Promise<ProviderQuotaReport | null> {
47
+ if (providerCodexAccountMode(provider, providerConfig) === "direct") {
48
+ const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh);
49
+ const quota = providerQuotaFromCodexQuota(snapshot.info.quota);
50
+ if (quota) quota.updatedAt = Date.now();
51
+ return quota
52
+ ? tagNativeMainReport(report(provider, "chatgpt:wham", quota), snapshot.mainIdentityGeneration)
53
+ : null;
54
+ }
55
+ const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, forceRefresh));
56
+ const accounts = snapshot.accounts;
57
+ const activeId = effectiveCodexAuthAccountId(config);
58
+ const capacityAccounts = accounts.map(account => ({
59
+ ...account,
60
+ active: account.id === activeId,
61
+ quota: providerQuotaFromCodexQuota(account.quota),
62
+ }));
63
+ const active = capacityAccounts.find(account => account.active)
64
+ ?? capacityAccounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)
65
+ ?? capacityAccounts[0];
66
+ const now = Date.now();
67
+ const capacity = aggregateCodexPoolCapacity(capacityAccounts, now);
68
+ if (capacity.aggregation && capacity.quota) {
69
+ return tagNativeMainReport(
70
+ report(
71
+ provider,
72
+ "chatgpt:wham",
73
+ capacity.quota as ProviderQuota,
74
+ publicCapacityAggregation(capacity.aggregation, "aggregate"),
75
+ ),
76
+ snapshot.mainIdentityGeneration,
77
+ );
78
+ }
79
+ const activeUsable = !!active && !active.paused && active.needsReauth !== true;
80
+ const quota = activeUsable && active?.quota
81
+ ? { ...active.quota, updatedAt: active.quota.updatedAt ?? Date.now() } as CodexCapacityQuota
82
+ : null;
83
+ const quotaFresh = !!quota
84
+ && Number.isFinite(quota.updatedAt)
85
+ && now - quota.updatedAt < CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
86
+ if (quota && quotaFresh) {
87
+ const fallback = report(
88
+ provider,
89
+ "chatgpt:wham",
90
+ quota as ProviderQuota,
91
+ capacity.aggregation
92
+ ? publicCapacityAggregation(capacity.aggregation, "effective-account-fallback")
93
+ : undefined,
94
+ );
95
+ return tagNativeMainReport(fallback, snapshot.mainIdentityGeneration);
96
+ }
97
+ if (capacity.aggregation) {
98
+ const updatedAt = Date.now();
99
+ return tagNativeMainReport(
100
+ {
101
+ provider,
102
+ label: providerLabel(provider),
103
+ source: "chatgpt:wham",
104
+ quota: { updatedAt },
105
+ updatedAt,
106
+ aggregation: publicCapacityAggregation(capacity.aggregation, "coverage-only"),
107
+ },
108
+ snapshot.mainIdentityGeneration,
109
+ );
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function centsValue(value: unknown): number | undefined {
115
+ const rec = asRecord(value);
116
+ return rec ? toFiniteNumber(rec.val) : undefined;
117
+ }
118
+
119
+ /** Decode JWT payload `sub` for xAI weekly credits when the stored credential lacks accountId. */
120
+ function xaiUserIdFromAccessToken(accessToken: string): string | undefined {
121
+ const parts = accessToken.split(".");
122
+ if (parts.length < 2 || !parts[1]) return undefined;
123
+ try {
124
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { sub?: unknown };
125
+ return typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Grok Build weekly credits envelope:
133
+ * `{ config: { creditUsagePercent?, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end } } }`.
134
+ * Omitted percent is treated as 0 (proto3 default).
135
+ */
136
+ export function parseXaiCreditsResponse(value: unknown): { percent: number; resetAt?: number } | null {
137
+ const body = asRecord(value);
138
+ const config = asRecord(body?.config);
139
+ if (!config) return null;
140
+ const period = asRecord(config.currentPeriod);
141
+ if (!period || period.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null;
142
+ let percent = 0;
143
+ if (config.creditUsagePercent !== undefined) {
144
+ const normalized = normalizePercent(config.creditUsagePercent);
145
+ if (normalized === undefined) return null;
146
+ percent = normalized;
147
+ }
148
+ const resetAt = normalizeResetAt(period.end);
149
+ return {
150
+ percent,
151
+ ...(resetAt !== undefined ? { resetAt } : {}),
152
+ };
153
+ }
154
+
155
+ async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise<ProviderQuota | null> {
156
+ try {
157
+ const response = await fetch(XAI_CREDITS_URL, {
158
+ redirect: "error",
159
+ headers: {
160
+ Accept: "application/json",
161
+ Authorization: `Bearer ${accessToken}`,
162
+ [XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli",
163
+ [XAI_GROK_COMPATIBILITY.headers.authenticateResponse]: "authenticate-response",
164
+ "x-userid": userId,
165
+ [XAI_GROK_COMPATIBILITY.headers.clientVersion]: XAI_GROK_CLIENT_VERSION,
166
+ },
167
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
168
+ });
169
+ if (!response.ok) return null;
170
+ const parsed = parseXaiCreditsResponse(await readQuotaJson(response));
171
+ if (!parsed) return null;
172
+ return {
173
+ weeklyPercent: parsed.percent,
174
+ ...(parsed.resetAt !== undefined ? { weeklyResetAt: parsed.resetAt } : {}),
175
+ updatedAt: Date.now(),
176
+ };
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ export async function fetchXaiQuota(provider: string, context: { accessToken: string; upstreamAccountId?: string }): Promise<ProviderQuotaReport | null> {
183
+ const { accessToken } = context;
184
+
185
+ // Prefer the SuperGrok weekly credits window that actually gates prompting (#1283).
186
+ const userId = context.upstreamAccountId?.trim() || xaiUserIdFromAccessToken(accessToken);
187
+ if (userId) {
188
+ const weekly = await fetchXaiWeeklyCredits(accessToken, userId);
189
+ if (weekly) return report(provider, "xai:grok-billing-credits", weekly);
190
+ }
191
+
192
+ // Legacy monthly dollar pool — retained when weekly is unavailable.
193
+ try {
194
+ const response = await fetch(XAI_BILLING_URL, {
195
+ redirect: "error",
196
+ headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
197
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
198
+ });
199
+ if (!response.ok) return null;
200
+ const body = asRecord(await readQuotaJson(response));
201
+ const config = asRecord(body?.config);
202
+ if (!config) return null;
203
+ const limitCents = centsValue(config.monthlyLimit);
204
+ const usedCents = centsValue(config.used);
205
+ if (limitCents === undefined || usedCents === undefined || limitCents <= 0) return null;
206
+ const percent = normalizePercent((usedCents / limitCents) * 100);
207
+ if (percent === undefined) return null;
208
+ return report(provider, "xai:grok-billing", {
209
+ monthlyPercent: percent,
210
+ monthlyResetAt: normalizeResetAt(config.billingPeriodEnd),
211
+ updatedAt: Date.now(),
212
+ });
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
219
+ const rec = asRecord(value);
220
+ if (!rec) return null;
221
+ const percent = normalizePercent(rec.utilization);
222
+ const resetAt = normalizeResetAt(rec.resets_at);
223
+ if (percent === undefined && resetAt === undefined) return null;
224
+ return { percent, resetAt };
225
+ }
226
+
227
+ function parseClaudeLimit(value: unknown): { label: string; percent: number; resetAt?: number } | null {
228
+ const rec = asRecord(value);
229
+ if (!rec) return null;
230
+ const percent = normalizePercent(rec.percent);
231
+ if (percent === undefined) return null;
232
+ const scope = asRecord(rec.scope);
233
+ const model = asRecord(scope?.model);
234
+ const rawLabel = String(model?.display_name ?? "").trim();
235
+ if (!rawLabel) return null;
236
+ const lowerLabel = rawLabel.toLowerCase();
237
+ const label = lowerLabel.includes("fable") ? "Fable"
238
+ : lowerLabel.includes("opus") ? "Opus"
239
+ : lowerLabel.includes("sonnet") ? "Sonnet"
240
+ : rawLabel;
241
+ const resetAt = normalizeResetAt(rec.resets_at);
242
+ return { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) };
243
+ }
244
+
245
+ /** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
246
+ const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
247
+
248
+ /**
249
+ * Anthropic per-credential usage.
250
+ *
251
+ * This endpoint reports quota only. Its body carries `five_hour`, `seven_day`, the
252
+ * model-scoped weekly buckets (`seven_day_fable`/`_opus`/`_sonnet`) and a `limits` array,
253
+ * and **no subscription or tier field** — nor does the OAuth token response, which yields only
254
+ * `account.uuid` and `account.email_address` (`src/oauth/anthropic.ts`). That is why
255
+ * `OAuthAccountSummary.plan` is `null` for Anthropic rather than populated here (#3777); it is
256
+ * a missing upstream field, not an unfinished mapping.
257
+ *
258
+ * A tier must not be inferred from what is here. Percentages are normalized per account, so a
259
+ * Max x5 seat at 50% is byte-identical to a Max x20 seat at 50%, and the presence of a
260
+ * model-scoped window tracks entitlement rather than seat size. Populate `plan` only when
261
+ * upstream returns the tier itself.
262
+ */
263
+ export async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
264
+ const joinable = anthropicUsageInflight.get(accessToken);
265
+ if (joinable) return joinable;
266
+
267
+ const probe = (async (): Promise<ProviderQuota | null> => {
268
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
269
+ headers: {
270
+ Accept: "application/json, text/plain, */*",
271
+ "Content-Type": "application/json",
272
+ "User-Agent": "claude-cli/2.1.63 (external, cli)",
273
+ "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",
274
+ Authorization: `Bearer ${accessToken}`,
275
+ },
276
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
277
+ });
278
+ if (!response.ok) return null;
279
+ const body = asRecord(await readQuotaJson(response));
280
+ if (!body) return null;
281
+ const fiveHour = parseClaudeBucket(body.five_hour);
282
+ const sevenDay = parseClaudeBucket(body.seven_day);
283
+ const fable = parseClaudeBucket(body.seven_day_fable);
284
+ const opus = parseClaudeBucket(body.seven_day_opus);
285
+ const sonnet = parseClaudeBucket(body.seven_day_sonnet);
286
+ const customWindows: ProviderQuotaWindow[] = [];
287
+ if (fable?.percent !== undefined) customWindows.push({ label: "Fable", percent: fable.percent, ...(fable.resetAt !== undefined ? { resetAt: fable.resetAt } : {}) });
288
+ if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
289
+ if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
290
+ const knownLabels = new Set(customWindows.map(window => window.label.toLowerCase()));
291
+ const limits = Array.isArray(body.limits) ? body.limits : [];
292
+ for (const rawLimit of limits) {
293
+ const limitRecord = asRecord(rawLimit);
294
+ // `session` and `weekly_all` mirror the canonical five-hour and weekly
295
+ // buckets above; only model-scoped weekly limits add a third window.
296
+ if (String(limitRecord?.kind ?? "").trim().toLowerCase() !== "weekly_scoped") continue;
297
+ const limit = parseClaudeLimit(rawLimit);
298
+ if (!limit || knownLabels.has(limit.label.toLowerCase())) continue;
299
+ knownLabels.add(limit.label.toLowerCase());
300
+ customWindows.push(limit);
301
+ }
302
+ const quota: ProviderQuota = {
303
+ // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
304
+ // rows: report it in the canonical fields so the dashboard renders it with the standard
305
+ // "5-hour limit" label and ordering instead of as a generic extra window.
306
+ ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
307
+ ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
308
+ ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
309
+ ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
310
+ ...(customWindows.length > 0 ? { customWindows } : {}),
311
+ updatedAt: Date.now(),
312
+ };
313
+ // Empty / schema-changed payloads must not cache as "success with no bars".
314
+ return hasQuotaRows(quota) ? quota : null;
315
+ })().finally(() => {
316
+ if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken);
317
+ });
318
+ anthropicUsageInflight.set(accessToken, probe);
319
+ return probe;
320
+ }
321
+
322
+ export async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaReport | null> {
323
+ // Capture the account we intend to probe before awaiting — a mid-flight active
324
+ // switch must not seed the wrong account's cache with this response.
325
+ const probedAccountId = getAccountSet("anthropic")?.activeAccountId;
326
+ const probedAccountKey = probedAccountId ? accountCacheKey("anthropic", probedAccountId) : null;
327
+ const writerGeneration = captureConfigGeneration();
328
+ let accessToken: string;
329
+ try {
330
+ accessToken = await getValidAccessToken("anthropic");
331
+ } catch {
332
+ return null;
333
+ }
334
+ const quota = await fetchAnthropicUsageQuota(accessToken);
335
+ if (!quota) return null;
336
+ // Share the active-account probe with the per-account cache so Providers-page
337
+ // loads do not double-hit Anthropic's rate-limited usage endpoint.
338
+ if (probedAccountId && probedAccountKey) {
339
+ const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken;
340
+ if (stillOwnsToken && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
341
+ accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota });
342
+ }
343
+ }
344
+ return report(provider, "anthropic:oauth-usage", quota);
345
+ }
346
+
347
+ /**
348
+ * Provider-level Kiro row: the active account's usage, shown on the Providers page.
349
+ *
350
+ * The per-account cache is seeded from the same probe so opening that page does not read
351
+ * the active account twice, and the account id is captured before the await so a
352
+ * concurrent account switch cannot file this answer under the wrong account.
353
+ */
354
+ export async function fetchKiroQuota(provider: string): Promise<ProviderQuotaReport | null> {
355
+ const probedAccountId = getAccountSet("kiro")?.activeAccountId;
356
+ if (!probedAccountId) return null;
357
+ const probedAccountKey = accountCacheKey("kiro", probedAccountId);
358
+ const writerGeneration = captureConfigGeneration();
359
+ let snapshot: KiroUsageSnapshot | null;
360
+ try {
361
+ snapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(probedAccountId));
362
+ } catch {
363
+ return null;
364
+ }
365
+ if (!snapshot) return null;
366
+ if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
367
+ accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota: snapshot.quota });
368
+ commitKiroAccountUsageState(probedAccountKey, snapshot);
369
+ }
370
+ return report(provider, "kiro:usage-limits", snapshot.quota);
371
+ }
372
+
373
+ /**
374
+ * Provider-level row probed from the key endpoint, for an account that CAN be probed.
375
+ *
376
+ * Written through the same account cache the passive path reads, so the measurement
377
+ * survives a restart and the per-account rows at oauth-account-routes.ts:313 pick it up
378
+ * with no mode change. Deliberately does not flip providerOAuthAccountQuotaMode: that
379
+ * mode selects readPassiveProviderAccountQuotas, and the probed per-account path it would
380
+ * switch to is gated on supportsPerAccountQuota, which has no meta-muse reader, so the
381
+ * GUI account list would go from showing observations to showing nothing.
382
+ */
383
+ export async function fetchMuseKeyQuota(provider: string): Promise<ProviderQuotaReport | null> {
384
+ const probedAccountId = getAccountSet(provider)?.activeAccountId;
385
+ if (!probedAccountId) return null;
386
+ const oauthAccessToken = getAccountCredential(provider, probedAccountId)?.muse?.oauthAccessToken;
387
+ // An imported or pasted credential has no account token and never will: it is
388
+ // capability, not provider id, that decides whether a probe is possible.
389
+ if (!oauthAccessToken) return null;
390
+ const probedAccountKey = accountCacheKey(provider, probedAccountId);
391
+ const writerGeneration = captureConfigGeneration();
392
+ const quota = await fetchMuseKeyQuotaSnapshot(probedAccountId, oauthAccessToken);
393
+ if (!quota) return null;
394
+ if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
395
+ // Hydrate before writing, for the same reason recordPassiveAccountQuota does:
396
+ // persistAccountQuotaCache serializes the whole in-memory map.
397
+ hydrateAccountQuotaCache();
398
+ accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota });
399
+ persistAccountQuotaCache();
400
+ }
401
+ return report(provider, `${provider}:key-endpoint`, quota);
402
+ }
403
+ /**
404
+ * Provider-level row for a passive provider: the ACTIVE account's last observed
405
+ * subscription windows, the same shape `fetchAnthropicQuota` and `fetchKiroQuota`
406
+ * return.
407
+ *
408
+ * Cache-only. A dashboard load or `ocx account refresh` must never spend an inference
409
+ * turn, so `forceRefresh` does not exist on this path — there is nothing to refresh.
410
+ * `report.updatedAt` is the observation time, which is what both GUI surfaces render
411
+ * as the relative age of the row.
412
+ */
413
+ export async function fetchPassiveProviderQuota(provider: string): Promise<ProviderQuotaReport | null> {
414
+ const activeId = getAccountSet(provider)?.activeAccountId;
415
+ if (!activeId) return null;
416
+ // Idempotent; without it a proxy restart shows nothing until the next streaming turn
417
+ // even though the last observation is on disk.
418
+ hydrateAccountQuotaCache();
419
+ const entry = accountQuotaCache.get(accountCacheKey(provider, activeId));
420
+ if (!entry?.quota) return null;
421
+ const built = report(provider, `${provider}:subscription-observation`, entry.quota);
422
+ // Tagged here rather than inside report(), which every probed path shares.
423
+ return built ? { ...built, observed: true } : null;
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Per-account quota (multiauth)
428
+ // ---------------------------------------------------------------------------
429
+
430
+
431
+ /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
432
+ export async function fetchCursorQuota(provider: string, accessToken: string): Promise<ProviderQuotaReport | null> {
433
+
434
+ const authHeaders = {
435
+ Accept: "application/json",
436
+ Authorization: `Bearer ${accessToken}`,
437
+ "User-Agent": "opencodex-quota",
438
+ } as const;
439
+
440
+ // Prefer dashboard period usage (Pro/Team/Ultra spend allowance in USD cents).
441
+ // Field names follow Cursor's Connect RPC shape (limit/remaining/includedSpend), not usedCents.
442
+ try {
443
+ const periodRes = await fetch("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
444
+ method: "POST",
445
+ redirect: "error",
446
+ headers: {
447
+ ...authHeaders,
448
+ "Content-Type": "application/json",
449
+ "Connect-Protocol-Version": "1",
450
+ },
451
+ body: "{}",
452
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
453
+ });
454
+ if (periodRes.ok) {
455
+ const body = asRecord(await readQuotaJson(periodRes));
456
+ const planUsage = asRecord(body?.planUsage);
457
+ if (planUsage) {
458
+ const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
459
+
460
+ // Primary meter: overall included allowance (Cursor Settings → Usage total %).
461
+ // autoPercentUsed / apiPercentUsed are secondary pools and must not replace the total.
462
+ const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents);
463
+ const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents);
464
+ const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used);
465
+ const totalSpend = toFiniteNumber(planUsage.totalSpend);
466
+ let used: number | undefined;
467
+ if (includedSpend !== undefined) used = includedSpend;
468
+ else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining);
469
+ else if (totalSpend !== undefined) used = totalSpend;
470
+ const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed)
471
+ ?? (limit !== undefined && limit > 0 && used !== undefined
472
+ ? normalizePercent((used / limit) * 100)
473
+ : undefined);
474
+
475
+ const autoPercent = normalizePercent(planUsage.autoPercentUsed);
476
+ const apiPercent = normalizePercent(planUsage.apiPercentUsed);
477
+ const customWindows: ProviderQuotaWindow[] = [];
478
+ if (autoPercent !== undefined) {
479
+ customWindows.push({
480
+ label: "First-party models",
481
+ percent: autoPercent,
482
+ ...(resetAt !== undefined ? { resetAt } : {}),
483
+ });
484
+ }
485
+ if (apiPercent !== undefined) {
486
+ customWindows.push({
487
+ label: "API usage",
488
+ percent: apiPercent,
489
+ ...(resetAt !== undefined ? { resetAt } : {}),
490
+ });
491
+ }
492
+
493
+ if (totalPercent !== undefined || customWindows.length > 0) {
494
+ const built = report(provider, "cursor:period-usage", {
495
+ ...(totalPercent !== undefined ? {
496
+ monthlyPercent: totalPercent,
497
+ ...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
498
+ } : {}),
499
+ ...(customWindows.length > 0 ? { customWindows } : {}),
500
+ updatedAt: Date.now(),
501
+ });
502
+ if (built) return { ...built, reverseEngineered: true };
503
+ }
504
+ }
505
+ }
506
+ } catch {
507
+ /* fall through */
508
+ }
509
+
510
+ // /api/usage/summary — same host, sometimes richer than /auth/usage for Team plans.
511
+ try {
512
+ const summaryRes = await fetch("https://api2.cursor.sh/api/usage/summary", {
513
+ headers: authHeaders,
514
+ redirect: "error",
515
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
516
+ });
517
+ if (summaryRes.ok) {
518
+ const body = asRecord(await readQuotaJson(summaryRes));
519
+ const individual = asRecord(body?.individualUsage);
520
+ const plan = asRecord(individual?.plan);
521
+ if (plan) {
522
+ const used = toFiniteNumber(plan.used);
523
+ const limit = toFiniteNumber(plan.limit);
524
+ const percent = normalizePercent(plan.totalPercentUsed)
525
+ ?? (used !== undefined && limit !== undefined && limit > 0
526
+ ? normalizePercent((used / limit) * 100)
527
+ : undefined);
528
+ if (percent !== undefined) {
529
+ const built = report(provider, "cursor:usage-summary", {
530
+ monthlyPercent: percent,
531
+ monthlyResetAt: normalizeResetAt(body?.billingCycleEnd),
532
+ updatedAt: Date.now(),
533
+ });
534
+ if (built) return { ...built, reverseEngineered: true };
535
+ }
536
+ }
537
+ }
538
+ } catch {
539
+ /* fall through to /auth/usage */
540
+ }
541
+
542
+ const response = await fetch("https://api2.cursor.sh/auth/usage", {
543
+ headers: authHeaders,
544
+ redirect: "error",
545
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
546
+ });
547
+ if (!response.ok) return null;
548
+ const body = asRecord(await readQuotaJson(response));
549
+ if (!body) return null;
550
+
551
+ // Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit.
552
+ let used: number | undefined;
553
+ let limit: number | undefined;
554
+ const gpt4 = asRecord(body["gpt-4"]);
555
+ if (gpt4) {
556
+ used = toFiniteNumber(gpt4.numRequests ?? gpt4.used);
557
+ limit = toFiniteNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests);
558
+ }
559
+ if (used === undefined || limit === undefined || limit <= 0) {
560
+ for (const [key, value] of Object.entries(body)) {
561
+ if (key === "startOfMonth" || key === "billingCycleStart") continue;
562
+ const bucket = asRecord(value);
563
+ if (!bucket) continue;
564
+ const bucketUsed = toFiniteNumber(bucket.numRequests ?? bucket.used);
565
+ const bucketLimit = toFiniteNumber(bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests);
566
+ if (bucketUsed !== undefined && bucketLimit !== undefined && bucketLimit > 0) {
567
+ used = bucketUsed;
568
+ limit = bucketLimit;
569
+ break;
570
+ }
571
+ }
572
+ }
573
+ if (used === undefined || limit === undefined || limit <= 0) return null;
574
+ const percent = normalizePercent((used / limit) * 100);
575
+ if (percent === undefined) return null;
576
+ const startOfMonth = normalizeResetAt(body.startOfMonth ?? body.billingCycleStart);
577
+ // Next reset = same day next month, computed in UTC to avoid timezone-shifted rollover.
578
+ const monthlyResetAt = startOfMonth !== undefined
579
+ ? (() => {
580
+ const start = new Date(startOfMonth);
581
+ return Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate());
582
+ })()
583
+ : undefined;
584
+ const built = report(provider, "cursor:auth-usage", {
585
+ monthlyPercent: percent,
586
+ ...(monthlyResetAt !== undefined ? { monthlyResetAt } : {}),
587
+ updatedAt: Date.now(),
588
+ });
589
+ return built ? { ...built, reverseEngineered: true } : null;
590
+ }