@bitkyc08/opencodex 2.86.0 → 2.87.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 (378) hide show
  1. package/gui/dist/assets/App-ZfYbLHkB.js +58 -0
  2. package/gui/dist/assets/App-wT3mXqFz.css +1 -0
  3. package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
  4. package/gui/dist/assets/index-CfYDMj34.js +94 -0
  5. package/gui/dist/assets/index-DPeB9HpV.css +1 -0
  6. package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic/beta-allowlist.ts +9 -8
  10. package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -23
  12. package/src/adapters/anthropic-progress.ts +84 -0
  13. package/src/adapters/anthropic.ts +108 -21
  14. package/src/adapters/base.ts +2 -0
  15. package/src/adapters/claude-cli/adapter.ts +111 -23
  16. package/src/adapters/claude-cli/profiles.ts +2 -1
  17. package/src/adapters/claude-cli/stable-replay.ts +108 -0
  18. package/src/adapters/claude-cli/usage-admission.ts +230 -0
  19. package/src/adapters/coding-agent/protocol.ts +1 -1
  20. package/src/adapters/coding-agent/turn.ts +18 -2
  21. package/src/adapters/dsh-account.ts +97 -0
  22. package/src/adapters/google.ts +35 -8
  23. package/src/adapters/openai-chat/messages.ts +4 -3
  24. package/src/adapters/openai-chat/passthrough.ts +8 -2
  25. package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
  26. package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
  27. package/src/adapters/openai-responses/passthrough.ts +57 -125
  28. package/src/adapters/openai-responses/request-strips.ts +25 -3
  29. package/src/adapters/openai-responses/transport.ts +81 -0
  30. package/src/adapters/qoder/adapter.ts +4 -2
  31. package/src/adapters/registry.ts +5 -0
  32. package/src/adapters/zed.ts +26 -1
  33. package/src/advisor/activity.ts +55 -0
  34. package/src/advisor/budget.ts +62 -0
  35. package/src/advisor/consult.ts +4 -1
  36. package/src/advisor/context.ts +16 -4
  37. package/src/advisor/evidence-tool.ts +21 -0
  38. package/src/advisor/evidence.ts +117 -0
  39. package/src/advisor/runtime.ts +79 -31
  40. package/src/advisor/settings.ts +11 -2
  41. package/src/bridge/errors.ts +19 -1
  42. package/src/bridge/internal.ts +3 -1
  43. package/src/bridge/response-json.ts +18 -3
  44. package/src/bridge/sse.ts +58 -8
  45. package/src/bridge/terminal-refusal.ts +59 -0
  46. package/src/claude/desktop-3p-library.ts +32 -7
  47. package/src/claude/desktop-3p.ts +55 -13
  48. package/src/claude/desktop-first-party.ts +1 -1
  49. package/src/claude/desktop-picker-platform.ts +4 -0
  50. package/src/claude/desktop-picker.ts +15 -8
  51. package/src/claude/desktop-system-proxy.ts +50 -4
  52. package/src/claude/inbound-content-options.ts +11 -0
  53. package/src/claude/inbound.ts +33 -0
  54. package/src/claude/intercept/picker-ca-startup.ts +2 -1
  55. package/src/claude/intercept/picker-runtime.ts +6 -5
  56. package/src/claude/intercept/picker-trust-windows.ts +338 -0
  57. package/src/claude/intercept/picker-trust.ts +19 -0
  58. package/src/claude/intercept/port-policy.ts +30 -0
  59. package/src/claude/intercept/runtime.ts +97 -94
  60. package/src/claude/outbound.ts +38 -13
  61. package/src/cli/account-api.ts +1 -0
  62. package/src/cli/account-policy-dto.ts +6 -0
  63. package/src/cli/account-policy.ts +34 -5
  64. package/src/cli/account-preservation.ts +44 -0
  65. package/src/cli/account.ts +8 -2
  66. package/src/cli/advisor.ts +17 -3
  67. package/src/cli/capabilities-accounts.ts +20 -1
  68. package/src/cli/capabilities-agents-routing.ts +5 -2
  69. package/src/cli/capabilities-base.ts +38 -10
  70. package/src/cli/capabilities-observe-system.ts +63 -3
  71. package/src/cli/capabilities-provider-models.ts +11 -0
  72. package/src/cli/codex-cli-update.ts +151 -41
  73. package/src/cli/codex-provider.ts +44 -0
  74. package/src/cli/combo-input.ts +10 -1
  75. package/src/cli/combo-result.ts +1 -1
  76. package/src/cli/combo.ts +2 -1
  77. package/src/cli/cross-home-owner.ts +17 -14
  78. package/src/cli/dispatch.ts +25 -4
  79. package/src/cli/doctor.ts +6 -1
  80. package/src/cli/ensure-desktop-cold-start.ts +60 -7
  81. package/src/cli/help.ts +3 -2
  82. package/src/cli/index.ts +1 -1
  83. package/src/cli/integration-plan-dto.ts +1 -0
  84. package/src/cli/log-history.ts +172 -0
  85. package/src/cli/models-runtime-subcommands.ts +1 -0
  86. package/src/cli/models-runtime.ts +72 -8
  87. package/src/cli/observe-stream.ts +2 -0
  88. package/src/cli/observe.ts +16 -4
  89. package/src/cli/provider.ts +27 -5
  90. package/src/cli/registry.ts +28 -8
  91. package/src/cli/runtime-api.ts +5 -3
  92. package/src/cli/system-command.ts +40 -8
  93. package/src/cli/uninstall-client-state.ts +32 -0
  94. package/src/cli/usage-compare.ts +309 -0
  95. package/src/cli/usage-model-search.ts +4 -4
  96. package/src/cli/usage-report.ts +2 -2
  97. package/src/cli/v2.ts +1 -1
  98. package/src/client/machine-listener.ts +1 -3
  99. package/src/client/runtime.ts +1 -0
  100. package/src/clients/client-path.ts +51 -0
  101. package/src/clients/config-export/contracts.ts +2 -1
  102. package/src/clients/config-export.ts +18 -44
  103. package/src/clients/gentle-shell-path.ts +87 -0
  104. package/src/codex/account-auto-switch.ts +2 -0
  105. package/src/codex/account-identity.ts +29 -0
  106. package/src/codex/account-label.ts +1 -1
  107. package/src/codex/account-lifecycle.ts +21 -5
  108. package/src/codex/account-preservation.ts +28 -0
  109. package/src/codex/account-use-remaining.ts +23 -0
  110. package/src/codex/app-server-processes.ts +97 -7
  111. package/src/codex/app-server-restart-service.ts +55 -4
  112. package/src/codex/auth-api/account-list.ts +4 -0
  113. package/src/codex/auth-api/account-pause-group.ts +6 -50
  114. package/src/codex/auth-api/account-preservation.ts +57 -0
  115. package/src/codex/auth-api/routes.ts +8 -0
  116. package/src/codex/auth-api/use-remaining-route.ts +36 -0
  117. package/src/codex/auth-api.ts +0 -1
  118. package/src/codex/auth-context.ts +2 -2
  119. package/src/codex/catalog/auto-review.ts +20 -3
  120. package/src/codex/catalog/build-entries.ts +9 -1
  121. package/src/codex/catalog/combo-member.ts +14 -5
  122. package/src/codex/catalog/configured-tiers.ts +1 -1
  123. package/src/codex/catalog/gather-capture.ts +2 -0
  124. package/src/codex/catalog/metadata.ts +11 -9
  125. package/src/codex/catalog/model-hints.ts +21 -16
  126. package/src/codex/catalog/model-visibility.ts +3 -1
  127. package/src/codex/catalog/parsing.ts +30 -86
  128. package/src/codex/catalog/provider-models.ts +36 -34
  129. package/src/codex/catalog/routed-gather.ts +12 -13
  130. package/src/codex/cli-install-provenance.ts +105 -17
  131. package/src/codex/cli-update-plan.ts +545 -0
  132. package/src/codex/config-write-lock.ts +23 -2
  133. package/src/codex/desktop-app/picker-freshness.ts +96 -0
  134. package/src/codex/desktop-app-restart.ts +21 -0
  135. package/src/codex/desktop-compatibility/installed-build.ts +24 -6
  136. package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
  137. package/src/codex/desktop-compatibility/runtime.ts +12 -3
  138. package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
  139. package/src/codex/features.ts +85 -45
  140. package/src/codex/inject/history-failure.ts +27 -0
  141. package/src/codex/inject/restore.ts +24 -2
  142. package/src/codex/inject/routing-classify.ts +4 -3
  143. package/src/codex/inject.ts +18 -27
  144. package/src/codex/main-account-hard-lock.ts +30 -1
  145. package/src/codex/native-profile-startup.ts +1 -1
  146. package/src/codex/ocx-compaction-history.ts +12 -10
  147. package/src/codex/paths.ts +86 -1
  148. package/src/codex/provider-routing.ts +174 -0
  149. package/src/codex/refresh.ts +2 -2
  150. package/src/codex/routing/health-store.ts +1 -1
  151. package/src/codex/routing/selection.ts +4 -3
  152. package/src/codex/routing/thread-affinity.ts +1 -1
  153. package/src/codex/signed-in-identity.ts +43 -0
  154. package/src/codex/subagent-model-fallback.ts +1 -1
  155. package/src/codex/sync.ts +6 -1
  156. package/src/codex/user-identity.ts +16 -5
  157. package/src/codex/windows-local-appdata.ts +84 -0
  158. package/src/combos/failover.ts +6 -1
  159. package/src/combos/index.ts +1 -0
  160. package/src/combos/resolve.ts +46 -10
  161. package/src/combos/types.ts +15 -0
  162. package/src/config/diagnostics.ts +33 -6
  163. package/src/config/live-reconcile.ts +38 -2
  164. package/src/config/load-degrade.ts +17 -0
  165. package/src/config/opencode-wire-upgrade.ts +54 -0
  166. package/src/config/opencode-zen-upgrade.ts +52 -0
  167. package/src/config/owner-registry.ts +22 -17
  168. package/src/config/provider-validation.ts +45 -2
  169. package/src/config/schema/config-schema.ts +22 -0
  170. package/src/config/schema/leaf-validators.ts +38 -9
  171. package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
  172. package/src/config/schema/native-reasoning-retention.ts +26 -0
  173. package/src/config.ts +2 -0
  174. package/src/generated/compatibility-version.json +608 -284
  175. package/src/grok/sync.ts +1 -1
  176. package/src/images/loop.ts +46 -8
  177. package/src/integrations/catalog-refresh.ts +1 -1
  178. package/src/integrations/mutation-plan.ts +1 -0
  179. package/src/integrations/registry.ts +8 -0
  180. package/src/lib/codex-restart-contract.ts +15 -0
  181. package/src/lib/desktop-upstream-tunnel.ts +18 -53
  182. package/src/lib/empty-tool-catalog.ts +62 -0
  183. package/src/lib/errors.ts +19 -3
  184. package/src/lib/gcp-adc.ts +96 -9
  185. package/src/lib/gcp-credential-input.ts +12 -0
  186. package/src/lib/lab-live-route-production.ts +5 -0
  187. package/src/lib/opaque-proxy-tunnel.ts +217 -0
  188. package/src/lib/redact.ts +2 -2
  189. package/src/oauth/account-quota-rank.ts +69 -2
  190. package/src/oauth/anthropic-account-refusal.ts +20 -2
  191. package/src/oauth/anthropic-oauth-definitions.ts +3 -1
  192. package/src/oauth/anthropic-routing.ts +1 -21
  193. package/src/oauth/anthropic.ts +38 -7
  194. package/src/oauth/callback-server.ts +27 -1
  195. package/src/oauth/chatgpt-device.ts +1 -1
  196. package/src/oauth/chatgpt-shared.ts +83 -0
  197. package/src/oauth/chatgpt.ts +8 -72
  198. package/src/oauth/dsh.ts +203 -0
  199. package/src/oauth/generic-account-failover.ts +33 -7
  200. package/src/oauth/generic-session-affinity.ts +125 -0
  201. package/src/oauth/index.ts +184 -109
  202. package/src/oauth/local-token-detect.ts +21 -1
  203. package/src/oauth/login-cli-errors.ts +44 -0
  204. package/src/oauth/login-cli.ts +69 -7
  205. package/src/oauth/login-flow-state.ts +371 -23
  206. package/src/oauth/meta-muse-errors.ts +41 -0
  207. package/src/oauth/meta-muse-keychain.ts +52 -0
  208. package/src/oauth/meta-muse.ts +51 -84
  209. package/src/oauth/model-discovery-auth.ts +3 -0
  210. package/src/oauth/pool-settings-capability.ts +4 -2
  211. package/src/oauth/refresh-flight-retirement.ts +48 -0
  212. package/src/oauth/session-affinity-key.ts +23 -0
  213. package/src/oauth/store-anthropic-instance.ts +9 -2
  214. package/src/oauth/store.ts +12 -5
  215. package/src/protocols/encoders/adapter-events.ts +5 -4
  216. package/src/protocols/encoders/messages.ts +9 -6
  217. package/src/protocols/provider-summary.ts +2 -1
  218. package/src/providers/api-key-cooldowns.ts +49 -0
  219. package/src/providers/api-key-resolve.ts +11 -6
  220. package/src/providers/api-key-response-ownership.ts +57 -0
  221. package/src/providers/api-key-selection.ts +31 -6
  222. package/src/providers/api-keys.ts +214 -3
  223. package/src/providers/claude-cli-usage.ts +96 -0
  224. package/src/providers/dsh-account-destination.ts +31 -0
  225. package/src/providers/dsh-account-models.ts +41 -0
  226. package/src/providers/gcp-credential-cleanup.ts +59 -0
  227. package/src/providers/key-failover.ts +54 -50
  228. package/src/providers/key-store.ts +7 -2
  229. package/src/providers/model-rename-fields.ts +3 -0
  230. package/src/providers/model-rename-migration.ts +2 -2
  231. package/src/providers/openai-tiers-destination.ts +11 -2
  232. package/src/providers/openai-tiers.ts +5 -12
  233. package/src/providers/opencode-go-transport.ts +3 -1
  234. package/src/providers/quota/account-cache.ts +6 -5
  235. package/src/providers/quota/account-row.ts +34 -0
  236. package/src/providers/quota/antigravity.ts +28 -7
  237. package/src/providers/quota/dsh-account.ts +70 -0
  238. package/src/providers/quota/vendor-probes-oauth.ts +1 -93
  239. package/src/providers/quota-types.ts +2 -0
  240. package/src/providers/quota.ts +14 -20
  241. package/src/providers/registry/entries-core.ts +37 -8
  242. package/src/providers/registry/types.ts +1 -1
  243. package/src/providers/request-pacing.ts +77 -19
  244. package/src/providers/routed-config.ts +339 -0
  245. package/src/providers/service-tier.ts +2 -1
  246. package/src/quota/window-mapping.ts +5 -2
  247. package/src/responses/anthropic-progress.ts +34 -0
  248. package/src/responses/assistant-replay.ts +35 -0
  249. package/src/responses/compaction-history-framing.ts +65 -0
  250. package/src/responses/compaction.ts +6 -1
  251. package/src/responses/incomplete-reason.ts +31 -0
  252. package/src/responses/parser.ts +50 -5
  253. package/src/responses/reasoning-envelope.ts +4 -0
  254. package/src/responses/reasoning-replay-cache.ts +24 -2
  255. package/src/responses/reasoning-retention.ts +173 -0
  256. package/src/responses/retained-compaction.ts +46 -0
  257. package/src/responses/schema.ts +23 -0
  258. package/src/router.ts +8 -321
  259. package/src/routing/health.ts +37 -22
  260. package/src/routing/history/indexer.ts +6 -5
  261. package/src/routing/history/ingest.ts +6 -2
  262. package/src/routing/profile.ts +1 -1
  263. package/src/server/admission-model-scope.ts +17 -0
  264. package/src/server/auth-cors.ts +11 -0
  265. package/src/server/chat-completions.ts +7 -1
  266. package/src/server/chat-native-reasoning.ts +123 -0
  267. package/src/server/chat-native-sse.ts +110 -7
  268. package/src/server/chat-native.ts +76 -17
  269. package/src/server/claude-messages.ts +55 -414
  270. package/src/server/effort-policy.ts +1 -1
  271. package/src/server/images.ts +15 -2
  272. package/src/server/index/model-alias-id.ts +36 -0
  273. package/src/server/index/optional-listeners.ts +2 -0
  274. package/src/server/index/serve-options.ts +9 -1
  275. package/src/server/index/startup-warnings.ts +2 -0
  276. package/src/server/index.ts +1 -1
  277. package/src/server/inference/advisor-diagnostics.ts +31 -0
  278. package/src/server/management/advisor-routes.ts +15 -4
  279. package/src/server/management/claude-desktop-picker-routes.ts +1 -1
  280. package/src/server/management/combo-routes.ts +176 -50
  281. package/src/server/management/companion-routes.ts +1 -1
  282. package/src/server/management/config-routes.ts +75 -4
  283. package/src/server/management/cursor-integration-routes.ts +1 -1
  284. package/src/server/management/model-routes.ts +11 -6
  285. package/src/server/management/model-rows.ts +14 -0
  286. package/src/server/management/native-integration-routes.ts +2 -7
  287. package/src/server/management/native-reasoning-retention-routes.ts +41 -0
  288. package/src/server/management/oauth-account-routes.ts +87 -14
  289. package/src/server/management/provider-gcp-credential-routes.ts +29 -0
  290. package/src/server/management/provider-key-pacing-routes.ts +31 -0
  291. package/src/server/management/provider-overwrite-carry.ts +7 -0
  292. package/src/server/management/provider-routes.ts +25 -30
  293. package/src/server/management/route-registry.ts +10 -1
  294. package/src/server/management-api.ts +3 -1
  295. package/src/server/messages-native-tool-guard.ts +185 -0
  296. package/src/server/messages-native.ts +101 -21
  297. package/src/server/messages-shared.ts +431 -0
  298. package/src/server/relay.ts +1 -1
  299. package/src/server/request-log-terminal-status.ts +1 -1
  300. package/src/server/request-log.ts +6 -1
  301. package/src/server/responses/account-change-state.ts +8 -2
  302. package/src/server/responses/adapter-continuation.ts +13 -1
  303. package/src/server/responses/adapter-delivery.ts +19 -6
  304. package/src/server/responses/adapter-dispatch.ts +19 -2
  305. package/src/server/responses/advisor-retention.ts +117 -0
  306. package/src/server/responses/advisor-slot.ts +73 -41
  307. package/src/server/responses/codex-ws-wire.ts +15 -8
  308. package/src/server/responses/compact.ts +303 -125
  309. package/src/server/responses/compaction-recovery.ts +18 -10
  310. package/src/server/responses/compaction-retention.ts +61 -0
  311. package/src/server/responses/context-overflow.ts +11 -6
  312. package/src/server/responses/core-auth.ts +6 -0
  313. package/src/server/responses/core-codex-account.ts +11 -1
  314. package/src/server/responses/core-combo-failure.ts +2 -0
  315. package/src/server/responses/core-combo.ts +120 -36
  316. package/src/server/responses/core-errors.ts +3 -3
  317. package/src/server/responses/core-lifetime.ts +5 -2
  318. package/src/server/responses/core-opaque-recovery.ts +35 -0
  319. package/src/server/responses/core-options.ts +5 -0
  320. package/src/server/responses/core-pipeline.ts +141 -0
  321. package/src/server/responses/core-replay.ts +18 -2
  322. package/src/server/responses/core.ts +2 -115
  323. package/src/server/responses/fetch-helpers.ts +73 -19
  324. package/src/server/responses/main-hard-lock-routing.ts +109 -0
  325. package/src/server/responses/native-response-control.ts +4 -2
  326. package/src/server/responses/passthrough-delivery.ts +3 -1
  327. package/src/server/responses/passthrough-dispatch.ts +15 -13
  328. package/src/server/responses/request-prepare.ts +76 -12
  329. package/src/server/responses/request-sidecar-auth.ts +2 -2
  330. package/src/server/responses/request-transport.ts +104 -27
  331. package/src/server/responses/response-effects.ts +1 -0
  332. package/src/server/responses/run-turn-execution.ts +96 -30
  333. package/src/server/responses/sidecar-execution.ts +20 -2
  334. package/src/server/responses/terminal-guard.ts +2 -16
  335. package/src/service/auth-preflight.ts +71 -0
  336. package/src/service/cli.ts +4 -2
  337. package/src/service/definition.ts +97 -0
  338. package/src/service/guards.ts +1 -61
  339. package/src/service/health-sentinel-linux.ts +112 -0
  340. package/src/service/health-sentinel-main.ts +76 -57
  341. package/src/service/health.ts +5 -89
  342. package/src/service/launchd.ts +27 -7
  343. package/src/service/logging.ts +72 -9
  344. package/src/service/repair.ts +2 -1
  345. package/src/service/state.ts +7 -8
  346. package/src/service/systemd.ts +1 -1
  347. package/src/service/windows-guarded-manager.ts +2 -1
  348. package/src/service/windows-ops.ts +1 -1
  349. package/src/service/windows-scheduler.ts +2 -7
  350. package/src/service/windows-taskxml.ts +2 -2
  351. package/src/service/windows-wscript.ts +14 -0
  352. package/src/service.ts +4 -2
  353. package/src/sidecar/auth.ts +2 -0
  354. package/src/types/config.ts +64 -3
  355. package/src/types/opencode-go.ts +61 -0
  356. package/src/types/opencode-zen.ts +29 -0
  357. package/src/types/provider.ts +32 -6
  358. package/src/types/request.ts +23 -2
  359. package/src/types/wire.ts +29 -37
  360. package/src/types.ts +1 -0
  361. package/src/update/npm-invocation.mjs +12 -2
  362. package/src/usage/cost.ts +48 -12
  363. package/src/usage/expected-prices.ts +3 -0
  364. package/src/usage/ledger-retention.ts +2 -1
  365. package/src/usage/ledger-scanner.ts +6 -5
  366. package/src/usage/log.ts +17 -8
  367. package/src/usage/summary.ts +22 -9
  368. package/src/usage/user-cost-overlays.ts +14 -19
  369. package/src/usage/user-cost-validation.ts +38 -0
  370. package/src/vision/eligibility.ts +13 -0
  371. package/src/web-search/loop.ts +55 -5
  372. package/src/web-search/progress-stream.ts +4 -1
  373. package/src/web-search/run-turn-loop.ts +9 -1
  374. package/gui/dist/assets/App-BjlttVLy.js +0 -58
  375. package/gui/dist/assets/App-CSJvmjNs.css +0 -1
  376. package/gui/dist/assets/index-BB6uK6tU.css +0 -1
  377. package/gui/dist/assets/index-BMssP4k3.js +0 -94
  378. package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
@@ -0,0 +1,83 @@
1
+ import type { OAuthCredentials } from "./types";
2
+
3
+ /**
4
+ * What the browser callback grant and the deviceauth grant share: the public PKCE client and the
5
+ * way a token response becomes credentials.
6
+ *
7
+ * This is a leaf on purpose. `./chatgpt-device` needs these, and `./chatgpt` needs the device
8
+ * grant for `flow: "device"`; importing them straight from `./chatgpt` made the pair a cycle.
9
+ */
10
+
11
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
12
+ const TOKEN_URL = "https://auth.openai.com/oauth/token";
13
+
14
+ /** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
15
+ export const CHATGPT_CLIENT_ID = CLIENT_ID;
16
+ export const CHATGPT_TOKEN_URL = TOKEN_URL;
17
+
18
+ export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
19
+ const parts = token.split(".");
20
+ if (parts.length !== 3 || !parts[1]) return undefined;
21
+ try {
22
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ export function extractAccountId(idToken?: string, accessToken?: string): string | undefined {
29
+ for (const token of [idToken, accessToken]) {
30
+ if (!token) continue;
31
+ const payload = decodeJwtPayload(token);
32
+ if (!payload) continue;
33
+ if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id;
34
+ const ns = payload["https://api.openai.com/auth"];
35
+ if (ns && typeof ns === "object" && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string") {
36
+ return (ns as Record<string, unknown>).chatgpt_account_id as string;
37
+ }
38
+ const orgs = payload.organizations;
39
+ if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string;
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
45
+ for (const token of [idToken, accessToken]) {
46
+ if (!token) continue;
47
+ const payload = decodeJwtPayload(token);
48
+ if (!payload) continue;
49
+ if (typeof payload.email === "string") return payload.email.toLowerCase();
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
55
+ const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
56
+ // This parses a response from an external boundary, so the access token is
57
+ // validated rather than cast. A 200 carrying no access_token would otherwise
58
+ // resolve a login as successful with an undefined credential, which then gets
59
+ // silently declined at persistence — a success message and no account.
60
+ const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
61
+ ? data.access_token
62
+ : undefined;
63
+ if (!accessToken) throw new Error("ChatGPT token response missing access token");
64
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
65
+ // ?? only guards null/undefined; NaN or a string expires_in would otherwise
66
+ // produce a NaN expiry that never compares as expired, and a negative duration
67
+ // would stamp an already-past expiry — both block refresh semantics.
68
+ const expiresIn =
69
+ typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0
70
+ ? data.expires_in
71
+ : 3600;
72
+ // The computed timestamp itself must stay finite: Number.MAX_VALUE passes
73
+ // Number.isFinite but overflows to Infinity once multiplied by 1000.
74
+ const computedExpires = Date.now() + expiresIn * 1000;
75
+ const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
76
+ return {
77
+ access: accessToken,
78
+ refresh: refreshToken,
79
+ expires,
80
+ accountId: extractAccountId(idToken, accessToken),
81
+ email: extractEmail(idToken, accessToken),
82
+ };
83
+ }
@@ -1,4 +1,5 @@
1
1
  import { OAuthCallbackFlow } from "./callback-server";
2
+ import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail } from "./chatgpt-shared";
2
3
  import type { OAuthController, OAuthCredentials } from "./types";
3
4
  import { generatePKCE } from "./pkce";
4
5
  import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure";
@@ -102,44 +103,20 @@ function chatGptTokenError(status: number, body: string, label: string): ChatGpt
102
103
  );
103
104
  }
104
105
 
105
- const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
106
+ /**
107
+ * The client and token decoding live in a leaf shared with the device grant.
108
+ * Re-exported so existing import sites keep their public helper surface.
109
+ */
110
+ export { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail };
111
+ const CLIENT_ID = CHATGPT_CLIENT_ID;
106
112
  const AUTH_URL = "https://auth.openai.com/oauth/authorize";
107
- const TOKEN_URL = "https://auth.openai.com/oauth/token";
113
+ const TOKEN_URL = CHATGPT_TOKEN_URL;
108
114
 
109
- /** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
110
- export const CHATGPT_CLIENT_ID = CLIENT_ID;
111
- export const CHATGPT_TOKEN_URL = TOKEN_URL;
112
115
  const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
113
116
  const CALLBACK_PORT = 1455;
114
117
  const CALLBACK_PATH = "/auth/callback";
115
118
  const ORIGINATOR = "opencodex";
116
119
 
117
- export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
118
- const parts = token.split(".");
119
- if (parts.length !== 3 || !parts[1]) return undefined;
120
- try {
121
- return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
122
- } catch {
123
- return undefined;
124
- }
125
- }
126
-
127
- export function extractAccountId(idToken?: string, accessToken?: string): string | undefined {
128
- for (const token of [idToken, accessToken]) {
129
- if (!token) continue;
130
- const payload = decodeJwtPayload(token);
131
- if (!payload) continue;
132
- if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id;
133
- const ns = payload["https://api.openai.com/auth"];
134
- if (ns && typeof ns === "object" && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string") {
135
- return (ns as Record<string, unknown>).chatgpt_account_id as string;
136
- }
137
- const orgs = payload.organizations;
138
- if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string;
139
- }
140
- return undefined;
141
- }
142
-
143
120
  /**
144
121
  * Three-way answer to "is this token marked as belonging to the ChatGPT account domain".
145
122
  * Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the
@@ -196,16 +173,6 @@ export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim {
196
173
  return accountId ? { kind: "valid", accountId } : { kind: "invalid" };
197
174
  }
198
175
 
199
- export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
200
- for (const token of [idToken, accessToken]) {
201
- if (!token) continue;
202
- const payload = decodeJwtPayload(token);
203
- if (!payload) continue;
204
- if (typeof payload.email === "string") return payload.email.toLowerCase();
205
- }
206
- return undefined;
207
- }
208
-
209
176
  /**
210
177
  * Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the
211
178
  * existing extractAccountId precedence (top-level, then namespaced, then organizations[0]).
@@ -233,37 +200,6 @@ export function extractAccountIdClaims(token?: string): { accountId: string | un
233
200
  };
234
201
  }
235
202
 
236
- export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
237
- const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
238
- // This parses a response from an external boundary, so the access token is
239
- // validated rather than cast. A 200 carrying no access_token would otherwise
240
- // resolve a login as successful with an undefined credential, which then gets
241
- // silently declined at persistence — a success message and no account.
242
- const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
243
- ? data.access_token
244
- : undefined;
245
- if (!accessToken) throw new Error("ChatGPT token response missing access token");
246
- const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
247
- // ?? only guards null/undefined; NaN or a string expires_in would otherwise
248
- // produce a NaN expiry that never compares as expired, and a negative duration
249
- // would stamp an already-past expiry — both block refresh semantics.
250
- const expiresIn =
251
- typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0
252
- ? data.expires_in
253
- : 3600;
254
- // The computed timestamp itself must stay finite: Number.MAX_VALUE passes
255
- // Number.isFinite but overflows to Infinity once multiplied by 1000.
256
- const computedExpires = Date.now() + expiresIn * 1000;
257
- const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
258
- return {
259
- access: accessToken,
260
- refresh: refreshToken,
261
- expires,
262
- accountId: extractAccountId(idToken, accessToken),
263
- email: extractEmail(idToken, accessToken),
264
- };
265
- }
266
-
267
203
  export class ChatGPTOAuthFlow extends OAuthCallbackFlow {
268
204
  #verifier = "";
269
205
  forceLogin = false;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * DeepSeek Harness Account OAuth implementation (Import-first).
3
+ *
4
+ * Imports existing authenticated account from DeepSeek Harness Desktop
5
+ * (`$DSH_HOME/.credentials.yaml`, default `~/.dsh/.credentials.yaml`).
6
+ *
7
+ * Invariant: The DSH credential store is strictly READ-ONLY to OpenCodeX.
8
+ * OpenCodeX NEVER mutates, overwrites, deletes, or changes permissions of `~/.dsh/.credentials.yaml`.
9
+ */
10
+
11
+ import type { OAuthController, OAuthCredentials } from "./types";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+
16
+ export const DSH_PLATFORM_ORIGIN = "https://platform.deepseek.com";
17
+ export const DSH_INFERENCE_ORIGIN = "https://api.deepseek.com/anthropic";
18
+
19
+ export interface DshAccountDetectionResult {
20
+ detected: boolean;
21
+ token?: string;
22
+ issuer?: string;
23
+ error?: string;
24
+ }
25
+
26
+ export interface DshValidatedAccountIdentity {
27
+ valid: boolean;
28
+ accountId?: string;
29
+ email?: string;
30
+ name?: string;
31
+ error?: string;
32
+ }
33
+
34
+ export function resolveDshCredentialsPath(): string {
35
+ const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
36
+ return join(dshHome, ".credentials.yaml");
37
+ }
38
+
39
+ /**
40
+ * Pure read-only probe to detect whether DeepSeek Harness Desktop is installed
41
+ * and signed in on this machine.
42
+ * NEVER writes, creates, mutates, or deletes any file.
43
+ */
44
+ export function detectDshAccount(): DshAccountDetectionResult {
45
+ const filePath = resolveDshCredentialsPath();
46
+ if (!existsSync(filePath)) {
47
+ return { detected: false };
48
+ }
49
+ try {
50
+ const content = readFileSync(filePath, "utf8");
51
+ const data = Bun.YAML.parse(content) as Record<string, unknown> | null;
52
+ if (!data || typeof data !== "object") {
53
+ return { detected: false };
54
+ }
55
+ const records = data.records as Record<string, unknown> | undefined;
56
+ const accountRecord = records?.["deepseek-account-platform/default"] as {
57
+ kind?: string;
58
+ payload?: { version?: number; token?: string; issuer?: string };
59
+ } | undefined;
60
+
61
+ if (accountRecord?.kind === "grant" && typeof accountRecord.payload?.token === "string" && accountRecord.payload.token.length > 0) {
62
+ return {
63
+ detected: true,
64
+ token: accountRecord.payload.token,
65
+ issuer: accountRecord.payload.issuer ?? DSH_PLATFORM_ORIGIN,
66
+ };
67
+ }
68
+ return { detected: false };
69
+ } catch {
70
+ return { detected: false, error: "CREDENTIAL_FILE_READ_FAILED" };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Validate token against DeepSeek's authoritative profile endpoint:
76
+ * GET https://platform.deepseek.com/auth-api/v0/users/current
77
+ */
78
+ export async function validateDshAccountToken(
79
+ token: string,
80
+ signal?: AbortSignal,
81
+ ): Promise<DshValidatedAccountIdentity> {
82
+ try {
83
+ const timeoutSignal = AbortSignal.timeout(10_000);
84
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
85
+ const response = await fetch(`${DSH_PLATFORM_ORIGIN}/auth-api/v0/users/current`, {
86
+ method: "GET",
87
+ headers: {
88
+ "x-dsh-auth-token": token,
89
+ "accept": "application/json",
90
+ },
91
+ redirect: "manual",
92
+ signal: combinedSignal,
93
+ });
94
+
95
+ if (response.status === 401) {
96
+ return { valid: false, error: "ACCOUNT_TOKEN_INVALID" };
97
+ }
98
+ if (!response.ok) {
99
+ return { valid: false, error: `Platform returned HTTP ${response.status}` };
100
+ }
101
+
102
+ const body = (await response.json()) as {
103
+ code?: number;
104
+ data?: {
105
+ biz_code?: number;
106
+ biz_data?: {
107
+ id?: string | null;
108
+ email?: string;
109
+ id_profile?: { name?: string | null; picture?: string | null };
110
+ };
111
+ };
112
+ };
113
+
114
+ if (body.code !== 0 || body.data?.biz_code !== 0 || !body.data.biz_data) {
115
+ return { valid: false, error: `Platform error code: ${body.code ?? "unknown"}` };
116
+ }
117
+
118
+ const biz = body.data.biz_data;
119
+ // Account-store slot matching and reauthentication require the same stable upstream ID.
120
+ // Email/phone/display names and a shared fallback must never identify an imported account.
121
+ const accountId = typeof biz.id === "string" ? biz.id.trim() : "";
122
+ if (!accountId) return { valid: false, error: "ACCOUNT_IDENTITY_UNVERIFIED" };
123
+ const email = typeof biz.email === "string" ? biz.email.trim() || undefined : undefined;
124
+ const name = typeof biz.id_profile?.name === "string" ? biz.id_profile.name.trim() || undefined : undefined;
125
+
126
+ return {
127
+ valid: true,
128
+ accountId,
129
+ email,
130
+ name,
131
+ };
132
+ } catch {
133
+ if (signal?.aborted) throw signal.reason ?? new DOMException("DSH login aborted", "AbortError");
134
+ // Parser and transport errors may contain profile data or credential-file contents.
135
+ return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
136
+ }
137
+ }
138
+
139
+ export interface DshLoginOptions {
140
+ forceLogin?: boolean;
141
+ }
142
+
143
+ /**
144
+ * Explicit user login action for DSH Account.
145
+ * Reuses the existing DSH local credential after live authoritative validation.
146
+ */
147
+ export async function loginDshAccount(
148
+ ctrl: OAuthController,
149
+ _opts: DshLoginOptions = {},
150
+ ): Promise<OAuthCredentials> {
151
+ if (ctrl.signal?.aborted) {
152
+ throw ctrl.signal.reason ?? new DOMException("DSH login aborted", "AbortError");
153
+ }
154
+
155
+ const detection = detectDshAccount();
156
+ if (!detection.detected || !detection.token) {
157
+ throw new Error(
158
+ "DeepSeek Harness account not found in ~/.dsh/.credentials.yaml. " +
159
+ "Please sign in to DeepSeek Harness Desktop first, then import your account.",
160
+ );
161
+ }
162
+
163
+ const validation = await validateDshAccountToken(detection.token, ctrl.signal);
164
+ if (!validation.valid || !validation.accountId) {
165
+ throw new Error(
166
+ `DeepSeek Harness account token in ~/.dsh/.credentials.yaml is invalid or expired (${validation.error ?? "unknown"}). ` +
167
+ "Please re-login in DeepSeek Harness Desktop, then try again.",
168
+ );
169
+ }
170
+
171
+ return {
172
+ access: detection.token,
173
+ refresh: detection.token,
174
+ expires: Number.MAX_SAFE_INTEGER,
175
+ accountId: validation.accountId,
176
+ email: validation.email,
177
+ source: "credential-file",
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Token refresh for DSH Account.
183
+ * DSH accounts use long-lived session grant tokens without an automatic rotation endpoint.
184
+ * In accordance with the explicit-import contract, background or automatic refresh MUST NOT
185
+ * automatically re-read or adopt new credentials from ~/.dsh/.credentials.yaml.
186
+ * If the grant is rejected or expires, this throws a terminal error so the account transitions
187
+ * to needsReauth, requiring explicit user re-import.
188
+ */
189
+ export async function refreshDshAccountToken(
190
+ _refreshToken: string,
191
+ _signal?: AbortSignal,
192
+ _credential?: OAuthCredentials,
193
+ _accountId?: string,
194
+ ): Promise<OAuthCredentials> {
195
+ throw new Error("DeepSeek Harness account session expired or revoked. Please sign in to DeepSeek Harness Desktop and re-import.");
196
+ }
197
+
198
+ /** Provider-owned auth hooks keep the shared OAuth registry within its line budget. */
199
+ export const DSH_ACCOUNT_AUTH = {
200
+ login: loginDshAccount,
201
+ refresh: refreshDshAccountToken,
202
+ defaultRefreshPolicy: "disabled" as const,
203
+ };
@@ -7,9 +7,9 @@ import { isBuiltinAnthropicInstanceRow } from "../providers/anthropic-instance";
7
7
  * exists is Anthropic's — behind its own opt-in. So xAI, Cursor, Kimi, GitHub Copilot,
8
8
  * Antigravity and Nous have no recovery path on a 429 even with several accounts logged in.
9
9
  *
10
- * Deliberately narrower than the Anthropic pool: no session affinity, no quota-ranked selection,
11
- * no probe leases. Those carry provider-specific meaning; this module only answers "the account
12
- * that just 429'd is cooled, is there another one we may use".
10
+ * Conversation ownership lives in generic-session-affinity.ts; this module owns account
11
+ * eligibility, quota-ranked preference and bounded refusal recovery. Probe leases remain
12
+ * provider-specific.
13
13
  *
14
14
  * NOT a home for Codex (`codex/routing.ts` owns quota scopes and probe leases) or Anthropic
15
15
  * (`oauth/anthropic-routing.ts` owns affinity and a fail-closed local-cli credential rule).
@@ -25,6 +25,7 @@ import {
25
25
  hasHeadroomEvidence,
26
26
  isAccountQuotaExhausted,
27
27
  rankAccountsByHeadroom,
28
+ rankAccountsByResetFirst,
28
29
  classifyModelFamilyForQuota,
29
30
  type QuotaModelFamily,
30
31
  } from "./account-quota-rank";
@@ -351,7 +352,7 @@ export function rotateAntigravityAccountOnAuthRefusal(
351
352
  }
352
353
 
353
354
  /** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
354
- type ActiveGenericStrategy = "round-robin" | "fill-first" | "least-loaded";
355
+ type ActiveGenericStrategy = "round-robin" | "fill-first" | "reset-first" | "least-loaded";
355
356
 
356
357
  /** Matches the Codex and Anthropic pools; the DTO still reports `null` for "not stored". */
357
358
  const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
@@ -365,10 +366,13 @@ const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
365
366
  * branch instead of a four-way one.
366
367
  */
367
368
  function activeGenericStrategy(config: OcxConfig, providerName: string): ActiveGenericStrategy | null {
369
+ // All pool strategies run behind the kernel flag; `quota` is the pre-kernel path.
370
+ // Letting `reset-first` bypass it would change 429 selection for pools that never
371
+ // opted into kernel-gated strategies.
368
372
  if (config.pool?.kernel !== true) return null;
369
373
  const raw = config.providers?.[providerName]?.oauthAccountFailover?.strategy;
370
374
  if (providerName === "kiro" && raw === "least-loaded") return raw;
371
- return raw === "round-robin" || raw === "fill-first" ? raw : null;
375
+ return raw === "round-robin" || raw === "fill-first" || raw === "reset-first" ? raw : null;
372
376
  }
373
377
 
374
378
  function genericStickyLimit(config: OcxConfig, providerName: string): number {
@@ -416,6 +420,7 @@ function pickFillFirstGenericAccount(
416
420
  now: number,
417
421
  requestedModelId?: string | null,
418
422
  accountRows?: ReadonlyMap<string, ProviderAccount>,
423
+ preferBelowThreshold = false,
419
424
  ): string | null {
420
425
  const stableAll = stableGenericRoster(providerName);
421
426
  if (stableAll.length < 2) return null;
@@ -431,6 +436,11 @@ function pickFillFirstGenericAccount(
431
436
  }
432
437
  const start = activeId ? stableAll.indexOf(activeId) : -1;
433
438
  const ring = start >= 0 ? [...stableAll.slice(start + 1), ...stableAll.slice(0, start)] : stableAll;
439
+ if (preferBelowThreshold) {
440
+ const below = ring.find(id => id !== activeId && eligible.has(id)
441
+ && !isOverAutoSwitchThreshold(providerName, id, threshold, requestedModelId, accountRows?.get(id)));
442
+ if (below) return below;
443
+ }
434
444
  for (const id of ring) {
435
445
  if (id !== activeId && eligible.has(id)) return id;
436
446
  }
@@ -574,6 +584,10 @@ export function rotateGenericOAuthAccountOnRefusal(
574
584
  }
575
585
  return null;
576
586
  }
587
+ if (strategy === "reset-first") {
588
+ return rankAccountsByResetFirst(providerName, candidates, requestedModelId, now,
589
+ new Map(set.accounts.map(account => [account.id, account])))[0] ?? null;
590
+ }
577
591
  // With no quota evidence this returns the ring untouched, so providers without
578
592
  // per-account quota keep exactly the traversal they have today.
579
593
  return rankAccountsByHeadroom(providerName, candidates, requestedModelId,
@@ -634,6 +648,7 @@ export function preferredInitialAccount(
634
648
  providerName: string,
635
649
  now = Date.now(),
636
650
  requestedModelId?: string | null,
651
+ activeAccountOverride?: string,
637
652
  ): string | null {
638
653
  // The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
639
654
  // refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
@@ -642,7 +657,7 @@ export function preferredInitialAccount(
642
657
  // active id separately would delay manual selection and account removal.
643
658
  const selected = getAccountSet(providerName);
644
659
  if (!selected) return null;
645
- const active = selected.activeAccountId;
660
+ const active = activeAccountOverride ?? selected.activeAccountId;
646
661
  const accountRows = new Map(selected.accounts.map(account => [account.id, account]));
647
662
  const order = selected.accounts
648
663
  .filter(account => account.paused !== true && account.needsReauth !== true)
@@ -694,9 +709,20 @@ export function preferredInitialAccount(
694
709
  return picked && picked !== active ? picked : null;
695
710
  }
696
711
  if (strategy === "fill-first") {
697
- const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows);
712
+ const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows, activeAccountOverride !== undefined);
698
713
  return picked && picked !== active ? picked : null;
699
714
  }
715
+ if (strategy === "reset-first") {
716
+ // Same candidate set as round-robin: paused, reauth-needed and cooled accounts are out,
717
+ // and an account whose model listing omits the requested model loses to one that has it.
718
+ // Reset ordering needs quota evidence; without it the ordinary active-account path runs.
719
+ const family = classifyModelFamilyForQuota(providerName, requestedModelId);
720
+ const eligibleNow = preferKiroModelSupport(providerName,
721
+ eligibleFailoverAccounts(providerName, now, family), requestedModelId, config);
722
+ if (eligibleNow.length === 0 || !hasHeadroomEvidence(providerName, eligibleNow, requestedModelId, accountRows)) return null;
723
+ const best = rankAccountsByResetFirst(providerName, eligibleNow, requestedModelId, now, accountRows)[0] ?? null;
724
+ return best && best !== active ? best : null;
725
+ }
700
726
 
701
727
  const activeRow = selected.accounts.find(account => account.id === active);
702
728
  if (activeRow && activeRow.paused !== true && activeRow.needsReauth !== true
@@ -0,0 +1,125 @@
1
+ /** Request-local conversation ownership for opt-in generic OAuth pools. No credentials are retained. */
2
+ import { createHash } from "node:crypto";
3
+ import { getConfigDir } from "../config/paths";
4
+ import { subscribeAccountSelections } from "../lib/account-selection-events";
5
+ import type { OcxConfig } from "../types";
6
+ import { classifyModelFamilyForQuota } from "./account-quota-rank";
7
+ import { eligibleFailoverAccounts, isGenericFailoverProvider, preferKiroModelSupport, preferredInitialAccount } from "./generic-account-failover";
8
+ import type { OAuthAccessSnapshot } from "./index";
9
+ import { captureOAuthAccountSelection, credentialGeneration, getAccountCredentialWithStatus } from "./store";
10
+ import type { OAuthAccountSelection } from "./types";
11
+
12
+ const IDLE_TTL_MS = 24 * 60 * 60_000;
13
+ const MAX_ENTRIES = 2_000;
14
+ const bindings = new Map<string, { accountId: string; lastUsedAt: number; selection: OAuthAccountSelection }>();
15
+ const prefix = (provider: string) => `${JSON.stringify([getConfigDir(), provider])}\u0000`;
16
+ const key = (provider: string, session: string) => prefix(provider) + "session:" + createHash("sha256").update(session).digest("hex");
17
+ const cursorKey = (provider: string) => prefix(provider) + "fill-first";
18
+
19
+ /** A persisted operator/roster/policy change revokes old session choices before its writer returns. */
20
+ export function clearGenericSessionAffinity(provider?: string): void {
21
+ if (!provider) { bindings.clear(); return; }
22
+ const scope = prefix(provider);
23
+ for (const id of bindings.keys()) if (id.startsWith(scope)) bindings.delete(id);
24
+ }
25
+ subscribeAccountSelections(event => { if (event.kind === "oauth") clearGenericSessionAffinity(event.provider); });
26
+
27
+ /** Unlike new-session strategy selection, keeping the surviving account needs no 2+ quorum. */
28
+ export function genericSessionAffinityEnabled(config: OcxConfig, provider: string): boolean {
29
+ const row = config.providers[provider];
30
+ const enabled = !!row && row.disabled !== true && provider !== "anthropic2" && isGenericFailoverProvider(provider, row)
31
+ && row.oauthAccountFailover?.sessionAffinity === "conversation"
32
+ && (row.oauthAccountFailover.enabled ?? config.oauthAccountFailover?.enabled) === true;
33
+ if (!enabled) clearGenericSessionAffinity(provider);
34
+ return enabled;
35
+ }
36
+
37
+ export function genericSessionAccountEligible(
38
+ config: OcxConfig, provider: string, accountId: string, model?: string | null, now = Date.now(), allowActiveFallback = false,
39
+ ): boolean {
40
+ const eligible = eligibleFailoverAccounts(provider, now, classifyModelFamilyForQuota(provider, model));
41
+ // Existing conversation ownership is independent of transient per-account load.
42
+ if (preferKiroModelSupport(provider, eligible, model).includes(accountId)) return true;
43
+ // Cached automatic exclusions remain preferences when no alternative exists. Preserve the
44
+ // ordinary active/singleton attempt and its upstream refusal instead of inventing a conflict.
45
+ if (!allowActiveFallback || eligible.length > 0 || captureOAuthAccountSelection(provider)?.accountId !== accountId) return false;
46
+ const row = getAccountCredentialWithStatus(provider, accountId);
47
+ return !!row && !row.paused && !row.needsReauth;
48
+ }
49
+
50
+ /** Peek only. The admitted snapshot, never a speculative token lookup, establishes ownership. */
51
+ export function genericSessionAccount(
52
+ config: OcxConfig, provider: string, session: string | null, model?: string | null, now = Date.now(),
53
+ ): string | null {
54
+ if (!genericSessionAffinityEnabled(config, provider) || !session) return null;
55
+ return readBinding(config, provider, key(provider, session), model, now);
56
+ }
57
+
58
+ function readBinding(config: OcxConfig, provider: string, id: string, model: string | null | undefined, now: number): string | null {
59
+ const bound = bindings.get(id);
60
+ if (!bound) return null;
61
+ const current = captureOAuthAccountSelection(provider);
62
+ if (!current || current.accountId !== bound.selection.accountId || current.revision !== bound.selection.revision
63
+ || now - bound.lastUsedAt >= IDLE_TTL_MS || now < bound.lastUsedAt
64
+ || !genericSessionAccountEligible(config, provider, bound.accountId, model, now)) {
65
+ bindings.delete(id);
66
+ return null;
67
+ }
68
+ return bound.accountId;
69
+ }
70
+
71
+ /** New sessions use the configured strategy; an ineligible active account cannot defeat release. */
72
+ export function preferredGenericSessionAccount(
73
+ config: OcxConfig, provider: string, session: string | null, model?: string | null, now = Date.now(),
74
+ ): string | null {
75
+ if (!genericSessionAffinityEnabled(config, provider)) return null;
76
+ const bound = genericSessionAccount(config, provider, session, model, now);
77
+ if (bound) return bound;
78
+ const cursor = readBinding(config, provider, cursorKey(provider), model, now);
79
+ const active = cursor ?? captureOAuthAccountSelection(provider)?.accountId;
80
+ const preferred = preferredInitialAccount(config, provider, now, model, active);
81
+ if (preferred) return preferred;
82
+ if (active && genericSessionAccountEligible(config, provider, active, model, now)) return active;
83
+ return preferKiroModelSupport(provider,
84
+ eligibleFailoverAccounts(provider, now, classifyModelFamilyForQuota(provider, model)), model, config)[0]
85
+ ?? captureOAuthAccountSelection(provider)?.accountId ?? null;
86
+ }
87
+
88
+ /**
89
+ * Validate against the operator's persisted selection without moving it. ALL automatic choices
90
+ * in affinity mode use this receipt, including unkeyed requests and failovers, so concurrent
91
+ * conversations cannot invalidate each other's selection revision. Manual A→B→A still fences.
92
+ */
93
+ export function admitGenericSessionAccount(
94
+ config: OcxConfig, provider: string, expected: OAuthAccountSelection,
95
+ snapshot: OAuthAccessSnapshot, model?: string | null, modeEnabled = genericSessionAffinityEnabled(config, provider),
96
+ ): OAuthAccountSelection | null {
97
+ if (!modeEnabled || snapshot.provider !== provider) return null;
98
+ const current = captureOAuthAccountSelection(provider);
99
+ if (!current || current.accountId !== expected.accountId || current.revision !== expected.revision) return null;
100
+ const row = getAccountCredentialWithStatus(provider, snapshot.accountId);
101
+ if (!row || row.paused || row.needsReauth || credentialGeneration(row.credential) !== snapshot.generation
102
+ || !genericSessionAccountEligible(config, provider, snapshot.accountId, model, Date.now(), true)) return null;
103
+ return current;
104
+ }
105
+
106
+ export function bindGenericSessionAccount(
107
+ config: OcxConfig, provider: string, session: string | null, accountId: string, model?: string | null, now = Date.now(),
108
+ ): void {
109
+ if (!genericSessionAffinityEnabled(config, provider)
110
+ || !genericSessionAccountEligible(config, provider, accountId, model, now)) return;
111
+ for (const [id, entry] of bindings) {
112
+ if (now - entry.lastUsedAt >= IDLE_TTL_MS || now < entry.lastUsedAt) bindings.delete(id);
113
+ }
114
+ const selection = captureOAuthAccountSelection(provider);
115
+ if (!selection) return;
116
+ const keep = (id: string) => {
117
+ bindings.delete(id);
118
+ bindings.set(id, { accountId, lastUsedAt: now, selection });
119
+ };
120
+ const hit = session && genericSessionAccount(config, provider, session, model, now) === accountId;
121
+ if (!hit && config.pool?.kernel === true && config.providers[provider]?.oauthAccountFailover?.strategy === "fill-first")
122
+ keep(cursorKey(provider));
123
+ if (session) keep(key(provider, session));
124
+ while (bindings.size > MAX_ENTRIES) bindings.delete(bindings.keys().next().value!);
125
+ }