@iislee/opencodex 2.11.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 (476) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/assets/architecture.png +0 -0
  4. package/assets/banner.png +0 -0
  5. package/assets/claude-code-models.gif +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +451 -0
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-DTpMHS4F.js +67 -0
  10. package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
  11. package/gui/dist/favicon.png +0 -0
  12. package/gui/dist/icons.svg +24 -0
  13. package/gui/dist/index.html +25 -0
  14. package/gui/dist/logo.png +0 -0
  15. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  16. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/claude.svg +1 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/cursor.svg +2 -0
  25. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  26. package/gui/dist/provider-icons/discord.svg +1 -0
  27. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  28. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  29. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok-color.svg +1 -0
  34. package/gui/dist/provider-icons/grok.svg +1 -0
  35. package/gui/dist/provider-icons/groq-color.svg +1 -0
  36. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  37. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  38. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  39. package/gui/dist/provider-icons/kiro.svg +14 -0
  40. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  41. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  42. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  43. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  44. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  45. package/gui/dist/provider-icons/openai.svg +1 -0
  46. package/gui/dist/provider-icons/opencode.svg +1 -0
  47. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  48. package/gui/dist/provider-icons/pi.svg +21 -0
  49. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  50. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  51. package/gui/dist/provider-icons/telegram.svg +1 -0
  52. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  53. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  54. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  55. package/package.json +102 -0
  56. package/src/AGENTS.md +28 -0
  57. package/src/adapters/anthropic-image-guard.ts +251 -0
  58. package/src/adapters/anthropic-image-normalize.ts +518 -0
  59. package/src/adapters/anthropic.ts +1003 -0
  60. package/src/adapters/azure.ts +36 -0
  61. package/src/adapters/base.ts +72 -0
  62. package/src/adapters/client-fingerprint.ts +59 -0
  63. package/src/adapters/cursor/arg-codec.ts +38 -0
  64. package/src/adapters/cursor/arg-normalize.ts +104 -0
  65. package/src/adapters/cursor/cursor-errors.ts +165 -0
  66. package/src/adapters/cursor/discovery.ts +276 -0
  67. package/src/adapters/cursor/effort-map.ts +127 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +211 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/kv-store.ts +52 -0
  72. package/src/adapters/cursor/live-models.ts +153 -0
  73. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  74. package/src/adapters/cursor/live-transport.ts +1214 -0
  75. package/src/adapters/cursor/mcp-config.ts +42 -0
  76. package/src/adapters/cursor/mcp-manager.ts +333 -0
  77. package/src/adapters/cursor/message-mapper.ts +49 -0
  78. package/src/adapters/cursor/native-exec-common.ts +55 -0
  79. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  80. package/src/adapters/cursor/native-exec-fs.ts +329 -0
  81. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  82. package/src/adapters/cursor/native-exec-network.ts +43 -0
  83. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  84. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  85. package/src/adapters/cursor/native-exec.ts +576 -0
  86. package/src/adapters/cursor/protobuf-events.ts +563 -0
  87. package/src/adapters/cursor/protobuf-request.ts +714 -0
  88. package/src/adapters/cursor/request-builder.ts +255 -0
  89. package/src/adapters/cursor/thread-continuity.ts +67 -0
  90. package/src/adapters/cursor/tool-definitions.ts +505 -0
  91. package/src/adapters/cursor/transport-retry.ts +132 -0
  92. package/src/adapters/cursor/transport.ts +57 -0
  93. package/src/adapters/cursor/types.ts +52 -0
  94. package/src/adapters/cursor.ts +196 -0
  95. package/src/adapters/google-antigravity-replay.ts +303 -0
  96. package/src/adapters/google-antigravity-wire.ts +108 -0
  97. package/src/adapters/google-errors.ts +85 -0
  98. package/src/adapters/google-http.ts +100 -0
  99. package/src/adapters/google-tool-schema.ts +173 -0
  100. package/src/adapters/google-truncation.ts +13 -0
  101. package/src/adapters/google-wire-compiler.ts +232 -0
  102. package/src/adapters/google.ts +758 -0
  103. package/src/adapters/identity.ts +44 -0
  104. package/src/adapters/image.ts +23 -0
  105. package/src/adapters/kiro-constants.ts +16 -0
  106. package/src/adapters/kiro-errors.ts +197 -0
  107. package/src/adapters/kiro-events.ts +179 -0
  108. package/src/adapters/kiro-images.ts +129 -0
  109. package/src/adapters/kiro-retry.ts +312 -0
  110. package/src/adapters/kiro-thinking.ts +96 -0
  111. package/src/adapters/kiro-tool-fallback.ts +36 -0
  112. package/src/adapters/kiro-tools.ts +215 -0
  113. package/src/adapters/kiro-truncation.ts +33 -0
  114. package/src/adapters/kiro-wire.ts +129 -0
  115. package/src/adapters/kiro.ts +1898 -0
  116. package/src/adapters/mimo-free.ts +263 -0
  117. package/src/adapters/openai-chat.ts +1005 -0
  118. package/src/adapters/openai-responses.ts +1137 -0
  119. package/src/adapters/run-turn-queue.ts +114 -0
  120. package/src/adapters/tool-catalog-nudge.ts +71 -0
  121. package/src/adapters/upstream-http-error.ts +48 -0
  122. package/src/bridge.ts +1619 -0
  123. package/src/chat/inbound.ts +295 -0
  124. package/src/chat/outbound.ts +765 -0
  125. package/src/claude/agents-inject.ts +243 -0
  126. package/src/claude/alias.ts +149 -0
  127. package/src/claude/auth-detect.ts +229 -0
  128. package/src/claude/auth-mode-migration.ts +32 -0
  129. package/src/claude/auth-mode.ts +62 -0
  130. package/src/claude/context-windows.ts +189 -0
  131. package/src/claude/desktop-3p-guard.ts +35 -0
  132. package/src/claude/desktop-3p-paths.ts +84 -0
  133. package/src/claude/desktop-3p.ts +381 -0
  134. package/src/claude/desktop-health.ts +26 -0
  135. package/src/claude/desktop-profile.ts +263 -0
  136. package/src/claude/gateway-cache.ts +70 -0
  137. package/src/claude/inbound-debug.ts +163 -0
  138. package/src/claude/inbound.ts +509 -0
  139. package/src/claude/model-info.ts +151 -0
  140. package/src/claude/outbound.ts +872 -0
  141. package/src/cli/access.ts +108 -0
  142. package/src/cli/account-api.ts +268 -0
  143. package/src/cli/account-auth.ts +223 -0
  144. package/src/cli/account-extended.ts +350 -0
  145. package/src/cli/account.ts +275 -0
  146. package/src/cli/agent-driven.ts +70 -0
  147. package/src/cli/agent.ts +184 -0
  148. package/src/cli/catalog-prewarm.ts +27 -0
  149. package/src/cli/claude-desktop.ts +188 -0
  150. package/src/cli/claude.ts +286 -0
  151. package/src/cli/codex-shim-autorestore.ts +45 -0
  152. package/src/cli/combo.ts +119 -0
  153. package/src/cli/config-command.ts +145 -0
  154. package/src/cli/debug.ts +228 -0
  155. package/src/cli/doctor.ts +930 -0
  156. package/src/cli/export-command.ts +187 -0
  157. package/src/cli/help.ts +354 -0
  158. package/src/cli/index.ts +1113 -0
  159. package/src/cli/init.ts +224 -0
  160. package/src/cli/integrations.ts +142 -0
  161. package/src/cli/interactive-confirm.ts +133 -0
  162. package/src/cli/internal-dispatch.ts +20 -0
  163. package/src/cli/models-runtime.ts +212 -0
  164. package/src/cli/models.ts +336 -0
  165. package/src/cli/observe.ts +117 -0
  166. package/src/cli/opencode.ts +586 -0
  167. package/src/cli/pi.ts +188 -0
  168. package/src/cli/provider-runtime.ts +162 -0
  169. package/src/cli/provider.ts +463 -0
  170. package/src/cli/runtime-api.ts +325 -0
  171. package/src/cli/star-prompt.ts +155 -0
  172. package/src/cli/status-oauth.ts +78 -0
  173. package/src/cli/status.ts +321 -0
  174. package/src/cli/sync-cloud.ts +283 -0
  175. package/src/cli/system-command.ts +112 -0
  176. package/src/cli/tray-proxy.ts +52 -0
  177. package/src/cli/v2.ts +173 -0
  178. package/src/cli.ts +10 -0
  179. package/src/clients/config-export.ts +377 -0
  180. package/src/clients/effective-status.ts +385 -0
  181. package/src/clients/probes/agy.ts +55 -0
  182. package/src/clients/probes/cc-switch.ts +110 -0
  183. package/src/clients/probes/claude.ts +90 -0
  184. package/src/clients/probes/codex.ts +125 -0
  185. package/src/clients/probes/grok.ts +29 -0
  186. package/src/clients/probes/opencode.ts +109 -0
  187. package/src/clients/probes/paseo.ts +55 -0
  188. package/src/clients/probes/pi.ts +55 -0
  189. package/src/cloud/onedrive-auth.ts +666 -0
  190. package/src/cloud/onedrive-graph.ts +108 -0
  191. package/src/cloud/settings.ts +75 -0
  192. package/src/cloud/sync.ts +212 -0
  193. package/src/cloud/types.ts +56 -0
  194. package/src/cloud/vault.ts +89 -0
  195. package/src/codex/account-id.ts +34 -0
  196. package/src/codex/account-label.ts +34 -0
  197. package/src/codex/account-lifecycle.ts +55 -0
  198. package/src/codex/account-namespace-match.ts +63 -0
  199. package/src/codex/account-namespaces.ts +149 -0
  200. package/src/codex/account-pause.ts +20 -0
  201. package/src/codex/account-runtime-state.ts +31 -0
  202. package/src/codex/account-store.ts +517 -0
  203. package/src/codex/account-usability.ts +20 -0
  204. package/src/codex/app-server-processes.ts +756 -0
  205. package/src/codex/auth-api.ts +1540 -0
  206. package/src/codex/auth-collision.ts +107 -0
  207. package/src/codex/auth-context.ts +352 -0
  208. package/src/codex/autostart-health.ts +149 -0
  209. package/src/codex/catalog/aggregation.ts +378 -0
  210. package/src/codex/catalog/bundled.ts +251 -0
  211. package/src/codex/catalog/effort.ts +355 -0
  212. package/src/codex/catalog/metadata.ts +180 -0
  213. package/src/codex/catalog/parsing.ts +456 -0
  214. package/src/codex/catalog/provider-fetch.ts +902 -0
  215. package/src/codex/catalog/sync.ts +620 -0
  216. package/src/codex/catalog.ts +12 -0
  217. package/src/codex/data/upstream-models.json +830 -0
  218. package/src/codex/exec-invocation.ts +22 -0
  219. package/src/codex/features.ts +969 -0
  220. package/src/codex/history-migration-guardian.ts +102 -0
  221. package/src/codex/history-provider.ts +776 -0
  222. package/src/codex/home.ts +206 -0
  223. package/src/codex/inject.ts +799 -0
  224. package/src/codex/injected-marker.ts +72 -0
  225. package/src/codex/journal.ts +163 -0
  226. package/src/codex/main-account-cache.ts +32 -0
  227. package/src/codex/main-account.ts +40 -0
  228. package/src/codex/model-cache.ts +227 -0
  229. package/src/codex/paths.ts +65 -0
  230. package/src/codex/plugins-doctor.ts +242 -0
  231. package/src/codex/pool-rotation.ts +225 -0
  232. package/src/codex/project-config-warnings.ts +411 -0
  233. package/src/codex/quota.ts +411 -0
  234. package/src/codex/refresh.ts +53 -0
  235. package/src/codex/routing.ts +1477 -0
  236. package/src/codex/runtime.ts +538 -0
  237. package/src/codex/shim.ts +1189 -0
  238. package/src/codex/subagent-defaults.ts +550 -0
  239. package/src/codex/subagent-model-fallback.ts +469 -0
  240. package/src/codex/sync.ts +130 -0
  241. package/src/codex/warmup.ts +192 -0
  242. package/src/codex/websocket-registry.ts +100 -0
  243. package/src/combos/failover.ts +140 -0
  244. package/src/combos/index.ts +41 -0
  245. package/src/combos/request.ts +62 -0
  246. package/src/combos/resolve.ts +232 -0
  247. package/src/combos/types.ts +326 -0
  248. package/src/config.ts +2356 -0
  249. package/src/generated/jawcode-model-metadata.ts +104 -0
  250. package/src/github/star-state.ts +203 -0
  251. package/src/grok/inject.ts +545 -0
  252. package/src/grok/status.ts +121 -0
  253. package/src/grok/sync.ts +103 -0
  254. package/src/grok/usage-hook/report.mjs +348 -0
  255. package/src/grok/usage-hook.ts +278 -0
  256. package/src/images/artifacts.ts +516 -0
  257. package/src/images/fulfill-video.ts +163 -0
  258. package/src/images/fulfill.ts +149 -0
  259. package/src/images/index.ts +4 -0
  260. package/src/images/loop.ts +829 -0
  261. package/src/images/plan.ts +133 -0
  262. package/src/images/synthetic-tool.ts +133 -0
  263. package/src/images/types.ts +41 -0
  264. package/src/images/xai-client.ts +141 -0
  265. package/src/images/xai-video-client.ts +163 -0
  266. package/src/index.ts +22 -0
  267. package/src/lib/abort.ts +146 -0
  268. package/src/lib/admin-secrets.ts +25 -0
  269. package/src/lib/admission.ts +83 -0
  270. package/src/lib/app-owned-memory-stores.ts +173 -0
  271. package/src/lib/app-owned-memory.ts +265 -0
  272. package/src/lib/bounded-body.ts +202 -0
  273. package/src/lib/bun-binary-validator.d.mts +3 -0
  274. package/src/lib/bun-binary-validator.mjs +18 -0
  275. package/src/lib/bun-runtime.ts +71 -0
  276. package/src/lib/bun-stream-caps.ts +126 -0
  277. package/src/lib/config-ownership.ts +360 -0
  278. package/src/lib/crash-guard.ts +344 -0
  279. package/src/lib/debug-log-buffer.ts +83 -0
  280. package/src/lib/debug-settings.ts +108 -0
  281. package/src/lib/debug.ts +31 -0
  282. package/src/lib/destination-policy.ts +316 -0
  283. package/src/lib/errors.ts +364 -0
  284. package/src/lib/eventstream-decoder.ts +253 -0
  285. package/src/lib/gcp-adc.ts +341 -0
  286. package/src/lib/injection-debug-log.ts +58 -0
  287. package/src/lib/open-url.ts +25 -0
  288. package/src/lib/pinned-http.ts +151 -0
  289. package/src/lib/privacy.ts +20 -0
  290. package/src/lib/process-control.ts +165 -0
  291. package/src/lib/provider-outbound.ts +170 -0
  292. package/src/lib/provider-url.ts +14 -0
  293. package/src/lib/proxy-env.ts +18 -0
  294. package/src/lib/redact.ts +105 -0
  295. package/src/lib/retry-after.ts +55 -0
  296. package/src/lib/service-secrets.ts +25 -0
  297. package/src/lib/shadow-call.ts +30 -0
  298. package/src/lib/sidecar-tracker.ts +52 -0
  299. package/src/lib/sse-decoder.ts +323 -0
  300. package/src/lib/state-store-registrations.ts +109 -0
  301. package/src/lib/state-store-sweeper.ts +184 -0
  302. package/src/lib/test-home-guard.ts +90 -0
  303. package/src/lib/token-estimate.ts +69 -0
  304. package/src/lib/translator-budget.ts +356 -0
  305. package/src/lib/upstream-retry.ts +239 -0
  306. package/src/lib/win-exec.ts +115 -0
  307. package/src/lib/win-paths.ts +68 -0
  308. package/src/lib/windows-elevation.ts +705 -0
  309. package/src/lib/windows-secret-acl.ts +514 -0
  310. package/src/lib/winsw.ts +375 -0
  311. package/src/oauth/anthropic-routing.ts +594 -0
  312. package/src/oauth/anthropic.ts +177 -0
  313. package/src/oauth/callback-server.ts +294 -0
  314. package/src/oauth/chatgpt.ts +150 -0
  315. package/src/oauth/cursor.ts +211 -0
  316. package/src/oauth/github-copilot.ts +428 -0
  317. package/src/oauth/google-antigravity.ts +230 -0
  318. package/src/oauth/health.ts +399 -0
  319. package/src/oauth/index.ts +1174 -0
  320. package/src/oauth/key-providers.ts +108 -0
  321. package/src/oauth/kimi.ts +213 -0
  322. package/src/oauth/kiro-credentials.ts +726 -0
  323. package/src/oauth/kiro.ts +577 -0
  324. package/src/oauth/local-token-detect.ts +121 -0
  325. package/src/oauth/log.ts +48 -0
  326. package/src/oauth/login-cli.ts +163 -0
  327. package/src/oauth/pkce.ts +15 -0
  328. package/src/oauth/store.ts +630 -0
  329. package/src/oauth/token-guardian.ts +303 -0
  330. package/src/oauth/types.ts +62 -0
  331. package/src/oauth/xai.ts +241 -0
  332. package/src/pi/extensions.ts +72 -0
  333. package/src/pi/home.ts +42 -0
  334. package/src/pi/index.ts +40 -0
  335. package/src/pi/models.ts +278 -0
  336. package/src/pi/packages.ts +219 -0
  337. package/src/pi/settings.ts +365 -0
  338. package/src/pi/status.ts +68 -0
  339. package/src/pi/sync.ts +75 -0
  340. package/src/providers/alibaba-region-backup.ts +75 -0
  341. package/src/providers/alibaba-region-migration.ts +156 -0
  342. package/src/providers/alibaba-region-startup.ts +36 -0
  343. package/src/providers/antigravity-models.ts +205 -0
  344. package/src/providers/api-keys.ts +140 -0
  345. package/src/providers/base-url-choices.ts +64 -0
  346. package/src/providers/context-cap.ts +65 -0
  347. package/src/providers/derive.ts +339 -0
  348. package/src/providers/free-directory.ts +184 -0
  349. package/src/providers/github-copilot-transport.ts +56 -0
  350. package/src/providers/key-failover.ts +203 -0
  351. package/src/providers/kiro-models.ts +67 -0
  352. package/src/providers/label.ts +19 -0
  353. package/src/providers/model-discovery.ts +356 -0
  354. package/src/providers/openai-sidecar.ts +175 -0
  355. package/src/providers/openai-tier-startup.ts +27 -0
  356. package/src/providers/openai-tiers.ts +301 -0
  357. package/src/providers/openai-virtual-models.ts +82 -0
  358. package/src/providers/openrouter-routing.ts +102 -0
  359. package/src/providers/provider-id-rewrite.ts +150 -0
  360. package/src/providers/quota.ts +1260 -0
  361. package/src/providers/registry.ts +1600 -0
  362. package/src/providers/slug-codec.ts +67 -0
  363. package/src/providers/xai-transport.ts +141 -0
  364. package/src/reasoning-effort.ts +135 -0
  365. package/src/responses/compaction.ts +117 -0
  366. package/src/responses/parser.ts +656 -0
  367. package/src/responses/reasoning-envelope.ts +52 -0
  368. package/src/responses/schema.ts +159 -0
  369. package/src/responses/spill-store.ts +394 -0
  370. package/src/responses/state.ts +895 -0
  371. package/src/responses/tool-groups.ts +19 -0
  372. package/src/router.ts +425 -0
  373. package/src/server/adapter-resolve.ts +80 -0
  374. package/src/server/auth-cors.ts +530 -0
  375. package/src/server/chat-completions.ts +368 -0
  376. package/src/server/claude-messages.ts +914 -0
  377. package/src/server/effort-policy.ts +172 -0
  378. package/src/server/gui-static.ts +123 -0
  379. package/src/server/image-retry.ts +42 -0
  380. package/src/server/images.ts +476 -0
  381. package/src/server/index.ts +1126 -0
  382. package/src/server/lifecycle.ts +227 -0
  383. package/src/server/live.ts +598 -0
  384. package/src/server/management/agent-settings-routes.ts +1169 -0
  385. package/src/server/management/api-access.ts +141 -0
  386. package/src/server/management/api-key-usage.ts +167 -0
  387. package/src/server/management/body.ts +35 -0
  388. package/src/server/management/clients-routes.ts +63 -0
  389. package/src/server/management/cloud-sync-routes.ts +266 -0
  390. package/src/server/management/combo-routes.ts +220 -0
  391. package/src/server/management/config-routes.ts +422 -0
  392. package/src/server/management/context.ts +31 -0
  393. package/src/server/management/logs-usage-routes.ts +707 -0
  394. package/src/server/management/model-routes.ts +525 -0
  395. package/src/server/management/oauth-account-routes.ts +563 -0
  396. package/src/server/management/provider-routes.ts +556 -0
  397. package/src/server/management/shared.ts +277 -0
  398. package/src/server/management/sidebar-routes.ts +90 -0
  399. package/src/server/management/system-restart.ts +179 -0
  400. package/src/server/management/system-routes.ts +117 -0
  401. package/src/server/management/usage-summary-cache.ts +86 -0
  402. package/src/server/management-api.ts +215 -0
  403. package/src/server/management-auth.ts +267 -0
  404. package/src/server/memory-watchdog.ts +156 -0
  405. package/src/server/port-reclaim.ts +307 -0
  406. package/src/server/ports.ts +116 -0
  407. package/src/server/proxy-liveness.ts +201 -0
  408. package/src/server/relay-eager.ts +313 -0
  409. package/src/server/relay.ts +1049 -0
  410. package/src/server/request-decompress.ts +132 -0
  411. package/src/server/request-log-conversation.ts +168 -0
  412. package/src/server/request-log.ts +1046 -0
  413. package/src/server/responses/collaboration.ts +354 -0
  414. package/src/server/responses/compact.ts +384 -0
  415. package/src/server/responses/core.ts +2758 -0
  416. package/src/server/responses/encrypted-payload.ts +308 -0
  417. package/src/server/responses/fetch-helpers.ts +157 -0
  418. package/src/server/responses/passthrough-error.ts +78 -0
  419. package/src/server/responses/terminal-guard.ts +230 -0
  420. package/src/server/responses/upstream-error.ts +48 -0
  421. package/src/server/responses-image-gen-repair.ts +132 -0
  422. package/src/server/responses-item-id-repair.ts +224 -0
  423. package/src/server/responses.ts +9 -0
  424. package/src/server/search.ts +136 -0
  425. package/src/server/sse-payload-rewrite.ts +175 -0
  426. package/src/server/startup-action-control.ts +308 -0
  427. package/src/server/startup-health-cache.ts +113 -0
  428. package/src/server/system-env.ts +413 -0
  429. package/src/server/windows-tcp-drop.ts +184 -0
  430. package/src/server/windows-tray-control.ts +41 -0
  431. package/src/server/ws-bridge.ts +471 -0
  432. package/src/service.ts +2554 -0
  433. package/src/stall-timeout.ts +20 -0
  434. package/src/storage/cleanup-job.ts +57 -0
  435. package/src/storage/cleanup.ts +3085 -0
  436. package/src/storage/policy-job.ts +457 -0
  437. package/src/storage/policy-scheduler.ts +40 -0
  438. package/src/storage/policy-worker.ts +59 -0
  439. package/src/storage/policy.ts +527 -0
  440. package/src/storage/restore-job.ts +299 -0
  441. package/src/storage/restore-worker.ts +58 -0
  442. package/src/storage/scanner.ts +238 -0
  443. package/src/storage/storage-mutation-coordinator.ts +139 -0
  444. package/src/storage/worker-lifecycle.ts +215 -0
  445. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  446. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  447. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  448. package/src/tray/assets/opencodex-tray.png +0 -0
  449. package/src/tray/windows-tray.ps1 +290 -0
  450. package/src/tray/windows.ts +730 -0
  451. package/src/types.ts +1237 -0
  452. package/src/update/badge.ts +72 -0
  453. package/src/update/index.ts +407 -0
  454. package/src/update/job.ts +1520 -0
  455. package/src/update/notify.ts +257 -0
  456. package/src/update/npm-invocation.d.mts +23 -0
  457. package/src/update/npm-invocation.mjs +94 -0
  458. package/src/update/tray-update-plan.d.mts +18 -0
  459. package/src/update/tray-update-plan.mjs +38 -0
  460. package/src/usage/cost.ts +0 -0
  461. package/src/usage/debug.ts +97 -0
  462. package/src/usage/expected-prices.ts +164 -0
  463. package/src/usage/log.ts +658 -0
  464. package/src/usage/summary.ts +585 -0
  465. package/src/usage/totals.ts +14 -0
  466. package/src/vision/anthropic-describe.ts +185 -0
  467. package/src/vision/describe.ts +125 -0
  468. package/src/vision/index.ts +467 -0
  469. package/src/web-search/anthropic-executor.ts +189 -0
  470. package/src/web-search/executor.ts +105 -0
  471. package/src/web-search/format-result.ts +89 -0
  472. package/src/web-search/index.ts +196 -0
  473. package/src/web-search/loop.ts +664 -0
  474. package/src/web-search/parse.ts +220 -0
  475. package/src/web-search/progress-stream.ts +342 -0
  476. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,1260 @@
1
+ import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api";
2
+ import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
3
+ import { resolveEnvValue } from "../config";
4
+ import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
5
+ import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
6
+ import { antigravityUserAgent } from "../adapters/client-fingerprint";
7
+ import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
8
+ import type { OcxConfig, OcxProviderConfig } from "../types";
9
+ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
10
+ import {
11
+ captureConfigGeneration,
12
+ sweepExpiredOnWrite,
13
+ type GenerationContext,
14
+ } from "../lib/state-store-sweeper";
15
+
16
+ /** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
17
+ const ACCOUNT_TOKEN_SKEW_MS = 60_000;
18
+
19
+ const CACHE_TTL_MS = 5 * 60_000;
20
+ const REQUEST_TIMEOUT_MS = 8_000;
21
+ const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
22
+ const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
23
+ /** Keep a failed probe's previous row at most this long before dropping it. */
24
+ const LAST_GOOD_MAX_AGE_MS = 30 * 60_000;
25
+
26
+ export interface ProviderQuotaWindow {
27
+ label: string;
28
+ percent: number;
29
+ resetAt?: number;
30
+ }
31
+
32
+ export interface ProviderQuota {
33
+ fiveHourPercent?: number;
34
+ fiveHourResetAt?: number;
35
+ weeklyPercent?: number;
36
+ weeklyResetAt?: number;
37
+ monthlyPercent?: number;
38
+ monthlyResetAt?: number;
39
+ customWindows?: ProviderQuotaWindow[];
40
+ updatedAt: number;
41
+ }
42
+
43
+ export interface ProviderQuotaReport {
44
+ provider: string;
45
+ label: string;
46
+ source: string;
47
+ quota: ProviderQuota;
48
+ updatedAt: number;
49
+ reverseEngineered?: boolean;
50
+ }
51
+
52
+ export interface ProviderQuotaResponse {
53
+ generatedAt: number;
54
+ reports: ProviderQuotaReport[];
55
+ }
56
+
57
+ let cache: { key: string; ts: number; response: ProviderQuotaResponse } | null = null;
58
+ const inflight = new Map<string, { epoch: number; promise: Promise<ProviderQuotaResponse> }>();
59
+ /** Bumped on cache clear and on force-refresh start; stale-epoch probes lose commit authority. */
60
+ let invalidationEpoch = 0;
61
+
62
+ /** Invalidate the report cache (e.g. after switching a provider's active account). */
63
+ export function clearProviderQuotaCache(): void {
64
+ cache = null;
65
+ invalidationEpoch += 1;
66
+ }
67
+
68
+ function cacheKey(config: OcxConfig): string {
69
+ const providers = Object.entries(config.providers)
70
+ .map(([name, provider]) => `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}`)
71
+ .sort()
72
+ .join("|");
73
+ return `${config.defaultProvider}|${config.activeCodexAccountId ?? ""}|${providers}`;
74
+ }
75
+
76
+ function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota {
77
+ if (!quota) return false;
78
+ return typeof quota.fiveHourPercent === "number"
79
+ || typeof quota.weeklyPercent === "number"
80
+ || typeof quota.monthlyPercent === "number"
81
+ || !!quota.customWindows?.some(window => typeof window.percent === "number");
82
+ }
83
+
84
+ function providerLabel(providerId: string): string {
85
+ return getProviderRegistryEntry(providerId)?.label ?? providerId;
86
+ }
87
+
88
+ function normalizeResetAt(value: unknown): number | undefined {
89
+ if (typeof value === "number" && Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
90
+ if (typeof value === "string" && value.trim()) {
91
+ const trimmed = value.trim();
92
+ // Cursor Connect RPC returns billingCycleEnd as a unix-ms decimal string ("1771077734000").
93
+ // Date.parse treats that as invalid; numeric epoch strings must be handled explicitly.
94
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
95
+ const numeric = Number(trimmed);
96
+ if (Number.isFinite(numeric)) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
97
+ }
98
+ const parsed = Date.parse(trimmed);
99
+ return Number.isFinite(parsed) ? parsed : undefined;
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ function toFiniteNumber(value: unknown): number | undefined {
105
+ if (typeof value === "number" && Number.isFinite(value)) return value;
106
+ if (typeof value === "string" && value.trim()) {
107
+ const parsed = Number(value);
108
+ return Number.isFinite(parsed) ? parsed : undefined;
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ function normalizePercent(value: unknown): number | undefined {
114
+ const numeric = toFiniteNumber(value);
115
+ return numeric === undefined ? undefined : Math.max(0, Math.min(100, numeric));
116
+ }
117
+
118
+ function asRecord(value: unknown): Record<string, unknown> | null {
119
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
120
+ }
121
+
122
+ function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean {
123
+ return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider);
124
+ }
125
+
126
+ function report(provider: string, source: string, quota: ProviderQuota): ProviderQuotaReport | null {
127
+ if (!hasQuotaRows(quota)) return null;
128
+ return {
129
+ provider,
130
+ label: providerLabel(provider),
131
+ source,
132
+ quota,
133
+ updatedAt: quota.updatedAt,
134
+ };
135
+ }
136
+
137
+ async function fetchChatGptForwardQuota(
138
+ config: OcxConfig,
139
+ provider: string,
140
+ providerConfig: OcxProviderConfig,
141
+ forceRefresh: boolean,
142
+ ): Promise<ProviderQuotaReport | null> {
143
+ if (providerCodexAccountMode(provider, providerConfig) === "direct") {
144
+ const main = await fetchMainAccountInfo(forceRefresh);
145
+ const quota = main.quota ? { ...main.quota, updatedAt: Date.now() } as ProviderQuota : null;
146
+ return quota ? report(provider, "chatgpt:wham", quota) : null;
147
+ }
148
+ const accounts = await listCodexAuthAccounts(config, forceRefresh);
149
+ const activeId = config.activeCodexAccountId || MAIN_CODEX_ACCOUNT_ID;
150
+ const active = accounts.find(account => account.id === activeId)
151
+ ?? accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)
152
+ ?? accounts[0];
153
+ const quota = active?.quota ? { ...active.quota, updatedAt: active.quota.updatedAt ?? Date.now() } as ProviderQuota : null;
154
+ return quota ? report(provider, "chatgpt:wham", quota) : null;
155
+ }
156
+
157
+ function centsValue(value: unknown): number | undefined {
158
+ const rec = asRecord(value);
159
+ return rec ? toFiniteNumber(rec.val) : undefined;
160
+ }
161
+
162
+ /** Money fields may be `{ val: number }` (cents or dollars) or bare numbers. */
163
+ function moneyValue(value: unknown): number | undefined {
164
+ return centsValue(value) ?? toFiniteNumber(value);
165
+ }
166
+
167
+ /** Grok CLI billing + rate-limit windows used for plan badges and quota bars. */
168
+ export interface XaiAccountUsage {
169
+ quota: ProviderQuota | null;
170
+ plan: string;
171
+ }
172
+
173
+ export interface XaiRateLimitWindows {
174
+ /** Used% of the CLI token window (shared SuperGrok compute pool best-effort). */
175
+ tokenUsedPercent?: number;
176
+ requestUsedPercent?: number;
177
+ tokensLimit?: number;
178
+ tokensRemaining?: number;
179
+ requestsLimit?: number;
180
+ requestsRemaining?: number;
181
+ }
182
+
183
+ /**
184
+ * Infer a cockpit-style plan label from Grok CLI billing.
185
+ * Consumer web task counts (high-freq / standard) are not on this OAuth surface.
186
+ */
187
+ export function inferXaiPlan(
188
+ monthlyLimit: number | undefined,
189
+ weeklyLimit: number | undefined,
190
+ hasGrokCodeAccess: boolean | undefined,
191
+ ): string {
192
+ if (typeof monthlyLimit === "number" && monthlyLimit >= 15_000) return "Grok Pro";
193
+ if (typeof monthlyLimit === "number" && monthlyLimit > 0) return "SuperGrok";
194
+ if (typeof weeklyLimit === "number" && weeklyLimit > 0) return "SuperGrok";
195
+ if (hasGrokCodeAccess) return "Grok Build";
196
+ // Prefer "Grok Free" so GUI Codex plan filters (isThirtyDayOnlyPlan) do not strip weekly bars.
197
+ return "Grok Free";
198
+ }
199
+
200
+ function usedPercentFromLimitRemaining(limit: number | undefined, remaining: number | undefined): number | undefined {
201
+ if (limit === undefined || remaining === undefined || limit <= 0) return undefined;
202
+ const used = Math.max(0, limit - remaining);
203
+ return normalizePercent((used / limit) * 100);
204
+ }
205
+
206
+ /**
207
+ * Merge monthly billing + credits-format weekly billing (CPA-Manager-Plus / CLIProxyAPI ecosystem).
208
+ * Weekly source of truth: GET /v1/billing?format=credits → creditUsagePercent + productUsage.
209
+ * Monthly: GET /v1/billing → monthlyLimit/used.
210
+ */
211
+ export function parseXaiBillingPayload(
212
+ monthlyBillingBody: unknown,
213
+ userBody?: unknown,
214
+ rateLimits?: XaiRateLimitWindows | null,
215
+ creditsBillingBody?: unknown,
216
+ now = Date.now(),
217
+ ): XaiAccountUsage | null {
218
+ const monthlyBody = asRecord(monthlyBillingBody);
219
+ const monthlyConfig = asRecord(monthlyBody?.config) ?? monthlyBody;
220
+ const creditsBody = asRecord(creditsBillingBody);
221
+ const creditsConfig = asRecord(creditsBody?.config) ?? creditsBody;
222
+
223
+ if (!monthlyConfig && !creditsConfig && !rateLimits) return null;
224
+
225
+ const monthlyLimit = monthlyConfig
226
+ ? moneyValue(monthlyConfig.monthlyLimit ?? monthlyConfig.monthly_limit)
227
+ : undefined;
228
+ const monthlyUsed = monthlyConfig
229
+ ? moneyValue(monthlyConfig.used ?? monthlyConfig.monthlyUsed ?? monthlyConfig.monthly_used)
230
+ : undefined;
231
+ const monthlyResetAt = monthlyConfig
232
+ ? normalizeResetAt(monthlyConfig.billingPeriodEnd ?? monthlyConfig.billing_period_end)
233
+ : undefined;
234
+
235
+ // Dollar weekly fields (legacy / rare) on either payload.
236
+ const dollarWeeklyLimit = moneyValue(
237
+ creditsConfig?.weeklyLimit ?? creditsConfig?.weekly_limit
238
+ ?? monthlyConfig?.weeklyLimit ?? monthlyConfig?.weekly_limit,
239
+ );
240
+ const dollarWeeklyUsed = moneyValue(
241
+ creditsConfig?.weeklyUsed ?? creditsConfig?.weekly_used
242
+ ?? monthlyConfig?.weeklyUsed ?? monthlyConfig?.weekly_used,
243
+ );
244
+
245
+ // Credits format: unified SuperGrok weekly pool (CPA xai_probe.go parseXAIBillingSummary).
246
+ const creditUsagePercent = creditsConfig
247
+ ? normalizePercent(creditsConfig.creditUsagePercent ?? creditsConfig.credit_usage_percent)
248
+ : undefined;
249
+ const currentPeriod = asRecord(creditsConfig?.currentPeriod ?? creditsConfig?.current_period);
250
+ const periodType = typeof currentPeriod?.type === "string" ? currentPeriod.type.toLowerCase() : "";
251
+ const weeklyResetAt = normalizeResetAt(
252
+ currentPeriod?.end
253
+ ?? creditsConfig?.billingPeriodEnd
254
+ ?? creditsConfig?.billing_period_end,
255
+ );
256
+
257
+ const user = asRecord(userBody);
258
+ const hasGrokCodeAccess = user?.hasGrokCodeAccess === true;
259
+ const plan = inferXaiPlan(monthlyLimit, dollarWeeklyLimit ?? (creditUsagePercent !== undefined ? 1 : undefined), hasGrokCodeAccess);
260
+
261
+ let monthlyPercent: number | undefined;
262
+ if (monthlyLimit !== undefined && monthlyLimit > 0 && monthlyUsed !== undefined) {
263
+ monthlyPercent = normalizePercent((monthlyUsed / monthlyLimit) * 100);
264
+ }
265
+
266
+ let weeklyPercent: number | undefined;
267
+ if (dollarWeeklyLimit !== undefined && dollarWeeklyLimit > 0 && dollarWeeklyUsed !== undefined) {
268
+ weeklyPercent = normalizePercent((dollarWeeklyUsed / dollarWeeklyLimit) * 100);
269
+ } else if (creditUsagePercent !== undefined) {
270
+ // CPA/CLIProxyAPI: primary weekly pool from billing?format=credits
271
+ weeklyPercent = creditUsagePercent;
272
+ } else if (rateLimits?.tokenUsedPercent !== undefined) {
273
+ // Fallback: CLI token window when credits omits creditUsagePercent (seen on some SuperGrok accounts).
274
+ weeklyPercent = rateLimits.tokenUsedPercent;
275
+ } else if (periodType.includes("weekly") && creditUsagePercent === undefined) {
276
+ // Credits acknowledges a weekly period but gives no %. Show 0 rather than hiding the bar.
277
+ weeklyPercent = 0;
278
+ }
279
+
280
+ const customWindows: ProviderQuotaWindow[] = [];
281
+ // productUsage from credits format (e.g. GrokBuild) — same labels as cockpit/CPA.
282
+ const productUsage = creditsConfig?.productUsage ?? creditsConfig?.product_usage;
283
+ if (Array.isArray(productUsage)) {
284
+ for (const raw of productUsage) {
285
+ const item = asRecord(raw);
286
+ if (!item) continue;
287
+ const product = typeof item.product === "string" && item.product.trim()
288
+ ? item.product.trim()
289
+ : "Product";
290
+ const pct = normalizePercent(item.usagePercent ?? item.usage_percent);
291
+ if (pct === undefined) continue;
292
+ customWindows.push({
293
+ label: product,
294
+ percent: pct,
295
+ ...(weeklyResetAt !== undefined ? { resetAt: weeklyResetAt } : {}),
296
+ });
297
+ }
298
+ }
299
+ if (rateLimits?.requestUsedPercent !== undefined) {
300
+ customWindows.push({
301
+ label: "Request window",
302
+ percent: rateLimits.requestUsedPercent,
303
+ });
304
+ }
305
+
306
+ if (
307
+ monthlyPercent === undefined
308
+ && weeklyPercent === undefined
309
+ && customWindows.length === 0
310
+ ) {
311
+ return { quota: null, plan };
312
+ }
313
+
314
+ const quota: ProviderQuota = {
315
+ ...(monthlyPercent !== undefined ? { monthlyPercent } : {}),
316
+ ...(monthlyResetAt !== undefined ? { monthlyResetAt } : {}),
317
+ ...(weeklyPercent !== undefined ? { weeklyPercent } : {}),
318
+ ...(weeklyResetAt !== undefined ? { weeklyResetAt } : {}),
319
+ ...(customWindows.length > 0 ? { customWindows } : {}),
320
+ updatedAt: now,
321
+ };
322
+ return { quota, plan };
323
+ }
324
+
325
+ function xaiGrokCliHeaders(accessToken: string): Record<string, string> {
326
+ // Match CPA-Manager-Plus / Grok shell so cli-chat-proxy accepts billing+chat probes.
327
+ return {
328
+ Accept: "*/*",
329
+ Authorization: `Bearer ${accessToken}`,
330
+ "Content-Type": "application/json",
331
+ "User-Agent": "grok-pager/0.2.101 grok-shell/0.2.101 (macos; aarch64)",
332
+ "x-grok-client-identifier": "grok-shell",
333
+ "x-grok-client-version": "0.2.101",
334
+ "X-XAI-Token-Auth": "xai-grok-cli",
335
+ };
336
+ }
337
+
338
+ async function fetchXaiUserProfile(accessToken: string): Promise<Record<string, unknown> | null> {
339
+ try {
340
+ const response = await fetch("https://cli-chat-proxy.grok.com/v1/user", {
341
+ headers: xaiGrokCliHeaders(accessToken),
342
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
343
+ });
344
+ if (!response.ok) return null;
345
+ return asRecord(await response.json().catch(() => null));
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ /** Minimal chat completion to read x-ratelimit-*-{tokens,requests} (weekly-ish CLI pool). */
352
+ export async function probeXaiRateLimitWindows(accessToken: string): Promise<XaiRateLimitWindows | null> {
353
+ try {
354
+ const response = await fetch("https://cli-chat-proxy.grok.com/v1/chat/completions", {
355
+ method: "POST",
356
+ headers: xaiGrokCliHeaders(accessToken),
357
+ body: JSON.stringify({
358
+ model: "grok-4.5",
359
+ stream: false,
360
+ max_tokens: 1,
361
+ messages: [{ role: "user", content: "ping" }],
362
+ }),
363
+ signal: AbortSignal.timeout(18_000),
364
+ });
365
+ // Headers are useful even on some non-2xx free-usage errors.
366
+ const limitTok = toFiniteNumber(response.headers.get("x-ratelimit-limit-tokens"));
367
+ const remainTok = toFiniteNumber(response.headers.get("x-ratelimit-remaining-tokens"));
368
+ const limitReq = toFiniteNumber(response.headers.get("x-ratelimit-limit-requests"));
369
+ const remainReq = toFiniteNumber(response.headers.get("x-ratelimit-remaining-requests"));
370
+ const tokenUsedPercent = usedPercentFromLimitRemaining(limitTok, remainTok);
371
+ const requestUsedPercent = usedPercentFromLimitRemaining(limitReq, remainReq);
372
+ if (
373
+ tokenUsedPercent === undefined
374
+ && requestUsedPercent === undefined
375
+ && limitTok === undefined
376
+ && limitReq === undefined
377
+ ) {
378
+ return null;
379
+ }
380
+ return {
381
+ ...(tokenUsedPercent !== undefined ? { tokenUsedPercent } : {}),
382
+ ...(requestUsedPercent !== undefined ? { requestUsedPercent } : {}),
383
+ ...(limitTok !== undefined ? { tokensLimit: limitTok } : {}),
384
+ ...(remainTok !== undefined ? { tokensRemaining: remainTok } : {}),
385
+ ...(limitReq !== undefined ? { requestsLimit: limitReq } : {}),
386
+ ...(remainReq !== undefined ? { requestsRemaining: remainReq } : {}),
387
+ };
388
+ } catch {
389
+ return null;
390
+ }
391
+ }
392
+
393
+ async function fetchXaiJson(url: string, accessToken: string): Promise<unknown | null> {
394
+ try {
395
+ const response = await fetch(url, {
396
+ headers: xaiGrokCliHeaders(accessToken),
397
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
398
+ });
399
+ if (!response.ok) return null;
400
+ return await response.json().catch(() => null);
401
+ } catch {
402
+ return null;
403
+ }
404
+ }
405
+
406
+ async function fetchXaiBillingUsage(accessToken: string): Promise<XaiAccountUsage | null> {
407
+ // CPA / CLIProxyAPI ecosystem: weekly credits + monthly dollars are separate GETs.
408
+ const [monthlyBilling, creditsBilling, user] = await Promise.all([
409
+ fetchXaiJson("https://cli-chat-proxy.grok.com/v1/billing", accessToken),
410
+ fetchXaiJson("https://cli-chat-proxy.grok.com/v1/billing?format=credits", accessToken),
411
+ fetchXaiUserProfile(accessToken),
412
+ ]);
413
+ // Rate-limit probe when credits format is missing OR lacks a usable weekly % / product split.
414
+ // (Some SuperGrok accounts return currentPeriod weekly but omit creditUsagePercent.)
415
+ let rateLimits: XaiRateLimitWindows | null = null;
416
+ const creditsConfig = asRecord(asRecord(creditsBilling)?.config);
417
+ const hasCreditsWeeklyPct = creditsConfig != null && (
418
+ creditsConfig.creditUsagePercent !== undefined
419
+ || creditsConfig.credit_usage_percent !== undefined
420
+ || (Array.isArray(creditsConfig.productUsage) && creditsConfig.productUsage.length > 0)
421
+ || (Array.isArray(creditsConfig.product_usage) && creditsConfig.product_usage.length > 0)
422
+ );
423
+ if (!hasCreditsWeeklyPct) {
424
+ rateLimits = await probeXaiRateLimitWindows(accessToken);
425
+ }
426
+ return parseXaiBillingPayload(monthlyBilling, user, rateLimits, creditsBilling);
427
+ }
428
+
429
+ async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | null> {
430
+ let accessToken: string;
431
+ try {
432
+ accessToken = await getValidAccessToken("xai");
433
+ } catch {
434
+ return null;
435
+ }
436
+ const usage = await fetchXaiBillingUsage(accessToken);
437
+ if (!usage?.quota) return null;
438
+ // Seed the active multiauth slot so the account list can reuse this probe.
439
+ const activeId = getAccountSet("xai")?.activeAccountId;
440
+ if (activeId) {
441
+ accountQuotaCache.set(accountCacheKey("xai", activeId), {
442
+ ts: Date.now(),
443
+ quota: usage.quota,
444
+ plan: usage.plan,
445
+ });
446
+ }
447
+ return report(provider, "xai:grok-billing", usage.quota);
448
+ }
449
+
450
+ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
451
+ const rec = asRecord(value);
452
+ if (!rec) return null;
453
+ const percent = normalizePercent(rec.utilization);
454
+ const resetAt = normalizeResetAt(rec.resets_at);
455
+ if (percent === undefined && resetAt === undefined) return null;
456
+ return { percent, resetAt };
457
+ }
458
+
459
+ /** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
460
+ const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
461
+
462
+ async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
463
+ const joinable = anthropicUsageInflight.get(accessToken);
464
+ if (joinable) return joinable;
465
+
466
+ const probe = (async (): Promise<ProviderQuota | null> => {
467
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
468
+ headers: {
469
+ Accept: "application/json, text/plain, */*",
470
+ "Content-Type": "application/json",
471
+ "User-Agent": "claude-cli/2.1.63 (external, cli)",
472
+ "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",
473
+ Authorization: `Bearer ${accessToken}`,
474
+ },
475
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
476
+ });
477
+ if (!response.ok) return null;
478
+ const body = asRecord(await response.json().catch(() => null));
479
+ if (!body) return null;
480
+ const fiveHour = parseClaudeBucket(body.five_hour);
481
+ const sevenDay = parseClaudeBucket(body.seven_day);
482
+ const opus = parseClaudeBucket(body.seven_day_opus);
483
+ const sonnet = parseClaudeBucket(body.seven_day_sonnet);
484
+ const customWindows: ProviderQuotaWindow[] = [];
485
+ if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
486
+ if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
487
+ const quota: ProviderQuota = {
488
+ // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly
489
+ // rows: report it in the canonical fields so the dashboard renders it with the standard
490
+ // "5-hour limit" label and ordering instead of as a generic extra window.
491
+ ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
492
+ ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
493
+ ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
494
+ ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
495
+ ...(customWindows.length > 0 ? { customWindows } : {}),
496
+ updatedAt: Date.now(),
497
+ };
498
+ // Empty / schema-changed payloads must not cache as "success with no bars".
499
+ return hasQuotaRows(quota) ? quota : null;
500
+ })().finally(() => {
501
+ if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken);
502
+ });
503
+ anthropicUsageInflight.set(accessToken, probe);
504
+ return probe;
505
+ }
506
+
507
+ async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaReport | null> {
508
+ // Capture the account we intend to probe before awaiting — a mid-flight active
509
+ // switch must not seed the wrong account's cache with this response.
510
+ const probedAccountId = getAccountSet("anthropic")?.activeAccountId;
511
+ const probedAccountKey = probedAccountId ? accountCacheKey("anthropic", probedAccountId) : null;
512
+ const writerGeneration = captureConfigGeneration();
513
+ let accessToken: string;
514
+ try {
515
+ accessToken = await getValidAccessToken("anthropic");
516
+ } catch {
517
+ return null;
518
+ }
519
+ const quota = await fetchAnthropicUsageQuota(accessToken);
520
+ if (!quota) return null;
521
+ // Share the active-account probe with the per-account cache so Providers-page
522
+ // loads do not double-hit Anthropic's rate-limited usage endpoint.
523
+ if (probedAccountId && probedAccountKey) {
524
+ const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken;
525
+ if (stillOwnsToken && mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
526
+ accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota });
527
+ }
528
+ }
529
+ return report(provider, "anthropic:oauth-usage", quota);
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Per-account quota (multiauth)
534
+ // ---------------------------------------------------------------------------
535
+
536
+ /**
537
+ * Anthropic reports usage per CREDENTIAL, so every logged-in account can be probed with its
538
+ * own bearer token — the active-account selection and the local usage log are irrelevant here.
539
+ * Mirrors the Codex pool behaviour (codex/auth-api.ts:fetchPoolAccountQuota), including a
540
+ * per-account TTL so N accounts cost at most N upstream calls per window.
541
+ *
542
+ * The TTL is deliberately longer than the provider-level one: this path multiplies by account
543
+ * count, and Anthropic rate-limits the usage endpoint (observed 429 under repeated probing).
544
+ */
545
+ const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000;
546
+ type AccountQuotaCacheEntry = {
547
+ ts: number;
548
+ quota: ProviderQuota | null;
549
+ /** Best-effort plan/tier label (xAI Grok Pro / SuperGrok / Free, …). */
550
+ plan?: string;
551
+ /** Last probe failed (429 / network / expired login); still may hold last-good quota. */
552
+ unavailable?: true;
553
+ };
554
+ const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
555
+ const accountQuotaInflight = new Map<string, Promise<AccountQuotaCacheEntry>>();
556
+ let lastReconciledGeneration = 0;
557
+ let liveAccountQuotaKeys = new Set<string>();
558
+ let liveProviderQuotaKeys = new Set<string>();
559
+
560
+ function mayCommitAccountQuotaKey(key: string, writerGeneration: number): boolean {
561
+ return writerGeneration >= lastReconciledGeneration || liveAccountQuotaKeys.has(key);
562
+ }
563
+
564
+ function mayCommitProviderQuotaKey(key: string, writerGeneration: number): boolean {
565
+ return writerGeneration >= lastReconciledGeneration || liveProviderQuotaKeys.has(key);
566
+ }
567
+
568
+ export interface ProviderAccountQuota {
569
+ accountId: string;
570
+ quota: ProviderQuota | null;
571
+ /** Best-effort plan/tier label when the upstream exposes one (xAI). */
572
+ plan?: string;
573
+ /** Set when the probe could not reach upstream (expired login, 429, network). */
574
+ unavailable?: true;
575
+ }
576
+
577
+ /** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
578
+ export function supportsPerAccountQuota(provider: string): boolean {
579
+ return provider === "anthropic" || provider === "xai";
580
+ }
581
+
582
+ function accountCacheKey(provider: string, accountId: string): string {
583
+ return `${provider}\u0000${accountId}`;
584
+ }
585
+
586
+ /**
587
+ * Synchronous last-good per-account quota read for routing. Never probes the network.
588
+ * Returns null when nothing is cached (or the cached row has no bars).
589
+ */
590
+ export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
591
+ const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
592
+ return entry?.quota ?? null;
593
+ }
594
+
595
+ /** Test-only: seed or clear the per-account quota cache without probing upstream. */
596
+ export function setCachedProviderAccountQuotaForTests(
597
+ provider: string,
598
+ accountId: string,
599
+ quota: ProviderQuota | null,
600
+ plan?: string,
601
+ ): void {
602
+ const key = accountCacheKey(provider, accountId);
603
+ if (quota === null && plan === undefined) {
604
+ accountQuotaCache.delete(key);
605
+ return;
606
+ }
607
+ accountQuotaCache.set(key, {
608
+ ts: Date.now(),
609
+ quota,
610
+ ...(plan !== undefined ? { plan } : {}),
611
+ });
612
+ }
613
+
614
+ export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number {
615
+ let removed = 0;
616
+ for (const [key, entry] of accountQuotaCache) {
617
+ if (entry.ts + ACCOUNT_QUOTA_TTL_MS > now) continue;
618
+ accountQuotaCache.delete(key);
619
+ removed += 1;
620
+ }
621
+ return removed;
622
+ }
623
+
624
+ export function reconcileProviderAccountQuotaRows(context: GenerationContext): number {
625
+ if (context.generation <= lastReconciledGeneration) return 0;
626
+ let removed = 0;
627
+ for (const key of accountQuotaCache.keys()) {
628
+ if (context.oauthAccountKeys.has(key)) continue;
629
+ accountQuotaCache.delete(key);
630
+ removed += 1;
631
+ }
632
+ if (cache) {
633
+ const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider));
634
+ removed += cache.response.reports.length - reports.length;
635
+ cache = { ...cache, response: { ...cache.response, reports } };
636
+ }
637
+ liveAccountQuotaKeys = new Set(context.oauthAccountKeys);
638
+ liveProviderQuotaKeys = new Set(context.providerNames);
639
+ lastReconciledGeneration = context.generation;
640
+ return removed;
641
+ }
642
+
643
+ /** Drop cached per-account rows (all, or just one provider's). */
644
+ export function clearAccountQuotaCache(provider?: string): void {
645
+ if (!provider) {
646
+ accountQuotaCache.clear();
647
+ accountQuotaInflight.clear();
648
+ return;
649
+ }
650
+ const prefix = `${provider}\u0000`;
651
+ for (const key of [...accountQuotaCache.keys()]) {
652
+ if (key.startsWith(prefix)) accountQuotaCache.delete(key);
653
+ }
654
+ // Drop in-flight probes too so a late resolve cannot repopulate after logout/remove.
655
+ for (const key of [...accountQuotaInflight.keys()]) {
656
+ if (key.startsWith(prefix)) accountQuotaInflight.delete(key);
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Resolve a bearer for quota probing without silently adopting a newer global
662
+ * Claude CLI credential into a background multiauth slot.
663
+ *
664
+ * - Fresh stored access → use as-is (no refresh).
665
+ * - Active account with expired access → normal refresh path.
666
+ * - Background `local-cli` with expired access → fail closed (unavailable):
667
+ * `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity.
668
+ * - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh;
669
+ * Anthropic's lock only adopts disk credentials for `local-cli` rows.
670
+ */
671
+ async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise<string> {
672
+ const stored = getAccountCredential(provider, accountId);
673
+ if (!stored) throw new Error("account credential missing");
674
+ if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access;
675
+ const activeId = getAccountSet(provider)?.activeAccountId;
676
+ if (activeId !== accountId && stored.source === "local-cli") {
677
+ throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe");
678
+ }
679
+ return getValidAccessTokenForAccount(provider, accountId);
680
+ }
681
+
682
+ async function fetchAccountQuota(
683
+ provider: string,
684
+ accountId: string,
685
+ forceRefresh: boolean,
686
+ ): Promise<AccountQuotaCacheEntry> {
687
+ const key = accountCacheKey(provider, accountId);
688
+ const writerGeneration = captureConfigGeneration();
689
+ const cached = accountQuotaCache.get(key);
690
+ if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached;
691
+ const joinable = accountQuotaInflight.get(key);
692
+ if (joinable) return joinable;
693
+
694
+ const probe = (async (): Promise<AccountQuotaCacheEntry> => {
695
+ try {
696
+ const token = await getTokenForAccountQuotaProbe(provider, accountId);
697
+ let quota: ProviderQuota | null = null;
698
+ let plan: string | undefined;
699
+ if (provider === "anthropic") {
700
+ quota = await fetchAnthropicUsageQuota(token);
701
+ } else if (provider === "xai") {
702
+ const usage = await fetchXaiBillingUsage(token);
703
+ quota = usage?.quota ?? null;
704
+ plan = usage?.plan;
705
+ // Plan-only rows (Free) still count as a successful probe.
706
+ if (usage && !quota) {
707
+ const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota: null, plan: usage.plan };
708
+ accountQuotaCache.set(key, entry);
709
+ return entry;
710
+ }
711
+ } else {
712
+ throw new Error(`per-account quota not implemented for ${provider}`);
713
+ }
714
+ if (!quota) {
715
+ // Preserve last-good bars and mark unavailable; advance TTL so failures
716
+ // negative-cache instead of re-probing on every GUI poll.
717
+ const entry: AccountQuotaCacheEntry = {
718
+ ts: Date.now(),
719
+ quota: cached?.quota ?? null,
720
+ ...(cached?.plan ? { plan: cached.plan } : plan ? { plan } : {}),
721
+ unavailable: true,
722
+ };
723
+ if (mayCommitAccountQuotaKey(key, writerGeneration)) {
724
+ accountQuotaCache.set(key, entry);
725
+ sweepExpiredOnWrite(entry.ts);
726
+ }
727
+ return entry;
728
+ }
729
+ const entry: AccountQuotaCacheEntry = {
730
+ ts: Date.now(),
731
+ quota,
732
+ ...(plan ? { plan } : cached?.plan ? { plan: cached.plan } : {}),
733
+ };
734
+ if (mayCommitAccountQuotaKey(key, writerGeneration)) {
735
+ accountQuotaCache.set(key, entry);
736
+ sweepExpiredOnWrite(entry.ts);
737
+ }
738
+ return entry;
739
+ } catch {
740
+ const entry: AccountQuotaCacheEntry = {
741
+ ts: Date.now(),
742
+ quota: cached?.quota ?? null,
743
+ ...(cached?.plan ? { plan: cached.plan } : {}),
744
+ unavailable: true,
745
+ };
746
+ if (mayCommitAccountQuotaKey(key, writerGeneration)) {
747
+ accountQuotaCache.set(key, entry);
748
+ sweepExpiredOnWrite(entry.ts);
749
+ }
750
+ return entry;
751
+ }
752
+ })().finally(() => {
753
+ if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key);
754
+ });
755
+ accountQuotaInflight.set(key, probe);
756
+ return probe;
757
+ }
758
+
759
+ /**
760
+ * Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a
761
+ * single failing account never blocks the others.
762
+ */
763
+ export async function fetchProviderAccountQuotas(
764
+ provider: string,
765
+ forceRefresh = false,
766
+ ): Promise<ProviderAccountQuota[]> {
767
+ if (!supportsPerAccountQuota(provider)) return [];
768
+ const set = getAccountSet(provider);
769
+ if (!set) return [];
770
+ return await Promise.all(set.accounts.map(async account => {
771
+ const entry = await fetchAccountQuota(provider, account.id, forceRefresh);
772
+ return {
773
+ accountId: account.id,
774
+ quota: entry.quota,
775
+ ...(entry.plan ? { plan: entry.plan } : {}),
776
+ ...(entry.unavailable ? { unavailable: true as const } : {}),
777
+ };
778
+ }));
779
+ }
780
+
781
+ function normalizedBaseUrl(value: string): string | null {
782
+ try {
783
+ const url = new URL(value);
784
+ if (url.search || url.hash) return null;
785
+ return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
786
+ } catch {
787
+ return null;
788
+ }
789
+ }
790
+
791
+ function quotaResetAt(row: Record<string, unknown>): number | undefined {
792
+ return normalizeResetAt(row.resetTime ?? row.resetAt ?? row.reset_time ?? row.reset_at);
793
+ }
794
+
795
+ function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean {
796
+ return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL;
797
+ }
798
+
799
+ /** Prefer the nested `data` shell when the outer object is only an envelope. */
800
+ function unwrapKimiQuotaPayload(value: unknown): Record<string, unknown> | null {
801
+ const body = asRecord(value);
802
+ if (!body) return null;
803
+ const nested = asRecord(body.data);
804
+ if (!nested) return body;
805
+ // A null/non-usable outer field is a placeholder, not data — an envelope like
806
+ // { usage: null, data: { usage: {...} } } must still unwrap to the nested payload.
807
+ const usable = (field: unknown): boolean => field !== undefined && field !== null;
808
+ const outerHasUsage = usable(body.usage) || usable(body.limits) || usable(body.totalQuota);
809
+ const nestedHasUsage = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota);
810
+ return !outerHasUsage && nestedHasUsage ? nested : body;
811
+ }
812
+
813
+ function kimiLimitLabel(item: Record<string, unknown>, detail: Record<string, unknown>): string {
814
+ return [item.name, item.title, item.scope, detail.name, detail.title]
815
+ .filter((value): value is string => typeof value === "string")
816
+ .join(" ")
817
+ .toLowerCase();
818
+ }
819
+
820
+ function parseKimiQuotaRow(value: unknown, resetFallback?: Record<string, unknown>): { percent: number; resetAt?: number } | null {
821
+ const row = asRecord(value);
822
+ if (!row) return null;
823
+ const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined);
824
+ const limit = toFiniteNumber(row.limit);
825
+ if (limit !== undefined && limit > 0) {
826
+ let used = toFiniteNumber(row.used);
827
+ if (used === undefined) {
828
+ const remaining = toFiniteNumber(row.remaining);
829
+ if (remaining !== undefined) used = limit - remaining;
830
+ }
831
+ if (used !== undefined) {
832
+ const percent = normalizePercent((used / limit) * 100);
833
+ if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
834
+ }
835
+ }
836
+ // Some payloads expose utilisation directly when limit/used arithmetic is absent.
837
+ const direct = normalizePercent(row.utilization ?? row.percent ?? row.usedPercent ?? row.used_percent);
838
+ return direct === undefined ? null : { percent: direct, ...(resetAt !== undefined ? { resetAt } : {}) };
839
+ }
840
+
841
+ function isKimiFiveHourLimit(item: Record<string, unknown>, detail: Record<string, unknown>, window: Record<string, unknown>): boolean {
842
+ const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration);
843
+ const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase();
844
+ if ((unit.includes("MINUTE") && duration === 300) || (unit.includes("HOUR") && duration === 5)) return true;
845
+ return /(^|\b)5\s*(?:h|hour)/.test(kimiLimitLabel(item, detail));
846
+ }
847
+
848
+ function isKimiWeeklyLimit(item: Record<string, unknown>, detail: Record<string, unknown>, window: Record<string, unknown>): boolean {
849
+ const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration);
850
+ const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase();
851
+ if ((unit.includes("DAY") && duration === 7) || (unit.includes("HOUR") && duration === 168)) return true;
852
+ return /weekly|7\s*(?:d|day)/.test(kimiLimitLabel(item, detail));
853
+ }
854
+
855
+ function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
856
+ const body = unwrapKimiQuotaPayload(value);
857
+ if (!body) return null;
858
+ let weekly = parseKimiQuotaRow(body.usage);
859
+ const total = parseKimiQuotaRow(body.totalQuota);
860
+ let fiveHour: { percent: number; resetAt?: number } | null = null;
861
+ if (Array.isArray(body.limits)) {
862
+ for (const rawItem of body.limits) {
863
+ const item = asRecord(rawItem);
864
+ if (!item) continue;
865
+ const detail = asRecord(item.detail) ?? item;
866
+ const window = asRecord(item.window) ?? {};
867
+ if (!fiveHour && isKimiFiveHourLimit(item, detail, window)) {
868
+ fiveHour = parseKimiQuotaRow(detail, window);
869
+ }
870
+ if (!weekly && isKimiWeeklyLimit(item, detail, window)) {
871
+ weekly = parseKimiQuotaRow(detail, window);
872
+ }
873
+ if (fiveHour && weekly) break;
874
+ }
875
+ }
876
+ const quota: ProviderQuota = {
877
+ ...(fiveHour ? {
878
+ fiveHourPercent: fiveHour.percent,
879
+ ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
880
+ } : {}),
881
+ ...(weekly ? {
882
+ weeklyPercent: weekly.percent,
883
+ ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
884
+ } : {}),
885
+ ...(total ? { customWindows: [{ label: "Total subscription credits", percent: total.percent, ...(total.resetAt !== undefined ? { resetAt: total.resetAt } : {}) }] } : {}),
886
+ updatedAt: Date.now(),
887
+ };
888
+ return hasQuotaRows(quota) ? quota : null;
889
+ }
890
+
891
+ async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise<string | null> {
892
+ if (config.authMode === "oauth") {
893
+ try {
894
+ return await getValidAccessToken("kimi");
895
+ } catch {
896
+ return null;
897
+ }
898
+ }
899
+ // ACTIVE key only: silently walking apiKeyPool when the primary env reference is
900
+ // unresolved would render a quota bar for a DIFFERENT account than the one routing
901
+ // requests — a wrong meter is worse than no meter.
902
+ const primary = resolveEnvValue(config.apiKey)?.trim();
903
+ return primary || null;
904
+ }
905
+
906
+ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
907
+ // Never release credentials to a user-edited or lookalike provider host.
908
+ if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null;
909
+ const accessToken = await resolveKimiQuotaBearer(config);
910
+ if (!accessToken) return null;
911
+ const response = await fetch(KIMI_CODE_USAGE_URL, {
912
+ headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
913
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
914
+ });
915
+ if (!response.ok) return null;
916
+ const quota = parseKimiQuotaPayload(await response.json().catch(() => null));
917
+ return quota ? report(provider, "kimi:usages", quota) : null;
918
+ }
919
+
920
+ /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
921
+ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
922
+ let accessToken: string;
923
+ try {
924
+ accessToken = await getValidAccessToken("cursor");
925
+ } catch {
926
+ return null;
927
+ }
928
+
929
+ const authHeaders = {
930
+ Accept: "application/json",
931
+ Authorization: `Bearer ${accessToken}`,
932
+ "User-Agent": "opencodex-quota",
933
+ } as const;
934
+
935
+ // Prefer dashboard period usage (Pro/Team/Ultra spend allowance in USD cents).
936
+ // Field names follow Cursor's Connect RPC shape (limit/remaining/includedSpend), not usedCents.
937
+ try {
938
+ const periodRes = await fetch("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
939
+ method: "POST",
940
+ headers: {
941
+ ...authHeaders,
942
+ "Content-Type": "application/json",
943
+ "Connect-Protocol-Version": "1",
944
+ },
945
+ body: "{}",
946
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
947
+ });
948
+ if (periodRes.ok) {
949
+ const body = asRecord(await periodRes.json().catch(() => null));
950
+ const planUsage = asRecord(body?.planUsage);
951
+ if (planUsage) {
952
+ const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
953
+
954
+ // Primary meter: overall included allowance (Cursor Settings → Usage total %).
955
+ // autoPercentUsed / apiPercentUsed are secondary pools and must not replace the total.
956
+ const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents);
957
+ const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents);
958
+ const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used);
959
+ const totalSpend = toFiniteNumber(planUsage.totalSpend);
960
+ let used: number | undefined;
961
+ if (includedSpend !== undefined) used = includedSpend;
962
+ else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining);
963
+ else if (totalSpend !== undefined) used = totalSpend;
964
+ const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed)
965
+ ?? (limit !== undefined && limit > 0 && used !== undefined
966
+ ? normalizePercent((used / limit) * 100)
967
+ : undefined);
968
+
969
+ const autoPercent = normalizePercent(planUsage.autoPercentUsed);
970
+ const apiPercent = normalizePercent(planUsage.apiPercentUsed);
971
+ const customWindows: ProviderQuotaWindow[] = [];
972
+ if (autoPercent !== undefined) {
973
+ customWindows.push({
974
+ label: "First-party models",
975
+ percent: autoPercent,
976
+ ...(resetAt !== undefined ? { resetAt } : {}),
977
+ });
978
+ }
979
+ if (apiPercent !== undefined) {
980
+ customWindows.push({
981
+ label: "API usage",
982
+ percent: apiPercent,
983
+ ...(resetAt !== undefined ? { resetAt } : {}),
984
+ });
985
+ }
986
+
987
+ if (totalPercent !== undefined || customWindows.length > 0) {
988
+ const built = report(provider, "cursor:period-usage", {
989
+ ...(totalPercent !== undefined ? {
990
+ monthlyPercent: totalPercent,
991
+ ...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
992
+ } : {}),
993
+ ...(customWindows.length > 0 ? { customWindows } : {}),
994
+ updatedAt: Date.now(),
995
+ });
996
+ if (built) return { ...built, reverseEngineered: true };
997
+ }
998
+ }
999
+ }
1000
+ } catch {
1001
+ /* fall through */
1002
+ }
1003
+
1004
+ // /api/usage/summary — same host, sometimes richer than /auth/usage for Team plans.
1005
+ try {
1006
+ const summaryRes = await fetch("https://api2.cursor.sh/api/usage/summary", {
1007
+ headers: authHeaders,
1008
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1009
+ });
1010
+ if (summaryRes.ok) {
1011
+ const body = asRecord(await summaryRes.json().catch(() => null));
1012
+ const individual = asRecord(body?.individualUsage);
1013
+ const plan = asRecord(individual?.plan);
1014
+ if (plan) {
1015
+ const used = toFiniteNumber(plan.used);
1016
+ const limit = toFiniteNumber(plan.limit);
1017
+ const percent = normalizePercent(plan.totalPercentUsed)
1018
+ ?? (used !== undefined && limit !== undefined && limit > 0
1019
+ ? normalizePercent((used / limit) * 100)
1020
+ : undefined);
1021
+ if (percent !== undefined) {
1022
+ const built = report(provider, "cursor:usage-summary", {
1023
+ monthlyPercent: percent,
1024
+ monthlyResetAt: normalizeResetAt(body?.billingCycleEnd),
1025
+ updatedAt: Date.now(),
1026
+ });
1027
+ if (built) return { ...built, reverseEngineered: true };
1028
+ }
1029
+ }
1030
+ }
1031
+ } catch {
1032
+ /* fall through to /auth/usage */
1033
+ }
1034
+
1035
+ const response = await fetch("https://api2.cursor.sh/auth/usage", {
1036
+ headers: authHeaders,
1037
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1038
+ });
1039
+ if (!response.ok) return null;
1040
+ const body = asRecord(await response.json().catch(() => null));
1041
+ if (!body) return null;
1042
+
1043
+ // Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit.
1044
+ let used: number | undefined;
1045
+ let limit: number | undefined;
1046
+ const gpt4 = asRecord(body["gpt-4"]);
1047
+ if (gpt4) {
1048
+ used = toFiniteNumber(gpt4.numRequests ?? gpt4.used);
1049
+ limit = toFiniteNumber(gpt4.maxRequestUsage ?? gpt4.limit ?? gpt4.maxRequests);
1050
+ }
1051
+ if (used === undefined || limit === undefined || limit <= 0) {
1052
+ for (const [key, value] of Object.entries(body)) {
1053
+ if (key === "startOfMonth" || key === "billingCycleStart") continue;
1054
+ const bucket = asRecord(value);
1055
+ if (!bucket) continue;
1056
+ const bucketUsed = toFiniteNumber(bucket.numRequests ?? bucket.used);
1057
+ const bucketLimit = toFiniteNumber(bucket.maxRequestUsage ?? bucket.limit ?? bucket.maxRequests);
1058
+ if (bucketUsed !== undefined && bucketLimit !== undefined && bucketLimit > 0) {
1059
+ used = bucketUsed;
1060
+ limit = bucketLimit;
1061
+ break;
1062
+ }
1063
+ }
1064
+ }
1065
+ if (used === undefined || limit === undefined || limit <= 0) return null;
1066
+ const percent = normalizePercent((used / limit) * 100);
1067
+ if (percent === undefined) return null;
1068
+ const startOfMonth = normalizeResetAt(body.startOfMonth ?? body.billingCycleStart);
1069
+ // Next reset = same day next month, computed in UTC to avoid timezone-shifted rollover.
1070
+ const monthlyResetAt = startOfMonth !== undefined
1071
+ ? (() => {
1072
+ const start = new Date(startOfMonth);
1073
+ return Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, start.getUTCDate());
1074
+ })()
1075
+ : undefined;
1076
+ const built = report(provider, "cursor:auth-usage", {
1077
+ monthlyPercent: percent,
1078
+ ...(monthlyResetAt !== undefined ? { monthlyResetAt } : {}),
1079
+ updatedAt: Date.now(),
1080
+ });
1081
+ return built ? { ...built, reverseEngineered: true } : null;
1082
+ }
1083
+
1084
+ function quotaInfoEntries(modelInfo: Record<string, unknown>): Record<string, unknown>[] {
1085
+ const entries: Record<string, unknown>[] = [];
1086
+ const add = (value: unknown, tier?: string) => {
1087
+ const rec = asRecord(value);
1088
+ if (!rec) return;
1089
+ entries.push(tier ? { ...rec, tier } : rec);
1090
+ };
1091
+ const addArray = (value: unknown) => {
1092
+ if (!Array.isArray(value)) return;
1093
+ for (const entry of value) add(entry);
1094
+ };
1095
+
1096
+ if (Array.isArray(modelInfo.quotaInfo)) addArray(modelInfo.quotaInfo);
1097
+ else add(modelInfo.quotaInfo);
1098
+ addArray(modelInfo.quotaInfos);
1099
+
1100
+ const byTier = asRecord(modelInfo.quotaInfoByTier);
1101
+ if (byTier) {
1102
+ for (const [tier, value] of Object.entries(byTier)) {
1103
+ if (Array.isArray(value)) {
1104
+ for (const entry of value) add(entry, tier);
1105
+ } else {
1106
+ add(value, tier);
1107
+ }
1108
+ }
1109
+ }
1110
+ return entries;
1111
+ }
1112
+
1113
+ function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, unknown>, quotaInfo: Record<string, unknown>): "Gem" | "Cla" | null {
1114
+ const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : "";
1115
+ const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : "";
1116
+ const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase();
1117
+ if (haystack.includes("gemini")) return "Gem";
1118
+ if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Cla";
1119
+ return null;
1120
+ }
1121
+
1122
+ function antigravityUsedPercent(quotaInfo: Record<string, unknown>): number | undefined {
1123
+ const remaining = normalizePercent(toFiniteNumber(quotaInfo.remainingFraction) !== undefined
1124
+ ? toFiniteNumber(quotaInfo.remainingFraction)! * 100
1125
+ : toFiniteNumber(quotaInfo.remainingPercentage) !== undefined
1126
+ ? toFiniteNumber(quotaInfo.remainingPercentage)! * 100
1127
+ : undefined);
1128
+ if (remaining === undefined) return undefined;
1129
+ return normalizePercent(100 - remaining);
1130
+ }
1131
+
1132
+ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
1133
+ const credential = getCredential("google-antigravity");
1134
+ if (!credential?.projectId) return null;
1135
+ let accessToken: string;
1136
+ try {
1137
+ accessToken = await getValidAccessToken("google-antigravity");
1138
+ } catch {
1139
+ return null;
1140
+ }
1141
+ const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, "");
1142
+ const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, {
1143
+ method: "POST",
1144
+ headers: {
1145
+ Accept: "application/json",
1146
+ "Content-Type": "application/json",
1147
+ "User-Agent": antigravityUserAgent(),
1148
+ Authorization: `Bearer ${accessToken}`,
1149
+ },
1150
+ body: JSON.stringify({ project: credential.projectId }),
1151
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1152
+ });
1153
+ if (!response.ok) return null;
1154
+ const body = asRecord(await response.json().catch(() => null));
1155
+ const models = asRecord(body?.models);
1156
+ if (!models) return null;
1157
+
1158
+ const windows = new Map<string, ProviderQuotaWindow>();
1159
+ for (const [modelId, rawModelInfo] of Object.entries(models)) {
1160
+ const modelInfo = asRecord(rawModelInfo);
1161
+ if (!modelInfo) continue;
1162
+ for (const quotaInfo of quotaInfoEntries(modelInfo)) {
1163
+ const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo);
1164
+ if (!label || windows.has(label)) continue;
1165
+ const percent = antigravityUsedPercent(quotaInfo);
1166
+ if (percent === undefined) continue;
1167
+ windows.set(label, {
1168
+ label,
1169
+ percent,
1170
+ ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}),
1171
+ });
1172
+ }
1173
+ }
1174
+
1175
+ const customWindows = ["Gem", "Cla"].flatMap(label => {
1176
+ const window = windows.get(label);
1177
+ return window ? [window] : [];
1178
+ });
1179
+ if (customWindows.length === 0) return null;
1180
+ return report(provider, "google-antigravity:fetchAvailableModels", {
1181
+ customWindows,
1182
+ updatedAt: Date.now(),
1183
+ });
1184
+ }
1185
+
1186
+ async function maybeFetchProviderQuota(
1187
+ name: string,
1188
+ provider: OcxProviderConfig,
1189
+ config: OcxConfig,
1190
+ forceRefresh: boolean,
1191
+ ): Promise<ProviderQuotaReport | null> {
1192
+ if (provider.disabled === true) return null;
1193
+ try {
1194
+ if (isBuiltInChatGptForwardProvider(name, provider)) return fetchChatGptForwardQuota(config, name, provider, forceRefresh);
1195
+ if (provider.authMode === "oauth" && name === "xai") return fetchXaiQuota(name);
1196
+ if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
1197
+ if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
1198
+ if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
1199
+ // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical
1200
+ // host and only for real key auth — forward/local modes carry no credential of ours.
1201
+ if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
1202
+ if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
1203
+ return fetchKimiQuota(name, provider);
1204
+ }
1205
+ return null;
1206
+ } catch {
1207
+ return null;
1208
+ }
1209
+ }
1210
+
1211
+ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise<ProviderQuotaResponse> {
1212
+ const key = cacheKey(config);
1213
+ const writerGeneration = captureConfigGeneration();
1214
+ const now = Date.now();
1215
+ // The cache fast path must not extend a preserved last-good row past its 30-minute bound:
1216
+ // a row preserved at age 29:59 plus a full 5-minute TTL would otherwise serve until ~35min.
1217
+ const cacheFresh = cache && cache.key === key && now - cache.ts < CACHE_TTL_MS
1218
+ && cache.response.reports.every(item => now - item.updatedAt < LAST_GOOD_MAX_AGE_MS);
1219
+ if (!forceRefresh && cacheFresh) return cache!.response;
1220
+ const joinable = inflight.get(key);
1221
+ if (!forceRefresh && joinable && joinable.epoch === invalidationEpoch) return joinable.promise;
1222
+ // A forced probe takes commit authority: older in-flight probes must not overwrite its result.
1223
+ if (forceRefresh) invalidationEpoch += 1;
1224
+ const epoch = invalidationEpoch;
1225
+
1226
+ const promise = (async (): Promise<ProviderQuotaResponse> => {
1227
+ const previous = cache && cache.key === key ? cache.response.reports : [];
1228
+ const fresh = (await Promise.all(
1229
+ Object.entries(config.providers).map(([name, provider]) => maybeFetchProviderQuota(name, provider, config, forceRefresh)),
1230
+ )).filter((item): item is ProviderQuotaReport => item !== null);
1231
+
1232
+ // Keep bounded last-good rows when a probe fails (e.g. transient upstream flake); never
1233
+ // re-stamp their timestamps, and drop rows older than LAST_GOOD_MAX_AGE_MS.
1234
+ // Note: the cache key encodes the provider set (name/adapter/authMode/disabled/baseUrl),
1235
+ // so previous rows always correspond to currently configured, enabled providers — a
1236
+ // disabled or removed provider changes the key and starts from an empty previous set.
1237
+ const cutoff = Date.now() - LAST_GOOD_MAX_AGE_MS;
1238
+ const byProvider = new Map<string, ProviderQuotaReport>();
1239
+ for (const item of previous) {
1240
+ if (item.updatedAt >= cutoff) byProvider.set(item.provider, item);
1241
+ }
1242
+ for (const item of fresh) byProvider.set(item.provider, item);
1243
+
1244
+ const response = { generatedAt: Date.now(), reports: [...byProvider.values()] };
1245
+ // Commit only when this probe still holds authority (no clear/force superseded it).
1246
+ if (epoch === invalidationEpoch) {
1247
+ const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
1248
+ cache = { key, ts: Date.now(), response: { ...response, reports } };
1249
+ }
1250
+ return response;
1251
+ })();
1252
+
1253
+ const entry = { epoch, promise };
1254
+ inflight.set(key, entry);
1255
+ try {
1256
+ return await promise;
1257
+ } finally {
1258
+ if (inflight.get(key) === entry) inflight.delete(key);
1259
+ }
1260
+ }