@bitkyc08/opencodex 2.41.0 → 2.43.0-preview.20260906

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 (260) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-VGeQEZ_v.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +54 -24
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/identity.ts +8 -2
  26. package/src/adapters/kiro-calibration.ts +181 -0
  27. package/src/adapters/kiro.ts +135 -3
  28. package/src/adapters/openai-responses.ts +259 -29
  29. package/src/adapters/responses-code-mode.ts +59 -0
  30. package/src/adapters/tool-catalog-nudge.ts +1 -1
  31. package/src/adapters/xai-schema-analysis.ts +86 -0
  32. package/src/adapters/xai-tool-schema.ts +2 -87
  33. package/src/adapters/xai-web-search.ts +1 -1
  34. package/src/bridge.ts +47 -13
  35. package/src/chat/inbound.ts +11 -3
  36. package/src/claude/inbound-content-options.ts +60 -0
  37. package/src/claude/inbound-model-options.ts +142 -0
  38. package/src/claude/inbound-records.ts +7 -0
  39. package/src/claude/inbound.ts +10 -202
  40. package/src/claude/model-info.ts +45 -0
  41. package/src/cli/account-auth.ts +49 -9
  42. package/src/cli/account-extended.ts +7 -1
  43. package/src/cli/capabilities.ts +15 -4
  44. package/src/cli/claude.ts +232 -39
  45. package/src/cli/config-command.ts +9 -1
  46. package/src/cli/dispatch.ts +5 -1
  47. package/src/cli/doctor.ts +10 -0
  48. package/src/cli/effort.ts +372 -0
  49. package/src/cli/export-command.ts +3 -9
  50. package/src/cli/help.ts +1 -0
  51. package/src/cli/index.ts +13 -0
  52. package/src/cli/init.ts +4 -0
  53. package/src/cli/model-selection-guidance.ts +30 -0
  54. package/src/cli/models-runtime.ts +3 -2
  55. package/src/cli/models.ts +8 -3
  56. package/src/cli/observe.ts +3 -1
  57. package/src/cli/opencode.ts +4 -1
  58. package/src/cli/provider-runtime.ts +65 -0
  59. package/src/cli/provider.ts +8 -0
  60. package/src/cli/registry.ts +16 -2
  61. package/src/cli/runtime-api.ts +3 -1
  62. package/src/cli/star-prompt.ts +22 -6
  63. package/src/cli/status-probes.ts +168 -0
  64. package/src/cli/status.ts +5 -168
  65. package/src/clients/config-export/constants.ts +69 -0
  66. package/src/clients/config-export/contracts.ts +154 -0
  67. package/src/clients/config-export/dsh.ts +132 -0
  68. package/src/clients/config-export/fast-models.ts +29 -0
  69. package/src/clients/config-export/mcode.ts +83 -0
  70. package/src/clients/config-export/model-metadata.ts +108 -0
  71. package/src/clients/config-export/omp.ts +104 -0
  72. package/src/clients/config-export/zcode.ts +92 -0
  73. package/src/clients/config-export.ts +18 -710
  74. package/src/codex/account-lifecycle.ts +20 -3
  75. package/src/codex/account-usability.ts +2 -0
  76. package/src/codex/auth-api.ts +272 -32
  77. package/src/codex/auth-context.ts +328 -24
  78. package/src/codex/catalog/effort.ts +44 -5
  79. package/src/codex/catalog/metadata.ts +149 -14
  80. package/src/codex/catalog/native-models.ts +116 -4
  81. package/src/codex/catalog/parsing.ts +122 -8
  82. package/src/codex/catalog/provider-fetch.ts +154 -23
  83. package/src/codex/catalog/reserve.ts +52 -0
  84. package/src/codex/catalog/sync.ts +89 -16
  85. package/src/codex/catalog.ts +1 -1
  86. package/src/codex/convergence-types.ts +1 -0
  87. package/src/codex/convergence.ts +2 -0
  88. package/src/codex/data/upstream-models.json +169 -0
  89. package/src/codex/forward-transport-headers.ts +25 -0
  90. package/src/codex/inject.ts +99 -34
  91. package/src/codex/injected-marker.ts +30 -4
  92. package/src/codex/journal.ts +14 -0
  93. package/src/codex/legacy-config-keys.ts +68 -0
  94. package/src/codex/log-guard/inspect-schema.ts +137 -0
  95. package/src/codex/log-guard/inspect.ts +2 -134
  96. package/src/codex/loopback-target.ts +54 -0
  97. package/src/codex/main-account-cache.ts +63 -1
  98. package/src/codex/main-account-hard-lock.ts +52 -0
  99. package/src/codex/main-account.ts +3 -1
  100. package/src/codex/management-convergence.ts +3 -0
  101. package/src/codex/model-entitlements.ts +54 -4
  102. package/src/codex/project-config-warnings.ts +92 -2
  103. package/src/codex/prompt-layers/encoding.ts +80 -0
  104. package/src/codex/prompt-layers/paths.ts +54 -0
  105. package/src/codex/prompt-layers/revision.ts +55 -0
  106. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  107. package/src/codex/prompt-layers/toml-read.ts +181 -0
  108. package/src/codex/prompt-layers.ts +14 -520
  109. package/src/codex/quota-auto-refresh-state.ts +16 -0
  110. package/src/codex/quota-auto-refresh.ts +219 -0
  111. package/src/codex/quota-types.ts +51 -0
  112. package/src/codex/quota.ts +252 -93
  113. package/src/codex/reserve-availability.ts +177 -0
  114. package/src/codex/routing.ts +28 -9
  115. package/src/codex/shim.ts +53 -11
  116. package/src/codex/subagent-model-fallback.ts +23 -3
  117. package/src/combos/failover.ts +125 -7
  118. package/src/combos/identifiers.ts +89 -0
  119. package/src/combos/index.ts +4 -0
  120. package/src/combos/resolve.ts +80 -9
  121. package/src/combos/types.ts +20 -93
  122. package/src/config/subagent-models.ts +24 -0
  123. package/src/config.ts +156 -13
  124. package/src/generated/compatibility-version.json +474 -178
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/integrations/journal.ts +65 -4
  127. package/src/integrations/store.ts +5 -0
  128. package/src/lab/events/limits.ts +4 -0
  129. package/src/lib/destination-policy.ts +31 -2
  130. package/src/lib/errors.ts +39 -0
  131. package/src/lib/provider-outbound.ts +69 -3
  132. package/src/lib/proxy-env.ts +22 -0
  133. package/src/lib/redact-folding.ts +176 -0
  134. package/src/lib/redact.ts +2 -175
  135. package/src/lib/state-store-sweeper.ts +20 -6
  136. package/src/lib/token-estimate.ts +94 -27
  137. package/src/lib/windows-user-principal.ts +53 -5
  138. package/src/oauth/account-quota-rank.ts +40 -1
  139. package/src/oauth/anthropic-routing.ts +99 -3
  140. package/src/oauth/chatgpt-device.ts +187 -0
  141. package/src/oauth/chatgpt.ts +31 -4
  142. package/src/oauth/generic-account-failover.ts +36 -13
  143. package/src/oauth/index.ts +140 -29
  144. package/src/oauth/log.ts +3 -0
  145. package/src/oauth/login-cli.ts +5 -0
  146. package/src/oauth/meta-muse.ts +117 -15
  147. package/src/oauth/pool-settings-capability.ts +15 -4
  148. package/src/providers/api-keys.ts +8 -10
  149. package/src/providers/default-aliases.ts +39 -0
  150. package/src/providers/derive.ts +10 -2
  151. package/src/providers/fastwire.ts +36 -7
  152. package/src/providers/initial-model-selection-runtime.ts +90 -0
  153. package/src/providers/initial-model-selection.ts +120 -0
  154. package/src/providers/key-failover.ts +134 -54
  155. package/src/providers/key-store.ts +11 -1
  156. package/src/providers/label.ts +1 -1
  157. package/src/providers/model-discovery.ts +76 -0
  158. package/src/providers/model-rename-startup.ts +72 -8
  159. package/src/providers/muse-subscription-usage.ts +95 -0
  160. package/src/providers/openai-sidecar.ts +17 -5
  161. package/src/providers/openai-tiers-destination.ts +102 -0
  162. package/src/providers/openai-tiers.ts +2 -99
  163. package/src/providers/opencode-go-transport.ts +41 -0
  164. package/src/providers/quota-key-accounts.ts +141 -0
  165. package/src/providers/quota-types.ts +9 -0
  166. package/src/providers/quota.ts +625 -98
  167. package/src/providers/registry.ts +60 -17
  168. package/src/providers/xai-responses-opt-in.ts +31 -5
  169. package/src/quota/reset-activation.ts +81 -0
  170. package/src/quota/reset-detector.ts +305 -0
  171. package/src/quota/reset-notify-config.ts +162 -0
  172. package/src/quota/reset-observer.ts +125 -0
  173. package/src/quota/reset-poller.ts +160 -0
  174. package/src/quota/reset-seen-store.ts +385 -0
  175. package/src/quota/reset-sinks.ts +199 -0
  176. package/src/quota/window-mapping.ts +106 -0
  177. package/src/responses/apply-patch-envelope.ts +46 -0
  178. package/src/responses/code-mode-helper-compat.ts +39 -1
  179. package/src/responses/custom-tool-compat.ts +10 -4
  180. package/src/responses/hosted-tool-policy.ts +12 -4
  181. package/src/responses/parser-content.ts +133 -0
  182. package/src/responses/parser-text-format.ts +24 -0
  183. package/src/responses/parser-tools.ts +188 -0
  184. package/src/responses/parser.ts +3 -326
  185. package/src/responses/state.ts +124 -28
  186. package/src/router.ts +48 -13
  187. package/src/routing/analytics.ts +1 -0
  188. package/src/routing/capability.ts +17 -4
  189. package/src/server/auth-cors.ts +7 -1
  190. package/src/server/background-lifecycle.ts +23 -1
  191. package/src/server/chat-completions.ts +25 -3
  192. package/src/server/claude-messages.ts +62 -5
  193. package/src/server/effort-row.ts +1 -1
  194. package/src/server/fast-row.ts +295 -0
  195. package/src/server/gui-static.ts +30 -4
  196. package/src/server/index.ts +122 -28
  197. package/src/server/live.ts +18 -4
  198. package/src/server/management/agent-settings-routes.ts +2 -2
  199. package/src/server/management/combo-routes.ts +37 -9
  200. package/src/server/management/config-routes.ts +93 -2
  201. package/src/server/management/integration-routes.ts +108 -0
  202. package/src/server/management/model-routes.ts +13 -3
  203. package/src/server/management/model-rows.ts +20 -1
  204. package/src/server/management/native-integration-routes.ts +4 -1
  205. package/src/server/management/oauth-account-routes.ts +45 -10
  206. package/src/server/management/provider-routes.ts +34 -3
  207. package/src/server/management/quota-reset-routes.ts +57 -0
  208. package/src/server/management/route-registry.ts +7 -4
  209. package/src/server/management/shared.ts +19 -5
  210. package/src/server/management/system-routes.ts +3 -2
  211. package/src/server/management-api.ts +14 -2
  212. package/src/server/ports.ts +12 -2
  213. package/src/server/relay-eager.ts +38 -23
  214. package/src/server/relay.ts +4 -0
  215. package/src/server/request-log.ts +6 -0
  216. package/src/server/responses/codex-ws-correlation.ts +65 -0
  217. package/src/server/responses/codex-ws-exchange.ts +261 -0
  218. package/src/server/responses/codex-ws-metadata.ts +134 -0
  219. package/src/server/responses/codex-ws-pool.ts +162 -0
  220. package/src/server/responses/codex-ws-request.ts +87 -0
  221. package/src/server/responses/codex-ws-session.ts +93 -0
  222. package/src/server/responses/codex-ws-wire.ts +144 -0
  223. package/src/server/responses/collaboration.ts +41 -1
  224. package/src/server/responses/compact.ts +105 -12
  225. package/src/server/responses/core.ts +510 -57
  226. package/src/server/responses/empty-completion-guard.ts +4 -0
  227. package/src/server/responses/fetch-helpers.ts +10 -3
  228. package/src/server/responses/input-admission.ts +16 -9
  229. package/src/server/responses/responses-field-backfill.ts +1 -1
  230. package/src/server/responses/ws-upstream.ts +34 -318
  231. package/src/server/responses-custom-tool-repair.ts +20 -4
  232. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  233. package/src/server/safe-response-headers.ts +23 -0
  234. package/src/server/search.ts +9 -0
  235. package/src/server/subagent-models-startup.ts +27 -0
  236. package/src/server/system-env-shell.ts +238 -0
  237. package/src/server/system-env.ts +7 -234
  238. package/src/server/ws-bridge.ts +3 -25
  239. package/src/server/xai-responses-startup.ts +21 -0
  240. package/src/service-manager-probe.ts +1 -1
  241. package/src/service.ts +55 -16
  242. package/src/types/config.ts +108 -12
  243. package/src/types/provider.ts +36 -7
  244. package/src/types/request.ts +8 -0
  245. package/src/types/tools.ts +26 -1
  246. package/src/types.ts +2 -0
  247. package/src/update/notify.ts +8 -2
  248. package/src/usage/cost.ts +38 -28
  249. package/src/usage/expected-prices.ts +34 -15
  250. package/src/usage/log.ts +2 -0
  251. package/src/usage/model-identity.ts +26 -0
  252. package/src/usage/summary.ts +15 -1
  253. package/src/vision/describe.ts +6 -0
  254. package/src/vision/image-rewrite.ts +108 -0
  255. package/src/vision/index.ts +19 -306
  256. package/src/vision/plan.ts +205 -0
  257. package/src/web-search/executor.ts +6 -0
  258. package/src/web-search/index.ts +8 -1
  259. package/gui/dist/assets/index-B2YjLA-i.css +0 -1
  260. package/gui/dist/assets/index-aPup8CKb.js +0 -112
@@ -152,6 +152,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
152
152
  export function clearAnthropicAccountPoolState(): void {
153
153
  upstreamHealth.clear();
154
154
  sessionAffinity.clear();
155
+ quorumCache = null;
155
156
  }
156
157
 
157
158
  export function anthropicSessionAffinitySizeForTests(): number {
@@ -236,6 +237,79 @@ export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
236
237
  .map(account => account.id);
237
238
  }
238
239
 
240
+ /**
241
+ * How long a quorum answer may be reused before the store is consulted again.
242
+ *
243
+ * This predicate now runs on the INITIAL resolution of every Anthropic request, not just after a
244
+ * 429, so an uncached implementation puts a synchronous file read in front of ordinary traffic:
245
+ * `getAccountSet` goes through `loadAuthStore`, which has no cache of its own and chmods the
246
+ * config dir, chmods the secret, reads the whole file and normalizes it on every call.
247
+ *
248
+ * Two seconds matches the generic module's `PRESENCE_CACHE_TTL_MS` for the same reason: short
249
+ * enough that a login in another window is visible before the operator can switch back and send a
250
+ * prompt, long enough that a burst of requests shares one read. The cache holds a BOOLEAN derived
251
+ * from a count — never a credential, never an account id.
252
+ *
253
+ * Staleness is bounded by consequence, not only by the TTL. Explicit invalidation covers the
254
+ * roster mutations this module can see (rotation, pool-state reset, affinity clear on account
255
+ * removal, manual selection), but not one it cannot: a 401 elsewhere flagging an account
256
+ * `needsReauth` drops the real quorum to one while a cached `true` survives for up to 2s.
257
+ *
258
+ * That window is harmless in both directions, which is why it is left rather than plumbed
259
+ * through the store. A stale `true` only lets the caller ASK for an alternate;
260
+ * `pickAlternateAnthropicAccount` re-reads the roster through `getEligibleAnthropicAccounts`,
261
+ * skips the reauth-flagged account and returns `null`, so the 429 surfaces exactly as it would
262
+ * have. A stale `false` costs one un-rotated 429 and self-corrects on the next read. Neither
263
+ * can dispatch on an unusable credential, which is the only outcome worth adding a store hook
264
+ * to prevent.
265
+ */
266
+ const QUORUM_CACHE_TTL_MS = 2_000;
267
+
268
+ let quorumCache: { value: boolean; readAt: number } | null = null;
269
+
270
+ /**
271
+ * Whether a 429 has somewhere to go: two or more accounts that could serve traffic if asked.
272
+ *
273
+ * Reactive failover is a safety net, not a routing policy. It runs only AFTER upstream refused,
274
+ * it cannot spread load across a healthy session, and it cannot fire at all unless the operator
275
+ * deliberately logged in twice. So it activates on presence, exactly like an `apiKeyPool` of two
276
+ * keys does in `providers/key-failover.ts` -- and unlike the PROACTIVE pool (affinity,
277
+ * quota-ranked new-session picks, `autoSwitchThreshold`, `strategy`), which changes which
278
+ * account serves a healthy request and therefore stays behind `anthropicAccountPool.enabled`.
279
+ *
280
+ * Cooldowns are deliberately ignored here. They are transient and per-request, while this
281
+ * answers the durable question "did the operator store a second account". Counting a cooled
282
+ * account as absent would switch the feature off for the length of the cooldown -- precisely
283
+ * when it is needed.
284
+ *
285
+ * `isPoolCredentialUsable` is still applied, so the fail-closed background `local-cli` rule
286
+ * holds: an expired background slot is not a quorum and cannot be adopted.
287
+ */
288
+ export function hasAnthropicFailoverQuorum(now = Date.now()): boolean {
289
+ // Monotonic guard: a caller-supplied `now` that predates the cached read (tests pass explicit
290
+ // clocks) must not be served from a future entry.
291
+ if (quorumCache && now >= quorumCache.readAt && now - quorumCache.readAt < QUORUM_CACHE_TTL_MS) {
292
+ return quorumCache.value;
293
+ }
294
+ const set = getAccountSet(PROVIDER);
295
+ let value = false;
296
+ if (set) {
297
+ let usable = 0;
298
+ for (const account of set.accounts) {
299
+ if (account.needsReauth === true) continue;
300
+ if (!isPoolCredentialUsable(account.id, now)) continue;
301
+ if (++usable >= 2) { value = true; break; }
302
+ }
303
+ }
304
+ quorumCache = { value, readAt: now };
305
+ return value;
306
+ }
307
+
308
+ /** Test seam and manual-recovery hook: force the next quorum question to re-read the store. */
309
+ export function forgetAnthropicFailoverQuorum(): void {
310
+ quorumCache = null;
311
+ }
312
+
239
313
  /** Earliest remaining cooldown among cooled Anthropic accounts, for client Retry-After. */
240
314
  export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | null {
241
315
  const set = getAccountSet(PROVIDER);
@@ -559,6 +633,10 @@ export function clearAnthropicSessionAffinityForAccount(accountId: string): void
559
633
  for (const [key, entry] of sessionAffinity) {
560
634
  if (entry.accountId === accountId) sessionAffinity.delete(key);
561
635
  }
636
+ // The roster just lost or changed a member. This is the account-removal path, so the next
637
+ // activation question must re-read rather than answer from a count taken while the account
638
+ // was still present -- otherwise a delete leaves a stale quorum for the length of the TTL.
639
+ quorumCache = null;
562
640
  }
563
641
 
564
642
  /**
@@ -573,7 +651,13 @@ export function rotateAnthropicAccountOn429(
573
651
  sessionKey?: string | null,
574
652
  now = Date.now(),
575
653
  ): string | null {
576
- if (!isAnthropicAccountPoolEnabled(config)) return null;
654
+ // Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
655
+ // session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
656
+ // of which move a HEALTHY request and stay opt-in. Rotating away from an account upstream has
657
+ // just rate-limited is a different thing: it only ever runs after a refusal, and stranding a
658
+ // 429 while a second logged-in account sits idle is a defect, not a configuration choice.
659
+ // Presence is the activation rule, the same one an apiKeyPool of two keys already uses.
660
+ if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null;
577
661
 
578
662
  const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
579
663
  const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS;
@@ -584,8 +668,17 @@ export function rotateAnthropicAccountOn429(
584
668
  sweepExpiredOnWrite(now);
585
669
  clearAnthropicSessionAffinityForAccount(failedAccountId);
586
670
  notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
587
-
588
- const next = pickAlternateAnthropicAccount(config, failedAccountId, now);
671
+ // A rotation means the roster in use just changed; do not answer the next activation question
672
+ // from a count read taken before the failure.
673
+ quorumCache = null;
674
+
675
+ // The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
676
+ // presence-only recovery must not silently reactivate round-robin/fill-first merely
677
+ // because those dormant values remain in config. The quota picker is the neutral
678
+ // recovery policy already used by the default strategy.
679
+ const next = isAnthropicAccountPoolEnabled(config)
680
+ ? pickAlternateAnthropicAccount(config, failedAccountId, now)
681
+ : pickLowestUsage(config, failedAccountId, now);
589
682
  if (!next) {
590
683
  console.warn("[anthropic-pool] all eligible Anthropic OAuth accounts are in cooldown; returning 429");
591
684
  return null;
@@ -614,6 +707,9 @@ export function promoteAnthropicActiveAccount(accountId: string): void {
614
707
  export function resetAnthropicRoutingForManualSelection(accountId: string): void {
615
708
  sessionAffinity.clear();
616
709
  seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
710
+ // A manual account selection is an operator statement about the roster; do not answer the
711
+ // next activation question from a count read before it.
712
+ quorumCache = null;
617
713
  }
618
714
 
619
715
  /**
@@ -0,0 +1,187 @@
1
+ import type { OAuthController, OAuthCredentials } from "./types";
2
+ import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt";
3
+
4
+ /**
5
+ * OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider.
6
+ *
7
+ * The callback flow in `./chatgpt` needs a browser and a listener on
8
+ * localhost:1455. A hub running headless in a container or over SSH has
9
+ * neither, which left "copy the long redirect URL out of the browser error
10
+ * page" as the only way to add an account there (#3366).
11
+ *
12
+ * This is the same grant Codex CLI uses. Three steps, and the middle one is
13
+ * where it differs from RFC 8628: the poll returns an authorization code plus
14
+ * a SERVER-generated PKCE verifier, which is then spent at the ordinary token
15
+ * endpoint. We never generate the verifier ourselves here.
16
+ */
17
+ const USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
18
+ const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
19
+ const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
20
+
21
+ /** Where the user types the short code. Fixed, and safe to show anywhere. */
22
+ export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
23
+
24
+ /** The grant's own lifetime. Polling past this only produces a worse error message. */
25
+ const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
26
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
27
+ const MIN_POLL_INTERVAL_MS = 1_000;
28
+ /**
29
+ * Above ~2^31 ms a timer overflows and fires immediately, which would turn a
30
+ * hostile or corrupt `interval` into a hot loop against an auth endpoint. The
31
+ * grant only lives 15 minutes, so anything longer is meaningless anyway.
32
+ */
33
+ const MAX_POLL_INTERVAL_MS = DEVICE_FLOW_TTL_MS;
34
+
35
+ /**
36
+ * Upstream sends `interval` as a number in some responses and a string in
37
+ * others. A string would make `setTimeout` treat it as 0 and turn the poll
38
+ * into a hot loop against an auth endpoint, so coerce and floor it.
39
+ */
40
+ function normalizeIntervalMs(raw: unknown): number {
41
+ const seconds = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
42
+ if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_POLL_INTERVAL_MS;
43
+ const ms = Math.round(seconds * 1000);
44
+ return Math.min(MAX_POLL_INTERVAL_MS, Math.max(MIN_POLL_INTERVAL_MS, ms));
45
+ }
46
+
47
+ function nonEmptyString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.length > 0 ? value : undefined;
49
+ }
50
+
51
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
52
+ if (signal?.aborted) throw new Error("Login cancelled");
53
+ await new Promise<void>((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ signal?.removeEventListener("abort", onAbort);
56
+ resolve();
57
+ }, ms);
58
+ const onAbort = (): void => {
59
+ clearTimeout(timer);
60
+ reject(new Error("Login cancelled"));
61
+ };
62
+ signal?.addEventListener("abort", onAbort, { once: true });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Device-flow errors carry the HTTP status and nothing else.
68
+ *
69
+ * The callback flow's `safeErrorDescription` reflects the upstream body into
70
+ * the message, which is fine for an OAuth error envelope but not here: these
71
+ * endpoints can echo request material, and this message reaches CLI output,
72
+ * the GUI, and issue reports.
73
+ */
74
+ function deviceError(stage: string, status: number): Error {
75
+ return new Error(`ChatGPT device authorization ${stage} failed: HTTP ${status}`);
76
+ }
77
+
78
+ interface DeviceUserCode {
79
+ deviceAuthId: string;
80
+ userCode: string;
81
+ intervalMs: number;
82
+ }
83
+
84
+ async function requestUserCode(signal?: AbortSignal): Promise<DeviceUserCode> {
85
+ const response = await fetch(USERCODE_URL, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
89
+ signal,
90
+ });
91
+ if (!response.ok) throw deviceError("request", response.status);
92
+ const payload = (await response.json()) as Record<string, unknown>;
93
+ const deviceAuthId = nonEmptyString(payload.device_auth_id);
94
+ // Upstream accepts both spellings, so a response using the alias must not be
95
+ // rejected as malformed.
96
+ const userCode = nonEmptyString(payload.user_code) ?? nonEmptyString(payload.usercode);
97
+ if (!deviceAuthId || !userCode) {
98
+ throw new Error("ChatGPT device authorization response missing required fields");
99
+ }
100
+ return { deviceAuthId, userCode, intervalMs: normalizeIntervalMs(payload.interval) };
101
+ }
102
+
103
+ interface DeviceGrant {
104
+ authorizationCode: string;
105
+ codeVerifier: string;
106
+ }
107
+
108
+ /**
109
+ * Poll until the user finishes at the verification page.
110
+ *
111
+ * Pending is signalled by 403/404 rather than an `authorization_pending` body,
112
+ * so status is the whole protocol here: any other non-2xx is terminal, and
113
+ * treating it as pending would keep hammering a permanently failing endpoint.
114
+ */
115
+ async function pollForGrant(
116
+ device: DeviceUserCode,
117
+ signal?: AbortSignal,
118
+ ): Promise<DeviceGrant> {
119
+ const deadline = Date.now() + DEVICE_FLOW_TTL_MS;
120
+ while (Date.now() < deadline) {
121
+ if (signal?.aborted) throw new Error("Login cancelled");
122
+ const response = await fetch(DEVICE_TOKEN_URL, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
126
+ signal,
127
+ });
128
+ if (response.status === 403 || response.status === 404) {
129
+ // Cap the wait at the time actually left. Sleeping a full interval past
130
+ // the deadline is how a 15-minute grant turns into a 20-minute wait.
131
+ const remaining = deadline - Date.now();
132
+ if (remaining <= 0) break;
133
+ await sleep(Math.min(device.intervalMs, remaining), signal);
134
+ continue;
135
+ }
136
+ if (!response.ok) throw deviceError("poll", response.status);
137
+ // The deadline is checked again here, not only at the top of the loop: a
138
+ // single poll can itself outlive the grant, and accepting a code that
139
+ // expired mid-flight just moves the failure to the token exchange.
140
+ if (Date.now() >= deadline) break;
141
+ const payload = (await response.json()) as Record<string, unknown>;
142
+ const authorizationCode = nonEmptyString(payload.authorization_code);
143
+ const codeVerifier = nonEmptyString(payload.code_verifier);
144
+ if (!authorizationCode || !codeVerifier) {
145
+ throw new Error("ChatGPT device authorization response missing required fields");
146
+ }
147
+ return { authorizationCode, codeVerifier };
148
+ }
149
+ throw new Error("ChatGPT device authorization expired");
150
+ }
151
+
152
+ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<OAuthCredentials> {
153
+ const response = await fetch(CHATGPT_TOKEN_URL, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
156
+ body: new URLSearchParams({
157
+ grant_type: "authorization_code",
158
+ client_id: CHATGPT_CLIENT_ID,
159
+ code: grant.authorizationCode,
160
+ code_verifier: grant.codeVerifier,
161
+ redirect_uri: DEVICE_REDIRECT_URI,
162
+ }).toString(),
163
+ signal,
164
+ });
165
+ if (!response.ok) throw deviceError("token exchange", response.status);
166
+ return credsFromToken((await response.json()) as Record<string, unknown>);
167
+ }
168
+
169
+ /**
170
+ * Run the device flow to completion.
171
+ *
172
+ * `deviceCode` in the `onAuth` payload is the HUMAN code, matching kimi, nous,
173
+ * and github-copilot. The opaque `device_auth_id` never leaves this module:
174
+ * every device-code surface renders `deviceCode` verbatim, and the management
175
+ * login route also uses its presence to decide a flow must not be handed to a
176
+ * local browser spawn.
177
+ */
178
+ export async function loginChatGPTDevice(ctrl: OAuthController): Promise<OAuthCredentials> {
179
+ const device = await requestUserCode(ctrl.signal);
180
+ ctrl.onAuth?.({
181
+ url: DEVICE_VERIFICATION_URL,
182
+ instructions: `Enter code: ${device.userCode}`,
183
+ deviceCode: device.userCode,
184
+ });
185
+ const grant = await pollForGrant(device, ctrl.signal);
186
+ return exchangeGrant(grant, ctrl.signal);
187
+ }
@@ -5,6 +5,10 @@ import { generatePKCE } from "./pkce";
5
5
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
6
6
  const AUTH_URL = "https://auth.openai.com/oauth/authorize";
7
7
  const TOKEN_URL = "https://auth.openai.com/oauth/token";
8
+
9
+ /** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
10
+ export const CHATGPT_CLIENT_ID = CLIENT_ID;
11
+ export const CHATGPT_TOKEN_URL = TOKEN_URL;
8
12
  const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
9
13
  const CALLBACK_PORT = 1455;
10
14
  const CALLBACK_PATH = "/auth/callback";
@@ -46,9 +50,17 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
46
50
  return undefined;
47
51
  }
48
52
 
49
- function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
53
+ export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
50
54
  const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
51
- const accessToken = data.access_token as string;
55
+ // This parses a response from an external boundary, so the access token is
56
+ // validated rather than cast. A 200 carrying no access_token would otherwise
57
+ // resolve a login as successful with an undefined credential, which then gets
58
+ // silently declined at persistence — a success message and no account.
59
+ const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
60
+ ? data.access_token
61
+ : undefined;
62
+ if (!accessToken) throw new Error("ChatGPT token response missing access token");
63
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
52
64
  // ?? only guards null/undefined; NaN or a string expires_in would otherwise
53
65
  // produce a NaN expiry that never compares as expired, and a negative duration
54
66
  // would stamp an already-past expiry — both block refresh semantics.
@@ -62,7 +74,7 @@ function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
62
74
  const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
63
75
  return {
64
76
  access: accessToken,
65
- refresh: (data.refresh_token as string) ?? "",
77
+ refresh: refreshToken,
66
78
  expires,
67
79
  accountId: extractAccountId(idToken, accessToken),
68
80
  email: extractEmail(idToken, accessToken),
@@ -135,7 +147,22 @@ function safeErrorDescription(resp: Response): Promise<string> {
135
147
  });
136
148
  }
137
149
 
138
- export async function loginChatGPT(ctrl: OAuthController, opts?: { forceLogin?: boolean }): Promise<OAuthCredentials> {
150
+ /**
151
+ * How the user proves identity. `browser` runs the localhost:1455 callback flow;
152
+ * `device` runs the deviceauth grant, which needs no local browser or listener
153
+ * and is the only workable path on a headless or remote hub (#3366).
154
+ */
155
+ export type ChatGPTLoginFlow = "browser" | "device";
156
+
157
+ export async function loginChatGPT(
158
+ ctrl: OAuthController,
159
+ opts?: { forceLogin?: boolean; flow?: ChatGPTLoginFlow },
160
+ ): Promise<OAuthCredentials> {
161
+ if (opts?.flow === "device") {
162
+ // Imported lazily so the callback flow does not pay for a module it never uses.
163
+ const { loginChatGPTDevice } = await import("./chatgpt-device");
164
+ return loginChatGPTDevice(ctrl);
165
+ }
139
166
  const flow = new ChatGPTOAuthFlow(ctrl);
140
167
  if (opts?.forceLogin) flow.forceLogin = true;
141
168
  return flow.login();
@@ -148,30 +148,51 @@ export function hasFailoverAccountQuorum(providerName: string, now = Date.now())
148
148
  }
149
149
 
150
150
  /**
151
- * Whether generic rotation is active for this provider.
151
+ * Whether REACTIVE 429 rotation is active for this provider.
152
152
  *
153
- * Precedence, most specific first:
153
+ * Presence is the only rule: two or more eligible stored accounts. The
154
+ * `oauthAccountFailover.enabled` booleans no longer suppress it.
154
155
  *
155
- * 1. `providers.<name>.oauthAccountFailover.enabled` an operator may accept rotation on one
156
- * provider and refuse it on another, because provider terms differ.
157
- * 2. `oauthAccountFailover.enabled` the global switch. Anyone who already wrote `false` keeps
158
- * strict single-account behaviour across this change.
159
- * 3. Presence: 2 or more eligible stored accounts (#2568d, owner decision).
156
+ * That is a deliberate narrowing of #2568d. Rotation here runs only after upstream has already
157
+ * refused the request, so the choice the old knob offered was between "retry on the second
158
+ * account you deliberately logged in" and "return a 429 while that account sits idle". The
159
+ * second is a defect, not a preference — and an operator who does not want rotation expresses
160
+ * that by not storing a second account, exactly as they do for `apiKeyPool`.
160
161
  *
161
- * Only an explicit boolean overrides presence. A malformed value falls through instead of
162
- * throwing, because a typo in a knob must not take a provider out of service.
162
+ * The knob is not gone. It still governs {@link isProactivePreferenceEnabled}, which decides
163
+ * whether a HEALTHY request may be steered to a different account before dispatch — a real
164
+ * behavioural choice that remains refusable — and it still carries `strategy` and
165
+ * `autoSwitchThreshold`.
163
166
  */
164
167
  export function isGenericOAuthFailoverEnabled(
165
168
  config: OcxConfig,
166
169
  providerName: string,
167
170
  now = Date.now(),
168
171
  ): boolean {
172
+ const provider = config.providers?.[providerName];
173
+ if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
174
+ return hasFailoverAccountQuorum(providerName, now);
175
+ }
176
+
177
+ /**
178
+ * Whether the pre-dispatch account PREFERENCE may run for this provider.
179
+ *
180
+ * Unlike reactive rotation, this moves a request that upstream has not refused, so it stays
181
+ * refusable: an explicit provider value wins over the global default, and a global `false`
182
+ * turns it off only when the provider has no override. A malformed value falls through rather
183
+ * than taking a provider out of service.
184
+ */
185
+ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
169
186
  const provider = config.providers?.[providerName];
170
187
  if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
171
188
  const perProvider = provider.oauthAccountFailover?.enabled;
172
- if (typeof perProvider === "boolean") return perProvider;
173
- const global = config.oauthAccountFailover?.enabled;
174
- if (typeof global === "boolean") return global;
189
+ // Preserve the published narrow-over-broad precedence. A provider-specific true may
190
+ // opt this provider into proactive preference even when the global default is false;
191
+ // a provider-specific false refuses it even when the global setting is true.
192
+ if (typeof perProvider === "boolean") {
193
+ return perProvider && hasFailoverAccountQuorum(providerName, now);
194
+ }
195
+ if (config.oauthAccountFailover?.enabled === false) return false;
175
196
  return hasFailoverAccountQuorum(providerName, now);
176
197
  }
177
198
 
@@ -265,7 +286,9 @@ export function preferredInitialAccount(
265
286
  providerName: string,
266
287
  now = Date.now(),
267
288
  ): string | null {
268
- if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
289
+ // The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
290
+ // refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
291
+ if (!isProactivePreferenceEnabled(config, providerName, now)) return null;
269
292
  // This runs on the initial resolution of EVERY request, and `loadAuthStore` has no
270
293
  // cache: each call chmods the config dir, chmods the secret, reads the whole file and
271
294
  // normalizes it (store.ts:136-151). So the store is consulted at most ONCE here, behind