codex-multi-auth 0.1.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 (327) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +162 -0
  3. package/assets/opencode-logo-ornate-dark.svg +18 -0
  4. package/assets/readme-hero.svg +31 -0
  5. package/config/README.md +87 -0
  6. package/config/minimal-opencode.json +13 -0
  7. package/config/opencode-legacy.json +571 -0
  8. package/config/opencode-modern.json +239 -0
  9. package/dist/index.d.ts +45 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +3160 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/accounts/rate-limits.d.ts +22 -0
  14. package/dist/lib/accounts/rate-limits.d.ts.map +1 -0
  15. package/dist/lib/accounts/rate-limits.js +63 -0
  16. package/dist/lib/accounts/rate-limits.js.map +1 -0
  17. package/dist/lib/accounts.d.ts +95 -0
  18. package/dist/lib/accounts.d.ts.map +1 -0
  19. package/dist/lib/accounts.js +668 -0
  20. package/dist/lib/accounts.js.map +1 -0
  21. package/dist/lib/audit.d.ts +45 -0
  22. package/dist/lib/audit.d.ts.map +1 -0
  23. package/dist/lib/audit.js +131 -0
  24. package/dist/lib/audit.js.map +1 -0
  25. package/dist/lib/auth/auth.d.ts +56 -0
  26. package/dist/lib/auth/auth.d.ts.map +1 -0
  27. package/dist/lib/auth/auth.js +214 -0
  28. package/dist/lib/auth/auth.js.map +1 -0
  29. package/dist/lib/auth/browser.d.ts +34 -0
  30. package/dist/lib/auth/browser.d.ts.map +1 -0
  31. package/dist/lib/auth/browser.js +185 -0
  32. package/dist/lib/auth/browser.js.map +1 -0
  33. package/dist/lib/auth/server.d.ts +24 -0
  34. package/dist/lib/auth/server.d.ts.map +1 -0
  35. package/dist/lib/auth/server.js +116 -0
  36. package/dist/lib/auth/server.js.map +1 -0
  37. package/dist/lib/auth/token-utils.d.ts +59 -0
  38. package/dist/lib/auth/token-utils.d.ts.map +1 -0
  39. package/dist/lib/auth/token-utils.js +331 -0
  40. package/dist/lib/auth/token-utils.js.map +1 -0
  41. package/dist/lib/auth-rate-limit.d.ts +20 -0
  42. package/dist/lib/auth-rate-limit.d.ts.map +1 -0
  43. package/dist/lib/auth-rate-limit.js +91 -0
  44. package/dist/lib/auth-rate-limit.js.map +1 -0
  45. package/dist/lib/auto-update-checker.d.ts +10 -0
  46. package/dist/lib/auto-update-checker.d.ts.map +1 -0
  47. package/dist/lib/auto-update-checker.js +216 -0
  48. package/dist/lib/auto-update-checker.js.map +1 -0
  49. package/dist/lib/capability-policy.d.ts +18 -0
  50. package/dist/lib/capability-policy.d.ts.map +1 -0
  51. package/dist/lib/capability-policy.js +150 -0
  52. package/dist/lib/capability-policy.js.map +1 -0
  53. package/dist/lib/circuit-breaker.d.ts +34 -0
  54. package/dist/lib/circuit-breaker.d.ts.map +1 -0
  55. package/dist/lib/circuit-breaker.js +124 -0
  56. package/dist/lib/circuit-breaker.js.map +1 -0
  57. package/dist/lib/cli.d.ts +64 -0
  58. package/dist/lib/cli.d.ts.map +1 -0
  59. package/dist/lib/cli.js +274 -0
  60. package/dist/lib/cli.js.map +1 -0
  61. package/dist/lib/codex-cli/observability.d.ts +22 -0
  62. package/dist/lib/codex-cli/observability.d.ts.map +1 -0
  63. package/dist/lib/codex-cli/observability.js +36 -0
  64. package/dist/lib/codex-cli/observability.js.map +1 -0
  65. package/dist/lib/codex-cli/state.d.ts +86 -0
  66. package/dist/lib/codex-cli/state.d.ts.map +1 -0
  67. package/dist/lib/codex-cli/state.js +470 -0
  68. package/dist/lib/codex-cli/state.js.map +1 -0
  69. package/dist/lib/codex-cli/sync.d.ts +27 -0
  70. package/dist/lib/codex-cli/sync.d.ts.map +1 -0
  71. package/dist/lib/codex-cli/sync.js +325 -0
  72. package/dist/lib/codex-cli/sync.js.map +1 -0
  73. package/dist/lib/codex-cli/writer.d.ts +12 -0
  74. package/dist/lib/codex-cli/writer.d.ts.map +1 -0
  75. package/dist/lib/codex-cli/writer.js +388 -0
  76. package/dist/lib/codex-cli/writer.js.map +1 -0
  77. package/dist/lib/codex-manager.d.ts +2 -0
  78. package/dist/lib/codex-manager.d.ts.map +1 -0
  79. package/dist/lib/codex-manager.js +4841 -0
  80. package/dist/lib/codex-manager.js.map +1 -0
  81. package/dist/lib/config.d.ts +269 -0
  82. package/dist/lib/config.d.ts.map +1 -0
  83. package/dist/lib/config.js +789 -0
  84. package/dist/lib/config.js.map +1 -0
  85. package/dist/lib/constants.d.ts +78 -0
  86. package/dist/lib/constants.d.ts.map +1 -0
  87. package/dist/lib/constants.js +78 -0
  88. package/dist/lib/constants.js.map +1 -0
  89. package/dist/lib/context-overflow.d.ts +27 -0
  90. package/dist/lib/context-overflow.d.ts.map +1 -0
  91. package/dist/lib/context-overflow.js +124 -0
  92. package/dist/lib/context-overflow.js.map +1 -0
  93. package/dist/lib/dashboard-settings.d.ts +90 -0
  94. package/dist/lib/dashboard-settings.d.ts.map +1 -0
  95. package/dist/lib/dashboard-settings.js +327 -0
  96. package/dist/lib/dashboard-settings.js.map +1 -0
  97. package/dist/lib/entitlement-cache.d.ts +41 -0
  98. package/dist/lib/entitlement-cache.d.ts.map +1 -0
  99. package/dist/lib/entitlement-cache.js +137 -0
  100. package/dist/lib/entitlement-cache.js.map +1 -0
  101. package/dist/lib/errors.d.ts +113 -0
  102. package/dist/lib/errors.d.ts.map +1 -0
  103. package/dist/lib/errors.js +103 -0
  104. package/dist/lib/errors.js.map +1 -0
  105. package/dist/lib/forecast.d.ts +42 -0
  106. package/dist/lib/forecast.d.ts.map +1 -0
  107. package/dist/lib/forecast.js +256 -0
  108. package/dist/lib/forecast.js.map +1 -0
  109. package/dist/lib/health.d.ts +33 -0
  110. package/dist/lib/health.d.ts.map +1 -0
  111. package/dist/lib/health.js +70 -0
  112. package/dist/lib/health.js.map +1 -0
  113. package/dist/lib/index.d.ts +32 -0
  114. package/dist/lib/index.d.ts.map +1 -0
  115. package/dist/lib/index.js +32 -0
  116. package/dist/lib/index.js.map +1 -0
  117. package/dist/lib/live-account-sync.d.ts +39 -0
  118. package/dist/lib/live-account-sync.d.ts.map +1 -0
  119. package/dist/lib/live-account-sync.js +196 -0
  120. package/dist/lib/live-account-sync.js.map +1 -0
  121. package/dist/lib/logger.d.ts +40 -0
  122. package/dist/lib/logger.d.ts.map +1 -0
  123. package/dist/lib/logger.js +364 -0
  124. package/dist/lib/logger.js.map +1 -0
  125. package/dist/lib/oauth-success.html +338 -0
  126. package/dist/lib/parallel-probe.d.ts +28 -0
  127. package/dist/lib/parallel-probe.d.ts.map +1 -0
  128. package/dist/lib/parallel-probe.js +97 -0
  129. package/dist/lib/parallel-probe.js.map +1 -0
  130. package/dist/lib/preemptive-quota-scheduler.d.ts +53 -0
  131. package/dist/lib/preemptive-quota-scheduler.d.ts.map +1 -0
  132. package/dist/lib/preemptive-quota-scheduler.js +220 -0
  133. package/dist/lib/preemptive-quota-scheduler.js.map +1 -0
  134. package/dist/lib/proactive-refresh.d.ts +66 -0
  135. package/dist/lib/proactive-refresh.d.ts.map +1 -0
  136. package/dist/lib/proactive-refresh.js +143 -0
  137. package/dist/lib/proactive-refresh.js.map +1 -0
  138. package/dist/lib/prompts/codex-opencode-bridge.d.ts +19 -0
  139. package/dist/lib/prompts/codex-opencode-bridge.d.ts.map +1 -0
  140. package/dist/lib/prompts/codex-opencode-bridge.js +169 -0
  141. package/dist/lib/prompts/codex-opencode-bridge.js.map +1 -0
  142. package/dist/lib/prompts/codex.d.ts +41 -0
  143. package/dist/lib/prompts/codex.d.ts.map +1 -0
  144. package/dist/lib/prompts/codex.js +383 -0
  145. package/dist/lib/prompts/codex.js.map +1 -0
  146. package/dist/lib/prompts/opencode-codex.d.ts +25 -0
  147. package/dist/lib/prompts/opencode-codex.d.ts.map +1 -0
  148. package/dist/lib/prompts/opencode-codex.js +270 -0
  149. package/dist/lib/prompts/opencode-codex.js.map +1 -0
  150. package/dist/lib/quota-cache.d.ts +68 -0
  151. package/dist/lib/quota-cache.d.ts.map +1 -0
  152. package/dist/lib/quota-cache.js +224 -0
  153. package/dist/lib/quota-cache.js.map +1 -0
  154. package/dist/lib/quota-probe.d.ts +49 -0
  155. package/dist/lib/quota-probe.d.ts.map +1 -0
  156. package/dist/lib/quota-probe.js +368 -0
  157. package/dist/lib/quota-probe.js.map +1 -0
  158. package/dist/lib/recovery/constants.d.ts +12 -0
  159. package/dist/lib/recovery/constants.d.ts.map +1 -0
  160. package/dist/lib/recovery/constants.js +31 -0
  161. package/dist/lib/recovery/constants.js.map +1 -0
  162. package/dist/lib/recovery/index.d.ts +12 -0
  163. package/dist/lib/recovery/index.d.ts.map +1 -0
  164. package/dist/lib/recovery/index.js +12 -0
  165. package/dist/lib/recovery/index.js.map +1 -0
  166. package/dist/lib/recovery/storage.d.ts +24 -0
  167. package/dist/lib/recovery/storage.d.ts.map +1 -0
  168. package/dist/lib/recovery/storage.js +362 -0
  169. package/dist/lib/recovery/storage.js.map +1 -0
  170. package/dist/lib/recovery/types.d.ts +116 -0
  171. package/dist/lib/recovery/types.d.ts.map +1 -0
  172. package/dist/lib/recovery/types.js +7 -0
  173. package/dist/lib/recovery/types.js.map +1 -0
  174. package/dist/lib/recovery.d.ts +31 -0
  175. package/dist/lib/recovery.d.ts.map +1 -0
  176. package/dist/lib/recovery.js +313 -0
  177. package/dist/lib/recovery.js.map +1 -0
  178. package/dist/lib/refresh-guardian.d.ts +31 -0
  179. package/dist/lib/refresh-guardian.d.ts.map +1 -0
  180. package/dist/lib/refresh-guardian.js +151 -0
  181. package/dist/lib/refresh-guardian.js.map +1 -0
  182. package/dist/lib/refresh-lease.d.ts +37 -0
  183. package/dist/lib/refresh-lease.d.ts.map +1 -0
  184. package/dist/lib/refresh-lease.js +335 -0
  185. package/dist/lib/refresh-lease.js.map +1 -0
  186. package/dist/lib/refresh-queue.d.ts +117 -0
  187. package/dist/lib/refresh-queue.d.ts.map +1 -0
  188. package/dist/lib/refresh-queue.js +297 -0
  189. package/dist/lib/refresh-queue.js.map +1 -0
  190. package/dist/lib/request/failure-policy.d.ts +42 -0
  191. package/dist/lib/request/failure-policy.d.ts.map +1 -0
  192. package/dist/lib/request/failure-policy.js +133 -0
  193. package/dist/lib/request/failure-policy.js.map +1 -0
  194. package/dist/lib/request/fetch-helpers.d.ts +152 -0
  195. package/dist/lib/request/fetch-helpers.d.ts.map +1 -0
  196. package/dist/lib/request/fetch-helpers.js +704 -0
  197. package/dist/lib/request/fetch-helpers.js.map +1 -0
  198. package/dist/lib/request/helpers/input-utils.d.ts +7 -0
  199. package/dist/lib/request/helpers/input-utils.d.ts.map +1 -0
  200. package/dist/lib/request/helpers/input-utils.js +214 -0
  201. package/dist/lib/request/helpers/input-utils.js.map +1 -0
  202. package/dist/lib/request/helpers/model-map.d.ts +28 -0
  203. package/dist/lib/request/helpers/model-map.d.ts.map +1 -0
  204. package/dist/lib/request/helpers/model-map.js +133 -0
  205. package/dist/lib/request/helpers/model-map.js.map +1 -0
  206. package/dist/lib/request/helpers/tool-utils.d.ts +29 -0
  207. package/dist/lib/request/helpers/tool-utils.d.ts.map +1 -0
  208. package/dist/lib/request/helpers/tool-utils.js +117 -0
  209. package/dist/lib/request/helpers/tool-utils.js.map +1 -0
  210. package/dist/lib/request/rate-limit-backoff.d.ts +17 -0
  211. package/dist/lib/request/rate-limit-backoff.d.ts.map +1 -0
  212. package/dist/lib/request/rate-limit-backoff.js +83 -0
  213. package/dist/lib/request/rate-limit-backoff.js.map +1 -0
  214. package/dist/lib/request/request-transformer.d.ts +107 -0
  215. package/dist/lib/request/request-transformer.d.ts.map +1 -0
  216. package/dist/lib/request/request-transformer.js +814 -0
  217. package/dist/lib/request/request-transformer.js.map +1 -0
  218. package/dist/lib/request/response-handler.d.ts +23 -0
  219. package/dist/lib/request/response-handler.d.ts.map +1 -0
  220. package/dist/lib/request/response-handler.js +155 -0
  221. package/dist/lib/request/response-handler.js.map +1 -0
  222. package/dist/lib/request/stream-failover.d.ts +21 -0
  223. package/dist/lib/request/stream-failover.d.ts.map +1 -0
  224. package/dist/lib/request/stream-failover.js +204 -0
  225. package/dist/lib/request/stream-failover.js.map +1 -0
  226. package/dist/lib/rotation.d.ts +146 -0
  227. package/dist/lib/rotation.d.ts.map +1 -0
  228. package/dist/lib/rotation.js +321 -0
  229. package/dist/lib/rotation.js.map +1 -0
  230. package/dist/lib/runtime-paths.d.ts +58 -0
  231. package/dist/lib/runtime-paths.d.ts.map +1 -0
  232. package/dist/lib/runtime-paths.js +164 -0
  233. package/dist/lib/runtime-paths.js.map +1 -0
  234. package/dist/lib/schemas.d.ts +435 -0
  235. package/dist/lib/schemas.d.ts.map +1 -0
  236. package/dist/lib/schemas.js +268 -0
  237. package/dist/lib/schemas.js.map +1 -0
  238. package/dist/lib/session-affinity.d.ts +23 -0
  239. package/dist/lib/session-affinity.d.ts.map +1 -0
  240. package/dist/lib/session-affinity.js +127 -0
  241. package/dist/lib/session-affinity.js.map +1 -0
  242. package/dist/lib/shutdown.d.ts +7 -0
  243. package/dist/lib/shutdown.d.ts.map +1 -0
  244. package/dist/lib/shutdown.js +43 -0
  245. package/dist/lib/shutdown.js.map +1 -0
  246. package/dist/lib/storage/migrations.d.ts +59 -0
  247. package/dist/lib/storage/migrations.d.ts.map +1 -0
  248. package/dist/lib/storage/migrations.js +41 -0
  249. package/dist/lib/storage/migrations.js.map +1 -0
  250. package/dist/lib/storage/paths.d.ts +51 -0
  251. package/dist/lib/storage/paths.d.ts.map +1 -0
  252. package/dist/lib/storage/paths.js +152 -0
  253. package/dist/lib/storage/paths.js.map +1 -0
  254. package/dist/lib/storage.d.ts +106 -0
  255. package/dist/lib/storage.d.ts.map +1 -0
  256. package/dist/lib/storage.js +896 -0
  257. package/dist/lib/storage.js.map +1 -0
  258. package/dist/lib/table-formatter.d.ts +32 -0
  259. package/dist/lib/table-formatter.d.ts.map +1 -0
  260. package/dist/lib/table-formatter.js +44 -0
  261. package/dist/lib/table-formatter.js.map +1 -0
  262. package/dist/lib/tools/hashline-tools.d.ts +51 -0
  263. package/dist/lib/tools/hashline-tools.d.ts.map +1 -0
  264. package/dist/lib/tools/hashline-tools.js +456 -0
  265. package/dist/lib/tools/hashline-tools.js.map +1 -0
  266. package/dist/lib/types.d.ts +130 -0
  267. package/dist/lib/types.d.ts.map +1 -0
  268. package/dist/lib/types.js +2 -0
  269. package/dist/lib/types.js.map +1 -0
  270. package/dist/lib/ui/ansi.d.ts +40 -0
  271. package/dist/lib/ui/ansi.d.ts.map +1 -0
  272. package/dist/lib/ui/ansi.js +68 -0
  273. package/dist/lib/ui/ansi.js.map +1 -0
  274. package/dist/lib/ui/auth-menu.d.ts +76 -0
  275. package/dist/lib/ui/auth-menu.d.ts.map +1 -0
  276. package/dist/lib/ui/auth-menu.js +590 -0
  277. package/dist/lib/ui/auth-menu.js.map +1 -0
  278. package/dist/lib/ui/confirm.d.ts +11 -0
  279. package/dist/lib/ui/confirm.d.ts.map +1 -0
  280. package/dist/lib/ui/confirm.js +29 -0
  281. package/dist/lib/ui/confirm.js.map +1 -0
  282. package/dist/lib/ui/copy.d.ts +123 -0
  283. package/dist/lib/ui/copy.d.ts.map +1 -0
  284. package/dist/lib/ui/copy.js +127 -0
  285. package/dist/lib/ui/copy.js.map +1 -0
  286. package/dist/lib/ui/format.d.ts +62 -0
  287. package/dist/lib/ui/format.d.ts.map +1 -0
  288. package/dist/lib/ui/format.js +205 -0
  289. package/dist/lib/ui/format.js.map +1 -0
  290. package/dist/lib/ui/runtime.d.ts +43 -0
  291. package/dist/lib/ui/runtime.d.ts.map +1 -0
  292. package/dist/lib/ui/runtime.js +69 -0
  293. package/dist/lib/ui/runtime.js.map +1 -0
  294. package/dist/lib/ui/select.d.ts +60 -0
  295. package/dist/lib/ui/select.d.ts.map +1 -0
  296. package/dist/lib/ui/select.js +467 -0
  297. package/dist/lib/ui/select.js.map +1 -0
  298. package/dist/lib/ui/theme.d.ts +56 -0
  299. package/dist/lib/ui/theme.d.ts.map +1 -0
  300. package/dist/lib/ui/theme.js +186 -0
  301. package/dist/lib/ui/theme.js.map +1 -0
  302. package/dist/lib/unified-settings.d.ts +71 -0
  303. package/dist/lib/unified-settings.d.ts.map +1 -0
  304. package/dist/lib/unified-settings.js +299 -0
  305. package/dist/lib/unified-settings.js.map +1 -0
  306. package/dist/lib/utils.d.ts +29 -0
  307. package/dist/lib/utils.d.ts.map +1 -0
  308. package/dist/lib/utils.js +54 -0
  309. package/dist/lib/utils.js.map +1 -0
  310. package/package.json +115 -0
  311. package/scripts/audit-dev-allowlist.js +128 -0
  312. package/scripts/bench-format/hashline-v2.mjs +642 -0
  313. package/scripts/bench-format/models.mjs +105 -0
  314. package/scripts/bench-format/opencode.mjs +205 -0
  315. package/scripts/bench-format/render.mjs +496 -0
  316. package/scripts/bench-format/stats.mjs +54 -0
  317. package/scripts/bench-format/tasks.mjs +151 -0
  318. package/scripts/benchmark-edit-formats.mjs +1161 -0
  319. package/scripts/benchmark-render-dashboard.mjs +49 -0
  320. package/scripts/codex-multi-auth.js +6 -0
  321. package/scripts/codex-routing.js +34 -0
  322. package/scripts/codex.js +122 -0
  323. package/scripts/copy-oauth-success.js +37 -0
  324. package/scripts/install-opencode-codex-auth.js +193 -0
  325. package/scripts/test-all-models.sh +7 -0
  326. package/scripts/test-model-matrix.js +424 -0
  327. package/scripts/validate-model-map.sh +7 -0
package/dist/index.js ADDED
@@ -0,0 +1,3160 @@
1
+ /**
2
+ * OpenAI ChatGPT (Codex) OAuth Authentication Plugin for opencode
3
+ *
4
+ * COMPLIANCE NOTICE:
5
+ * This plugin uses OpenAI's official OAuth authentication flow (the same method
6
+ * used by OpenAI's official Codex CLI at https://github.com/openai/codex).
7
+ *
8
+ * INTENDED USE: Personal development and coding assistance with your own
9
+ * ChatGPT Plus/Pro subscription.
10
+ *
11
+ * NOT INTENDED FOR: Commercial resale, multi-user services, high-volume
12
+ * automated extraction, or any use that violates OpenAI's Terms of Service.
13
+ *
14
+ * Users are responsible for ensuring their usage complies with:
15
+ * - OpenAI Terms of Use: https://openai.com/policies/terms-of-use/
16
+ * - OpenAI Usage Policies: https://openai.com/policies/usage-policies/
17
+ *
18
+ * For production applications, use the OpenAI Platform API: https://platform.openai.com/
19
+ *
20
+ * @license MIT with Usage Disclaimer (see LICENSE file)
21
+ * @author numman-ali
22
+ * @repository https://github.com/ndycode/codex-multi-auth
23
+
24
+ */
25
+ import { tool } from "@opencode-ai/plugin/tool";
26
+ import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, redactOAuthUrlForLog, REDIRECT_URI, } from "./lib/auth/auth.js";
27
+ import { queuedRefresh } from "./lib/refresh-queue.js";
28
+ import { openBrowserUrl } from "./lib/auth/browser.js";
29
+ import { startLocalOAuthServer } from "./lib/auth/server.js";
30
+ import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
31
+ import { getCodexMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getToastDurationMs, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getLiveAccountSync, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, getSessionAffinity, getSessionAffinityTtlMs, getSessionAffinityMaxEntries, getProactiveRefreshGuardian, getProactiveRefreshIntervalMs, getProactiveRefreshBufferMs, getNetworkErrorCooldownMs, getServerErrorCooldownMs, getStorageBackupEnabled, getPreemptiveQuotaEnabled, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, getPreemptiveQuotaMaxDeferralMs, loadPluginConfig, } from "./lib/config.js";
32
+ import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
33
+ import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrelationId, clearCorrelationId, } from "./lib/logger.js";
34
+ import { checkAndNotify } from "./lib/auto-update-checker.js";
35
+ import { handleContextOverflow } from "./lib/context-overflow.js";
36
+ import { AccountManager, getAccountIdCandidates, extractAccountEmail, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, sanitizeEmail, selectBestAccountCandidate, shouldUpdateAccountIdFromToken, resolveRequestAccountId, parseRateLimitReason, lookupCodexCliTokensByEmail, isCodexCliSyncEnabled, } from "./lib/accounts.js";
37
+ import { getStoragePath, loadAccounts, saveAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, exportAccounts, importAccounts, loadFlaggedAccounts, saveFlaggedAccounts, clearFlaggedAccounts, StorageError, formatStorageErrorHint, setStorageBackupEnabled, } from "./lib/storage.js";
38
+ import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
39
+ import { applyFastSessionDefaults } from "./lib/request/request-transformer.js";
40
+ import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js";
41
+ import { isEmptyResponse } from "./lib/request/response-handler.js";
42
+ import { addJitter } from "./lib/rotation.js";
43
+ import { SessionAffinityStore } from "./lib/session-affinity.js";
44
+ import { LiveAccountSync } from "./lib/live-account-sync.js";
45
+ import { RefreshGuardian } from "./lib/refresh-guardian.js";
46
+ import { evaluateFailurePolicy, } from "./lib/request/failure-policy.js";
47
+ import { EntitlementCache, resolveEntitlementAccountKey, } from "./lib/entitlement-cache.js";
48
+ import { PreemptiveQuotaScheduler, readQuotaSchedulerSnapshot, } from "./lib/preemptive-quota-scheduler.js";
49
+ import { CapabilityPolicyStore } from "./lib/capability-policy.js";
50
+ import { withStreamingFailover } from "./lib/request/stream-failover.js";
51
+ import { buildTableHeader, buildTableRow } from "./lib/table-formatter.js";
52
+ import { setUiRuntimeOptions } from "./lib/ui/runtime.js";
53
+ import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js";
54
+ import { getModelFamily, getCodexInstructions, MODEL_FAMILIES, prewarmCodexInstructions, } from "./lib/prompts/codex.js";
55
+ import { prewarmOpenCodeCodexPrompt } from "./lib/prompts/opencode-codex.js";
56
+ import { createSessionRecoveryHook, isRecoverableError, detectErrorType, getRecoveryToastContent, } from "./lib/recovery.js";
57
+ import { createHashlineEditTool, createHashlineReadTool, } from "./lib/tools/hashline-tools.js";
58
+ import { registerCleanup } from "./lib/shutdown.js";
59
+ /**
60
+ * OpenAI Codex OAuth authentication plugin for opencode
61
+ *
62
+ * This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro
63
+ * OAuth authentication, allowing users to leverage their ChatGPT subscription
64
+ * instead of OpenAI Platform API credits.
65
+ *
66
+ * @example
67
+ * ```json
68
+ * {
69
+ * "plugin": ["codex-multi-auth"],
70
+
71
+ * "model": "openai/gpt-5-codex"
72
+ * }
73
+ * ```
74
+ */
75
+ // eslint-disable-next-line @typescript-eslint/require-await
76
+ export const OpenAIOAuthPlugin = async ({ client }) => {
77
+ initLogger(client);
78
+ let cachedAccountManager = null;
79
+ let accountManagerPromise = null;
80
+ let loaderMutex = null;
81
+ let startupPrewarmTriggered = false;
82
+ let lastCodexCliActiveSyncIndex = null;
83
+ let perProjectStorageWarningShown = false;
84
+ let liveAccountSync = null;
85
+ let liveAccountSyncPath = null;
86
+ let refreshGuardian = null;
87
+ let refreshGuardianConfigKey = null;
88
+ let sessionAffinityStore = new SessionAffinityStore();
89
+ let sessionAffinityConfigKey = null;
90
+ const entitlementCache = new EntitlementCache();
91
+ const preemptiveQuotaScheduler = new PreemptiveQuotaScheduler();
92
+ const capabilityPolicyStore = new CapabilityPolicyStore();
93
+ let accountReloadInFlight = null;
94
+ const exposeAdvancedCodexTools = (process.env.CODEX_MULTI_AUTH_EXPOSE_ADMIN_TOOLS ?? "").trim() === "1";
95
+ const MIN_BACKOFF_MS = 100;
96
+ const STREAM_FAILOVER_MAX_BY_MODE = {
97
+ aggressive: 1,
98
+ balanced: 2,
99
+ conservative: 2,
100
+ };
101
+ const STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE = {
102
+ aggressive: 10_000,
103
+ balanced: 15_000,
104
+ conservative: 20_000,
105
+ };
106
+ const parseFailoverMode = (value) => {
107
+ const normalized = (value ?? "").trim().toLowerCase();
108
+ if (normalized === "aggressive")
109
+ return "aggressive";
110
+ if (normalized === "conservative")
111
+ return "conservative";
112
+ return "balanced";
113
+ };
114
+ const parseEnvInt = (value) => {
115
+ if (value === undefined)
116
+ return undefined;
117
+ const parsed = Number.parseInt(value, 10);
118
+ return Number.isFinite(parsed) ? parsed : undefined;
119
+ };
120
+ const sanitizeResponseHeadersForLog = (headers) => {
121
+ const allowed = new Set([
122
+ "content-type",
123
+ "x-request-id",
124
+ "x-openai-request-id",
125
+ "x-codex-plan-type",
126
+ "x-codex-active-limit",
127
+ "x-codex-primary-used-percent",
128
+ "x-codex-primary-window-minutes",
129
+ "x-codex-primary-reset-at",
130
+ "x-codex-primary-reset-after-seconds",
131
+ "x-codex-secondary-used-percent",
132
+ "x-codex-secondary-window-minutes",
133
+ "x-codex-secondary-reset-at",
134
+ "x-codex-secondary-reset-after-seconds",
135
+ "retry-after",
136
+ "x-ratelimit-reset",
137
+ "x-ratelimit-reset-requests",
138
+ ]);
139
+ const sanitized = {};
140
+ for (const [rawName, rawValue] of headers.entries()) {
141
+ const name = rawName.toLowerCase();
142
+ if (!allowed.has(name))
143
+ continue;
144
+ sanitized[name] = rawValue;
145
+ }
146
+ return sanitized;
147
+ };
148
+ const runtimeMetrics = {
149
+ startedAt: Date.now(),
150
+ totalRequests: 0,
151
+ successfulRequests: 0,
152
+ failedRequests: 0,
153
+ rateLimitedResponses: 0,
154
+ serverErrors: 0,
155
+ networkErrors: 0,
156
+ userAborts: 0,
157
+ authRefreshFailures: 0,
158
+ emptyResponseRetries: 0,
159
+ accountRotations: 0,
160
+ sameAccountRetries: 0,
161
+ streamFailoverAttempts: 0,
162
+ streamFailoverRecoveries: 0,
163
+ streamFailoverCrossAccountRecoveries: 0,
164
+ cumulativeLatencyMs: 0,
165
+ lastRequestAt: null,
166
+ lastError: null,
167
+ };
168
+ const resolveAccountSelection = (tokens) => {
169
+ const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim();
170
+ if (override) {
171
+ const suffix = override.length > 6 ? override.slice(-6) : override;
172
+ logInfo(`Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`);
173
+ return {
174
+ ...tokens,
175
+ accountIdOverride: override,
176
+ accountIdSource: "manual",
177
+ accountLabel: `Override [id:${suffix}]`,
178
+ };
179
+ }
180
+ const candidates = getAccountIdCandidates(tokens.access, tokens.idToken);
181
+ if (candidates.length === 0) {
182
+ return tokens;
183
+ }
184
+ if (candidates.length === 1) {
185
+ const [candidate] = candidates;
186
+ if (candidate) {
187
+ return {
188
+ ...tokens,
189
+ accountIdOverride: candidate.accountId,
190
+ accountIdSource: candidate.source,
191
+ accountLabel: candidate.label,
192
+ };
193
+ }
194
+ }
195
+ // Auto-select the best workspace candidate without prompting.
196
+ // This honors org/default/id-token signals and avoids forcing personal token IDs.
197
+ const choice = selectBestAccountCandidate(candidates);
198
+ if (!choice)
199
+ return tokens;
200
+ return {
201
+ ...tokens,
202
+ accountIdOverride: choice.accountId,
203
+ accountIdSource: choice.source ?? "token",
204
+ accountLabel: choice.label,
205
+ };
206
+ };
207
+ const buildManualOAuthFlow = (pkce, url, expectedState, onSuccess) => ({
208
+ url,
209
+ method: "code",
210
+ instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL,
211
+ validate: (input) => {
212
+ const parsed = parseAuthorizationInput(input);
213
+ if (!parsed.code) {
214
+ return "No authorization code found. Paste the full callback URL (e.g., http://localhost:1455/auth/callback?code=...)";
215
+ }
216
+ if (!parsed.state) {
217
+ return "Missing OAuth state. Paste the full callback URL including both code and state parameters.";
218
+ }
219
+ if (parsed.state !== expectedState) {
220
+ return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt.";
221
+ }
222
+ return undefined;
223
+ },
224
+ callback: async (input) => {
225
+ const parsed = parseAuthorizationInput(input);
226
+ if (!parsed.code || !parsed.state) {
227
+ return {
228
+ type: "failed",
229
+ reason: "invalid_response",
230
+ message: "Missing authorization code or OAuth state",
231
+ };
232
+ }
233
+ if (parsed.state !== expectedState) {
234
+ return {
235
+ type: "failed",
236
+ reason: "invalid_response",
237
+ message: "OAuth state mismatch. Restart login and try again.",
238
+ };
239
+ }
240
+ const tokens = await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
241
+ if (tokens?.type === "success") {
242
+ const resolved = resolveAccountSelection(tokens);
243
+ if (onSuccess) {
244
+ await onSuccess(resolved);
245
+ }
246
+ return resolved;
247
+ }
248
+ return tokens?.type === "failed"
249
+ ? tokens
250
+ : { type: "failed" };
251
+ },
252
+ });
253
+ const runOAuthFlow = async (forceNewLogin = false) => {
254
+ const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin });
255
+ logInfo(`OAuth URL: ${redactOAuthUrlForLog(url)}`);
256
+ let serverInfo = null;
257
+ try {
258
+ serverInfo = await startLocalOAuthServer({ state });
259
+ }
260
+ catch (err) {
261
+ logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${err?.message ?? String(err)}`);
262
+ serverInfo = null;
263
+ }
264
+ openBrowserUrl(url);
265
+ if (!serverInfo || !serverInfo.ready) {
266
+ serverInfo?.close();
267
+ const message = `\n[${PLUGIN_NAME}] OAuth callback server failed to start. ` +
268
+ `Please retry with "${AUTH_LABELS.OAUTH_MANUAL}".\n`;
269
+ logWarn(message);
270
+ return { type: "failed" };
271
+ }
272
+ const result = await serverInfo.waitForCode(state);
273
+ serverInfo.close();
274
+ if (!result) {
275
+ return { type: "failed", reason: "unknown", message: "OAuth callback timeout or cancelled" };
276
+ }
277
+ return await exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI);
278
+ };
279
+ const persistAccountPool = async (results, replaceAll = false) => {
280
+ if (results.length === 0)
281
+ return;
282
+ await withAccountStorageTransaction(async (loadedStorage, persist) => {
283
+ const now = Date.now();
284
+ const stored = replaceAll ? null : loadedStorage;
285
+ const accounts = stored?.accounts ? [...stored.accounts] : [];
286
+ const indexByRefreshToken = new Map();
287
+ const indexByAccountId = new Map();
288
+ const indexByEmail = new Map();
289
+ for (let i = 0; i < accounts.length; i += 1) {
290
+ const account = accounts[i];
291
+ if (!account)
292
+ continue;
293
+ if (account.refreshToken) {
294
+ indexByRefreshToken.set(account.refreshToken, i);
295
+ }
296
+ if (account.accountId) {
297
+ indexByAccountId.set(account.accountId, i);
298
+ }
299
+ if (account.email) {
300
+ indexByEmail.set(account.email, i);
301
+ }
302
+ }
303
+ for (const result of results) {
304
+ const accountId = result.accountIdOverride ?? extractAccountId(result.access);
305
+ const accountIdSource = accountId
306
+ ? result.accountIdSource ??
307
+ (result.accountIdOverride ? "manual" : "token")
308
+ : undefined;
309
+ const accountLabel = result.accountLabel;
310
+ const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken));
311
+ const existingByEmail = accountEmail && indexByEmail.has(accountEmail)
312
+ ? indexByEmail.get(accountEmail)
313
+ : undefined;
314
+ const existingById = accountId && indexByAccountId.has(accountId)
315
+ ? indexByAccountId.get(accountId)
316
+ : undefined;
317
+ const existingByToken = indexByRefreshToken.get(result.refresh);
318
+ const existingIndex = existingById ?? existingByEmail ?? existingByToken;
319
+ if (existingIndex === undefined) {
320
+ const newIndex = accounts.length;
321
+ accounts.push({
322
+ accountId,
323
+ accountIdSource,
324
+ accountLabel,
325
+ email: accountEmail,
326
+ refreshToken: result.refresh,
327
+ accessToken: result.access,
328
+ expiresAt: result.expires,
329
+ addedAt: now,
330
+ lastUsed: now,
331
+ });
332
+ indexByRefreshToken.set(result.refresh, newIndex);
333
+ if (accountId) {
334
+ indexByAccountId.set(accountId, newIndex);
335
+ }
336
+ if (accountEmail) {
337
+ indexByEmail.set(accountEmail, newIndex);
338
+ }
339
+ continue;
340
+ }
341
+ const existing = accounts[existingIndex];
342
+ if (!existing)
343
+ continue;
344
+ const oldToken = existing.refreshToken;
345
+ const oldEmail = existing.email;
346
+ const nextEmail = accountEmail ?? existing.email;
347
+ const nextAccountId = accountId ?? existing.accountId;
348
+ const nextAccountIdSource = accountId ? accountIdSource ?? existing.accountIdSource : existing.accountIdSource;
349
+ const nextAccountLabel = accountLabel ?? existing.accountLabel;
350
+ accounts[existingIndex] = {
351
+ ...existing,
352
+ accountId: nextAccountId,
353
+ accountIdSource: nextAccountIdSource,
354
+ accountLabel: nextAccountLabel,
355
+ email: nextEmail,
356
+ refreshToken: result.refresh,
357
+ accessToken: result.access,
358
+ expiresAt: result.expires,
359
+ lastUsed: now,
360
+ };
361
+ if (oldToken !== result.refresh) {
362
+ indexByRefreshToken.delete(oldToken);
363
+ indexByRefreshToken.set(result.refresh, existingIndex);
364
+ }
365
+ if (accountId) {
366
+ indexByAccountId.set(accountId, existingIndex);
367
+ }
368
+ if (oldEmail && oldEmail !== nextEmail) {
369
+ indexByEmail.delete(oldEmail);
370
+ }
371
+ if (nextEmail) {
372
+ indexByEmail.set(nextEmail, existingIndex);
373
+ }
374
+ }
375
+ if (accounts.length === 0)
376
+ return;
377
+ const activeIndex = replaceAll
378
+ ? 0
379
+ : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex)
380
+ ? stored.activeIndex
381
+ : 0;
382
+ const clampedActiveIndex = Math.max(0, Math.min(activeIndex, accounts.length - 1));
383
+ const activeIndexByFamily = {};
384
+ for (const family of MODEL_FAMILIES) {
385
+ const storedFamilyIndex = stored?.activeIndexByFamily?.[family];
386
+ const rawFamilyIndex = replaceAll
387
+ ? 0
388
+ : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex)
389
+ ? storedFamilyIndex
390
+ : clampedActiveIndex;
391
+ activeIndexByFamily[family] = Math.max(0, Math.min(Math.floor(rawFamilyIndex), accounts.length - 1));
392
+ }
393
+ await persist({
394
+ version: 3,
395
+ accounts,
396
+ activeIndex: clampedActiveIndex,
397
+ activeIndexByFamily,
398
+ });
399
+ });
400
+ };
401
+ const showToast = async (message, variant = "success", options) => {
402
+ try {
403
+ await client.tui.showToast({
404
+ body: {
405
+ message,
406
+ variant,
407
+ ...(options?.title && { title: options.title }),
408
+ ...(options?.duration && { duration: options.duration }),
409
+ },
410
+ });
411
+ }
412
+ catch {
413
+ // Ignore when TUI is not available.
414
+ }
415
+ };
416
+ const resolveActiveIndex = (storage, family = "codex") => {
417
+ const total = storage.accounts.length;
418
+ if (total === 0)
419
+ return 0;
420
+ const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex;
421
+ const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0;
422
+ return Math.max(0, Math.min(raw, total - 1));
423
+ };
424
+ const hydrateEmails = async (storage) => {
425
+ if (!storage)
426
+ return storage;
427
+ const skipHydrate = process.env.VITEST_WORKER_ID !== undefined ||
428
+ process.env.NODE_ENV === "test" ||
429
+ process.env.OPENCODE_SKIP_EMAIL_HYDRATE === "1";
430
+ if (skipHydrate)
431
+ return storage;
432
+ const accountsCopy = storage.accounts.map((account) => account ? { ...account } : account);
433
+ const accountsToHydrate = accountsCopy.filter((account) => account && !account.email);
434
+ if (accountsToHydrate.length === 0)
435
+ return storage;
436
+ let changed = false;
437
+ await Promise.all(accountsToHydrate.map(async (account) => {
438
+ try {
439
+ const refreshed = await queuedRefresh(account.refreshToken);
440
+ if (refreshed.type !== "success")
441
+ return;
442
+ const id = extractAccountId(refreshed.access);
443
+ const email = sanitizeEmail(extractAccountEmail(refreshed.access, refreshed.idToken));
444
+ if (id &&
445
+ id !== account.accountId &&
446
+ shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId)) {
447
+ account.accountId = id;
448
+ account.accountIdSource = "token";
449
+ changed = true;
450
+ }
451
+ if (email && email !== account.email) {
452
+ account.email = email;
453
+ changed = true;
454
+ }
455
+ if (refreshed.access && refreshed.access !== account.accessToken) {
456
+ account.accessToken = refreshed.access;
457
+ changed = true;
458
+ }
459
+ if (typeof refreshed.expires === "number" && refreshed.expires !== account.expiresAt) {
460
+ account.expiresAt = refreshed.expires;
461
+ changed = true;
462
+ }
463
+ if (refreshed.refresh && refreshed.refresh !== account.refreshToken) {
464
+ account.refreshToken = refreshed.refresh;
465
+ changed = true;
466
+ }
467
+ }
468
+ catch {
469
+ logWarn(`[${PLUGIN_NAME}] Failed to hydrate email for account`);
470
+ }
471
+ }));
472
+ if (changed) {
473
+ storage.accounts = accountsCopy;
474
+ await saveAccounts(storage);
475
+ }
476
+ return storage;
477
+ };
478
+ const getRateLimitResetTimeForFamily = (account, now, family) => {
479
+ const times = account.rateLimitResetTimes;
480
+ if (!times)
481
+ return null;
482
+ let minReset = null;
483
+ const prefix = `${family}:`;
484
+ for (const [key, value] of Object.entries(times)) {
485
+ if (typeof value !== "number")
486
+ continue;
487
+ if (value <= now)
488
+ continue;
489
+ if (key !== family && !key.startsWith(prefix))
490
+ continue;
491
+ if (minReset === null || value < minReset) {
492
+ minReset = value;
493
+ }
494
+ }
495
+ return minReset;
496
+ };
497
+ const formatRateLimitEntry = (account, now, family = "codex") => {
498
+ const resetAt = getRateLimitResetTimeForFamily(account, now, family);
499
+ if (typeof resetAt !== "number")
500
+ return null;
501
+ const remaining = resetAt - now;
502
+ if (remaining <= 0)
503
+ return null;
504
+ return `resets in ${formatWaitTime(remaining)}`;
505
+ };
506
+ const applyUiRuntimeFromConfig = (pluginConfig) => {
507
+ return setUiRuntimeOptions({
508
+ v2Enabled: getCodexTuiV2(pluginConfig),
509
+ colorProfile: getCodexTuiColorProfile(pluginConfig),
510
+ glyphMode: getCodexTuiGlyphMode(pluginConfig),
511
+ });
512
+ };
513
+ const resolveUiRuntime = () => {
514
+ return applyUiRuntimeFromConfig(loadPluginConfig());
515
+ };
516
+ const getStatusMarker = (ui, status) => {
517
+ if (!ui.v2Enabled) {
518
+ if (status === "ok")
519
+ return "✓";
520
+ if (status === "warning")
521
+ return "!";
522
+ return "✗";
523
+ }
524
+ if (status === "ok")
525
+ return ui.theme.glyphs.check;
526
+ if (status === "warning")
527
+ return "!";
528
+ return ui.theme.glyphs.cross;
529
+ };
530
+ const invalidateAccountManagerCache = () => {
531
+ cachedAccountManager = null;
532
+ accountManagerPromise = null;
533
+ };
534
+ const reloadAccountManagerFromDisk = async (authFallback) => {
535
+ if (accountReloadInFlight) {
536
+ return accountReloadInFlight;
537
+ }
538
+ accountReloadInFlight = (async () => {
539
+ const reloaded = await AccountManager.loadFromDisk(authFallback);
540
+ cachedAccountManager = reloaded;
541
+ accountManagerPromise = Promise.resolve(reloaded);
542
+ return reloaded;
543
+ })();
544
+ try {
545
+ return await accountReloadInFlight;
546
+ }
547
+ finally {
548
+ accountReloadInFlight = null;
549
+ }
550
+ };
551
+ const applyAccountStorageScope = (pluginConfig) => {
552
+ const perProjectAccounts = getPerProjectAccounts(pluginConfig);
553
+ setStorageBackupEnabled(getStorageBackupEnabled(pluginConfig));
554
+ if (isCodexCliSyncEnabled()) {
555
+ if (perProjectAccounts && !perProjectStorageWarningShown) {
556
+ perProjectStorageWarningShown = true;
557
+ logWarn(`[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`);
558
+ }
559
+ setStoragePath(null);
560
+ return;
561
+ }
562
+ setStoragePath(perProjectAccounts ? process.cwd() : null);
563
+ };
564
+ const ensureLiveAccountSync = async (pluginConfig, authFallback) => {
565
+ if (!getLiveAccountSync(pluginConfig)) {
566
+ if (liveAccountSync) {
567
+ liveAccountSync.stop();
568
+ liveAccountSync = null;
569
+ liveAccountSyncPath = null;
570
+ }
571
+ return;
572
+ }
573
+ const targetPath = getStoragePath();
574
+ if (!liveAccountSync) {
575
+ liveAccountSync = new LiveAccountSync(async () => {
576
+ await reloadAccountManagerFromDisk(authFallback);
577
+ }, {
578
+ debounceMs: getLiveAccountSyncDebounceMs(pluginConfig),
579
+ pollIntervalMs: getLiveAccountSyncPollMs(pluginConfig),
580
+ });
581
+ registerCleanup(() => {
582
+ liveAccountSync?.stop();
583
+ });
584
+ }
585
+ if (liveAccountSyncPath !== targetPath) {
586
+ let switched = false;
587
+ for (let attempt = 0; attempt < 3; attempt += 1) {
588
+ try {
589
+ await liveAccountSync.syncToPath(targetPath);
590
+ liveAccountSyncPath = targetPath;
591
+ switched = true;
592
+ break;
593
+ }
594
+ catch (error) {
595
+ const code = error?.code;
596
+ if (code !== "EBUSY" && code !== "EPERM") {
597
+ throw error;
598
+ }
599
+ await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt));
600
+ }
601
+ }
602
+ if (!switched) {
603
+ logWarn(`[${PLUGIN_NAME}] Live account sync path switch failed due to transient filesystem locks; keeping previous watcher.`);
604
+ }
605
+ }
606
+ };
607
+ const ensureRefreshGuardian = (pluginConfig) => {
608
+ if (!getProactiveRefreshGuardian(pluginConfig)) {
609
+ if (refreshGuardian) {
610
+ refreshGuardian.stop();
611
+ refreshGuardian = null;
612
+ refreshGuardianConfigKey = null;
613
+ }
614
+ return;
615
+ }
616
+ const intervalMs = getProactiveRefreshIntervalMs(pluginConfig);
617
+ const bufferMs = getProactiveRefreshBufferMs(pluginConfig);
618
+ const configKey = `${intervalMs}:${bufferMs}`;
619
+ if (refreshGuardian && refreshGuardianConfigKey === configKey)
620
+ return;
621
+ if (refreshGuardian) {
622
+ refreshGuardian.stop();
623
+ }
624
+ refreshGuardian = new RefreshGuardian(() => cachedAccountManager, { intervalMs, bufferMs });
625
+ refreshGuardianConfigKey = configKey;
626
+ refreshGuardian.start();
627
+ registerCleanup(() => {
628
+ refreshGuardian?.stop();
629
+ });
630
+ };
631
+ const ensureSessionAffinity = (pluginConfig) => {
632
+ if (!getSessionAffinity(pluginConfig)) {
633
+ sessionAffinityStore = null;
634
+ sessionAffinityConfigKey = null;
635
+ return;
636
+ }
637
+ const ttlMs = getSessionAffinityTtlMs(pluginConfig);
638
+ const maxEntries = getSessionAffinityMaxEntries(pluginConfig);
639
+ const configKey = `${ttlMs}:${maxEntries}`;
640
+ if (sessionAffinityStore && sessionAffinityConfigKey === configKey)
641
+ return;
642
+ sessionAffinityStore = new SessionAffinityStore({ ttlMs, maxEntries });
643
+ sessionAffinityConfigKey = configKey;
644
+ };
645
+ const applyPreemptiveQuotaSettings = (pluginConfig) => {
646
+ preemptiveQuotaScheduler.configure({
647
+ enabled: getPreemptiveQuotaEnabled(pluginConfig),
648
+ remainingPercentThresholdPrimary: getPreemptiveQuotaRemainingPercent5h(pluginConfig),
649
+ remainingPercentThresholdSecondary: getPreemptiveQuotaRemainingPercent7d(pluginConfig),
650
+ maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig),
651
+ });
652
+ };
653
+ // Event handler for session recovery and account selection
654
+ const eventHandler = async (input) => {
655
+ try {
656
+ const { event } = input;
657
+ // Handle TUI account selection events
658
+ // Accepts generic selection events with an index property
659
+ if (event.type === "account.select" ||
660
+ event.type === "openai.account.select") {
661
+ const props = event.properties;
662
+ // Filter by provider if specified
663
+ if (props.provider && props.provider !== "openai" && props.provider !== PROVIDER_ID) {
664
+ return;
665
+ }
666
+ const index = props.index ?? props.accountIndex;
667
+ if (typeof index === "number") {
668
+ const storage = await loadAccounts();
669
+ if (!storage || index < 0 || index >= storage.accounts.length) {
670
+ return;
671
+ }
672
+ const now = Date.now();
673
+ const account = storage.accounts[index];
674
+ if (account) {
675
+ account.lastUsed = now;
676
+ account.lastSwitchReason = "rotation";
677
+ }
678
+ storage.activeIndex = index;
679
+ storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
680
+ for (const family of MODEL_FAMILIES) {
681
+ storage.activeIndexByFamily[family] = index;
682
+ }
683
+ await saveAccounts(storage);
684
+ if (cachedAccountManager) {
685
+ await cachedAccountManager.syncCodexCliActiveSelectionForIndex(index);
686
+ }
687
+ lastCodexCliActiveSyncIndex = index;
688
+ // Reload manager from disk so we don't overwrite newer rotated
689
+ // refresh tokens with stale in-memory state.
690
+ if (cachedAccountManager) {
691
+ await reloadAccountManagerFromDisk();
692
+ }
693
+ await showToast(`Switched to account ${index + 1}`, "info");
694
+ }
695
+ }
696
+ }
697
+ catch (error) {
698
+ logDebug(`[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`);
699
+ }
700
+ };
701
+ // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically.
702
+ resolveUiRuntime();
703
+ return {
704
+ event: eventHandler,
705
+ auth: {
706
+ provider: PROVIDER_ID,
707
+ /**
708
+ * Loader function that configures OAuth authentication and request handling
709
+ *
710
+ * This function:
711
+ * 1. Validates OAuth authentication
712
+ * 2. Loads multi-account pool from disk (fallback to current auth)
713
+ * 3. Loads user configuration from opencode.json
714
+ * 4. Fetches Codex system instructions from GitHub (cached)
715
+ * 5. Returns SDK configuration with custom fetch implementation
716
+ *
717
+ * @param getAuth - Function to retrieve current auth state
718
+ * @param provider - Provider configuration from opencode.json
719
+ * @returns SDK configuration object or empty object for non-OAuth auth
720
+ */
721
+ async loader(getAuth, provider) {
722
+ const auth = await getAuth();
723
+ const pluginConfig = loadPluginConfig();
724
+ applyUiRuntimeFromConfig(pluginConfig);
725
+ applyAccountStorageScope(pluginConfig);
726
+ ensureSessionAffinity(pluginConfig);
727
+ ensureRefreshGuardian(pluginConfig);
728
+ applyPreemptiveQuotaSettings(pluginConfig);
729
+ // Only handle OAuth auth type, skip API key auth
730
+ if (auth.type !== "oauth") {
731
+ return {};
732
+ }
733
+ // Prefer multi-account auth metadata when available, but still handle
734
+ // plain OAuth credentials (for OpenCode versions that inject internal
735
+ // Codex auth first and omit the multiAccount marker).
736
+ const authWithMulti = auth;
737
+ if (!authWithMulti.multiAccount) {
738
+ logDebug(`[${PLUGIN_NAME}] Auth is missing multiAccount marker; continuing with single-account compatibility mode`);
739
+ }
740
+ // Acquire mutex for thread-safe initialization
741
+ // Use while loop to handle multiple concurrent waiters correctly
742
+ while (loaderMutex) {
743
+ await loaderMutex;
744
+ }
745
+ let resolveMutex;
746
+ loaderMutex = new Promise((resolve) => {
747
+ resolveMutex = resolve;
748
+ });
749
+ try {
750
+ await ensureLiveAccountSync(pluginConfig, auth);
751
+ if (!accountManagerPromise) {
752
+ await reloadAccountManagerFromDisk(auth);
753
+ }
754
+ const managerPromise = accountManagerPromise ??
755
+ reloadAccountManagerFromDisk(auth);
756
+ let accountManager = await managerPromise;
757
+ cachedAccountManager = accountManager;
758
+ const refreshToken = auth.type === "oauth" ? auth.refresh : "";
759
+ const needsPersist = refreshToken &&
760
+ !accountManager.hasRefreshToken(refreshToken);
761
+ if (needsPersist) {
762
+ await accountManager.saveToDisk();
763
+ }
764
+ if (accountManager.getAccountCount() === 0) {
765
+ logDebug(`[${PLUGIN_NAME}] No OAuth accounts available (run codex login)`);
766
+ return {};
767
+ }
768
+ // Extract user configuration (global + per-model options)
769
+ const providerConfig = provider;
770
+ const userConfig = {
771
+ global: providerConfig?.options || {},
772
+ models: providerConfig?.models || {},
773
+ };
774
+ // Load plugin configuration and determine CODEX_MODE
775
+ // Priority: CODEX_MODE env var > config file > default (true)
776
+ const codexMode = getCodexMode(pluginConfig);
777
+ const fastSessionEnabled = getFastSession(pluginConfig);
778
+ const fastSessionStrategy = getFastSessionStrategy(pluginConfig);
779
+ const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig);
780
+ const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
781
+ const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
782
+ const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
783
+ const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
784
+ const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
785
+ const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig);
786
+ const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback";
787
+ const fallbackToGpt52OnUnsupportedGpt53 = getFallbackToGpt52OnUnsupportedGpt53(pluginConfig);
788
+ const unsupportedCodexFallbackChain = getUnsupportedCodexFallbackChain(pluginConfig);
789
+ const toastDurationMs = getToastDurationMs(pluginConfig);
790
+ const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig);
791
+ const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig);
792
+ const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig);
793
+ const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig);
794
+ const failoverMode = parseFailoverMode(process.env.CODEX_AUTH_FAILOVER_MODE);
795
+ const streamFailoverMax = Math.max(0, parseEnvInt(process.env.CODEX_AUTH_STREAM_FAILOVER_MAX) ??
796
+ STREAM_FAILOVER_MAX_BY_MODE[failoverMode]);
797
+ const streamFailoverSoftTimeoutMs = Math.max(1_000, parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_SOFT_TIMEOUT_MS) ??
798
+ STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE[failoverMode]);
799
+ const streamFailoverHardTimeoutMs = Math.max(streamFailoverSoftTimeoutMs, parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_HARD_TIMEOUT_MS) ??
800
+ streamStallTimeoutMs);
801
+ const maxSameAccountRetries = failoverMode === "conservative" ? 2 : failoverMode === "balanced" ? 1 : 0;
802
+ const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
803
+ const autoResumeEnabled = getAutoResume(pluginConfig);
804
+ const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig);
805
+ const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig);
806
+ const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
807
+ const effectiveUserConfig = fastSessionEnabled
808
+ ? applyFastSessionDefaults(userConfig)
809
+ : userConfig;
810
+ if (fastSessionEnabled) {
811
+ logDebug("Fast session mode enabled", {
812
+ reasoningEffort: "none/low",
813
+ reasoningSummary: "auto",
814
+ textVerbosity: "low",
815
+ fastSessionStrategy,
816
+ fastSessionMaxInputItems,
817
+ });
818
+ }
819
+ const prewarmEnabled = process.env.CODEX_AUTH_PREWARM !== "0" &&
820
+ process.env.VITEST !== "true" &&
821
+ process.env.NODE_ENV !== "test";
822
+ if (!startupPrewarmTriggered && prewarmEnabled) {
823
+ startupPrewarmTriggered = true;
824
+ const configuredModels = Object.keys(userConfig.models ?? {});
825
+ prewarmCodexInstructions(configuredModels);
826
+ if (codexMode) {
827
+ prewarmOpenCodeCodexPrompt();
828
+ }
829
+ }
830
+ const recoveryHook = sessionRecoveryEnabled
831
+ ? createSessionRecoveryHook({ client, directory: process.cwd() }, { sessionRecovery: true, autoResume: autoResumeEnabled })
832
+ : null;
833
+ checkAndNotify(async (message, variant) => {
834
+ await showToast(message, variant);
835
+ }).catch((err) => {
836
+ logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`);
837
+ });
838
+ // Return SDK configuration
839
+ return {
840
+ apiKey: DUMMY_API_KEY,
841
+ baseURL: CODEX_BASE_URL,
842
+ /**
843
+ * Custom fetch implementation for Codex API
844
+ *
845
+ * Handles:
846
+ * - Token refresh when expired
847
+ * - URL rewriting for Codex backend
848
+ * - Request body transformation
849
+ * - OAuth header injection
850
+ * - SSE to JSON conversion for non-tool requests
851
+ * - Error handling and logging
852
+ *
853
+ * @param input - Request URL or Request object
854
+ * @param init - Request options
855
+ * @returns Response from Codex API
856
+ */
857
+ async fetch(input, init) {
858
+ try {
859
+ if (cachedAccountManager && cachedAccountManager !== accountManager) {
860
+ accountManager = cachedAccountManager;
861
+ }
862
+ // Step 1: Extract and rewrite URL for Codex backend
863
+ const originalUrl = extractRequestUrl(input);
864
+ const url = rewriteUrlForCodex(originalUrl);
865
+ // Step 3: Transform request body with model-specific Codex instructions
866
+ // Instructions are fetched per model family (codex-max, codex, gpt-5.1)
867
+ // Capture original stream value before transformation
868
+ // generateText() sends no stream field, streamText() sends stream=true
869
+ const normalizeRequestInit = async (requestInput, requestInit) => {
870
+ if (requestInit)
871
+ return requestInit;
872
+ if (!(requestInput instanceof Request))
873
+ return requestInit;
874
+ const method = requestInput.method || "GET";
875
+ const normalized = {
876
+ method,
877
+ headers: new Headers(requestInput.headers),
878
+ };
879
+ if (method !== "GET" && method !== "HEAD") {
880
+ try {
881
+ const bodyText = await requestInput.clone().text();
882
+ if (bodyText) {
883
+ normalized.body = bodyText;
884
+ }
885
+ }
886
+ catch {
887
+ // Body may be unreadable; proceed without it.
888
+ }
889
+ }
890
+ return normalized;
891
+ };
892
+ const parseRequestBodyFromInit = async (body) => {
893
+ if (!body)
894
+ return {};
895
+ try {
896
+ if (typeof body === "string") {
897
+ return JSON.parse(body);
898
+ }
899
+ if (body instanceof Uint8Array) {
900
+ return JSON.parse(new TextDecoder().decode(body));
901
+ }
902
+ if (body instanceof ArrayBuffer) {
903
+ return JSON.parse(new TextDecoder().decode(new Uint8Array(body)));
904
+ }
905
+ if (ArrayBuffer.isView(body)) {
906
+ const view = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
907
+ return JSON.parse(new TextDecoder().decode(view));
908
+ }
909
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
910
+ return JSON.parse(await body.text());
911
+ }
912
+ }
913
+ catch {
914
+ logWarn("Failed to parse request body, using empty object");
915
+ }
916
+ return {};
917
+ };
918
+ const baseInit = await normalizeRequestInit(input, init);
919
+ const originalBody = await parseRequestBodyFromInit(baseInit?.body);
920
+ const isStreaming = originalBody.stream === true;
921
+ const parsedBody = Object.keys(originalBody).length > 0 ? originalBody : undefined;
922
+ const transformation = await transformRequestForCodex(baseInit, url, effectiveUserConfig, codexMode, parsedBody, {
923
+ fastSession: fastSessionEnabled,
924
+ fastSessionStrategy,
925
+ fastSessionMaxInputItems,
926
+ });
927
+ let requestInit = transformation?.updatedInit ?? baseInit;
928
+ let transformedBody = transformation?.body;
929
+ const promptCacheKey = transformedBody?.prompt_cache_key;
930
+ let model = transformedBody?.model;
931
+ let modelFamily = model ? getModelFamily(model) : "gpt-5.1";
932
+ let quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
933
+ const threadIdCandidate = (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "")
934
+ .toString()
935
+ .trim() || undefined;
936
+ const sessionAffinityKey = threadIdCandidate ?? promptCacheKey ?? null;
937
+ const effectivePromptCacheKey = (sessionAffinityKey ?? promptCacheKey ?? "").toString().trim() || undefined;
938
+ const preferredSessionAccountIndex = sessionAffinityStore?.getPreferredAccountIndex(sessionAffinityKey);
939
+ sessionAffinityStore?.prune();
940
+ const requestCorrelationId = setCorrelationId(threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined);
941
+ runtimeMetrics.lastRequestAt = Date.now();
942
+ const abortSignal = requestInit?.signal ?? init?.signal ?? null;
943
+ const sleep = (ms) => new Promise((resolve, reject) => {
944
+ if (abortSignal?.aborted) {
945
+ reject(new Error("Aborted"));
946
+ return;
947
+ }
948
+ const timeout = setTimeout(() => {
949
+ cleanup();
950
+ resolve();
951
+ }, ms);
952
+ const onAbort = () => {
953
+ cleanup();
954
+ reject(new Error("Aborted"));
955
+ };
956
+ const cleanup = () => {
957
+ clearTimeout(timeout);
958
+ abortSignal?.removeEventListener("abort", onAbort);
959
+ };
960
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
961
+ });
962
+ const sleepWithCountdown = async (totalMs, message, intervalMs = 5000) => {
963
+ const startTime = Date.now();
964
+ const endTime = startTime + totalMs;
965
+ while (Date.now() < endTime) {
966
+ if (abortSignal?.aborted) {
967
+ throw new Error("Aborted");
968
+ }
969
+ const remaining = Math.max(0, endTime - Date.now());
970
+ const waitLabel = formatWaitTime(remaining);
971
+ await showToast(`${message} (${waitLabel} remaining)`, "warning", { duration: Math.min(intervalMs + 1000, toastDurationMs) });
972
+ const sleepTime = Math.min(intervalMs, remaining);
973
+ if (sleepTime > 0) {
974
+ await sleep(sleepTime);
975
+ }
976
+ else {
977
+ break;
978
+ }
979
+ }
980
+ };
981
+ let allRateLimitedRetries = 0;
982
+ let emptyResponseRetries = 0;
983
+ const attemptedUnsupportedFallbackModels = new Set();
984
+ if (model) {
985
+ attemptedUnsupportedFallbackModels.add(model);
986
+ }
987
+ while (true) {
988
+ const accountCount = accountManager.getAccountCount();
989
+ const attempted = new Set();
990
+ let restartAccountTraversalWithFallback = false;
991
+ let usedPreferredSessionAccount = false;
992
+ const capabilityBoostByAccount = {};
993
+ const accountSnapshotSource = accountManager;
994
+ const accountSnapshotList = typeof accountSnapshotSource.getAccountsSnapshot === "function"
995
+ ? accountSnapshotSource.getAccountsSnapshot() ?? []
996
+ : [];
997
+ if (accountSnapshotList.length === 0 &&
998
+ typeof accountSnapshotSource.getAccountByIndex === "function") {
999
+ for (let accountSnapshotIndex = 0; accountSnapshotIndex < accountCount; accountSnapshotIndex += 1) {
1000
+ const candidate = accountSnapshotSource.getAccountByIndex(accountSnapshotIndex);
1001
+ if (candidate) {
1002
+ accountSnapshotList.push(candidate);
1003
+ }
1004
+ }
1005
+ }
1006
+ for (const candidate of accountSnapshotList) {
1007
+ const accountKey = resolveEntitlementAccountKey(candidate);
1008
+ capabilityBoostByAccount[candidate.index] = capabilityPolicyStore.getBoost(accountKey, model ?? modelFamily);
1009
+ }
1010
+ while (attempted.size < Math.max(1, accountCount)) {
1011
+ let account = null;
1012
+ if (!usedPreferredSessionAccount &&
1013
+ typeof preferredSessionAccountIndex === "number") {
1014
+ usedPreferredSessionAccount = true;
1015
+ if (accountManager.isAccountAvailableForFamily(preferredSessionAccountIndex, modelFamily, model)) {
1016
+ account = accountManager.getAccountByIndex(preferredSessionAccountIndex);
1017
+ if (account) {
1018
+ account.lastUsed = Date.now();
1019
+ accountManager.markSwitched(account, "rotation", modelFamily);
1020
+ }
1021
+ }
1022
+ else {
1023
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1024
+ }
1025
+ }
1026
+ if (!account) {
1027
+ account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, {
1028
+ pidOffsetEnabled,
1029
+ scoreBoostByAccount: capabilityBoostByAccount,
1030
+ });
1031
+ }
1032
+ if (!account || attempted.has(account.index)) {
1033
+ break;
1034
+ }
1035
+ attempted.add(account.index);
1036
+ // Log account selection for debugging rotation
1037
+ logDebug(`Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`);
1038
+ let accountAuth = accountManager.toAuthDetails(account);
1039
+ try {
1040
+ if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
1041
+ accountAuth = (await refreshAndUpdateToken(accountAuth, client));
1042
+ accountManager.updateFromAuth(account, accountAuth);
1043
+ accountManager.clearAuthFailures(account);
1044
+ accountManager.saveToDiskDebounced();
1045
+ }
1046
+ }
1047
+ catch (err) {
1048
+ logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
1049
+ runtimeMetrics.authRefreshFailures++;
1050
+ runtimeMetrics.failedRequests++;
1051
+ runtimeMetrics.accountRotations++;
1052
+ runtimeMetrics.lastError = err?.message ?? String(err);
1053
+ const failures = accountManager.incrementAuthFailures(account);
1054
+ const accountLabel = formatAccountLabel(account, account.index);
1055
+ const authFailurePolicy = evaluateFailurePolicy({
1056
+ kind: "auth-refresh",
1057
+ consecutiveAuthFailures: failures,
1058
+ });
1059
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1060
+ if (authFailurePolicy.removeAccount) {
1061
+ const removedIndex = account.index;
1062
+ sessionAffinityStore?.forgetAccount(removedIndex);
1063
+ accountManager.removeAccount(account);
1064
+ sessionAffinityStore?.reindexAfterRemoval(removedIndex);
1065
+ accountManager.saveToDiskDebounced();
1066
+ await showToast(`Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, "error", { duration: toastDurationMs * 2 });
1067
+ continue;
1068
+ }
1069
+ if (typeof authFailurePolicy.cooldownMs === "number" &&
1070
+ authFailurePolicy.cooldownReason) {
1071
+ accountManager.markAccountCoolingDown(account, authFailurePolicy.cooldownMs, authFailurePolicy.cooldownReason);
1072
+ }
1073
+ accountManager.saveToDiskDebounced();
1074
+ continue;
1075
+ }
1076
+ const hadAccountId = !!account.accountId;
1077
+ const tokenAccountId = extractAccountId(accountAuth.access);
1078
+ const accountId = resolveRequestAccountId(account.accountId, account.accountIdSource, tokenAccountId);
1079
+ const entitlementAccountKey = resolveEntitlementAccountKey({
1080
+ accountId: hadAccountId ? account.accountId : undefined,
1081
+ email: account.email,
1082
+ index: account.index,
1083
+ });
1084
+ if (!accountId) {
1085
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
1086
+ accountManager.saveToDiskDebounced();
1087
+ continue;
1088
+ }
1089
+ account.accountId = accountId;
1090
+ if (!hadAccountId && tokenAccountId && accountId === tokenAccountId) {
1091
+ account.accountIdSource = account.accountIdSource ?? "token";
1092
+ }
1093
+ account.email =
1094
+ extractAccountEmail(accountAuth.access) ?? account.email;
1095
+ const entitlementBlock = entitlementCache.isBlocked(entitlementAccountKey, model ?? modelFamily);
1096
+ if (entitlementBlock.blocked) {
1097
+ runtimeMetrics.accountRotations++;
1098
+ runtimeMetrics.lastError = `Entitlement cached block for account ${account.index + 1}`;
1099
+ logWarn(`Skipping account ${account.index + 1} due to cached entitlement block (${formatWaitTime(entitlementBlock.waitMs)} remaining).`);
1100
+ continue;
1101
+ }
1102
+ if (accountCount > 1 &&
1103
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
1104
+ const accountLabel = formatAccountLabel(account, account.index);
1105
+ await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
1106
+ accountManager.markToastShown(account.index);
1107
+ }
1108
+ const headers = createCodexHeaders(requestInit, accountId, accountAuth.access, {
1109
+ model,
1110
+ promptCacheKey: effectivePromptCacheKey,
1111
+ });
1112
+ const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`;
1113
+ const capabilityModelKey = model ?? modelFamily;
1114
+ const quotaDeferral = preemptiveQuotaScheduler.getDeferral(quotaScheduleKey);
1115
+ if (quotaDeferral.defer && quotaDeferral.waitMs > 0) {
1116
+ accountManager.markRateLimitedWithReason(account, quotaDeferral.waitMs, modelFamily, "quota", model);
1117
+ accountManager.recordRateLimit(account, modelFamily, model);
1118
+ runtimeMetrics.accountRotations++;
1119
+ runtimeMetrics.lastError = `Preemptive quota deferral for account ${account.index + 1}`;
1120
+ accountManager.saveToDiskDebounced();
1121
+ continue;
1122
+ }
1123
+ // Consume a token before making the request for proactive rate limiting
1124
+ const tokenConsumed = accountManager.consumeToken(account, modelFamily, model);
1125
+ if (!tokenConsumed) {
1126
+ accountManager.recordRateLimit(account, modelFamily, model);
1127
+ runtimeMetrics.accountRotations++;
1128
+ runtimeMetrics.lastError =
1129
+ `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`;
1130
+ logWarn(`Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`);
1131
+ break;
1132
+ }
1133
+ let sameAccountRetryCount = 0;
1134
+ let successAccountForResponse = account;
1135
+ while (true) {
1136
+ let response;
1137
+ const fetchStart = performance.now();
1138
+ // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any)
1139
+ const fetchController = new AbortController();
1140
+ const requestTimeoutMs = fetchTimeoutMs;
1141
+ const fetchTimeoutId = setTimeout(() => fetchController.abort(new Error("Request timeout")), requestTimeoutMs);
1142
+ const onUserAbort = abortSignal
1143
+ ? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"))
1144
+ : null;
1145
+ if (abortSignal?.aborted) {
1146
+ clearTimeout(fetchTimeoutId);
1147
+ fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"));
1148
+ }
1149
+ else if (abortSignal && onUserAbort) {
1150
+ abortSignal.addEventListener("abort", onUserAbort, { once: true });
1151
+ }
1152
+ try {
1153
+ runtimeMetrics.totalRequests++;
1154
+ response = await fetch(url, {
1155
+ ...requestInit,
1156
+ headers,
1157
+ signal: fetchController.signal,
1158
+ });
1159
+ }
1160
+ catch (networkError) {
1161
+ const isUserAbort = abortSignal?.aborted ||
1162
+ (networkError instanceof Error &&
1163
+ (networkError.name === "AbortError" || /abort/i.test(networkError.message)));
1164
+ if (isUserAbort) {
1165
+ runtimeMetrics.userAborts++;
1166
+ runtimeMetrics.lastError = "request aborted by user";
1167
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1168
+ break;
1169
+ }
1170
+ const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
1171
+ logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
1172
+ runtimeMetrics.failedRequests++;
1173
+ runtimeMetrics.networkErrors++;
1174
+ runtimeMetrics.accountRotations++;
1175
+ runtimeMetrics.lastError = errorMsg;
1176
+ const policy = evaluateFailurePolicy({ kind: "network", failoverMode }, { networkCooldownMs: networkErrorCooldownMs });
1177
+ if (policy.refundToken) {
1178
+ accountManager.refundToken(account, modelFamily, model);
1179
+ }
1180
+ if (policy.recordFailure) {
1181
+ accountManager.recordFailure(account, modelFamily, model);
1182
+ capabilityPolicyStore.recordFailure(entitlementAccountKey, capabilityModelKey);
1183
+ }
1184
+ if (policy.retrySameAccount &&
1185
+ sameAccountRetryCount < maxSameAccountRetries) {
1186
+ sameAccountRetryCount += 1;
1187
+ runtimeMetrics.sameAccountRetries += 1;
1188
+ const retryDelayMs = Math.max(MIN_BACKOFF_MS, Math.floor(policy.retryDelayMs ?? 250));
1189
+ await sleep(addJitter(retryDelayMs, 0.2));
1190
+ continue;
1191
+ }
1192
+ if (typeof policy.cooldownMs === "number" &&
1193
+ policy.cooldownReason) {
1194
+ accountManager.markAccountCoolingDown(account, policy.cooldownMs, policy.cooldownReason);
1195
+ accountManager.saveToDiskDebounced();
1196
+ }
1197
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1198
+ break;
1199
+ }
1200
+ finally {
1201
+ clearTimeout(fetchTimeoutId);
1202
+ if (abortSignal && onUserAbort) {
1203
+ abortSignal.removeEventListener("abort", onUserAbort);
1204
+ }
1205
+ }
1206
+ const fetchLatencyMs = Math.round(performance.now() - fetchStart);
1207
+ logRequest(LOG_STAGES.RESPONSE, {
1208
+ status: response.status,
1209
+ ok: response.ok,
1210
+ statusText: response.statusText,
1211
+ latencyMs: fetchLatencyMs,
1212
+ headers: sanitizeResponseHeadersForLog(response.headers),
1213
+ });
1214
+ const quotaSnapshot = readQuotaSchedulerSnapshot(response.headers, response.status);
1215
+ if (quotaSnapshot) {
1216
+ preemptiveQuotaScheduler.update(quotaScheduleKey, quotaSnapshot);
1217
+ }
1218
+ if (!response.ok) {
1219
+ const contextOverflowResult = await handleContextOverflow(response, model);
1220
+ if (contextOverflowResult.handled) {
1221
+ return contextOverflowResult.response;
1222
+ }
1223
+ const { response: errorResponse, rateLimit, errorBody } = await handleErrorResponse(response, {
1224
+ requestCorrelationId,
1225
+ threadId: threadIdCandidate,
1226
+ });
1227
+ const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody);
1228
+ const hasRemainingAccounts = attempted.size < Math.max(1, accountCount);
1229
+ const blockedModel = unsupportedModelInfo.unsupportedModel ?? model ?? "requested model";
1230
+ const blockedModelNormalized = blockedModel.toLowerCase();
1231
+ const shouldForceSparkFallback = unsupportedModelInfo.isUnsupported &&
1232
+ (blockedModelNormalized === "gpt-5.3-codex-spark" ||
1233
+ blockedModelNormalized.includes("gpt-5.3-codex-spark"));
1234
+ const allowUnsupportedFallback = fallbackOnUnsupportedCodexModel || shouldForceSparkFallback;
1235
+ // Entitlements can differ by account/workspace, so try remaining
1236
+ // accounts before degrading the model via fallback.
1237
+ // Spark entitlement is commonly unavailable on non-Pro/Business workspaces;
1238
+ // force direct fallback instead of traversing every account/workspace first.
1239
+ if (unsupportedModelInfo.isUnsupported &&
1240
+ hasRemainingAccounts &&
1241
+ !shouldForceSparkFallback) {
1242
+ entitlementCache.markBlocked(entitlementAccountKey, blockedModel, "unsupported-model");
1243
+ capabilityPolicyStore.recordUnsupported(entitlementAccountKey, blockedModel);
1244
+ accountManager.refundToken(account, modelFamily, model);
1245
+ accountManager.recordFailure(account, modelFamily, model);
1246
+ capabilityPolicyStore.recordFailure(entitlementAccountKey, capabilityModelKey);
1247
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1248
+ account.lastSwitchReason = "rotation";
1249
+ runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`;
1250
+ logWarn(`Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, {
1251
+ unsupportedCodexPolicy,
1252
+ requestedModel: blockedModel,
1253
+ effectiveModel: blockedModel,
1254
+ fallbackApplied: false,
1255
+ fallbackReason: "unsupported-model-entitlement",
1256
+ });
1257
+ break;
1258
+ }
1259
+ const fallbackModel = resolveUnsupportedCodexFallbackModel({
1260
+ requestedModel: model,
1261
+ errorBody,
1262
+ attemptedModels: attemptedUnsupportedFallbackModels,
1263
+ fallbackOnUnsupportedCodexModel: allowUnsupportedFallback,
1264
+ fallbackToGpt52OnUnsupportedGpt53,
1265
+ customChain: unsupportedCodexFallbackChain,
1266
+ });
1267
+ if (fallbackModel) {
1268
+ const previousModel = model ?? "gpt-5-codex";
1269
+ const previousModelFamily = modelFamily;
1270
+ attemptedUnsupportedFallbackModels.add(previousModel);
1271
+ attemptedUnsupportedFallbackModels.add(fallbackModel);
1272
+ entitlementCache.markBlocked(entitlementAccountKey, previousModel, "unsupported-model");
1273
+ capabilityPolicyStore.recordUnsupported(entitlementAccountKey, previousModel);
1274
+ accountManager.refundToken(account, previousModelFamily, previousModel);
1275
+ model = fallbackModel;
1276
+ modelFamily = getModelFamily(model);
1277
+ quotaKey = `${modelFamily}:${model}`;
1278
+ if (transformedBody && typeof transformedBody === "object") {
1279
+ transformedBody = { ...transformedBody, model };
1280
+ }
1281
+ else {
1282
+ let fallbackBody = { model };
1283
+ if (requestInit?.body && typeof requestInit.body === "string") {
1284
+ try {
1285
+ const parsed = JSON.parse(requestInit.body);
1286
+ fallbackBody = { ...parsed, model };
1287
+ }
1288
+ catch {
1289
+ // Keep minimal fallback body if parsing fails.
1290
+ }
1291
+ }
1292
+ transformedBody = fallbackBody;
1293
+ }
1294
+ requestInit = {
1295
+ ...(requestInit ?? {}),
1296
+ body: JSON.stringify(transformedBody),
1297
+ };
1298
+ runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`;
1299
+ logWarn(`Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, {
1300
+ unsupportedCodexPolicy,
1301
+ requestedModel: previousModel,
1302
+ effectiveModel: model,
1303
+ fallbackApplied: true,
1304
+ fallbackReason: "unsupported-model-entitlement",
1305
+ });
1306
+ await showToast(`Model ${previousModel} is not available for this account. Retrying with ${model}.`, "warning", { duration: toastDurationMs });
1307
+ restartAccountTraversalWithFallback = true;
1308
+ break;
1309
+ }
1310
+ if (unsupportedModelInfo.isUnsupported && !allowUnsupportedFallback) {
1311
+ entitlementCache.markBlocked(entitlementAccountKey, blockedModel, "unsupported-model");
1312
+ capabilityPolicyStore.recordUnsupported(entitlementAccountKey, blockedModel);
1313
+ runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`;
1314
+ logWarn(`Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, {
1315
+ unsupportedCodexPolicy,
1316
+ requestedModel: blockedModel,
1317
+ effectiveModel: blockedModel,
1318
+ fallbackApplied: false,
1319
+ fallbackReason: "unsupported-model-entitlement",
1320
+ });
1321
+ await showToast(`Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, "warning", { duration: toastDurationMs });
1322
+ }
1323
+ if (unsupportedModelInfo.isUnsupported &&
1324
+ allowUnsupportedFallback &&
1325
+ !hasRemainingAccounts &&
1326
+ !fallbackModel) {
1327
+ entitlementCache.markBlocked(entitlementAccountKey, blockedModel, "unsupported-model");
1328
+ capabilityPolicyStore.recordUnsupported(entitlementAccountKey, blockedModel);
1329
+ }
1330
+ if (errorResponse.status === 403 && !unsupportedModelInfo.isUnsupported) {
1331
+ entitlementCache.markBlocked(entitlementAccountKey, model ?? modelFamily, "plan-entitlement");
1332
+ capabilityPolicyStore.recordFailure(entitlementAccountKey, capabilityModelKey);
1333
+ }
1334
+ if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
1335
+ const errorType = detectErrorType(errorBody);
1336
+ const toastContent = getRecoveryToastContent(errorType);
1337
+ await showToast(`${toastContent.title}: ${toastContent.message}`, "warning", { duration: toastDurationMs });
1338
+ logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`);
1339
+ }
1340
+ // Handle 5xx server errors by rotating to another account
1341
+ if (response.status >= 500 && response.status < 600) {
1342
+ logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`);
1343
+ runtimeMetrics.failedRequests++;
1344
+ runtimeMetrics.serverErrors++;
1345
+ runtimeMetrics.accountRotations++;
1346
+ runtimeMetrics.lastError = `HTTP ${response.status}`;
1347
+ const policy = evaluateFailurePolicy({ kind: "server", failoverMode }, { serverCooldownMs: serverErrorCooldownMs });
1348
+ if (policy.refundToken) {
1349
+ accountManager.refundToken(account, modelFamily, model);
1350
+ }
1351
+ if (policy.recordFailure) {
1352
+ accountManager.recordFailure(account, modelFamily, model);
1353
+ capabilityPolicyStore.recordFailure(entitlementAccountKey, capabilityModelKey);
1354
+ }
1355
+ if (policy.retrySameAccount &&
1356
+ sameAccountRetryCount < maxSameAccountRetries) {
1357
+ sameAccountRetryCount += 1;
1358
+ runtimeMetrics.sameAccountRetries += 1;
1359
+ const retryDelayMs = Math.max(MIN_BACKOFF_MS, Math.floor(policy.retryDelayMs ?? 500));
1360
+ await sleep(addJitter(retryDelayMs, 0.2));
1361
+ continue;
1362
+ }
1363
+ if (typeof policy.cooldownMs === "number" &&
1364
+ policy.cooldownReason) {
1365
+ accountManager.markAccountCoolingDown(account, policy.cooldownMs, policy.cooldownReason);
1366
+ accountManager.saveToDiskDebounced();
1367
+ }
1368
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1369
+ break;
1370
+ }
1371
+ if (rateLimit) {
1372
+ runtimeMetrics.rateLimitedResponses++;
1373
+ const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
1374
+ preemptiveQuotaScheduler.markRateLimited(quotaScheduleKey, delayMs);
1375
+ const waitLabel = formatWaitTime(delayMs);
1376
+ if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
1377
+ if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
1378
+ await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
1379
+ accountManager.markToastShown(account.index);
1380
+ }
1381
+ await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2));
1382
+ continue;
1383
+ }
1384
+ accountManager.markRateLimitedWithReason(account, delayMs, modelFamily, parseRateLimitReason(rateLimit.code), model);
1385
+ accountManager.recordRateLimit(account, modelFamily, model);
1386
+ account.lastSwitchReason = "rate-limit";
1387
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1388
+ runtimeMetrics.accountRotations++;
1389
+ accountManager.saveToDiskDebounced();
1390
+ logWarn(`Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`);
1391
+ if (accountManager.getAccountCount() > 1 &&
1392
+ accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
1393
+ await showToast(`Rate limited. Switching accounts (retry in ${waitLabel}).`, "warning", { duration: toastDurationMs });
1394
+ accountManager.markToastShown(account.index);
1395
+ }
1396
+ break;
1397
+ }
1398
+ if (!rateLimit &&
1399
+ !unsupportedModelInfo.isUnsupported &&
1400
+ errorResponse.status !== 403) {
1401
+ capabilityPolicyStore.recordFailure(entitlementAccountKey, capabilityModelKey);
1402
+ }
1403
+ runtimeMetrics.failedRequests++;
1404
+ runtimeMetrics.lastError = `HTTP ${response.status}`;
1405
+ return errorResponse;
1406
+ }
1407
+ resetRateLimitBackoff(account.index, quotaKey);
1408
+ runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs;
1409
+ let responseForSuccess = response;
1410
+ if (isStreaming) {
1411
+ const streamFallbackCandidateOrder = [
1412
+ account.index,
1413
+ ...accountManager
1414
+ .getAccountsSnapshot()
1415
+ .map((candidate) => candidate.index)
1416
+ .filter((index) => index !== account.index),
1417
+ ];
1418
+ responseForSuccess = withStreamingFailover(response, async (failoverAttempt, emittedBytes) => {
1419
+ if (abortSignal?.aborted) {
1420
+ return null;
1421
+ }
1422
+ runtimeMetrics.streamFailoverAttempts += 1;
1423
+ for (const candidateIndex of streamFallbackCandidateOrder) {
1424
+ if (abortSignal?.aborted) {
1425
+ return null;
1426
+ }
1427
+ if (!accountManager.isAccountAvailableForFamily(candidateIndex, modelFamily, model)) {
1428
+ continue;
1429
+ }
1430
+ const fallbackAccount = accountManager.getAccountByIndex(candidateIndex);
1431
+ if (!fallbackAccount)
1432
+ continue;
1433
+ let fallbackAuth = accountManager.toAuthDetails(fallbackAccount);
1434
+ try {
1435
+ if (shouldRefreshToken(fallbackAuth, tokenRefreshSkewMs)) {
1436
+ fallbackAuth = (await refreshAndUpdateToken(fallbackAuth, client));
1437
+ accountManager.updateFromAuth(fallbackAccount, fallbackAuth);
1438
+ accountManager.clearAuthFailures(fallbackAccount);
1439
+ accountManager.saveToDiskDebounced();
1440
+ }
1441
+ }
1442
+ catch (refreshError) {
1443
+ logWarn(`Stream failover refresh failed for account ${fallbackAccount.index + 1}.`, {
1444
+ error: refreshError instanceof Error
1445
+ ? refreshError.message
1446
+ : String(refreshError),
1447
+ });
1448
+ continue;
1449
+ }
1450
+ const fallbackTokenAccountId = extractAccountId(fallbackAuth.access);
1451
+ const fallbackAccountId = resolveRequestAccountId(fallbackAccount.accountId, fallbackAccount.accountIdSource, fallbackTokenAccountId);
1452
+ if (!fallbackAccountId) {
1453
+ continue;
1454
+ }
1455
+ if (!accountManager.consumeToken(fallbackAccount, modelFamily, model)) {
1456
+ continue;
1457
+ }
1458
+ const fallbackHeaders = createCodexHeaders(requestInit, fallbackAccountId, fallbackAuth.access, {
1459
+ model,
1460
+ promptCacheKey: effectivePromptCacheKey,
1461
+ });
1462
+ const fallbackController = new AbortController();
1463
+ const fallbackTimeoutId = setTimeout(() => fallbackController.abort(new Error("Request timeout")), fetchTimeoutMs);
1464
+ const onFallbackAbort = abortSignal
1465
+ ? () => fallbackController.abort(abortSignal.reason ?? new Error("Aborted by user"))
1466
+ : null;
1467
+ if (abortSignal && onFallbackAbort) {
1468
+ abortSignal.addEventListener("abort", onFallbackAbort, {
1469
+ once: true,
1470
+ });
1471
+ }
1472
+ try {
1473
+ runtimeMetrics.totalRequests++;
1474
+ const fallbackResponse = await fetch(url, {
1475
+ ...requestInit,
1476
+ headers: fallbackHeaders,
1477
+ signal: fallbackController.signal,
1478
+ });
1479
+ const fallbackSnapshot = readQuotaSchedulerSnapshot(fallbackResponse.headers, fallbackResponse.status);
1480
+ if (fallbackSnapshot) {
1481
+ preemptiveQuotaScheduler.update(`${resolveEntitlementAccountKey(fallbackAccount)}:${model ?? modelFamily}`, fallbackSnapshot);
1482
+ }
1483
+ if (!fallbackResponse.ok) {
1484
+ try {
1485
+ await fallbackResponse.body?.cancel();
1486
+ }
1487
+ catch {
1488
+ // Best effort cleanup before trying next fallback account.
1489
+ }
1490
+ if (fallbackResponse.status === 429) {
1491
+ const retryAfterRaw = fallbackResponse.headers
1492
+ .get("retry-after")
1493
+ ?.trim();
1494
+ const retryAfterMs = retryAfterRaw && /^\d+$/.test(retryAfterRaw)
1495
+ ? Number.parseInt(retryAfterRaw, 10) * 1000
1496
+ : 60_000;
1497
+ accountManager.markRateLimitedWithReason(fallbackAccount, retryAfterMs, modelFamily, "quota", model);
1498
+ accountManager.recordRateLimit(fallbackAccount, modelFamily, model);
1499
+ }
1500
+ else {
1501
+ accountManager.recordFailure(fallbackAccount, modelFamily, model);
1502
+ }
1503
+ capabilityPolicyStore.recordFailure(resolveEntitlementAccountKey(fallbackAccount), capabilityModelKey);
1504
+ continue;
1505
+ }
1506
+ successAccountForResponse = fallbackAccount;
1507
+ runtimeMetrics.streamFailoverRecoveries += 1;
1508
+ if (fallbackAccount.index !== account.index) {
1509
+ runtimeMetrics.streamFailoverCrossAccountRecoveries += 1;
1510
+ runtimeMetrics.accountRotations += 1;
1511
+ sessionAffinityStore?.remember(sessionAffinityKey, fallbackAccount.index);
1512
+ }
1513
+ logInfo(`Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, { emittedBytes });
1514
+ return fallbackResponse;
1515
+ }
1516
+ catch (streamFailoverError) {
1517
+ accountManager.refundToken(fallbackAccount, modelFamily, model);
1518
+ accountManager.recordFailure(fallbackAccount, modelFamily, model);
1519
+ capabilityPolicyStore.recordFailure(resolveEntitlementAccountKey(fallbackAccount), capabilityModelKey);
1520
+ logWarn(`Stream failover attempt ${failoverAttempt} failed for account ${fallbackAccount.index + 1}.`, {
1521
+ emittedBytes,
1522
+ error: streamFailoverError instanceof Error
1523
+ ? streamFailoverError.message
1524
+ : String(streamFailoverError),
1525
+ });
1526
+ continue;
1527
+ }
1528
+ finally {
1529
+ clearTimeout(fallbackTimeoutId);
1530
+ if (abortSignal && onFallbackAbort) {
1531
+ abortSignal.removeEventListener("abort", onFallbackAbort);
1532
+ }
1533
+ }
1534
+ }
1535
+ return null;
1536
+ }, {
1537
+ maxFailovers: streamFailoverMax,
1538
+ softTimeoutMs: streamFailoverSoftTimeoutMs,
1539
+ hardTimeoutMs: streamFailoverHardTimeoutMs,
1540
+ requestInstanceId: requestCorrelationId ?? undefined,
1541
+ });
1542
+ }
1543
+ const successResponse = await handleSuccessResponse(responseForSuccess, isStreaming, {
1544
+ streamStallTimeoutMs,
1545
+ });
1546
+ if (!isStreaming && emptyResponseMaxRetries > 0) {
1547
+ const clonedResponse = successResponse.clone();
1548
+ try {
1549
+ const bodyText = await clonedResponse.text();
1550
+ const parsedBody = bodyText ? JSON.parse(bodyText) : null;
1551
+ if (isEmptyResponse(parsedBody)) {
1552
+ if (emptyResponseRetries < emptyResponseMaxRetries) {
1553
+ emptyResponseRetries++;
1554
+ runtimeMetrics.emptyResponseRetries++;
1555
+ logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`);
1556
+ await showToast(`Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, "warning", { duration: toastDurationMs });
1557
+ accountManager.refundToken(account, modelFamily, model);
1558
+ accountManager.recordFailure(account, modelFamily, model);
1559
+ capabilityPolicyStore.recordFailure(entitlementAccountKey, capabilityModelKey);
1560
+ const emptyPolicy = evaluateFailurePolicy({
1561
+ kind: "empty-response",
1562
+ failoverMode,
1563
+ });
1564
+ if (emptyPolicy.retrySameAccount &&
1565
+ sameAccountRetryCount < maxSameAccountRetries) {
1566
+ sameAccountRetryCount += 1;
1567
+ runtimeMetrics.sameAccountRetries += 1;
1568
+ const retryDelayMs = Math.max(0, Math.floor(emptyPolicy.retryDelayMs ?? emptyResponseRetryDelayMs));
1569
+ if (retryDelayMs > 0) {
1570
+ await sleep(addJitter(retryDelayMs, 0.2));
1571
+ }
1572
+ continue;
1573
+ }
1574
+ sessionAffinityStore?.forgetSession(sessionAffinityKey);
1575
+ await sleep(addJitter(emptyResponseRetryDelayMs, 0.2));
1576
+ break;
1577
+ }
1578
+ logWarn(`Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`);
1579
+ }
1580
+ }
1581
+ catch {
1582
+ // Intentionally empty: non-JSON response bodies should be returned as-is
1583
+ }
1584
+ }
1585
+ if (successAccountForResponse.index !== account.index) {
1586
+ accountManager.markSwitched(successAccountForResponse, "rotation", modelFamily);
1587
+ }
1588
+ const successAccountKey = resolveEntitlementAccountKey(successAccountForResponse);
1589
+ accountManager.recordSuccess(successAccountForResponse, modelFamily, model);
1590
+ capabilityPolicyStore.recordSuccess(successAccountKey, capabilityModelKey);
1591
+ entitlementCache.clear(successAccountKey, capabilityModelKey);
1592
+ sessionAffinityStore?.remember(sessionAffinityKey, successAccountForResponse.index);
1593
+ runtimeMetrics.successfulRequests++;
1594
+ runtimeMetrics.lastError = null;
1595
+ if (lastCodexCliActiveSyncIndex !== successAccountForResponse.index) {
1596
+ void accountManager.syncCodexCliActiveSelectionForIndex(successAccountForResponse.index);
1597
+ lastCodexCliActiveSyncIndex = successAccountForResponse.index;
1598
+ }
1599
+ return successResponse;
1600
+ }
1601
+ if (restartAccountTraversalWithFallback) {
1602
+ break;
1603
+ }
1604
+ }
1605
+ if (restartAccountTraversalWithFallback) {
1606
+ continue;
1607
+ }
1608
+ const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
1609
+ const count = accountManager.getAccountCount();
1610
+ if (retryAllAccountsRateLimited &&
1611
+ count > 0 &&
1612
+ waitMs > 0 &&
1613
+ (retryAllAccountsMaxWaitMs === 0 ||
1614
+ waitMs <= retryAllAccountsMaxWaitMs) &&
1615
+ allRateLimitedRetries < retryAllAccountsMaxRetries) {
1616
+ const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
1617
+ await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
1618
+ allRateLimitedRetries++;
1619
+ continue;
1620
+ }
1621
+ const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
1622
+ const message = count === 0
1623
+ ? "No Codex accounts configured. Run `codex login`."
1624
+ : waitMs > 0
1625
+ ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`codex login\`.`
1626
+ : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
1627
+ runtimeMetrics.failedRequests++;
1628
+ runtimeMetrics.lastError = message;
1629
+ return new Response(JSON.stringify({ error: { message } }), {
1630
+ status: waitMs > 0 ? 429 : 503,
1631
+ headers: {
1632
+ "content-type": "application/json; charset=utf-8",
1633
+ },
1634
+ });
1635
+ }
1636
+ }
1637
+ finally {
1638
+ clearCorrelationId();
1639
+ }
1640
+ },
1641
+ };
1642
+ }
1643
+ finally {
1644
+ resolveMutex?.();
1645
+ loaderMutex = null;
1646
+ }
1647
+ },
1648
+ methods: [
1649
+ {
1650
+ label: AUTH_LABELS.OAUTH,
1651
+ type: "oauth",
1652
+ authorize: async (inputs) => {
1653
+ const authPluginConfig = loadPluginConfig();
1654
+ applyUiRuntimeFromConfig(authPluginConfig);
1655
+ applyAccountStorageScope(authPluginConfig);
1656
+ const accounts = [];
1657
+ const noBrowser = inputs?.noBrowser === "true" ||
1658
+ inputs?.["no-browser"] === "true";
1659
+ const useManualMode = noBrowser;
1660
+ const explicitLoginMode = inputs?.loginMode === "fresh" || inputs?.loginMode === "add"
1661
+ ? inputs.loginMode
1662
+ : null;
1663
+ let startFresh = explicitLoginMode === "fresh";
1664
+ let refreshAccountIndex;
1665
+ const clampActiveIndices = (storage) => {
1666
+ const count = storage.accounts.length;
1667
+ if (count === 0) {
1668
+ storage.activeIndex = 0;
1669
+ storage.activeIndexByFamily = {};
1670
+ return;
1671
+ }
1672
+ storage.activeIndex = Math.max(0, Math.min(storage.activeIndex, count - 1));
1673
+ storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
1674
+ for (const family of MODEL_FAMILIES) {
1675
+ const raw = storage.activeIndexByFamily[family];
1676
+ const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex;
1677
+ storage.activeIndexByFamily[family] = Math.max(0, Math.min(candidate, count - 1));
1678
+ }
1679
+ };
1680
+ const isFlaggableFailure = (failure) => {
1681
+ if (failure.reason === "missing_refresh")
1682
+ return true;
1683
+ if (failure.statusCode === 401)
1684
+ return true;
1685
+ if (failure.statusCode !== 400)
1686
+ return false;
1687
+ const message = (failure.message ?? "").toLowerCase();
1688
+ return (message.includes("invalid_grant") ||
1689
+ message.includes("invalid refresh") ||
1690
+ message.includes("token has been revoked"));
1691
+ };
1692
+ const parseFiniteNumberHeader = (headers, name) => {
1693
+ const raw = headers.get(name);
1694
+ if (!raw)
1695
+ return undefined;
1696
+ const parsed = Number(raw);
1697
+ return Number.isFinite(parsed) ? parsed : undefined;
1698
+ };
1699
+ const parseFiniteIntHeader = (headers, name) => {
1700
+ const raw = headers.get(name);
1701
+ if (!raw)
1702
+ return undefined;
1703
+ const parsed = Number.parseInt(raw, 10);
1704
+ return Number.isFinite(parsed) ? parsed : undefined;
1705
+ };
1706
+ const parseResetAtMs = (headers, prefix) => {
1707
+ const resetAfterSeconds = parseFiniteIntHeader(headers, `${prefix}-reset-after-seconds`);
1708
+ if (typeof resetAfterSeconds === "number" &&
1709
+ Number.isFinite(resetAfterSeconds) &&
1710
+ resetAfterSeconds > 0) {
1711
+ return Date.now() + resetAfterSeconds * 1000;
1712
+ }
1713
+ const resetAtRaw = headers.get(`${prefix}-reset-at`);
1714
+ if (!resetAtRaw)
1715
+ return undefined;
1716
+ const trimmed = resetAtRaw.trim();
1717
+ if (/^\d+$/.test(trimmed)) {
1718
+ const parsedNumber = Number.parseInt(trimmed, 10);
1719
+ if (Number.isFinite(parsedNumber) && parsedNumber > 0) {
1720
+ // Upstream sometimes returns seconds since epoch.
1721
+ return parsedNumber < 10_000_000_000 ? parsedNumber * 1000 : parsedNumber;
1722
+ }
1723
+ }
1724
+ const parsedDate = Date.parse(trimmed);
1725
+ return Number.isFinite(parsedDate) ? parsedDate : undefined;
1726
+ };
1727
+ const hasCodexQuotaHeaders = (headers) => {
1728
+ const keys = [
1729
+ "x-codex-primary-used-percent",
1730
+ "x-codex-primary-window-minutes",
1731
+ "x-codex-primary-reset-at",
1732
+ "x-codex-primary-reset-after-seconds",
1733
+ "x-codex-secondary-used-percent",
1734
+ "x-codex-secondary-window-minutes",
1735
+ "x-codex-secondary-reset-at",
1736
+ "x-codex-secondary-reset-after-seconds",
1737
+ ];
1738
+ return keys.some((key) => headers.get(key) !== null);
1739
+ };
1740
+ const parseCodexQuotaSnapshot = (headers, status) => {
1741
+ if (!hasCodexQuotaHeaders(headers))
1742
+ return null;
1743
+ const primaryPrefix = "x-codex-primary";
1744
+ const secondaryPrefix = "x-codex-secondary";
1745
+ const primary = {
1746
+ usedPercent: parseFiniteNumberHeader(headers, `${primaryPrefix}-used-percent`),
1747
+ windowMinutes: parseFiniteIntHeader(headers, `${primaryPrefix}-window-minutes`),
1748
+ resetAtMs: parseResetAtMs(headers, primaryPrefix),
1749
+ };
1750
+ const secondary = {
1751
+ usedPercent: parseFiniteNumberHeader(headers, `${secondaryPrefix}-used-percent`),
1752
+ windowMinutes: parseFiniteIntHeader(headers, `${secondaryPrefix}-window-minutes`),
1753
+ resetAtMs: parseResetAtMs(headers, secondaryPrefix),
1754
+ };
1755
+ const planTypeRaw = headers.get("x-codex-plan-type");
1756
+ const planType = planTypeRaw && planTypeRaw.trim() ? planTypeRaw.trim() : undefined;
1757
+ const activeLimit = parseFiniteIntHeader(headers, "x-codex-active-limit");
1758
+ return { status, planType, activeLimit, primary, secondary };
1759
+ };
1760
+ const formatQuotaWindowLabel = (windowMinutes) => {
1761
+ if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) {
1762
+ return "quota";
1763
+ }
1764
+ if (windowMinutes % 1440 === 0)
1765
+ return `${windowMinutes / 1440}d`;
1766
+ if (windowMinutes % 60 === 0)
1767
+ return `${windowMinutes / 60}h`;
1768
+ return `${windowMinutes}m`;
1769
+ };
1770
+ const formatResetAt = (resetAtMs) => {
1771
+ if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0)
1772
+ return undefined;
1773
+ const date = new Date(resetAtMs);
1774
+ if (!Number.isFinite(date.getTime()))
1775
+ return undefined;
1776
+ const now = new Date();
1777
+ const sameDay = now.getFullYear() === date.getFullYear() &&
1778
+ now.getMonth() === date.getMonth() &&
1779
+ now.getDate() === date.getDate();
1780
+ const time = date.toLocaleTimeString(undefined, {
1781
+ hour: "2-digit",
1782
+ minute: "2-digit",
1783
+ hour12: false,
1784
+ });
1785
+ if (sameDay)
1786
+ return time;
1787
+ const day = date.toLocaleDateString(undefined, { month: "short", day: "2-digit" });
1788
+ return `${time} on ${day}`;
1789
+ };
1790
+ const formatCodexQuotaLine = (snapshot) => {
1791
+ const summarizeWindow = (label, window) => {
1792
+ const used = window.usedPercent;
1793
+ const left = typeof used === "number" && Number.isFinite(used)
1794
+ ? Math.max(0, Math.min(100, Math.round(100 - used)))
1795
+ : undefined;
1796
+ const reset = formatResetAt(window.resetAtMs);
1797
+ let summary = label;
1798
+ if (left !== undefined)
1799
+ summary = `${summary} ${left}% left`;
1800
+ if (reset)
1801
+ summary = `${summary} (resets ${reset})`;
1802
+ return summary;
1803
+ };
1804
+ const primaryLabel = formatQuotaWindowLabel(snapshot.primary.windowMinutes);
1805
+ const secondaryLabel = formatQuotaWindowLabel(snapshot.secondary.windowMinutes);
1806
+ const parts = [
1807
+ summarizeWindow(primaryLabel, snapshot.primary),
1808
+ summarizeWindow(secondaryLabel, snapshot.secondary),
1809
+ ];
1810
+ if (snapshot.planType)
1811
+ parts.push(`plan:${snapshot.planType}`);
1812
+ if (typeof snapshot.activeLimit === "number" && Number.isFinite(snapshot.activeLimit)) {
1813
+ parts.push(`active:${snapshot.activeLimit}`);
1814
+ }
1815
+ if (snapshot.status === 429)
1816
+ parts.push("rate-limited");
1817
+ return parts.join(", ");
1818
+ };
1819
+ const fetchCodexQuotaSnapshot = async (params) => {
1820
+ const QUOTA_PROBE_MODELS = ["gpt-5-codex", "gpt-5.3-codex", "gpt-5.2-codex"];
1821
+ let lastError = null;
1822
+ for (const model of QUOTA_PROBE_MODELS) {
1823
+ try {
1824
+ const instructions = await getCodexInstructions(model);
1825
+ const probeBody = {
1826
+ model,
1827
+ stream: true,
1828
+ store: false,
1829
+ include: ["reasoning.encrypted_content"],
1830
+ instructions,
1831
+ input: [
1832
+ {
1833
+ type: "message",
1834
+ role: "user",
1835
+ content: [{ type: "input_text", text: "quota ping" }],
1836
+ },
1837
+ ],
1838
+ reasoning: { effort: "none", summary: "auto" },
1839
+ text: { verbosity: "low" },
1840
+ };
1841
+ const headers = createCodexHeaders(undefined, params.accountId, params.accessToken, {
1842
+ model,
1843
+ });
1844
+ headers.set("content-type", "application/json; charset=utf-8");
1845
+ const controller = new AbortController();
1846
+ const timeout = setTimeout(() => controller.abort(), 15_000);
1847
+ let response;
1848
+ try {
1849
+ response = await fetch(`${CODEX_BASE_URL}/codex/responses`, {
1850
+ method: "POST",
1851
+ headers,
1852
+ body: JSON.stringify(probeBody),
1853
+ signal: controller.signal,
1854
+ });
1855
+ }
1856
+ finally {
1857
+ clearTimeout(timeout);
1858
+ }
1859
+ const snapshot = parseCodexQuotaSnapshot(response.headers, response.status);
1860
+ if (snapshot) {
1861
+ // We only need headers; cancel the SSE stream immediately.
1862
+ try {
1863
+ await response.body?.cancel();
1864
+ }
1865
+ catch {
1866
+ // Ignore cancellation failures.
1867
+ }
1868
+ return snapshot;
1869
+ }
1870
+ if (!response.ok) {
1871
+ const bodyText = await response.text().catch(() => "");
1872
+ let errorBody = undefined;
1873
+ try {
1874
+ errorBody = bodyText ? JSON.parse(bodyText) : undefined;
1875
+ }
1876
+ catch {
1877
+ errorBody = { error: { message: bodyText } };
1878
+ }
1879
+ const unsupportedInfo = getUnsupportedCodexModelInfo(errorBody);
1880
+ if (unsupportedInfo.isUnsupported) {
1881
+ lastError = new Error(unsupportedInfo.message ?? `Model '${model}' unsupported for this account`);
1882
+ continue;
1883
+ }
1884
+ const message = (typeof errorBody?.error?.message === "string"
1885
+ ? errorBody.error?.message
1886
+ : bodyText) || `HTTP ${response.status}`;
1887
+ throw new Error(message);
1888
+ }
1889
+ lastError = new Error("Codex response did not include quota headers");
1890
+ }
1891
+ catch (error) {
1892
+ lastError = error instanceof Error ? error : new Error(String(error));
1893
+ }
1894
+ }
1895
+ throw lastError ?? new Error("Failed to fetch quotas");
1896
+ };
1897
+ const runAccountCheck = async (deepProbe) => {
1898
+ const loadedStorage = await hydrateEmails(await loadAccounts());
1899
+ const workingStorage = loadedStorage
1900
+ ? {
1901
+ ...loadedStorage,
1902
+ accounts: loadedStorage.accounts.map((account) => ({ ...account })),
1903
+ activeIndexByFamily: loadedStorage.activeIndexByFamily
1904
+ ? { ...loadedStorage.activeIndexByFamily }
1905
+ : {},
1906
+ }
1907
+ : { version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} };
1908
+ if (workingStorage.accounts.length === 0) {
1909
+ console.log("\nNo accounts to check.\n");
1910
+ return;
1911
+ }
1912
+ const flaggedStorage = await loadFlaggedAccounts();
1913
+ let storageChanged = false;
1914
+ let flaggedChanged = false;
1915
+ const removeFromActive = new Set();
1916
+ const total = workingStorage.accounts.length;
1917
+ let ok = 0;
1918
+ let disabled = 0;
1919
+ let errors = 0;
1920
+ console.log(`\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`);
1921
+ for (let i = 0; i < total; i += 1) {
1922
+ const account = workingStorage.accounts[i];
1923
+ if (!account)
1924
+ continue;
1925
+ const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`;
1926
+ if (account.enabled === false) {
1927
+ disabled += 1;
1928
+ console.log(`[${i + 1}/${total}] ${label}: DISABLED`);
1929
+ continue;
1930
+ }
1931
+ try {
1932
+ // If we already have a valid cached access token, don't force-refresh.
1933
+ // This avoids flagging accounts where the refresh token has been burned
1934
+ // but the access token is still valid (same behavior as Codex CLI).
1935
+ const nowMs = Date.now();
1936
+ let accessToken = null;
1937
+ let tokenAccountId = undefined;
1938
+ let authDetail = "OK";
1939
+ if (account.accessToken &&
1940
+ (typeof account.expiresAt !== "number" ||
1941
+ !Number.isFinite(account.expiresAt) ||
1942
+ account.expiresAt > nowMs)) {
1943
+ accessToken = account.accessToken;
1944
+ authDetail = "OK (cached access)";
1945
+ tokenAccountId = extractAccountId(account.accessToken);
1946
+ if (tokenAccountId &&
1947
+ shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) &&
1948
+ tokenAccountId !== account.accountId) {
1949
+ account.accountId = tokenAccountId;
1950
+ account.accountIdSource = "token";
1951
+ storageChanged = true;
1952
+ }
1953
+ }
1954
+ // If Codex CLI has a valid cached access token for this email, use it
1955
+ // instead of forcing a refresh.
1956
+ if (!accessToken) {
1957
+ const cached = await lookupCodexCliTokensByEmail(account.email);
1958
+ if (cached &&
1959
+ (typeof cached.expiresAt !== "number" ||
1960
+ !Number.isFinite(cached.expiresAt) ||
1961
+ cached.expiresAt > nowMs)) {
1962
+ accessToken = cached.accessToken;
1963
+ authDetail = "OK (Codex CLI cache)";
1964
+ if (cached.refreshToken && cached.refreshToken !== account.refreshToken) {
1965
+ account.refreshToken = cached.refreshToken;
1966
+ storageChanged = true;
1967
+ }
1968
+ if (cached.accessToken && cached.accessToken !== account.accessToken) {
1969
+ account.accessToken = cached.accessToken;
1970
+ storageChanged = true;
1971
+ }
1972
+ if (cached.expiresAt !== account.expiresAt) {
1973
+ account.expiresAt = cached.expiresAt;
1974
+ storageChanged = true;
1975
+ }
1976
+ const hydratedEmail = sanitizeEmail(extractAccountEmail(cached.accessToken));
1977
+ if (hydratedEmail && hydratedEmail !== account.email) {
1978
+ account.email = hydratedEmail;
1979
+ storageChanged = true;
1980
+ }
1981
+ tokenAccountId = extractAccountId(cached.accessToken);
1982
+ if (tokenAccountId &&
1983
+ shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) &&
1984
+ tokenAccountId !== account.accountId) {
1985
+ account.accountId = tokenAccountId;
1986
+ account.accountIdSource = "token";
1987
+ storageChanged = true;
1988
+ }
1989
+ }
1990
+ }
1991
+ if (!accessToken) {
1992
+ const refreshResult = await queuedRefresh(account.refreshToken);
1993
+ if (refreshResult.type !== "success") {
1994
+ errors += 1;
1995
+ const message = refreshResult.message ?? refreshResult.reason ?? "refresh failed";
1996
+ console.log(`[${i + 1}/${total}] ${label}: ERROR (${message})`);
1997
+ if (deepProbe && isFlaggableFailure(refreshResult)) {
1998
+ const existingIndex = flaggedStorage.accounts.findIndex((flagged) => flagged.refreshToken === account.refreshToken);
1999
+ const flaggedRecord = {
2000
+ ...account,
2001
+ flaggedAt: Date.now(),
2002
+ flaggedReason: "token-invalid",
2003
+ lastError: message,
2004
+ };
2005
+ if (existingIndex >= 0) {
2006
+ flaggedStorage.accounts[existingIndex] = flaggedRecord;
2007
+ }
2008
+ else {
2009
+ flaggedStorage.accounts.push(flaggedRecord);
2010
+ }
2011
+ removeFromActive.add(account.refreshToken);
2012
+ flaggedChanged = true;
2013
+ }
2014
+ continue;
2015
+ }
2016
+ accessToken = refreshResult.access;
2017
+ authDetail = "OK";
2018
+ if (refreshResult.refresh !== account.refreshToken) {
2019
+ account.refreshToken = refreshResult.refresh;
2020
+ storageChanged = true;
2021
+ }
2022
+ if (refreshResult.access && refreshResult.access !== account.accessToken) {
2023
+ account.accessToken = refreshResult.access;
2024
+ storageChanged = true;
2025
+ }
2026
+ if (typeof refreshResult.expires === "number" &&
2027
+ refreshResult.expires !== account.expiresAt) {
2028
+ account.expiresAt = refreshResult.expires;
2029
+ storageChanged = true;
2030
+ }
2031
+ const hydratedEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken));
2032
+ if (hydratedEmail && hydratedEmail !== account.email) {
2033
+ account.email = hydratedEmail;
2034
+ storageChanged = true;
2035
+ }
2036
+ tokenAccountId = extractAccountId(refreshResult.access);
2037
+ if (tokenAccountId &&
2038
+ shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) &&
2039
+ tokenAccountId !== account.accountId) {
2040
+ account.accountId = tokenAccountId;
2041
+ account.accountIdSource = "token";
2042
+ storageChanged = true;
2043
+ }
2044
+ }
2045
+ if (!accessToken) {
2046
+ throw new Error("Missing access token after refresh");
2047
+ }
2048
+ if (deepProbe) {
2049
+ ok += 1;
2050
+ const detail = tokenAccountId
2051
+ ? `${authDetail} (id:${tokenAccountId.slice(-6)})`
2052
+ : authDetail;
2053
+ console.log(`[${i + 1}/${total}] ${label}: ${detail}`);
2054
+ continue;
2055
+ }
2056
+ try {
2057
+ const requestAccountId = resolveRequestAccountId(account.accountId, account.accountIdSource, tokenAccountId) ??
2058
+ tokenAccountId ??
2059
+ account.accountId;
2060
+ if (!requestAccountId) {
2061
+ throw new Error("Missing accountId for quota probe");
2062
+ }
2063
+ const snapshot = await fetchCodexQuotaSnapshot({
2064
+ accountId: requestAccountId,
2065
+ accessToken,
2066
+ });
2067
+ ok += 1;
2068
+ console.log(`[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`);
2069
+ }
2070
+ catch (error) {
2071
+ errors += 1;
2072
+ const message = error instanceof Error ? error.message : String(error);
2073
+ console.log(`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`);
2074
+ }
2075
+ }
2076
+ catch (error) {
2077
+ errors += 1;
2078
+ const message = error instanceof Error ? error.message : String(error);
2079
+ console.log(`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`);
2080
+ }
2081
+ }
2082
+ if (removeFromActive.size > 0) {
2083
+ workingStorage.accounts = workingStorage.accounts.filter((account) => !removeFromActive.has(account.refreshToken));
2084
+ clampActiveIndices(workingStorage);
2085
+ storageChanged = true;
2086
+ }
2087
+ if (storageChanged) {
2088
+ await saveAccounts(workingStorage);
2089
+ invalidateAccountManagerCache();
2090
+ }
2091
+ if (flaggedChanged) {
2092
+ await saveFlaggedAccounts(flaggedStorage);
2093
+ }
2094
+ console.log("");
2095
+ console.log(`Results: ${ok} ok, ${errors} error, ${disabled} disabled`);
2096
+ if (removeFromActive.size > 0) {
2097
+ console.log(`Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`);
2098
+ }
2099
+ console.log("");
2100
+ };
2101
+ const verifyFlaggedAccounts = async () => {
2102
+ const flaggedStorage = await loadFlaggedAccounts();
2103
+ if (flaggedStorage.accounts.length === 0) {
2104
+ console.log("\nNo flagged accounts to verify.\n");
2105
+ return;
2106
+ }
2107
+ console.log("\nVerifying flagged accounts...\n");
2108
+ const remaining = [];
2109
+ const restored = [];
2110
+ for (let i = 0; i < flaggedStorage.accounts.length; i += 1) {
2111
+ const flagged = flaggedStorage.accounts[i];
2112
+ if (!flagged)
2113
+ continue;
2114
+ const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`;
2115
+ try {
2116
+ const cached = await lookupCodexCliTokensByEmail(flagged.email);
2117
+ const now = Date.now();
2118
+ if (cached &&
2119
+ typeof cached.expiresAt === "number" &&
2120
+ Number.isFinite(cached.expiresAt) &&
2121
+ cached.expiresAt > now) {
2122
+ const refreshToken = typeof cached.refreshToken === "string" && cached.refreshToken.trim()
2123
+ ? cached.refreshToken.trim()
2124
+ : flagged.refreshToken;
2125
+ const resolved = resolveAccountSelection({
2126
+ type: "success",
2127
+ access: cached.accessToken,
2128
+ refresh: refreshToken,
2129
+ expires: cached.expiresAt,
2130
+ multiAccount: true,
2131
+ });
2132
+ if (!resolved.accountIdOverride && flagged.accountId) {
2133
+ resolved.accountIdOverride = flagged.accountId;
2134
+ resolved.accountIdSource = flagged.accountIdSource ?? "manual";
2135
+ }
2136
+ if (!resolved.accountLabel && flagged.accountLabel) {
2137
+ resolved.accountLabel = flagged.accountLabel;
2138
+ }
2139
+ restored.push(resolved);
2140
+ console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`);
2141
+ continue;
2142
+ }
2143
+ const refreshResult = await queuedRefresh(flagged.refreshToken);
2144
+ if (refreshResult.type !== "success") {
2145
+ console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`);
2146
+ remaining.push(flagged);
2147
+ continue;
2148
+ }
2149
+ const resolved = resolveAccountSelection(refreshResult);
2150
+ if (!resolved.accountIdOverride && flagged.accountId) {
2151
+ resolved.accountIdOverride = flagged.accountId;
2152
+ resolved.accountIdSource = flagged.accountIdSource ?? "manual";
2153
+ }
2154
+ if (!resolved.accountLabel && flagged.accountLabel) {
2155
+ resolved.accountLabel = flagged.accountLabel;
2156
+ }
2157
+ restored.push(resolved);
2158
+ console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`);
2159
+ }
2160
+ catch (error) {
2161
+ const message = error instanceof Error ? error.message : String(error);
2162
+ console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`);
2163
+ remaining.push({
2164
+ ...flagged,
2165
+ lastError: message,
2166
+ });
2167
+ }
2168
+ }
2169
+ if (restored.length > 0) {
2170
+ await persistAccountPool(restored, false);
2171
+ invalidateAccountManagerCache();
2172
+ }
2173
+ await saveFlaggedAccounts({
2174
+ version: 1,
2175
+ accounts: remaining,
2176
+ });
2177
+ console.log("");
2178
+ console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`);
2179
+ console.log("");
2180
+ };
2181
+ if (!explicitLoginMode) {
2182
+ while (true) {
2183
+ const loadedStorage = await hydrateEmails(await loadAccounts());
2184
+ const workingStorage = loadedStorage
2185
+ ? {
2186
+ ...loadedStorage,
2187
+ accounts: loadedStorage.accounts.map((account) => ({ ...account })),
2188
+ activeIndexByFamily: loadedStorage.activeIndexByFamily
2189
+ ? { ...loadedStorage.activeIndexByFamily }
2190
+ : {},
2191
+ }
2192
+ : { version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {} };
2193
+ const flaggedStorage = await loadFlaggedAccounts();
2194
+ if (workingStorage.accounts.length === 0 && flaggedStorage.accounts.length === 0) {
2195
+ break;
2196
+ }
2197
+ const now = Date.now();
2198
+ const activeIndex = resolveActiveIndex(workingStorage, "codex");
2199
+ const existingAccounts = workingStorage.accounts.map((account, index) => {
2200
+ let status;
2201
+ if (account.enabled === false) {
2202
+ status = "disabled";
2203
+ }
2204
+ else if (typeof account.coolingDownUntil === "number" &&
2205
+ account.coolingDownUntil > now) {
2206
+ status = "cooldown";
2207
+ }
2208
+ else if (formatRateLimitEntry(account, now)) {
2209
+ status = "rate-limited";
2210
+ }
2211
+ else if (index === activeIndex) {
2212
+ status = "active";
2213
+ }
2214
+ else {
2215
+ status = "ok";
2216
+ }
2217
+ return {
2218
+ accountId: account.accountId,
2219
+ accountLabel: account.accountLabel,
2220
+ email: account.email,
2221
+ index,
2222
+ addedAt: account.addedAt,
2223
+ lastUsed: account.lastUsed,
2224
+ status,
2225
+ isCurrentAccount: index === activeIndex,
2226
+ enabled: account.enabled !== false,
2227
+ };
2228
+ });
2229
+ const menuResult = await promptLoginMode(existingAccounts, {
2230
+ flaggedCount: flaggedStorage.accounts.length,
2231
+ });
2232
+ if (menuResult.mode === "cancel") {
2233
+ return {
2234
+ url: "",
2235
+ instructions: "Authentication cancelled",
2236
+ method: "auto",
2237
+ callback: () => Promise.resolve({
2238
+ type: "failed",
2239
+ }),
2240
+ };
2241
+ }
2242
+ if (menuResult.mode === "check") {
2243
+ await runAccountCheck(false);
2244
+ continue;
2245
+ }
2246
+ if (menuResult.mode === "deep-check") {
2247
+ await runAccountCheck(true);
2248
+ continue;
2249
+ }
2250
+ if (menuResult.mode === "verify-flagged") {
2251
+ await verifyFlaggedAccounts();
2252
+ continue;
2253
+ }
2254
+ if (menuResult.mode === "manage") {
2255
+ if (typeof menuResult.deleteAccountIndex === "number") {
2256
+ const target = workingStorage.accounts[menuResult.deleteAccountIndex];
2257
+ if (target) {
2258
+ workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1);
2259
+ clampActiveIndices(workingStorage);
2260
+ await saveAccounts(workingStorage);
2261
+ await saveFlaggedAccounts({
2262
+ version: 1,
2263
+ accounts: flaggedStorage.accounts.filter((flagged) => flagged.refreshToken !== target.refreshToken),
2264
+ });
2265
+ invalidateAccountManagerCache();
2266
+ console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`);
2267
+ }
2268
+ continue;
2269
+ }
2270
+ if (typeof menuResult.toggleAccountIndex === "number") {
2271
+ const target = workingStorage.accounts[menuResult.toggleAccountIndex];
2272
+ if (target) {
2273
+ target.enabled = target.enabled === false ? true : false;
2274
+ await saveAccounts(workingStorage);
2275
+ invalidateAccountManagerCache();
2276
+ console.log(`\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`);
2277
+ }
2278
+ continue;
2279
+ }
2280
+ if (typeof menuResult.refreshAccountIndex === "number") {
2281
+ refreshAccountIndex = menuResult.refreshAccountIndex;
2282
+ startFresh = false;
2283
+ break;
2284
+ }
2285
+ continue;
2286
+ }
2287
+ if (menuResult.mode === "fresh") {
2288
+ startFresh = true;
2289
+ if (menuResult.deleteAll) {
2290
+ await clearAccounts();
2291
+ await clearFlaggedAccounts();
2292
+ invalidateAccountManagerCache();
2293
+ console.log("\nDeleted all accounts. Starting fresh.\n");
2294
+ }
2295
+ break;
2296
+ }
2297
+ startFresh = false;
2298
+ break;
2299
+ }
2300
+ }
2301
+ const latestStorage = await loadAccounts();
2302
+ const existingCount = latestStorage?.accounts.length ?? 0;
2303
+ const requestedCount = Number.parseInt(inputs?.accountCount ?? "1", 10);
2304
+ const normalizedRequested = Number.isFinite(requestedCount) ? requestedCount : 1;
2305
+ const availableSlots = refreshAccountIndex !== undefined
2306
+ ? 1
2307
+ : startFresh
2308
+ ? ACCOUNT_LIMITS.MAX_ACCOUNTS
2309
+ : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount;
2310
+ if (availableSlots <= 0) {
2311
+ return {
2312
+ url: "",
2313
+ instructions: "Account limit reached. Remove an account or start fresh.",
2314
+ method: "auto",
2315
+ callback: () => Promise.resolve({
2316
+ type: "failed",
2317
+ }),
2318
+ };
2319
+ }
2320
+ let targetCount = Math.max(1, Math.min(normalizedRequested, availableSlots));
2321
+ if (refreshAccountIndex !== undefined) {
2322
+ targetCount = 1;
2323
+ }
2324
+ if (useManualMode) {
2325
+ targetCount = 1;
2326
+ }
2327
+ if (useManualMode) {
2328
+ const { pkce, state, url } = await createAuthorizationFlow();
2329
+ return buildManualOAuthFlow(pkce, url, state, async (tokens) => {
2330
+ try {
2331
+ await persistAccountPool([tokens], startFresh);
2332
+ invalidateAccountManagerCache();
2333
+ }
2334
+ catch (err) {
2335
+ const storagePath = getStoragePath();
2336
+ const errorCode = err?.code || "UNKNOWN";
2337
+ const hint = err instanceof StorageError
2338
+ ? err.hint
2339
+ : formatStorageErrorHint(err, storagePath);
2340
+ logError(`[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${err?.message ?? String(err)}`);
2341
+ await showToast(hint, "error", {
2342
+ title: "Account Persistence Failed",
2343
+ duration: 10000,
2344
+ });
2345
+ }
2346
+ });
2347
+ }
2348
+ const explicitCountProvided = typeof inputs?.accountCount === "string" && inputs.accountCount.trim().length > 0;
2349
+ while (accounts.length < targetCount) {
2350
+ logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`);
2351
+ const forceNewLogin = accounts.length > 0 || refreshAccountIndex !== undefined;
2352
+ const result = await runOAuthFlow(forceNewLogin);
2353
+ let resolved = null;
2354
+ if (result.type === "success") {
2355
+ resolved = resolveAccountSelection(result);
2356
+ const email = extractAccountEmail(resolved.access, resolved.idToken);
2357
+ const accountId = resolved.accountIdOverride ?? extractAccountId(resolved.access);
2358
+ const label = resolved.accountLabel ?? email ?? accountId ?? "Unknown account";
2359
+ logInfo(`Authenticated as: ${label}`);
2360
+ const isDuplicate = accounts.some((account) => (accountId &&
2361
+ (account.accountIdOverride ?? extractAccountId(account.access)) === accountId) ||
2362
+ (email && extractAccountEmail(account.access, account.idToken) === email));
2363
+ if (isDuplicate) {
2364
+ logWarn(`WARNING: duplicate account login detected (${label}). Existing entry will be updated.`);
2365
+ }
2366
+ }
2367
+ if (result.type === "failed") {
2368
+ if (accounts.length === 0) {
2369
+ return {
2370
+ url: "",
2371
+ instructions: "Authentication failed.",
2372
+ method: "auto",
2373
+ callback: () => Promise.resolve(result),
2374
+ };
2375
+ }
2376
+ logWarn(`[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`);
2377
+ break;
2378
+ }
2379
+ if (!resolved) {
2380
+ continue;
2381
+ }
2382
+ accounts.push(resolved);
2383
+ await showToast(`Account ${accounts.length} authenticated`, "success");
2384
+ try {
2385
+ const isFirstAccount = accounts.length === 1;
2386
+ await persistAccountPool([resolved], isFirstAccount && startFresh);
2387
+ invalidateAccountManagerCache();
2388
+ }
2389
+ catch (err) {
2390
+ const storagePath = getStoragePath();
2391
+ const errorCode = err?.code || "UNKNOWN";
2392
+ const hint = err instanceof StorageError
2393
+ ? err.hint
2394
+ : formatStorageErrorHint(err, storagePath);
2395
+ logError(`[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${err?.message ?? String(err)}`);
2396
+ await showToast(hint, "error", {
2397
+ title: "Account Persistence Failed",
2398
+ duration: 10000,
2399
+ });
2400
+ }
2401
+ if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) {
2402
+ break;
2403
+ }
2404
+ if (!explicitCountProvided &&
2405
+ refreshAccountIndex === undefined &&
2406
+ accounts.length < availableSlots &&
2407
+ accounts.length >= targetCount) {
2408
+ const addMore = await promptAddAnotherAccount(accounts.length);
2409
+ if (addMore) {
2410
+ targetCount = Math.min(targetCount + 1, availableSlots);
2411
+ continue;
2412
+ }
2413
+ break;
2414
+ }
2415
+ }
2416
+ const primary = accounts[0];
2417
+ if (!primary) {
2418
+ return {
2419
+ url: "",
2420
+ instructions: "Authentication cancelled",
2421
+ method: "auto",
2422
+ callback: () => Promise.resolve({
2423
+ type: "failed",
2424
+ }),
2425
+ };
2426
+ }
2427
+ let actualAccountCount = accounts.length;
2428
+ try {
2429
+ const finalStorage = await loadAccounts();
2430
+ if (finalStorage) {
2431
+ actualAccountCount = finalStorage.accounts.length;
2432
+ }
2433
+ }
2434
+ catch (err) {
2435
+ logWarn(`[${PLUGIN_NAME}] Failed to load final account count: ${err?.message ?? String(err)}`);
2436
+ }
2437
+ return {
2438
+ url: "",
2439
+ instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
2440
+ method: "auto",
2441
+ callback: () => Promise.resolve(primary),
2442
+ };
2443
+ },
2444
+ },
2445
+ {
2446
+ label: AUTH_LABELS.OAUTH_MANUAL,
2447
+ type: "oauth",
2448
+ authorize: async () => {
2449
+ // Initialize storage path for manual OAuth flow
2450
+ // Must happen BEFORE persistAccountPool to ensure correct storage location
2451
+ const manualPluginConfig = loadPluginConfig();
2452
+ applyUiRuntimeFromConfig(manualPluginConfig);
2453
+ applyAccountStorageScope(manualPluginConfig);
2454
+ const { pkce, state, url } = await createAuthorizationFlow();
2455
+ return buildManualOAuthFlow(pkce, url, state, async (tokens) => {
2456
+ try {
2457
+ await persistAccountPool([tokens], false);
2458
+ }
2459
+ catch (err) {
2460
+ const storagePath = getStoragePath();
2461
+ const errorCode = err?.code || "UNKNOWN";
2462
+ const hint = err instanceof StorageError ? err.hint : formatStorageErrorHint(err, storagePath);
2463
+ logError(`[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${err?.message ?? String(err)}`);
2464
+ await showToast(hint, "error", { title: "Account Persistence Failed", duration: 10000 });
2465
+ }
2466
+ });
2467
+ },
2468
+ },
2469
+ ],
2470
+ },
2471
+ tool: {
2472
+ edit: createHashlineEditTool(),
2473
+ // OpenCode v1.2.x exposes apply_patch (not edit) to the model.
2474
+ // Register the same hashline-capable implementation under both names.
2475
+ apply_patch: createHashlineEditTool(),
2476
+ hashline_read: createHashlineReadTool(),
2477
+ "codex-list": tool({
2478
+ description: "List all Codex OAuth accounts and the current active index.",
2479
+ args: {},
2480
+ async execute() {
2481
+ const ui = resolveUiRuntime();
2482
+ const storage = await loadAccounts();
2483
+ const storePath = getStoragePath();
2484
+ if (!storage || storage.accounts.length === 0) {
2485
+ if (ui.v2Enabled) {
2486
+ return [
2487
+ ...formatUiHeader(ui, "Codex accounts"),
2488
+ "",
2489
+ formatUiItem(ui, "No accounts configured.", "warning"),
2490
+ formatUiItem(ui, "Run: codex login", "accent"),
2491
+ formatUiKeyValue(ui, "Storage", storePath, "muted"),
2492
+ ].join("\n");
2493
+ }
2494
+ return [
2495
+ "No Codex accounts configured.",
2496
+ "",
2497
+ "Add accounts:",
2498
+ " codex login",
2499
+ "",
2500
+ `Storage: ${storePath}`,
2501
+ ].join("\n");
2502
+ }
2503
+ const now = Date.now();
2504
+ const activeIndex = resolveActiveIndex(storage, "codex");
2505
+ if (ui.v2Enabled) {
2506
+ const lines = [
2507
+ ...formatUiHeader(ui, "Codex accounts"),
2508
+ formatUiKeyValue(ui, "Total", String(storage.accounts.length)),
2509
+ formatUiKeyValue(ui, "Storage", storePath, "muted"),
2510
+ "",
2511
+ ...formatUiSection(ui, "Accounts"),
2512
+ ];
2513
+ storage.accounts.forEach((account, index) => {
2514
+ const label = formatAccountLabel(account, index);
2515
+ const badges = [];
2516
+ if (index === activeIndex)
2517
+ badges.push(formatUiBadge(ui, "current", "accent"));
2518
+ if (account.enabled === false)
2519
+ badges.push(formatUiBadge(ui, "disabled", "danger"));
2520
+ const rateLimit = formatRateLimitEntry(account, now);
2521
+ if (rateLimit)
2522
+ badges.push(formatUiBadge(ui, "rate-limited", "warning"));
2523
+ if (typeof account.coolingDownUntil === "number" &&
2524
+ account.coolingDownUntil > now) {
2525
+ badges.push(formatUiBadge(ui, "cooldown", "warning"));
2526
+ }
2527
+ if (badges.length === 0) {
2528
+ badges.push(formatUiBadge(ui, "ok", "success"));
2529
+ }
2530
+ lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim()));
2531
+ if (rateLimit) {
2532
+ lines.push(` ${paintUiText(ui, `rate limit: ${rateLimit}`, "muted")}`);
2533
+ }
2534
+ });
2535
+ lines.push("");
2536
+ lines.push(...formatUiSection(ui, "Commands"));
2537
+ lines.push(formatUiItem(ui, "Add account: codex login", "accent"));
2538
+ lines.push(formatUiItem(ui, "Switch account: codex-switch <index>"));
2539
+ lines.push(formatUiItem(ui, "Detailed status: codex-status"));
2540
+ lines.push(formatUiItem(ui, "Health check: codex-health"));
2541
+ return lines.join("\n");
2542
+ }
2543
+ const listTableOptions = {
2544
+ columns: [
2545
+ { header: "#", width: 3 },
2546
+ { header: "Label", width: 42 },
2547
+ { header: "Status", width: 20 },
2548
+ ],
2549
+ };
2550
+ const lines = [
2551
+ `Codex Accounts (${storage.accounts.length}):`,
2552
+ "",
2553
+ ...buildTableHeader(listTableOptions),
2554
+ ];
2555
+ storage.accounts.forEach((account, index) => {
2556
+ const label = formatAccountLabel(account, index);
2557
+ const statuses = [];
2558
+ const rateLimit = formatRateLimitEntry(account, now);
2559
+ if (index === activeIndex)
2560
+ statuses.push("active");
2561
+ if (rateLimit)
2562
+ statuses.push("rate-limited");
2563
+ if (typeof account.coolingDownUntil ===
2564
+ "number" &&
2565
+ account.coolingDownUntil > now) {
2566
+ statuses.push("cooldown");
2567
+ }
2568
+ const statusText = statuses.length > 0 ? statuses.join(", ") : "ok";
2569
+ lines.push(buildTableRow([String(index + 1), label, statusText], listTableOptions));
2570
+ });
2571
+ lines.push("");
2572
+ lines.push(`Storage: ${storePath}`);
2573
+ lines.push("");
2574
+ lines.push("Commands:");
2575
+ lines.push(" - Add account: codex login");
2576
+ lines.push(" - Switch account: codex-switch");
2577
+ lines.push(" - Status details: codex-status");
2578
+ lines.push(" - Health check: codex-health");
2579
+ return lines.join("\n");
2580
+ },
2581
+ }),
2582
+ "codex-switch": tool({
2583
+ description: "Switch active Codex account by index (1-based).",
2584
+ args: {
2585
+ index: tool.schema.number().describe("Account number to switch to (1-based, e.g., 1 for first account)"),
2586
+ },
2587
+ async execute({ index }) {
2588
+ const ui = resolveUiRuntime();
2589
+ const storage = await loadAccounts();
2590
+ if (!storage || storage.accounts.length === 0) {
2591
+ if (ui.v2Enabled) {
2592
+ return [
2593
+ ...formatUiHeader(ui, "Switch account"),
2594
+ "",
2595
+ formatUiItem(ui, "No accounts configured.", "warning"),
2596
+ formatUiItem(ui, "Run: codex login", "accent"),
2597
+ ].join("\n");
2598
+ }
2599
+ return "No Codex accounts configured. Run: codex login";
2600
+ }
2601
+ const targetIndex = Math.floor((index ?? 0) - 1);
2602
+ if (!Number.isFinite(targetIndex) ||
2603
+ targetIndex < 0 ||
2604
+ targetIndex >= storage.accounts.length) {
2605
+ if (ui.v2Enabled) {
2606
+ return [
2607
+ ...formatUiHeader(ui, "Switch account"),
2608
+ "",
2609
+ formatUiItem(ui, `Invalid account number: ${index}`, "danger"),
2610
+ formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"),
2611
+ ].join("\n");
2612
+ }
2613
+ return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`;
2614
+ }
2615
+ const now = Date.now();
2616
+ const account = storage.accounts[targetIndex];
2617
+ if (account) {
2618
+ account.lastUsed = now;
2619
+ account.lastSwitchReason = "rotation";
2620
+ }
2621
+ storage.activeIndex = targetIndex;
2622
+ storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
2623
+ for (const family of MODEL_FAMILIES) {
2624
+ storage.activeIndexByFamily[family] = targetIndex;
2625
+ }
2626
+ try {
2627
+ await saveAccounts(storage);
2628
+ }
2629
+ catch (saveError) {
2630
+ logWarn("Failed to save account switch", { error: String(saveError) });
2631
+ if (ui.v2Enabled) {
2632
+ return [
2633
+ ...formatUiHeader(ui, "Switch account"),
2634
+ "",
2635
+ formatUiItem(ui, `Switched to ${formatAccountLabel(account, targetIndex)}`, "warning"),
2636
+ formatUiItem(ui, "Failed to persist change. It may be lost on restart.", "danger"),
2637
+ ].join("\n");
2638
+ }
2639
+ return `Switched to ${formatAccountLabel(account, targetIndex)} but failed to persist. Changes may be lost on restart.`;
2640
+ }
2641
+ if (cachedAccountManager) {
2642
+ await reloadAccountManagerFromDisk();
2643
+ }
2644
+ const label = formatAccountLabel(account, targetIndex);
2645
+ if (ui.v2Enabled) {
2646
+ return [
2647
+ ...formatUiHeader(ui, "Switch account"),
2648
+ "",
2649
+ formatUiItem(ui, `${getStatusMarker(ui, "ok")} Switched to ${label}`, "success"),
2650
+ ].join("\n");
2651
+ }
2652
+ return `Switched to account: ${label}`;
2653
+ },
2654
+ }),
2655
+ "codex-status": tool({
2656
+ description: "Show detailed status of Codex accounts and rate limits.",
2657
+ args: {},
2658
+ async execute() {
2659
+ const ui = resolveUiRuntime();
2660
+ const storage = await loadAccounts();
2661
+ if (!storage || storage.accounts.length === 0) {
2662
+ if (ui.v2Enabled) {
2663
+ return [
2664
+ ...formatUiHeader(ui, "Account status"),
2665
+ "",
2666
+ formatUiItem(ui, "No accounts configured.", "warning"),
2667
+ formatUiItem(ui, "Run: codex login", "accent"),
2668
+ ].join("\n");
2669
+ }
2670
+ return "No Codex accounts configured. Run: codex login";
2671
+ }
2672
+ const now = Date.now();
2673
+ const activeIndex = resolveActiveIndex(storage, "codex");
2674
+ if (ui.v2Enabled) {
2675
+ const lines = [
2676
+ ...formatUiHeader(ui, "Account status"),
2677
+ formatUiKeyValue(ui, "Total", String(storage.accounts.length)),
2678
+ "",
2679
+ ...formatUiSection(ui, "Accounts"),
2680
+ ];
2681
+ storage.accounts.forEach((account, index) => {
2682
+ const label = formatAccountLabel(account, index);
2683
+ const badges = [];
2684
+ if (index === activeIndex)
2685
+ badges.push(formatUiBadge(ui, "active", "accent"));
2686
+ if (account.enabled === false)
2687
+ badges.push(formatUiBadge(ui, "disabled", "danger"));
2688
+ const rateLimit = formatRateLimitEntry(account, now) ?? "none";
2689
+ const cooldown = formatCooldown(account, now) ?? "none";
2690
+ if (rateLimit !== "none")
2691
+ badges.push(formatUiBadge(ui, "rate-limited", "warning"));
2692
+ if (cooldown !== "none")
2693
+ badges.push(formatUiBadge(ui, "cooldown", "warning"));
2694
+ if (badges.length === 0)
2695
+ badges.push(formatUiBadge(ui, "ok", "success"));
2696
+ lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim()));
2697
+ lines.push(` ${formatUiKeyValue(ui, "rate limit", rateLimit, rateLimit === "none" ? "muted" : "warning")}`);
2698
+ lines.push(` ${formatUiKeyValue(ui, "cooldown", cooldown, cooldown === "none" ? "muted" : "warning")}`);
2699
+ });
2700
+ lines.push("");
2701
+ lines.push(...formatUiSection(ui, "Active index by model family"));
2702
+ for (const family of MODEL_FAMILIES) {
2703
+ const idx = storage.activeIndexByFamily?.[family];
2704
+ const familyIndexLabel = typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-";
2705
+ lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`));
2706
+ }
2707
+ lines.push("");
2708
+ lines.push(...formatUiSection(ui, "Rate limits by model family (per account)"));
2709
+ storage.accounts.forEach((account, index) => {
2710
+ const statuses = MODEL_FAMILIES.map((family) => {
2711
+ const resetAt = getRateLimitResetTimeForFamily(account, now, family);
2712
+ if (typeof resetAt !== "number")
2713
+ return `${family}=ok`;
2714
+ return `${family}=${formatWaitTime(resetAt - now)}`;
2715
+ });
2716
+ lines.push(formatUiItem(ui, `Account ${index + 1}: ${statuses.join(" | ")}`));
2717
+ });
2718
+ return lines.join("\n");
2719
+ }
2720
+ const statusTableOptions = {
2721
+ columns: [
2722
+ { header: "#", width: 3 },
2723
+ { header: "Label", width: 42 },
2724
+ { header: "Active", width: 6 },
2725
+ { header: "Rate Limit", width: 16 },
2726
+ { header: "Cooldown", width: 16 },
2727
+ { header: "Last Used", width: 16 },
2728
+ ],
2729
+ };
2730
+ const lines = [
2731
+ `Account Status (${storage.accounts.length} total):`,
2732
+ "",
2733
+ ...buildTableHeader(statusTableOptions),
2734
+ ];
2735
+ storage.accounts.forEach((account, index) => {
2736
+ const label = formatAccountLabel(account, index);
2737
+ const active = index === activeIndex ? "Yes" : "No";
2738
+ const rateLimit = formatRateLimitEntry(account, now) ?? "None";
2739
+ const cooldown = formatCooldown(account, now) ?? "No";
2740
+ const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0
2741
+ ? `${formatWaitTime(now - account.lastUsed)} ago`
2742
+ : "-";
2743
+ lines.push(buildTableRow([String(index + 1), label, active, rateLimit, cooldown, lastUsed], statusTableOptions));
2744
+ });
2745
+ lines.push("");
2746
+ lines.push("Active index by model family:");
2747
+ for (const family of MODEL_FAMILIES) {
2748
+ const idx = storage.activeIndexByFamily?.[family];
2749
+ const familyIndexLabel = typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-";
2750
+ lines.push(` ${family}: ${familyIndexLabel}`);
2751
+ }
2752
+ lines.push("");
2753
+ lines.push("Rate limits by model family (per account):");
2754
+ storage.accounts.forEach((account, index) => {
2755
+ const statuses = MODEL_FAMILIES.map((family) => {
2756
+ const resetAt = getRateLimitResetTimeForFamily(account, now, family);
2757
+ if (typeof resetAt !== "number")
2758
+ return `${family}=ok`;
2759
+ return `${family}=${formatWaitTime(resetAt - now)}`;
2760
+ });
2761
+ lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`);
2762
+ });
2763
+ return lines.join("\n");
2764
+ },
2765
+ }),
2766
+ ...(exposeAdvancedCodexTools ? {
2767
+ "codex-metrics": tool({
2768
+ description: "Show runtime request metrics for this plugin process.",
2769
+ args: {},
2770
+ execute() {
2771
+ const ui = resolveUiRuntime();
2772
+ const now = Date.now();
2773
+ const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt);
2774
+ const total = runtimeMetrics.totalRequests;
2775
+ const successful = runtimeMetrics.successfulRequests;
2776
+ const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0";
2777
+ const avgLatencyMs = successful > 0
2778
+ ? Math.round(runtimeMetrics.cumulativeLatencyMs / successful)
2779
+ : 0;
2780
+ const liveSyncSnapshot = liveAccountSync?.getSnapshot();
2781
+ const guardianStats = refreshGuardian?.getStats();
2782
+ const sessionAffinityEntries = sessionAffinityStore?.size() ?? 0;
2783
+ const lastRequest = runtimeMetrics.lastRequestAt !== null
2784
+ ? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago`
2785
+ : "never";
2786
+ const lines = [
2787
+ "Codex Plugin Metrics:",
2788
+ "",
2789
+ `Uptime: ${formatWaitTime(uptimeMs)}`,
2790
+ `Total upstream requests: ${total}`,
2791
+ `Successful responses: ${successful}`,
2792
+ `Failed responses: ${runtimeMetrics.failedRequests}`,
2793
+ `Success rate: ${successRate}%`,
2794
+ `Average successful latency: ${avgLatencyMs}ms`,
2795
+ `Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`,
2796
+ `Server errors (5xx): ${runtimeMetrics.serverErrors}`,
2797
+ `Network errors: ${runtimeMetrics.networkErrors}`,
2798
+ `User aborts: ${runtimeMetrics.userAborts}`,
2799
+ `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`,
2800
+ `Account rotations: ${runtimeMetrics.accountRotations}`,
2801
+ `Same-account retries: ${runtimeMetrics.sameAccountRetries}`,
2802
+ `Stream failover attempts: ${runtimeMetrics.streamFailoverAttempts}`,
2803
+ `Stream failover recoveries: ${runtimeMetrics.streamFailoverRecoveries}`,
2804
+ `Stream failover cross-account recoveries: ${runtimeMetrics.streamFailoverCrossAccountRecoveries}`,
2805
+ `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`,
2806
+ `Session affinity entries: ${sessionAffinityEntries}`,
2807
+ `Live sync: ${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`,
2808
+ `Refresh guardian: ${guardianStats ? "on" : "off"} (${guardianStats?.refreshed ?? 0} refreshed, ${guardianStats?.failed ?? 0} failed)`,
2809
+ `Last upstream request: ${lastRequest}`,
2810
+ ];
2811
+ if (runtimeMetrics.lastError) {
2812
+ lines.push(`Last error: ${runtimeMetrics.lastError}`);
2813
+ }
2814
+ if (ui.v2Enabled) {
2815
+ const styled = [
2816
+ ...formatUiHeader(ui, "Codex plugin metrics"),
2817
+ formatUiKeyValue(ui, "Uptime", formatWaitTime(uptimeMs)),
2818
+ formatUiKeyValue(ui, "Total upstream requests", String(total)),
2819
+ formatUiKeyValue(ui, "Successful responses", String(successful), "success"),
2820
+ formatUiKeyValue(ui, "Failed responses", String(runtimeMetrics.failedRequests), "danger"),
2821
+ formatUiKeyValue(ui, "Success rate", `${successRate}%`, "accent"),
2822
+ formatUiKeyValue(ui, "Average successful latency", `${avgLatencyMs}ms`),
2823
+ formatUiKeyValue(ui, "Rate-limited responses", String(runtimeMetrics.rateLimitedResponses), "warning"),
2824
+ formatUiKeyValue(ui, "Server errors (5xx)", String(runtimeMetrics.serverErrors), "danger"),
2825
+ formatUiKeyValue(ui, "Network errors", String(runtimeMetrics.networkErrors), "danger"),
2826
+ formatUiKeyValue(ui, "User aborts", String(runtimeMetrics.userAborts), "muted"),
2827
+ formatUiKeyValue(ui, "Auth refresh failures", String(runtimeMetrics.authRefreshFailures), "warning"),
2828
+ formatUiKeyValue(ui, "Account rotations", String(runtimeMetrics.accountRotations), "accent"),
2829
+ formatUiKeyValue(ui, "Same-account retries", String(runtimeMetrics.sameAccountRetries), "warning"),
2830
+ formatUiKeyValue(ui, "Stream failover attempts", String(runtimeMetrics.streamFailoverAttempts), "muted"),
2831
+ formatUiKeyValue(ui, "Stream failover recoveries", String(runtimeMetrics.streamFailoverRecoveries), "success"),
2832
+ formatUiKeyValue(ui, "Stream failover cross-account recoveries", String(runtimeMetrics.streamFailoverCrossAccountRecoveries), "accent"),
2833
+ formatUiKeyValue(ui, "Empty-response retries", String(runtimeMetrics.emptyResponseRetries), "warning"),
2834
+ formatUiKeyValue(ui, "Session affinity entries", String(sessionAffinityEntries), "muted"),
2835
+ formatUiKeyValue(ui, "Live sync", `${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, liveSyncSnapshot?.running ? "success" : "muted"),
2836
+ formatUiKeyValue(ui, "Refresh guardian", guardianStats
2837
+ ? `on (${guardianStats.refreshed} refreshed, ${guardianStats.failed} failed)`
2838
+ : "off", guardianStats ? "success" : "muted"),
2839
+ formatUiKeyValue(ui, "Last upstream request", lastRequest, "muted"),
2840
+ ];
2841
+ if (runtimeMetrics.lastError) {
2842
+ styled.push(formatUiKeyValue(ui, "Last error", runtimeMetrics.lastError, "danger"));
2843
+ }
2844
+ return Promise.resolve(styled.join("\n"));
2845
+ }
2846
+ return Promise.resolve(lines.join("\n"));
2847
+ },
2848
+ }),
2849
+ } : {}),
2850
+ "codex-health": tool({
2851
+ description: "Check health of all Codex accounts by validating refresh tokens.",
2852
+ args: {},
2853
+ async execute() {
2854
+ const ui = resolveUiRuntime();
2855
+ const storage = await loadAccounts();
2856
+ if (!storage || storage.accounts.length === 0) {
2857
+ if (ui.v2Enabled) {
2858
+ return [
2859
+ ...formatUiHeader(ui, "Health check"),
2860
+ "",
2861
+ formatUiItem(ui, "No accounts configured.", "warning"),
2862
+ formatUiItem(ui, "Run: codex login", "accent"),
2863
+ ].join("\n");
2864
+ }
2865
+ return "No Codex accounts configured. Run: codex login";
2866
+ }
2867
+ const results = ui.v2Enabled
2868
+ ? []
2869
+ : [`Health Check (${storage.accounts.length} accounts):`, ""];
2870
+ let healthyCount = 0;
2871
+ let unhealthyCount = 0;
2872
+ for (let i = 0; i < storage.accounts.length; i++) {
2873
+ const account = storage.accounts[i];
2874
+ if (!account)
2875
+ continue;
2876
+ const label = formatAccountLabel(account, i);
2877
+ try {
2878
+ const refreshResult = await queuedRefresh(account.refreshToken);
2879
+ if (refreshResult.type === "success") {
2880
+ results.push(` ${getStatusMarker(ui, "ok")} ${label}: Healthy`);
2881
+ healthyCount++;
2882
+ }
2883
+ else {
2884
+ results.push(` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`);
2885
+ unhealthyCount++;
2886
+ }
2887
+ }
2888
+ catch (error) {
2889
+ const errorMsg = error instanceof Error ? error.message : String(error);
2890
+ results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`);
2891
+ unhealthyCount++;
2892
+ }
2893
+ }
2894
+ results.push("");
2895
+ results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`);
2896
+ if (ui.v2Enabled) {
2897
+ return [
2898
+ ...formatUiHeader(ui, "Health check"),
2899
+ "",
2900
+ ...results.map((line) => paintUiText(ui, line, "normal")),
2901
+ ].join("\n");
2902
+ }
2903
+ return results.join("\n");
2904
+ },
2905
+ }),
2906
+ ...(exposeAdvancedCodexTools ? {
2907
+ "codex-remove": tool({
2908
+ description: "Remove a Codex account by index (1-based). Use codex-list to list accounts first.",
2909
+ args: {
2910
+ index: tool.schema.number().describe("Account number to remove (1-based, e.g., 1 for first account)"),
2911
+ },
2912
+ async execute({ index }) {
2913
+ const ui = resolveUiRuntime();
2914
+ const storage = await loadAccounts();
2915
+ if (!storage || storage.accounts.length === 0) {
2916
+ if (ui.v2Enabled) {
2917
+ return [
2918
+ ...formatUiHeader(ui, "Remove account"),
2919
+ "",
2920
+ formatUiItem(ui, "No accounts configured.", "warning"),
2921
+ ].join("\n");
2922
+ }
2923
+ return "No Codex accounts configured. Nothing to remove.";
2924
+ }
2925
+ const targetIndex = Math.floor((index ?? 0) - 1);
2926
+ if (!Number.isFinite(targetIndex) ||
2927
+ targetIndex < 0 ||
2928
+ targetIndex >= storage.accounts.length) {
2929
+ if (ui.v2Enabled) {
2930
+ return [
2931
+ ...formatUiHeader(ui, "Remove account"),
2932
+ "",
2933
+ formatUiItem(ui, `Invalid account number: ${index}`, "danger"),
2934
+ formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"),
2935
+ formatUiItem(ui, "Use codex-list to list all accounts.", "accent"),
2936
+ ].join("\n");
2937
+ }
2938
+ return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`;
2939
+ }
2940
+ const account = storage.accounts[targetIndex];
2941
+ if (!account) {
2942
+ return `Account ${index} not found.`;
2943
+ }
2944
+ const label = formatAccountLabel(account, targetIndex);
2945
+ storage.accounts.splice(targetIndex, 1);
2946
+ if (storage.accounts.length === 0) {
2947
+ storage.activeIndex = 0;
2948
+ storage.activeIndexByFamily = {};
2949
+ }
2950
+ else {
2951
+ if (storage.activeIndex >= storage.accounts.length) {
2952
+ storage.activeIndex = 0;
2953
+ }
2954
+ else if (storage.activeIndex > targetIndex) {
2955
+ storage.activeIndex -= 1;
2956
+ }
2957
+ if (storage.activeIndexByFamily) {
2958
+ for (const family of MODEL_FAMILIES) {
2959
+ const idx = storage.activeIndexByFamily[family];
2960
+ if (typeof idx === "number") {
2961
+ if (idx >= storage.accounts.length) {
2962
+ storage.activeIndexByFamily[family] = 0;
2963
+ }
2964
+ else if (idx > targetIndex) {
2965
+ storage.activeIndexByFamily[family] = idx - 1;
2966
+ }
2967
+ }
2968
+ }
2969
+ }
2970
+ }
2971
+ try {
2972
+ await saveAccounts(storage);
2973
+ }
2974
+ catch (saveError) {
2975
+ logWarn("Failed to save account removal", { error: String(saveError) });
2976
+ if (ui.v2Enabled) {
2977
+ return [
2978
+ ...formatUiHeader(ui, "Remove account"),
2979
+ "",
2980
+ formatUiItem(ui, `Removed ${formatAccountLabel(account, targetIndex)} from memory`, "warning"),
2981
+ formatUiItem(ui, "Failed to persist. Change may be lost on restart.", "danger"),
2982
+ ].join("\n");
2983
+ }
2984
+ return `Removed ${formatAccountLabel(account, targetIndex)} from memory but failed to persist. Changes may be lost on restart.`;
2985
+ }
2986
+ if (cachedAccountManager) {
2987
+ await reloadAccountManagerFromDisk();
2988
+ }
2989
+ const remaining = storage.accounts.length;
2990
+ if (ui.v2Enabled) {
2991
+ return [
2992
+ ...formatUiHeader(ui, "Remove account"),
2993
+ "",
2994
+ formatUiItem(ui, `${getStatusMarker(ui, "ok")} Removed: ${label}`, "success"),
2995
+ remaining > 0
2996
+ ? formatUiKeyValue(ui, "Remaining accounts", String(remaining))
2997
+ : formatUiItem(ui, "No accounts remaining. Run: codex login", "warning"),
2998
+ ].join("\n");
2999
+ }
3000
+ return [
3001
+ `Removed: ${label}`,
3002
+ "",
3003
+ remaining > 0
3004
+ ? `Remaining accounts: ${remaining}`
3005
+ : "No accounts remaining. Run: codex login",
3006
+ ].join("\n");
3007
+ },
3008
+ }),
3009
+ "codex-refresh": tool({
3010
+ description: "Manually refresh OAuth tokens for all accounts to verify they're still valid.",
3011
+ args: {},
3012
+ async execute() {
3013
+ const ui = resolveUiRuntime();
3014
+ const storage = await loadAccounts();
3015
+ if (!storage || storage.accounts.length === 0) {
3016
+ if (ui.v2Enabled) {
3017
+ return [
3018
+ ...formatUiHeader(ui, "Refresh accounts"),
3019
+ "",
3020
+ formatUiItem(ui, "No accounts configured.", "warning"),
3021
+ formatUiItem(ui, "Run: codex login", "accent"),
3022
+ ].join("\n");
3023
+ }
3024
+ return "No Codex accounts configured. Run: codex login";
3025
+ }
3026
+ const results = ui.v2Enabled
3027
+ ? []
3028
+ : [`Refreshing ${storage.accounts.length} account(s):`, ""];
3029
+ let refreshedCount = 0;
3030
+ let failedCount = 0;
3031
+ for (let i = 0; i < storage.accounts.length; i++) {
3032
+ const account = storage.accounts[i];
3033
+ if (!account)
3034
+ continue;
3035
+ const label = formatAccountLabel(account, i);
3036
+ try {
3037
+ const refreshResult = await queuedRefresh(account.refreshToken);
3038
+ if (refreshResult.type === "success") {
3039
+ account.refreshToken = refreshResult.refresh;
3040
+ account.accessToken = refreshResult.access;
3041
+ account.expiresAt = refreshResult.expires;
3042
+ results.push(` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`);
3043
+ refreshedCount++;
3044
+ }
3045
+ else {
3046
+ results.push(` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`);
3047
+ failedCount++;
3048
+ }
3049
+ }
3050
+ catch (error) {
3051
+ const errorMsg = error instanceof Error ? error.message : String(error);
3052
+ results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`);
3053
+ failedCount++;
3054
+ }
3055
+ }
3056
+ await saveAccounts(storage);
3057
+ if (cachedAccountManager) {
3058
+ await reloadAccountManagerFromDisk();
3059
+ }
3060
+ results.push("");
3061
+ results.push(`Summary: ${refreshedCount} refreshed, ${failedCount} failed`);
3062
+ if (ui.v2Enabled) {
3063
+ return [
3064
+ ...formatUiHeader(ui, "Refresh accounts"),
3065
+ "",
3066
+ ...results.map((line) => paintUiText(ui, line, "normal")),
3067
+ ].join("\n");
3068
+ }
3069
+ return results.join("\n");
3070
+ },
3071
+ }),
3072
+ "codex-export": tool({
3073
+ description: "Export accounts to a JSON file for backup or migration to another machine.",
3074
+ args: {
3075
+ path: tool.schema.string().describe("File path to export to (e.g., ~/codex-backup.json)"),
3076
+ force: tool.schema.boolean().optional().describe("Overwrite existing file (default: true)"),
3077
+ },
3078
+ async execute({ path: filePath, force }) {
3079
+ const ui = resolveUiRuntime();
3080
+ try {
3081
+ await exportAccounts(filePath, force ?? true);
3082
+ const storage = await loadAccounts();
3083
+ const count = storage?.accounts.length ?? 0;
3084
+ if (ui.v2Enabled) {
3085
+ return [
3086
+ ...formatUiHeader(ui, "Export accounts"),
3087
+ "",
3088
+ formatUiItem(ui, `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success"),
3089
+ formatUiKeyValue(ui, "Path", filePath, "muted"),
3090
+ ].join("\n");
3091
+ }
3092
+ return `Exported ${count} account(s) to: ${filePath}`;
3093
+ }
3094
+ catch (error) {
3095
+ const msg = error instanceof Error ? error.message : String(error);
3096
+ if (ui.v2Enabled) {
3097
+ return [
3098
+ ...formatUiHeader(ui, "Export accounts"),
3099
+ "",
3100
+ formatUiItem(ui, `${getStatusMarker(ui, "error")} Export failed`, "danger"),
3101
+ formatUiKeyValue(ui, "Error", msg, "danger"),
3102
+ ].join("\n");
3103
+ }
3104
+ return `Export failed: ${msg}`;
3105
+ }
3106
+ },
3107
+ }),
3108
+ "codex-import": tool({
3109
+ description: "Import accounts from a JSON file, merging with existing accounts.",
3110
+ args: {
3111
+ path: tool.schema.string().describe("File path to import from (e.g., ~/codex-backup.json)"),
3112
+ },
3113
+ async execute({ path: filePath }) {
3114
+ const ui = resolveUiRuntime();
3115
+ try {
3116
+ const result = await importAccounts(filePath);
3117
+ invalidateAccountManagerCache();
3118
+ const lines = [`Import complete.`, ``];
3119
+ if (result.imported > 0) {
3120
+ lines.push(`New accounts: ${result.imported}`);
3121
+ }
3122
+ if (result.skipped > 0) {
3123
+ lines.push(`Duplicates skipped: ${result.skipped}`);
3124
+ }
3125
+ lines.push(`Total accounts: ${result.total}`);
3126
+ if (ui.v2Enabled) {
3127
+ const styled = [
3128
+ ...formatUiHeader(ui, "Import accounts"),
3129
+ "",
3130
+ formatUiItem(ui, `${getStatusMarker(ui, "ok")} Import complete`, "success"),
3131
+ formatUiKeyValue(ui, "Path", filePath, "muted"),
3132
+ formatUiKeyValue(ui, "New accounts", String(result.imported), result.imported > 0 ? "success" : "muted"),
3133
+ formatUiKeyValue(ui, "Duplicates skipped", String(result.skipped), result.skipped > 0 ? "warning" : "muted"),
3134
+ formatUiKeyValue(ui, "Total accounts", String(result.total), "accent"),
3135
+ ];
3136
+ return styled.join("\n");
3137
+ }
3138
+ return lines.join("\n");
3139
+ }
3140
+ catch (error) {
3141
+ const msg = error instanceof Error ? error.message : String(error);
3142
+ if (ui.v2Enabled) {
3143
+ return [
3144
+ ...formatUiHeader(ui, "Import accounts"),
3145
+ "",
3146
+ formatUiItem(ui, `${getStatusMarker(ui, "error")} Import failed`, "danger"),
3147
+ formatUiKeyValue(ui, "Error", msg, "danger"),
3148
+ ].join("\n");
3149
+ }
3150
+ return `Import failed: ${msg}`;
3151
+ }
3152
+ },
3153
+ }),
3154
+ } : {}),
3155
+ },
3156
+ };
3157
+ };
3158
+ export const OpenAIAuthPlugin = OpenAIOAuthPlugin;
3159
+ export default OpenAIOAuthPlugin;
3160
+ //# sourceMappingURL=index.js.map