mixdog 0.9.3 → 0.9.4

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 (329) hide show
  1. package/package.json +7 -3
  2. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  3. package/scripts/bench/lead-review-tasks.json +20 -0
  4. package/scripts/bench/r4-mixed-tasks.json +20 -0
  5. package/scripts/bench/review-tasks.json +20 -0
  6. package/scripts/bench/round-codex.json +114 -0
  7. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  8. package/scripts/bench/round-mixdog-lead.json +269 -0
  9. package/scripts/bench/round-mixdog.json +126 -0
  10. package/scripts/bench/round-r10-bigsample.json +679 -0
  11. package/scripts/bench/round-r11-codexalign.json +257 -0
  12. package/scripts/bench/round-r4-codex.json +114 -0
  13. package/scripts/bench/round-r4-mixed.json +225 -0
  14. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  15. package/scripts/bench/round-r6-codex.json +114 -0
  16. package/scripts/bench/round-r6-solo.json +257 -0
  17. package/scripts/bench/round-r7-full.json +254 -0
  18. package/scripts/bench/round-r8-fulldefault.json +255 -0
  19. package/scripts/bench-run.mjs +215 -29
  20. package/scripts/freevar-smoke.mjs +95 -0
  21. package/scripts/internal-comms-bench.mjs +1 -0
  22. package/scripts/internal-comms-smoke.mjs +10 -9
  23. package/scripts/mouse-probe.mjs +45 -0
  24. package/scripts/output-style-bench.mjs +13 -6
  25. package/scripts/output-style-smoke.mjs +4 -4
  26. package/scripts/provider-toolcall-test.mjs +7 -3
  27. package/scripts/recall-usecase-cases.json +18 -0
  28. package/scripts/recall-usecase-probe.json +6 -0
  29. package/scripts/session-bench.mjs +152 -6
  30. package/scripts/tool-smoke.mjs +23 -63
  31. package/scripts/tui-render-smoke.mjs +90 -0
  32. package/scripts/webhook-smoke.mjs +208 -0
  33. package/src/agents/debugger/AGENT.md +4 -1
  34. package/src/agents/heavy-worker/AGENT.md +6 -5
  35. package/src/agents/maintainer/AGENT.md +4 -0
  36. package/src/agents/reviewer/AGENT.md +2 -1
  37. package/src/agents/worker/AGENT.md +8 -4
  38. package/src/lib/rules-builder.cjs +4 -0
  39. package/src/mixdog-session-runtime.mjs +632 -2042
  40. package/src/output-styles/default.md +34 -9
  41. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  42. package/src/output-styles/minimal.md +4 -1
  43. package/src/output-styles/simple.md +22 -7
  44. package/src/rules/agent/00-common.md +2 -0
  45. package/src/rules/lead/lead-brief.md +12 -0
  46. package/src/rules/lead/lead-tool.md +0 -11
  47. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  49. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  50. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  51. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  52. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  53. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  54. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  55. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  56. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  57. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  58. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  59. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  60. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  62. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  63. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  65. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  66. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  67. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  69. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  70. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  75. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +297 -2123
  76. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +130 -1002
  77. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  80. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  82. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  83. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  84. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  85. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  86. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  87. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  88. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  89. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  90. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  91. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  93. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  94. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  95. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  97. package/src/runtime/agent/orchestrator/session/loop.mjs +251 -397
  98. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  99. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  100. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  101. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  102. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  103. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  104. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  105. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  106. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  107. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  108. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  109. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +32 -41
  110. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  113. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  115. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  116. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  118. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  119. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  120. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  124. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  125. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  126. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  128. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  130. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  131. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  132. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  133. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  134. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  136. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  137. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  138. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  139. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  140. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  141. package/src/runtime/channels/backends/discord.mjs +12 -292
  142. package/src/runtime/channels/index.mjs +229 -663
  143. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  144. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  145. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  146. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  147. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  148. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  149. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  150. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  151. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  152. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  153. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  154. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  155. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  156. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  157. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  158. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  159. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  160. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  161. package/src/runtime/channels/lib/webhook.mjs +36 -570
  162. package/src/runtime/channels/tool-defs.mjs +11 -130
  163. package/src/runtime/memory/index.mjs +201 -1948
  164. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  165. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  166. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  167. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  168. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  169. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  170. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  171. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  172. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  173. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  174. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  175. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  177. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  178. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  179. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  180. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  181. package/src/runtime/memory/tool-defs.mjs +5 -13
  182. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  183. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  184. package/src/runtime/search/lib/web-tools.mjs +24 -602
  185. package/src/runtime/shared/atomic-file.mjs +26 -1
  186. package/src/runtime/shared/launcher-control.mjs +2 -2
  187. package/src/runtime/shared/tool-primitives.mjs +308 -0
  188. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  189. package/src/runtime/shared/tool-surface.mjs +80 -898
  190. package/src/runtime/shared/transcript-writer.mjs +23 -0
  191. package/src/runtime/shared/update-checker.mjs +7 -4
  192. package/src/session-runtime/config-helpers.mjs +84 -2
  193. package/src/session-runtime/config-lifecycle.mjs +232 -0
  194. package/src/session-runtime/cwd-plugins.mjs +226 -0
  195. package/src/session-runtime/mcp-glue.mjs +177 -0
  196. package/src/session-runtime/model-recency.mjs +111 -0
  197. package/src/session-runtime/native-search.mjs +247 -0
  198. package/src/session-runtime/output-styles.mjs +11 -9
  199. package/src/session-runtime/prewarm.mjs +142 -0
  200. package/src/session-runtime/provider-models.mjs +278 -0
  201. package/src/session-runtime/provider-usage.mjs +120 -0
  202. package/src/session-runtime/quick-model-rows.mjs +170 -0
  203. package/src/session-runtime/quick-search-models.mjs +46 -0
  204. package/src/session-runtime/session-hooks.mjs +93 -0
  205. package/src/session-runtime/settings-api.mjs +319 -0
  206. package/src/session-runtime/tool-catalog.mjs +29 -29
  207. package/src/session-runtime/tool-defs.mjs +84 -0
  208. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  209. package/src/standalone/agent-tool/helpers.mjs +237 -0
  210. package/src/standalone/agent-tool/notify.mjs +107 -0
  211. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  212. package/src/standalone/agent-tool/render.mjs +152 -0
  213. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  214. package/src/standalone/agent-tool.mjs +110 -671
  215. package/src/standalone/channel-worker.mjs +4 -7
  216. package/src/standalone/explore-tool.mjs +30 -9
  217. package/src/standalone/hook-bus/config.mjs +207 -0
  218. package/src/standalone/hook-bus/constants.mjs +90 -0
  219. package/src/standalone/hook-bus/handlers.mjs +481 -0
  220. package/src/standalone/hook-bus/payload.mjs +31 -0
  221. package/src/standalone/hook-bus/rules.mjs +77 -0
  222. package/src/standalone/hook-bus.mjs +77 -870
  223. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  224. package/src/standalone/opencode-go-login.mjs +5 -1
  225. package/src/standalone/provider-admin.mjs +1 -16
  226. package/src/standalone/usage-dashboard.mjs +3 -1
  227. package/src/tui/App.jsx +945 -8094
  228. package/src/tui/app/app-format.mjs +206 -0
  229. package/src/tui/app/channel-pickers.mjs +510 -0
  230. package/src/tui/app/clipboard.mjs +67 -0
  231. package/src/tui/app/core-memory-picker.mjs +210 -0
  232. package/src/tui/app/extension-pickers.mjs +506 -0
  233. package/src/tui/app/input-parsers.mjs +193 -0
  234. package/src/tui/app/maintenance-pickers.mjs +324 -0
  235. package/src/tui/app/model-options.mjs +330 -0
  236. package/src/tui/app/model-picker.mjs +365 -0
  237. package/src/tui/app/onboarding-steps.mjs +400 -0
  238. package/src/tui/app/project-picker.mjs +247 -0
  239. package/src/tui/app/provider-setup-picker.mjs +580 -0
  240. package/src/tui/app/resume-picker.mjs +55 -0
  241. package/src/tui/app/route-pickers.mjs +419 -0
  242. package/src/tui/app/settings-picker.mjs +490 -0
  243. package/src/tui/app/slash-commands.mjs +101 -0
  244. package/src/tui/app/slash-dispatch.mjs +427 -0
  245. package/src/tui/app/text-layout.mjs +46 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  247. package/src/tui/app/transcript-window.mjs +671 -0
  248. package/src/tui/app/use-mouse-input.mjs +460 -0
  249. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  250. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  251. package/src/tui/app/use-transcript-window.mjs +589 -0
  252. package/src/tui/components/ConfirmBar.jsx +1 -1
  253. package/src/tui/components/Picker.jsx +32 -4
  254. package/src/tui/components/PromptInput.jsx +23 -101
  255. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  256. package/src/tui/components/StatusLine.jsx +63 -12
  257. package/src/tui/components/TextEntryPanel.jsx +11 -0
  258. package/src/tui/components/ToolExecution.jsx +52 -594
  259. package/src/tui/components/TranscriptItem.jsx +105 -0
  260. package/src/tui/components/UsagePanel.jsx +18 -4
  261. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  262. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  263. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  264. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  265. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  266. package/src/tui/display-width.mjs +20 -3
  267. package/src/tui/dist/index.mjs +19652 -18630
  268. package/src/tui/engine/agent-job-feed.mjs +133 -0
  269. package/src/tui/engine/notification-plan.mjs +76 -0
  270. package/src/tui/engine/render-timing.mjs +17 -0
  271. package/src/tui/engine/tool-approval.mjs +94 -0
  272. package/src/tui/engine/tool-card-results.mjs +234 -0
  273. package/src/tui/engine/tool-result-status.mjs +135 -0
  274. package/src/tui/engine.mjs +122 -562
  275. package/src/tui/figures.mjs +5 -0
  276. package/src/tui/index.jsx +105 -0
  277. package/src/tui/input-editing.mjs +2 -2
  278. package/src/tui/markdown/format-token.mjs +4 -1
  279. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  280. package/src/tui/theme.mjs +6 -0
  281. package/src/ui/statusline-agents.mjs +213 -0
  282. package/src/ui/statusline-format.mjs +146 -0
  283. package/src/ui/statusline-segments.mjs +148 -0
  284. package/src/ui/statusline.mjs +67 -501
  285. package/src/ui/tool-card.mjs +0 -1
  286. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  287. package/src/workflows/default/WORKFLOW.md +1 -1
  288. package/src/workflows/sequential/WORKFLOW.md +1 -1
  289. package/vendor/ink/build/display-width.js +19 -3
  290. package/vendor/ink/build/ink.js +103 -6
  291. package/vendor/ink/build/log-update.js +17 -3
  292. package/vendor/ink/build/wrap-text.js +125 -0
  293. package/scripts/_test-folder-dialog.mjs +0 -30
  294. package/scripts/fix-brief-fn.mjs +0 -35
  295. package/scripts/fix-format-tool-surface.mjs +0 -24
  296. package/scripts/fix-tool-exec-visible.mjs +0 -42
  297. package/scripts/patch-agent-brief.mjs +0 -48
  298. package/scripts/patch-app.mjs +0 -21
  299. package/scripts/patch-app2.mjs +0 -18
  300. package/scripts/patch-dist-brief.mjs +0 -96
  301. package/scripts/patch-tool-exec.mjs +0 -70
  302. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  303. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  304. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  305. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  306. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  307. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  308. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  309. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  310. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  311. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  312. package/src/standalone/agent-task-status.test.mjs +0 -76
  313. package/src/tui/components/tool-output-format.test.mjs +0 -399
  314. package/src/tui/display-width.test.mjs +0 -35
  315. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  316. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  317. package/src/tui/input-editing.selection.test.mjs +0 -75
  318. package/src/tui/markdown/format-token.test.mjs +0 -354
  319. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  320. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  321. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  322. package/src/tui/paste-fix.test.mjs +0 -119
  323. package/src/tui/prompt-history-store.test.mjs +0 -52
  324. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  325. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  326. package/src/ui/markdown.test.mjs +0 -70
  327. package/src/ui/statusline-context-label.test.mjs +0 -15
  328. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  329. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -7,12 +7,12 @@
7
7
  * openai-oauth-ws.mjs; this file owns auth, model catalog, request-body
8
8
  * shape, and HTTP/SSE fallback when WebSocket transport is unhealthy.
9
9
  */
10
- import { createServer } from 'http';
11
- import { randomBytes, createHash } from 'crypto';
10
+ import { createHash } from 'crypto';
12
11
  import { readFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
13
12
  import { join } from 'path';
14
13
  import { getPluginData } from '../config.mjs';
15
14
  import { enrichModels } from './model-catalog.mjs';
15
+ import { sanitizeModelList } from './model-list-sanitize.mjs';
16
16
  import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
17
17
  import { makeModelCache } from './model-cache.mjs';
18
18
 
@@ -51,11 +51,31 @@ import {
51
51
  isResponsesFreeformTool,
52
52
  toResponsesCustomTool,
53
53
  } from './custom-tool-wire.mjs';
54
+ import {
55
+ sendViaHttpSse,
56
+ _envFlag,
57
+ _envPositiveInt,
58
+ _shouldUseOpenAIHttpFallback,
59
+ } from './openai-oauth-http-sse.mjs';
60
+ import { createOpenAIOAuthLogin } from './openai-oauth-login.mjs';
61
+ import {
62
+ _displayCodexModel,
63
+ _codexFamily,
64
+ _normalizeCodexModel,
65
+ _compareVersion,
66
+ _isMainCodexFamily,
67
+ _markLatestCodex,
68
+ } from './openai-codex-model.mjs';
69
+ export { _displayCodexModel };
70
+
71
+ // Legacy import path for scripts/tool-smoke.mjs (single-emit SSE smoke).
72
+ export { sendViaHttpSse };
54
73
  // --- Constants ---
55
74
  const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
56
- const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
75
+ // Exported for openai-oauth-http-sse.mjs (fallback transport headers/URL).
76
+ export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
57
77
  const TOKEN_URL = 'https://auth.openai.com/oauth/token';
58
- const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
78
+ export const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
59
79
  // Version string baked into the models endpoint query — the OAuth backend
60
80
  // rejects the request without it, and gates new model exposures (e.g.
61
81
  // gpt-5.5 only on >= 0.130.0) on this client_version header; older versions
@@ -63,7 +83,11 @@ const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
63
83
  // dynamically from npm so newly-shipped models surface within a day instead
64
84
  // of waiting on a hardcoded bump here. Cached 24h in-process; npm failure
65
85
  // falls back to the floor below.
66
- const CODEX_CLIENT_VERSION_FLOOR = '0.130.0';
86
+ // Offline fallback only — _resolveCodexClientVersion() fetches the live
87
+ // @openai/codex latest from npm first. Bumped to the current release
88
+ // (0.142.5, verified 2026-07-03) so the offline path stays close to what the
89
+ // backend expects for client-version gating.
90
+ const CODEX_CLIENT_VERSION_FLOOR = '0.142.5';
67
91
  const CODEX_VERSION_CACHE_TTL_MS = 24 * 60 * 60_000;
68
92
  let _codexVersionCache = { value: null, fetchedAt: 0 };
69
93
 
@@ -118,10 +142,6 @@ let _codexRefreshInFlight = null;
118
142
  let _oauthRefreshInFlight = null;
119
143
  let _lastCodexListModelsError = '';
120
144
 
121
- export function getOpenAIOAuthModelCatalogError() {
122
- return _lastCodexListModelsError;
123
- }
124
-
125
145
  function _codexCatalogHas(id) {
126
146
  if (!id || !Array.isArray(_inMemoryCodexCatalog)) return false;
127
147
  return _inMemoryCodexCatalog.some(m => m.id === id);
@@ -155,106 +175,6 @@ export function codexModelSupportsServiceTier(id, serviceTier) {
155
175
  return tiers.some(t => t?.id === serviceTier);
156
176
  }
157
177
 
158
- // OAuth catalog returns dated ids (gpt-5.4-mini-2026-03-17). Strip the trailing
159
- // -YYYY-MM-DD to get the version alias (gpt-5.4-mini). Unknown shapes pass
160
- // through unchanged.
161
- function _displayCodexModel(id) {
162
- if (!id || typeof id !== 'string') return id;
163
- return id.replace(/-\d{4}-\d{2}-\d{2}$/, '');
164
- }
165
-
166
- function _positiveCodexContextWindow(value) {
167
- const n = Number(value);
168
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
169
- }
170
-
171
- function _codexContextWindowFromApi(m) {
172
- return _positiveCodexContextWindow(m?.context_window)
173
- || _positiveCodexContextWindow(m?.max_context_window)
174
- || null;
175
- }
176
-
177
- function _normalizeCodexModel(m) {
178
- const id = m?.slug || m?.id;
179
- const family = _codexFamily(id);
180
- const serviceTiers = Array.isArray(m?.service_tiers)
181
- ? m.service_tiers
182
- .map(t => ({
183
- id: String(t?.id || '').trim(),
184
- name: String(t?.name || '').trim(),
185
- description: String(t?.description || '').trim(),
186
- }))
187
- .filter(t => t.id)
188
- : [];
189
- const additionalSpeedTiers = Array.isArray(m?.additional_speed_tiers)
190
- ? m.additional_speed_tiers.map(t => String(t || '').trim()).filter(Boolean)
191
- : [];
192
- // Catalog ids are version aliases without separate display dating.
193
- return {
194
- id,
195
- name: m?.display_name || id,
196
- display: m?.display_name || id,
197
- family,
198
- provider: 'openai-oauth',
199
- contextWindow: _codexContextWindowFromApi(m),
200
- maxContextWindow: _positiveCodexContextWindow(m?.max_context_window),
201
- outputTokens: m?.max_output_tokens || m?.output_tokens || 32768,
202
- autoCompactTokenLimit: m?.auto_compact_token_limit || null,
203
- effectiveContextWindowPercent: m?.effective_context_window_percent || null,
204
- tier: 'version',
205
- latest: false,
206
- description: m?.description || '',
207
- reasoningLevels: (m?.supported_reasoning_levels || []).map(r => r.effort),
208
- serviceTiers,
209
- defaultServiceTier: m?.default_service_tier || null,
210
- additionalSpeedTiers,
211
- };
212
- }
213
-
214
- function _codexFamily(id) {
215
- const s = String(id || '').toLowerCase();
216
- if (s.includes('nano')) return 'gpt-nano';
217
- if (s.includes('mini')) return 'gpt-mini';
218
- if (s.includes('codex')) return 'gpt-codex';
219
- if (s.startsWith('gpt-5.5')) return 'gpt-5.5';
220
- if (s.startsWith('gpt-5.4')) return 'gpt-5.4';
221
- if (s.startsWith('gpt-5.2')) return 'gpt-5.2';
222
- if (s.startsWith('gpt-5')) return 'gpt-5';
223
- return 'gpt';
224
- }
225
-
226
- // Compare two model ids by the X.Y version embedded in `gpt-X.Y`. Mirrors
227
- // anthropic-oauth's _compareVersion; these ids have no trailing date so
228
- // the version lives in the dotted number, not a -YYYY-MM-DD suffix.
229
- function _compareVersion(a, b) {
230
- const na = (String(a).match(/gpt-(\d+)\.(\d+)/) || []).slice(1).map(Number);
231
- const nb = (String(b).match(/gpt-(\d+)\.(\d+)/) || []).slice(1).map(Number);
232
- for (let i = 0; i < Math.max(na.length, nb.length); i++) {
233
- if ((na[i] || 0) !== (nb[i] || 0)) return (na[i] || 0) - (nb[i] || 0);
234
- }
235
- return String(a).localeCompare(String(b));
236
- }
237
-
238
- // Main gpt-5 chat family only: exclude the mini/nano/codex variants so "latest"
239
- // resolves to the flagship, not a smaller sibling.
240
- function _isMainCodexFamily(family) {
241
- return typeof family === 'string' && family.startsWith('gpt-5');
242
- }
243
-
244
- // Mark the highest-version model per family as `latest: true`. VERSION-based
245
- // (ids carry no `created`), mirroring anthropic-oauth's per-family pass.
246
- function _markLatestCodex(models) {
247
- const byFamily = new Map();
248
- for (const m of models) {
249
- if (!m?.id) continue;
250
- const cur = byFamily.get(m.family);
251
- if (!cur || _compareVersion(m.id, cur.id) > 0) {
252
- byFamily.set(m.family, m);
253
- }
254
- }
255
- for (const m of byFamily.values()) m.latest = true;
256
- }
257
-
258
178
  // Newest MAIN gpt-5 chat model by version, read from the SYNC in-memory
259
179
  // catalog mirror. Returns null until populated; callers warm via
260
180
  // ensureLatestCodexModel when null.
@@ -577,7 +497,35 @@ function toOpenAIResponsesTool(t) {
577
497
  };
578
498
  }
579
499
 
500
+ // codex build_reasoning() (core/src/client.rs:785-805) only attaches the
501
+ // reasoning object when model_info.supports_reasoning_summaries; models
502
+ // without summary support get NO reasoning field at all. Mirror that via the
503
+ // cached codex catalog; unknown models default to true (gpt-5 family all
504
+ // support summaries) so a cold catalog cannot strip reasoning from the wire.
505
+ function _codexModelSupportsReasoningSummaries(id) {
506
+ const info = _findCachedCodexModel(id);
507
+ if (!info) return true;
508
+ const flags = [info.supportsReasoningSummaries, info.supports_reasoning_summaries, info.supportsReasoning, info.supports_reasoning];
509
+ for (const flag of flags) {
510
+ if (typeof flag === 'boolean') return flag;
511
+ }
512
+ return true;
513
+ }
514
+
515
+ // codex reasoning_effort_for_request (core/src/client.rs): `ultra` collapses to
516
+ // `max` on the wire — the openai-oauth backend does not accept `ultra`. Every
517
+ // other effort passes through unchanged; empty/unknown falls back to medium.
518
+ function _normalizeReasoningEffort(effort) {
519
+ const e = String(effort || '').trim().toLowerCase();
520
+ if (!e) return 'medium';
521
+ if (e === 'ultra') return 'max';
522
+ return e;
523
+ }
524
+
580
525
  export function buildRequestBody(messages, model, tools, sendOpts) {
526
+ // codex reasoning_effort_for_request: `ultra` collapses to `max` on the
527
+ // wire (the only remap; every other effort passes through). Default medium.
528
+ // Kept inline (not a module const) so buildRequestBody stays self-contained.
581
529
  // Extract system/instructions
582
530
  const systemMsgs = messages.filter(m => m.role === 'system');
583
531
  const instructions = systemMsgs.map(m => m.content).join('\n\n') || 'You are a helpful assistant.';
@@ -596,17 +544,32 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
596
544
  const value = String(item || '').trim();
597
545
  if (value && !include.includes(value)) include.push(value);
598
546
  }
547
+ // Field order MIRRORS codex-rs ResponsesApiRequest (common.rs struct order):
548
+ // model, instructions, input, tools, tool_choice, parallel_tool_calls,
549
+ // reasoning, store, stream, include, service_tier, prompt_cache_key, text.
550
+ // JSON serialization order is load-bearing for the server prompt cache
551
+ // (exact-prefix match): matching codex's byte layout keeps our requests on
552
+ // the same cache-routing shape codex warms. tools/service_tier/
553
+ // prompt_cache_key are appended below in the same relative order.
599
554
  const body = {
600
555
  model,
601
556
  instructions,
602
557
  input,
558
+ tool_choice: opts.toolChoice || 'auto',
559
+ parallel_tool_calls: true,
560
+ // codex build_reasoning() sends { effort, summary } — summary defaults to
561
+ // ReasoningSummary::Auto (protocol config_types.rs), serialized lowercase
562
+ // as "auto". Matching this keeps our reasoning object byte-identical to
563
+ // codex so the server prompt-cache prefix hash lines up. codex also
564
+ // normalizes `ultra` -> `max` on the wire (reasoning_effort_for_request
565
+ // in core/src/client.rs); the openai-oauth backend does not accept
566
+ // `ultra` as a wire value, so mirror that mapping here.
567
+ ...(_codexModelSupportsReasoningSummaries(model)
568
+ ? { reasoning: { effort: _normalizeReasoningEffort(opts.effort), summary: 'auto' } }
569
+ : {}),
603
570
  store: process.env.MIXDOG_OAI_STORE === 'true' ? true : false,
604
571
  stream: true,
605
- reasoning: { effort: opts.effort || 'medium' },
606
- text: { verbosity: 'medium' },
607
572
  include,
608
- tool_choice: opts.toolChoice || 'auto',
609
- parallel_tool_calls: true,
610
573
  };
611
574
  const maxOutputTokens = Number(opts.maxOutputTokens ?? opts.outputTokens ?? opts.max_output_tokens);
612
575
  if (_envFlag('MIXDOG_OPENAI_OAUTH_SEND_MAX_OUTPUT_TOKENS', false)
@@ -625,20 +588,22 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
625
588
  }
626
589
  // Add tools. `nativeTools` are server-hosted Responses tools (for
627
590
  // example web_search) and must be passed through without wrapping them as
628
- // function tools.
591
+ // function tools. codex places `tools` right after `input` (before
592
+ // tool_choice); we insert it there via a rebuilt object so serialization
593
+ // order matches, rather than appending it last.
629
594
  const functionTools = tools?.length ? tools.map(toOpenAIResponsesTool) : [];
630
595
  const nativeTools = Array.isArray(opts.nativeTools)
631
596
  ? opts.nativeTools.filter(t => t && typeof t === 'object')
632
597
  : [];
633
- if (functionTools.length || nativeTools.length) {
634
- body.tools = [...nativeTools, ...functionTools];
635
- }
598
+ const toolsList = (functionTools.length || nativeTools.length)
599
+ ? [...nativeTools, ...functionTools]
600
+ : null;
636
601
  const promptCacheProvider = opts.promptCacheProvider || 'openai-oauth';
637
602
  const promptCacheLane = opts.promptCacheLane || resolveProviderPromptCacheLane(promptCacheProvider, opts);
638
- body.prompt_cache_key = buildStableProviderPromptCacheKey(promptCacheProvider, opts, {
603
+ const promptCacheKey = buildStableProviderPromptCacheKey(promptCacheProvider, opts, {
639
604
  model,
640
605
  instructions,
641
- tools: body.tools || [],
606
+ tools: toolsList || [],
642
607
  effort: body.reasoning?.effort,
643
608
  fast: opts.fast === true,
644
609
  serviceTier: body.service_tier || '',
@@ -647,716 +612,41 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
647
612
  cacheLaneSlot: promptCacheLane.slot,
648
613
  cacheLaneShards: promptCacheLane.shards,
649
614
  });
615
+ // codex only serializes `text` when a verbosity/schema is configured
616
+ // (common.rs create_text_param_for_request returns None otherwise); it has
617
+ // no unconditional { verbosity: 'medium' }. Sending an extra text object
618
+ // shifts every following byte vs codex's prefix layout.
619
+ const verbosity = typeof opts.verbosity === 'string' && opts.verbosity.trim()
620
+ ? opts.verbosity.trim().toLowerCase()
621
+ : null;
622
+ // Rebuild the body in codex struct order so JSON serialization is
623
+ // byte-compatible with codex: ... input, tools, tool_choice,
624
+ // parallel_tool_calls, reasoning, store, stream, include, service_tier,
625
+ // prompt_cache_key, text. service_tier is only present when fast set it.
626
+ const ordered = {
627
+ model: body.model,
628
+ instructions: body.instructions,
629
+ input: body.input,
630
+ ...(toolsList ? { tools: toolsList } : {}),
631
+ tool_choice: body.tool_choice,
632
+ parallel_tool_calls: body.parallel_tool_calls,
633
+ reasoning: body.reasoning,
634
+ store: body.store,
635
+ stream: body.stream,
636
+ include: body.include,
637
+ ...(body.service_tier ? { service_tier: body.service_tier } : {}),
638
+ prompt_cache_key: promptCacheKey,
639
+ ...(verbosity ? { text: { verbosity } } : {}),
640
+ ...(body.max_output_tokens ? { max_output_tokens: body.max_output_tokens } : {}),
641
+ };
650
642
  // NOTE: prompt_cache_retention is a public OpenAI Responses API parameter,
651
643
  // but the openai-oauth endpoint still rejects it ("Unsupported parameter:
652
644
  // prompt_cache_retention", re-probed 2026-06-22). Leave retention on the
653
645
  // openai-oauth server default; public OpenAI direct injects 24h separately.
654
- return body;
655
- }
656
-
657
- function _envFlag(name, fallback = true) {
658
- const raw = process.env[name];
659
- if (raw == null || raw === '') return fallback;
660
- return !['0', 'false', 'off', 'no'].includes(String(raw).toLowerCase());
661
- }
662
-
663
- function _envPositiveInt(name, fallback) {
664
- const raw = process.env[name];
665
- if (raw == null || raw === '') return fallback;
666
- const n = Number(raw);
667
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
668
- }
669
-
670
- // Completed function_call.arguments parse for the OpenAI Responses stream.
671
- // A function_call item arrives only on a completion/done signal, so a
672
- // non-empty-but-malformed
673
- // arguments string is deterministic bad JSON — NOT mid-stream truncation.
674
- // Empty/whitespace input legitimately means "no arguments" → {}. A non-empty
675
- // string that fails JSON.parse is surfaced as an invalid-args MARKER (instead
676
- // of being silently swallowed to {}) so the dispatch loop turns it into an
677
- // is_error tool_result and the model self-corrects in the same turn.
678
- function _parseJsonObject(value) {
679
- const text = typeof value === 'string' ? value : (value == null ? '' : String(value));
680
- if (text.trim() === '') return {};
681
- try {
682
- const parsed = JSON.parse(text);
683
- return parsed && typeof parsed === 'object' ? parsed : {};
684
- } catch (err) {
685
- return makeInvalidToolArgsMarker(text, err instanceof Error ? err.message : String(err));
686
- }
646
+ return ordered;
687
647
  }
688
648
 
689
- function _extractCachedTokens(usage) {
690
- const details = usage?.input_tokens_details || usage?.prompt_tokens_details || {};
691
- return Number(details.cached_tokens ?? details.cached ?? usage?.cached_tokens ?? 0) || 0;
692
- }
693
-
694
- function _sseEventsFromBuffer(buffer) {
695
- const frames = [];
696
- let rest = buffer.replace(/\r\n/g, '\n');
697
- let idx;
698
- while ((idx = rest.indexOf('\n\n')) >= 0) {
699
- frames.push(rest.slice(0, idx));
700
- rest = rest.slice(idx + 2);
701
- }
702
- return { frames, rest };
703
- }
704
-
705
- function _parseSseFrame(frame) {
706
- const lines = String(frame || '').split('\n');
707
- const data = [];
708
- for (const line of lines) {
709
- if (!line || line.startsWith(':')) continue;
710
- if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
711
- }
712
- if (!data.length) return null;
713
- const raw = data.join('\n').trim();
714
- if (!raw || raw === '[DONE]') return null;
715
- try { return JSON.parse(raw); } catch { return null; }
716
- }
717
-
718
- function _incompleteReasonFromEvent(event) {
719
- const reasonObj = event?.response?.incomplete_details
720
- || event?.incomplete_details
721
- || event?.response?.status_details
722
- || null;
723
- return String(reasonObj?.reason || event?.response?.status || 'incomplete');
724
- }
725
-
726
- function _isMaxOutputIncompleteReason(reason) {
727
- return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
728
- }
729
-
730
- function _pushOutputTextAnnotations(part, citations, citationKeys) {
731
- const annotations = Array.isArray(part?.annotations) ? part.annotations : [];
732
- for (const raw of annotations) {
733
- const url = raw?.url || raw?.uri || raw?.href || '';
734
- if (!url || citationKeys.has(url)) continue;
735
- citationKeys.add(url);
736
- citations.push({
737
- title: raw?.title || '',
738
- url,
739
- snippet: raw?.snippet || raw?.text || raw?.description || '',
740
- source: 'openai-oauth',
741
- });
742
- }
743
- }
744
-
745
- function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
746
- const headers = {
747
- Authorization: `Bearer ${auth.access_token}`,
748
- 'Content-Type': 'application/json',
749
- Accept: 'text/event-stream',
750
- 'OpenAI-Beta': 'responses=experimental',
751
- originator: CODEX_OAUTH_ORIGINATOR,
752
- 'chatgpt-account-id': auth.account_id || '',
753
- 'x-client-request-id': randomBytes(16).toString('hex'),
754
- };
755
- if (cacheKey) headers.session_id = String(cacheKey);
756
- return headers;
757
- }
758
-
759
- // WS→HTTP/SSE fallback predicate → shared shouldFallbackTransport
760
- // (retry-classifier.mjs). The per-provider env flag is computed here and passed
761
- // as `enabled`; the deny-order + allow-list are identical to the former copy.
762
- function _shouldUseOpenAIHttpFallback(err, externalSignal) {
763
- return shouldFallbackTransport(err, {
764
- signal: externalSignal,
765
- enabled: _envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true),
766
- });
767
- }
768
-
769
- // Exported for the single-emit regression smoke (scripts/openai-oauth-
770
- // http-sse-toolcall-smoke.mjs): the SSE stream can surface the same
771
- // function_call across response.function_call_arguments.done +
772
- // response.output_item.done + response.completed, and onToolCall must fire
773
- // exactly once per call id. No production caller imports this name; the
774
- // provider invokes it internally.
775
- export async function sendViaHttpSse({
776
- auth,
777
- body,
778
- opts,
779
- onStreamDelta,
780
- onToolCall,
781
- onTextDelta,
782
- onStageChange,
783
- externalSignal,
784
- poolKey,
785
- cacheKey,
786
- iteration,
787
- useModel,
788
- fetchFn = fetch,
789
- } = {}) {
790
- // P1 audit fix: no fixed wall-clock total cap on the HTTP/SSE fallback
791
- // stream. The old createTimeoutSignal(..., PROVIDER_GENERATE_TOTAL_TIMEOUT_MS)
792
- // killed a healthy, still-streaming turn purely on elapsed time, unlike
793
- // every other streaming provider path (anthropic-oauth uses the same
794
- // createPassthroughSignal pattern — see anthropic-oauth.mjs "Option A").
795
- // The stream is bounded instead by:
796
- // (a) headerTimeout below (PROVIDER_HTTP_RESPONSE_TIMEOUT_MS) for a
797
- // socket that never sends the initial response,
798
- // (b) the SEMANTIC idle watchdog (_armSemanticIdle /
799
- // PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS), which resets on every
800
- // meaningful() chunk — a live stream stays alive, a truly silent
801
- // one still aborts, and
802
- // (c) externalSignal (client disconnect / replaced-by-newer-request).
803
- const totalTimeout = createPassthroughSignal(externalSignal);
804
- const headerTimeout = createTimeoutSignal(
805
- totalTimeout.signal,
806
- PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
807
- 'OpenAI OAuth HTTP fallback initial response',
808
- );
809
- const headers = _buildOpenAIHttpFallbackHeaders({ auth, cacheKey });
810
- const fetchStartedAt = Date.now();
811
- let response;
812
- try {
813
- try { onStageChange?.('requesting'); } catch {}
814
- response = await fetchFn(CODEX_RESPONSES_URL, {
815
- method: 'POST',
816
- headers,
817
- body: JSON.stringify(body),
818
- signal: headerTimeout.signal,
819
- dispatcher: getLlmDispatcher(),
820
- });
821
- } catch (err) {
822
- if (headerTimeout.signal?.aborted && headerTimeout.signal.reason instanceof Error) throw headerTimeout.signal.reason;
823
- throw err;
824
- } finally {
825
- headerTimeout.cleanup();
826
- }
827
-
828
- traceAgentFetch({
829
- sessionId: poolKey,
830
- headersMs: Date.now() - fetchStartedAt,
831
- httpStatus: response.status,
832
- provider: 'openai-oauth',
833
- model: useModel,
834
- transport: 'http',
835
- });
836
-
837
- if (!response.ok) {
838
- const text = await response.text().catch(() => '');
839
- const err = new Error(`OpenAI OAuth HTTP fallback ${response.status}: ${text.slice(0, 200)}`);
840
- err.httpStatus = response.status;
841
- err.headers = response.headers;
842
- populateHttpStatusFromMessage(err, text);
843
- totalTimeout.cleanup();
844
- throw err;
845
- }
846
- if (!response.body) {
847
- totalTimeout.cleanup();
848
- throw new Error('OpenAI OAuth HTTP fallback returned no response body');
849
- }
850
-
851
- try { onStageChange?.('streaming'); } catch {}
852
- const sseStartedAt = Date.now();
853
- const reader = response.body.getReader();
854
- const decoder = new TextDecoder();
855
- // After headerTimeout.cleanup() the in-flight fetch no longer carries a live
856
- // signal, so a totalTimeout / external abort that fires during a pending
857
- // reader.read() would otherwise leave the pooled request hanging. Keep the
858
- // reader tied to totalTimeout for the whole stream: on abort, cancel the
859
- // reader so the awaited read() unblocks and the socket is released back to
860
- // the shared pool instead of leaking. reader.cancel() may resolve the
861
- // pending read() as {done:true} rather than rejecting, which would let a
862
- // partial response surface as success — so record the abort reason and
863
- // re-throw it after the loop unblocks (see below).
864
- let _streamAbortReason = null;
865
- let _onTotalAbort = null;
866
- if (totalTimeout.signal) {
867
- _onTotalAbort = () => {
868
- const reason = totalTimeout.signal.reason;
869
- _streamAbortReason = reason instanceof Error
870
- ? reason
871
- : new Error('OpenAI OAuth HTTP fallback aborted');
872
- try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
873
- };
874
- if (totalTimeout.signal.aborted) _onTotalAbort();
875
- else totalTimeout.signal.addEventListener('abort', _onTotalAbort, { once: true });
876
- }
877
- // SEMANTIC idle watchdog: reset ONLY on meaningful() (text/reasoning/tool
878
- // deltas), never on raw bytes/keepalive frames, so a stream that emits some
879
- // deltas then goes silent trips a short, named terminal failure instead of
880
- // hanging until the 30-min agent watchdog. Disablable via the shared env.
881
- let _semanticIdleTimer = null;
882
- const _clearSemanticIdle = () => {
883
- if (_semanticIdleTimer) { clearTimeout(_semanticIdleTimer); _semanticIdleTimer = null; }
884
- };
885
- const _armSemanticIdle = () => {
886
- if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED || !(PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS > 0)) return;
887
- _clearSemanticIdle();
888
- _semanticIdleTimer = setTimeout(() => {
889
- _streamAbortReason = streamStalledError('OpenAI OAuth HTTP fallback', PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS, { emittedToolCall: emittedToolCallIds.size > 0 });
890
- // Partial-final recovery: attach the
891
- // streamed partial state so the agent loop can accept a wedged FINAL
892
- // no-tool summary as a successful partial-final instead of dropping
893
- // the result. pendingToolUse gates out any mid-flight tool call.
894
- try {
895
- _streamAbortReason.partialContent = content;
896
- _streamAbortReason.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
897
- _streamAbortReason.pendingToolUse = pendingCalls.size > 0 || emittedToolCallIds.size > 0;
898
- _streamAbortReason.partialModel = model || undefined;
899
- } catch { /* best-effort enrichment */ }
900
- try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
901
- }, PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS);
902
- try { _semanticIdleTimer.unref?.(); } catch {}
903
- };
904
- let buffer = '';
905
- let content = '';
906
- let model = '';
907
- let responseId = '';
908
- let serviceTier = '';
909
- let usage = null;
910
- let ttftMs = null;
911
- const toolCalls = [];
912
- const pendingCalls = new Map();
913
- const reasoningItems = [];
914
- const citations = [];
915
- const citationKeys = new Set();
916
- const webSearchCalls = [];
917
- const webSearchCallKeys = new Set();
918
- let completed = false;
919
- let stopReason = null;
920
- // Gateway live-text relay invariant: set once a non-empty text chunk has
921
- // been forwarded to the client. A failure afterwards is non-retryable —
922
- // the rendered text cannot be withdrawn and a re-request would concatenate
923
- // a second attempt.
924
- let emittedText = false;
925
-
926
- // Single-emit guard for tool calls (matches the WS path's
927
- // emittedToolCall intent). The HTTP/SSE event stream can surface the
928
- // same function_call across multiple frames — response.function_call_arguments.done,
929
- // response.output_item.done, and the final response.completed.output
930
- // bundle. Each frame independently completes the call (id + name) and
931
- // would re-invoke onToolCall, double-executing a side-effecting tool.
932
- // Route every emit through emitToolCall: it fires the callback exactly
933
- // once per unique call id, the first time the call is complete. A call
934
- // whose id/name only arrives in a later frame is NOT dropped — its
935
- // first complete frame still emits; only redundant re-emits are
936
- // suppressed.
937
- const emittedToolCallIds = new Set();
938
- // Fix 2: cross-path name+args dedupe. A text-leaked synthetic and an
939
- // identical native function_call must fire onToolCall exactly once.
940
- const _toolDedupe = createToolCallDedupe();
941
- const emitToolCall = (call) => {
942
- if (!call || !call.id) return;
943
- if (emittedToolCallIds.has(call.id)) return;
944
- emittedToolCallIds.add(call.id);
945
- if (!_toolDedupe.shouldDispatch(call.name, call.arguments)) return;
946
- try { onToolCall?.(call); } catch {}
947
- };
948
-
949
- // Leaked tool-call guard. The model sometimes emits a tool call as plain
950
- // text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
951
- // `<|channel|>...to=functions.NAME...<|call|>`) inside
952
- // `response.output_text.delta` instead of a native function_call. Route
953
- // text through the guard so leaked calls are suppressed from the visible
954
- // stream, synthesized (native `call_...` id shape), and dispatched like
955
- // native ones. Known tool names come from the request body so recovery
956
- // only fires for tools the model was actually offered. Additive: the
957
- // native function_call path is untouched.
958
- const _leakKnownTools = new Set(
959
- (Array.isArray(body?.tools) ? body.tools : [])
960
- .map((t) => (typeof t?.name === 'string' ? t.name : null))
961
- .filter(Boolean),
962
- );
963
- const leakGuard = createLeakGuard({ knownToolNames: _leakKnownTools, harmony: true });
964
- const dispatchLeakedCall = (recovered) => {
965
- let args = recovered?.arguments;
966
- if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
967
- const call = {
968
- id: `call_leaked_${randomBytes(8).toString('hex')}`,
969
- name: recovered.name,
970
- arguments: args,
971
- };
972
- toolCalls.push(call);
973
- emitToolCall(call);
974
- };
975
- const relayLeakText = (delta) => {
976
- if (!leakGuard.enabled) {
977
- content += delta || '';
978
- if (delta && onTextDelta) {
979
- emittedText = true;
980
- try { onTextDelta(delta); } catch {}
981
- }
982
- return;
983
- }
984
- const { text, calls } = leakGuard.push(delta);
985
- if (text) {
986
- content += text;
987
- if (onTextDelta) {
988
- emittedText = true;
989
- try { onTextDelta(text); } catch {}
990
- }
991
- }
992
- for (const c of calls) dispatchLeakedCall(c);
993
- };
994
- const flushLeak = () => {
995
- if (!leakGuard.enabled) return;
996
- const { text, calls } = leakGuard.flush();
997
- if (text) {
998
- content += text;
999
- if (onTextDelta) {
1000
- emittedText = true;
1001
- try { onTextDelta(text); } catch {}
1002
- }
1003
- }
1004
- for (const c of calls) dispatchLeakedCall(c);
1005
- };
1006
-
1007
- const pushWebSearchCall = (item) => {
1008
- if (!item || item.type !== 'web_search_call') return;
1009
- const key = item.id || JSON.stringify(item.action || item);
1010
- if (webSearchCallKeys.has(key)) return;
1011
- webSearchCallKeys.add(key);
1012
- webSearchCalls.push({ id: item.id || '', status: item.status || '', action: item.action || null });
1013
- };
1014
- const pushReasoningItem = (item) => {
1015
- if (item?.type === 'reasoning' && item.encrypted_content && !reasoningItems.some(r => r.id === item.id)) {
1016
- reasoningItems.push({
1017
- id: item.id || '',
1018
- encrypted_content: item.encrypted_content,
1019
- summary: Array.isArray(item.summary) ? item.summary : [],
1020
- });
1021
- }
1022
- };
1023
- const pushToolSearchCall = (item) => {
1024
- if (!item || item.type !== 'tool_search_call') return;
1025
- const callId = item.call_id || item.id || '';
1026
- if (!callId || toolCalls.some(t => t.id === callId)) return;
1027
- let args = {};
1028
- if (item.arguments && typeof item.arguments === 'object') {
1029
- args = item.arguments;
1030
- } else if (typeof item.arguments === 'string' && item.arguments.trim()) {
1031
- // Non-empty but malformed tool_search arguments are deterministic
1032
- // bad JSON (the item is only emitted on completion). Surface an
1033
- // invalid-args marker instead of swallowing to {} so the model can
1034
- // self-correct in the same turn.
1035
- args = _parseJsonObject(item.arguments);
1036
- }
1037
- const call = {
1038
- id: callId,
1039
- name: 'tool_search',
1040
- arguments: args,
1041
- nativeType: 'tool_search_call',
1042
- };
1043
- toolCalls.push(call);
1044
- emitToolCall(call);
1045
- };
1046
- const pushCustomToolCall = (item) => {
1047
- const call = customToolCallFromResponseItem(item);
1048
- if (!call || toolCalls.some(t => t.id === call.id)) return;
1049
- toolCalls.push(call);
1050
- emitToolCall(call);
1051
- };
1052
- const meaningful = () => {
1053
- if (ttftMs == null) ttftMs = Date.now() - sseStartedAt;
1054
- _armSemanticIdle();
1055
- try { onStreamDelta?.(); } catch {}
1056
- };
1057
- const handleEvent = (event) => {
1058
- if (!event || typeof event.type !== 'string') return;
1059
- switch (event.type) {
1060
- case 'response.created':
1061
- if (event.response?.model) model = event.response.model;
1062
- if (event.response?.id) responseId = event.response.id;
1063
- break;
1064
- case 'response.output_text.delta':
1065
- meaningful();
1066
- relayLeakText(event.delta || '');
1067
- break;
1068
- case 'response.reasoning_text.delta':
1069
- case 'response.reasoning_summary_text.delta':
1070
- meaningful();
1071
- break;
1072
- case 'response.output_item.added':
1073
- if (event.item?.type === 'function_call') {
1074
- pendingCalls.set(event.item.id || '', {
1075
- name: event.item.name || '',
1076
- callId: event.item.call_id || '',
1077
- });
1078
- } else if (event.item?.type === 'tool_search_call') {
1079
- // Mark tool_search as in-flight the moment the item is
1080
- // added, mirroring function_call above, so the semantic
1081
- // idle watchdog's pendingToolUse gate (pendingCalls.size)
1082
- // sees a mid-flight tool_search and never lets stall
1083
- // recovery drop it before response.output_item.done.
1084
- // kind:'tool_search' tags the entry so the shared
1085
- // function_call_arguments.done handler (below) never
1086
- // mistakes it for a function call by id collision/empty id.
1087
- if (event.item.id) {
1088
- pendingCalls.set(event.item.id, {
1089
- name: 'tool_search',
1090
- callId: event.item.call_id || '',
1091
- kind: 'tool_search',
1092
- });
1093
- }
1094
- }
1095
- break;
1096
- case 'response.function_call_arguments.delta':
1097
- meaningful();
1098
- break;
1099
- case 'response.function_call_arguments.done': {
1100
- const itemId = event.item_id || '';
1101
- const pending = pendingCalls.get(itemId);
1102
- if (pending?.kind === 'tool_search') { meaningful(); break; }
1103
- const call = {
1104
- id: pending?.callId || event.call_id || '',
1105
- name: pending?.name || event.name || '',
1106
- arguments: _parseJsonObject(event.arguments),
1107
- _pendingItemId: itemId,
1108
- };
1109
- toolCalls.push(call);
1110
- if (call.id && call.name) {
1111
- delete call._pendingItemId;
1112
- emitToolCall(call);
1113
- }
1114
- meaningful();
1115
- break;
1116
- }
1117
- case 'response.custom_tool_call_input.delta':
1118
- meaningful();
1119
- break;
1120
- case 'response.output_item.done': {
1121
- const item = event.item || {};
1122
- pushReasoningItem(item);
1123
- pushWebSearchCall(item);
1124
- if (item.type === 'function_call') {
1125
- const tc = toolCalls.find(t => t._pendingItemId === (item.id || ''));
1126
- if (tc) {
1127
- if (!tc.id && item.call_id) tc.id = item.call_id;
1128
- if (!tc.name && item.name) tc.name = item.name;
1129
- if (tc.id && tc.name) {
1130
- delete tc._pendingItemId;
1131
- emitToolCall(tc);
1132
- }
1133
- }
1134
- } else if (item.type === 'tool_search_call') {
1135
- pendingCalls.delete(item.id || '');
1136
- pushToolSearchCall(item);
1137
- } else if (item.type === 'custom_tool_call') {
1138
- pushCustomToolCall(item);
1139
- meaningful();
1140
- }
1141
- break;
1142
- }
1143
- case 'response.completed': {
1144
- const resp = event.response || {};
1145
- serviceTier = resp.service_tier || resp.serviceTier || serviceTier;
1146
- if (!model && resp.model) model = resp.model;
1147
- if (!responseId && resp.id) responseId = resp.id;
1148
- if (resp.usage) {
1149
- usage = {
1150
- inputTokens: resp.usage.input_tokens || 0,
1151
- outputTokens: resp.usage.output_tokens || 0,
1152
- cachedTokens: _extractCachedTokens(resp.usage),
1153
- promptTokens: resp.usage.input_tokens || 0,
1154
- raw: serviceTier ? { ...resp.usage, service_tier: serviceTier } : resp.usage,
1155
- };
1156
- }
1157
- for (const item of resp.output || []) {
1158
- if (item.type === 'message') {
1159
- for (const part of item.content || []) {
1160
- if (!content && part.type === 'output_text') {
1161
- // Completed-output fallback (no streamed text).
1162
- // Route through the leak guard so a tool call
1163
- // leaked only in the final bundle is recovered
1164
- // rather than surfaced as visible content. push
1165
- // with final=true flushes fully (no held tail).
1166
- if (leakGuard.enabled) {
1167
- const { text, calls } = leakGuard.push(part.text || '', true);
1168
- content += text;
1169
- for (const c of calls) dispatchLeakedCall(c);
1170
- } else {
1171
- content += part.text || '';
1172
- }
1173
- }
1174
- if (part.type === 'output_text') _pushOutputTextAnnotations(part, citations, citationKeys);
1175
- }
1176
- } else if (item.type === 'reasoning') {
1177
- pushReasoningItem(item);
1178
- } else if (item.type === 'web_search_call') {
1179
- pushWebSearchCall(item);
1180
- } else if (item.type === 'tool_search_call') {
1181
- pushToolSearchCall(item);
1182
- } else if (item.type === 'custom_tool_call') {
1183
- pushCustomToolCall(item);
1184
- meaningful();
1185
- } else if (item.type === 'function_call') {
1186
- // Match the still-pending placeholder by item id, or
1187
- // an already-recorded call by its canonical call_id —
1188
- // so a call completed at args.done / output_item.done
1189
- // is reused here rather than re-pushed as a duplicate.
1190
- const tc = toolCalls.find(t =>
1191
- t._pendingItemId === (item.id || '')
1192
- || (item.call_id && t.id === item.call_id));
1193
- if (tc) {
1194
- if (!tc.id && item.call_id) tc.id = item.call_id;
1195
- if (!tc.name && item.name) tc.name = item.name;
1196
- if (tc.id && tc.name) {
1197
- delete tc._pendingItemId;
1198
- emitToolCall(tc);
1199
- }
1200
- } else if (item.call_id && item.name) {
1201
- const call = {
1202
- id: item.call_id,
1203
- name: item.name,
1204
- arguments: _parseJsonObject(item.arguments),
1205
- };
1206
- toolCalls.push(call);
1207
- emitToolCall(call);
1208
- }
1209
- }
1210
- }
1211
- completed = true;
1212
- break;
1213
- }
1214
- case 'response.done':
1215
- if (!event.response || event.response.status === 'completed') completed = true;
1216
- else if (event.response.status === 'failed') {
1217
- const msg = event.response?.error?.message || 'response.done failed';
1218
- const err = new Error(`OpenAI OAuth HTTP fallback response.done failed: ${msg}`);
1219
- populateHttpStatusFromMessage(err, msg);
1220
- throw err;
1221
- } else if (event.response.status === 'incomplete') {
1222
- const reason = _incompleteReasonFromEvent(event);
1223
- if (_isMaxOutputIncompleteReason(reason)) {
1224
- completed = true;
1225
- stopReason = 'length';
1226
- break;
1227
- }
1228
- throw new Error(`OpenAI OAuth HTTP fallback response.done incomplete: ${reason}`);
1229
- }
1230
- break;
1231
- case 'response.failed': {
1232
- const msg = event.response?.error?.message || event.error?.message || event.message || 'response.failed';
1233
- const err = new Error(`OpenAI OAuth HTTP fallback response.failed: ${msg}`);
1234
- populateHttpStatusFromMessage(err, msg);
1235
- throw err;
1236
- }
1237
- case 'response.incomplete': {
1238
- const reason = _incompleteReasonFromEvent(event);
1239
- if (_isMaxOutputIncompleteReason(reason)) {
1240
- completed = true;
1241
- stopReason = 'length';
1242
- break;
1243
- }
1244
- throw new Error(`OpenAI OAuth HTTP fallback response.incomplete: ${reason}`);
1245
- }
1246
- case 'error': {
1247
- const msg = event.message || event.error?.message || 'unknown';
1248
- const err = new Error(`OpenAI OAuth HTTP fallback error: ${msg}`);
1249
- populateHttpStatusFromMessage(err, msg);
1250
- throw err;
1251
- }
1252
- default:
1253
- break;
1254
- }
1255
- };
1256
-
1257
- try {
1258
- while (true) {
1259
- if (totalTimeout.signal?.aborted) {
1260
- _clearSemanticIdle();
1261
- const reason = totalTimeout.signal.reason;
1262
- throw reason instanceof Error ? reason : new Error('OpenAI OAuth HTTP fallback aborted');
1263
- }
1264
- if (_streamAbortReason) throw _streamAbortReason;
1265
- const { value, done } = await reader.read();
1266
- if (done) break;
1267
- buffer += decoder.decode(value, { stream: true });
1268
- const parsed = _sseEventsFromBuffer(buffer);
1269
- buffer = parsed.rest;
1270
- for (const frame of parsed.frames) {
1271
- const event = _parseSseFrame(frame);
1272
- if (event) handleEvent(event);
1273
- }
1274
- }
1275
- // The read() above can unblock via reader.cancel() as {done:true} on an
1276
- // external/total-timeout abort. Surface that as the abort/timeout error
1277
- // instead of treating the partial stream as a successful response.
1278
- if (_streamAbortReason) throw _streamAbortReason;
1279
- buffer += decoder.decode();
1280
- const parsed = _sseEventsFromBuffer(buffer + '\n\n');
1281
- for (const frame of parsed.frames) {
1282
- const event = _parseSseFrame(frame);
1283
- if (event) handleEvent(event);
1284
- }
1285
- // Flush any partial-sentinel tail held back mid-stream so legitimate
1286
- // trailing text is never lost (streamed-text path).
1287
- flushLeak();
1288
- } catch (err) {
1289
- // Live-text invariant: once a non-empty chunk has been relayed it
1290
- // cannot be withdrawn — flag the error so no upstream layer retries.
1291
- if (emittedText && err) { try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {} }
1292
- throw err;
1293
- } finally {
1294
- _clearSemanticIdle();
1295
- try { reader.releaseLock?.(); } catch {}
1296
- if (_onTotalAbort && totalTimeout.signal) {
1297
- try { totalTimeout.signal.removeEventListener('abort', _onTotalAbort); } catch {}
1298
- }
1299
- totalTimeout.cleanup();
1300
- }
1301
-
1302
- const unresolved = toolCalls.find(t => t._pendingItemId);
1303
- if (unresolved) {
1304
- throw new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`);
1305
- }
1306
- if (!completed && !content && !toolCalls.length) {
1307
- throw new Error('OpenAI OAuth HTTP fallback ended before response.completed');
1308
- }
1309
-
1310
- const liveModel = model || useModel;
1311
- traceAgentSse({
1312
- sessionId: poolKey,
1313
- sseParseMs: Date.now() - sseStartedAt,
1314
- ttftMs,
1315
- provider: 'openai-oauth',
1316
- model: liveModel,
1317
- transport: 'sse',
1318
- });
1319
- if (usage) {
1320
- traceAgentUsage({
1321
- sessionId: poolKey,
1322
- iteration,
1323
- inputTokens: usage.inputTokens || 0,
1324
- outputTokens: usage.outputTokens || 0,
1325
- cachedTokens: usage.cachedTokens || 0,
1326
- promptTokens: usage.promptTokens || 0,
1327
- model: liveModel,
1328
- modelDisplay: _displayCodexModel(liveModel),
1329
- responseId: responseId || null,
1330
- rawUsage: usage.raw || null,
1331
- provider: 'openai-oauth',
1332
- serviceTier,
1333
- });
1334
- }
1335
- // Dedupe the returned array by name+args (Fix 2, array side): a synthetic
1336
- // leaked call and an identical native function_call must not both survive,
1337
- // else the agent loop executes the side-effecting tool twice.
1338
- const _returnedToolCalls = toolCalls.length
1339
- ? dedupeToolCallList(toolCalls.map(({ _pendingItemId, ...t }) => t))
1340
- : undefined;
1341
- return {
1342
- content,
1343
- model: liveModel,
1344
- reasoningItems: reasoningItems.length ? reasoningItems : undefined,
1345
- toolCalls: _returnedToolCalls,
1346
- citations: citations.length ? citations : undefined,
1347
- webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
1348
- usage: usage || undefined,
1349
- stopReason: stopReason || undefined,
1350
- // P1 audit fix: text-only max-output cutoff (openai-oauth HTTP/SSE
1351
- // fallback maps status:'incomplete'/reason=max_output_tokens to
1352
- // stopReason='length' above and treats it as success). Flag it so
1353
- // loop.mjs can surface a truncation warning instead of accepting
1354
- // silently-cut content as a clean final answer.
1355
- ...(stopReason === 'length' && content.length > 0 ? { truncated: true } : {}),
1356
- responseId: responseId || undefined,
1357
- serviceTier: serviceTier || undefined,
1358
- };
1359
- }
649
+ // --- HTTP/SSE fallback transport: extracted to openai-oauth-http-sse.mjs ---
1360
650
 
1361
651
  // --- Provider ---
1362
652
  export class OpenAIOAuthProvider {
@@ -1507,18 +797,12 @@ export class OpenAIOAuthProvider {
1507
797
  const _authP = this.ensureAuth();
1508
798
  let auth = await _authP;
1509
799
  const body = await _bodyP;
1510
- // poolKey cacheKey by design (see openai-oauth-ws.mjs header note).
1511
- // poolKey is per-session so parallel reviewer/worker callers each
1512
- // get their own socket bucket a sibling cannot grab a mid-turn
1513
- // entry and trip the backend's "No tool call found for function call
1514
- // output with call_id …" rejection. cacheKey is prefix-scoped
1515
- // (base namespace + model/system/tools hash) and feeds both
1516
- // `body.prompt_cache_key` and the handshake `session_id` header, so
1517
- // compatible prefixes share cache without main/worker lanes evicting
1518
- // each other.
1519
- // poolKey defaults to sessionId (per-session socket isolation); cacheKey
1520
- // never falls back to sessionId, so a fresh session still reuses the
1521
- // warm prefix cache for the same route/prefix.
800
+ // poolKey != cacheKey by design (see openai-oauth-ws.mjs header note).
801
+ // poolKey is per-session so parallel reviewer/worker callers each get
802
+ // their own socket bucket. cacheKey is the Codex-style prompt_cache_key:
803
+ // by default it is the session/thread identity (clamped to 64 chars) and
804
+ // feeds both `body.prompt_cache_key` and the OAuth WS handshake
805
+ // `session_id`, so each long-lived thread keeps a stable cache shard.
1522
806
  const poolKey = opts.sessionId || null;
1523
807
  const cacheKey = body.prompt_cache_key || resolveProviderCacheKey(opts, 'openai-oauth');
1524
808
  const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
@@ -1734,7 +1018,7 @@ export class OpenAIOAuthProvider {
1734
1018
  const items = Array.isArray(data?.models) ? data.models : [];
1735
1019
  const normalized = items.map(m => _normalizeCodexModel(m));
1736
1020
  _markLatestCodex(normalized);
1737
- const enriched = await enrichModels(normalized);
1021
+ const enriched = sanitizeModelList((await enrichModels(normalized)).filter(Boolean), { provider: 'openai-oauth' });
1738
1022
  await _saveCodexModelCache(enriched);
1739
1023
  _lastCodexListModelsError = '';
1740
1024
  return enriched;
@@ -1772,7 +1056,7 @@ export class OpenAIOAuthProvider {
1772
1056
  const items = Array.isArray(data?.models) ? data.models : [];
1773
1057
  const normalized = items.map(m => _normalizeCodexModel(m));
1774
1058
  _markLatestCodex(normalized);
1775
- const enriched = await enrichModels(normalized);
1059
+ const enriched = sanitizeModelList((await enrichModels(normalized)).filter(Boolean), { provider: 'openai-oauth' });
1776
1060
  await _saveCodexModelCache(enriched);
1777
1061
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(`[openai-oauth] catalog refreshed (${enriched.length} models)\n`);
1778
1062
  return enriched;
@@ -1791,168 +1075,12 @@ export class OpenAIOAuthProvider {
1791
1075
  }
1792
1076
  }
1793
1077
 
1794
- const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
1795
- const CODEX_OAUTH_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
1796
- const CALLBACK_HOST = '127.0.0.1';
1797
- const CALLBACK_PORT = 1455;
1798
- const CALLBACK_PATH = '/auth/callback';
1799
- const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
1800
- const LOGIN_TIMEOUT_MS = 5 * 60_000;
1801
- const TOKEN_TIMEOUT_MS = 30_000;
1802
-
1803
- function generatePKCE() {
1804
- const verifier = randomBytes(64).toString('base64url');
1805
- const challenge = createHash('sha256').update(verifier).digest('base64url');
1806
- return { verifier, challenge };
1807
- }
1808
-
1809
- function _scrubOAuthLoginBody(text) {
1810
- return String(text || '')
1811
- .replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token":"[REDACTED]"')
1812
- .replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"')
1813
- .replace(/"id_token"\s*:\s*"[^"]+"/g, '"id_token":"[REDACTED]"')
1814
- .replace(/[A-Za-z0-9_-]{32,}\.[A-Za-z0-9._-]+/g, '[REDACTED]');
1815
- }
1816
-
1817
- function _parseOAuthCodeInput(input) {
1818
- const value = String(input || '').trim();
1819
- if (!value) return { code: '', state: '' };
1820
- try {
1821
- const url = new URL(value);
1822
- const code = url.searchParams.get('code') || '';
1823
- const state = url.searchParams.get('state') || '';
1824
- if (code || state) return { code, state };
1825
- } catch { /* not a URL */ }
1826
- if (value.includes('#')) {
1827
- const [code, state] = value.split('#', 2);
1828
- return { code: String(code || '').trim(), state: String(state || '').trim() };
1829
- }
1830
- if (value.includes('code=')) {
1831
- const params = new URLSearchParams(value.startsWith('?') ? value.slice(1) : value);
1832
- return { code: params.get('code') || '', state: params.get('state') || '' };
1833
- }
1834
- return { code: value, state: '' };
1835
- }
1836
-
1837
- async function exchangeAuthorizationCode({ pkce, code }) {
1838
- const cleanCode = String(code || '').trim();
1839
- if (!cleanCode) throw new Error('[openai-oauth] authorization code is required');
1840
- const tokenRes = await fetch(TOKEN_URL, {
1841
- method: 'POST',
1842
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1843
- body: new URLSearchParams({
1844
- grant_type: 'authorization_code',
1845
- code: cleanCode,
1846
- redirect_uri: REDIRECT_URI,
1847
- client_id: CLIENT_ID,
1848
- code_verifier: pkce.verifier,
1849
- }),
1850
- redirect: 'error',
1851
- signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
1852
- });
1853
- if (!tokenRes.ok) {
1854
- const text = await tokenRes.text().catch(() => '');
1855
- throw new Error(`[openai-oauth] token exchange ${tokenRes.status}: ${_scrubOAuthLoginBody(text).slice(0, 500)}`);
1856
- }
1857
- const json = await tokenRes.json();
1858
- if (!json.access_token || !json.refresh_token) {
1859
- throw new Error('[openai-oauth] token exchange response missing access_token or refresh_token');
1860
- }
1861
- const expiresAt = (typeof json.expires_in === 'number'
1862
- ? Date.now() + json.expires_in * 1000
1863
- : 0) || _expiryFromAccessToken(json.access_token);
1864
- const tokens = {
1865
- access_token: json.access_token,
1866
- refresh_token: json.refresh_token,
1867
- expires_at: expiresAt,
1868
- account_id: extractAccountId(json.access_token),
1869
- };
1870
- saveTokens(tokens);
1871
- return tokens;
1872
- }
1873
-
1874
- export async function beginOAuthLogin() {
1875
- const pkce = generatePKCE();
1876
- const state = randomBytes(16).toString('hex');
1877
- const url = new URL(AUTHORIZE_URL);
1878
- url.searchParams.set('response_type', 'code');
1879
- url.searchParams.set('client_id', CLIENT_ID);
1880
- url.searchParams.set('redirect_uri', REDIRECT_URI);
1881
- url.searchParams.set('scope', CODEX_OAUTH_SCOPE);
1882
- url.searchParams.set('code_challenge', pkce.challenge);
1883
- url.searchParams.set('code_challenge_method', 'S256');
1884
- url.searchParams.set('id_token_add_organizations', 'true');
1885
- url.searchParams.set('codex_cli_simplified_flow', 'true');
1886
- url.searchParams.set('state', state);
1887
- url.searchParams.set('originator', CODEX_OAUTH_ORIGINATOR);
1888
-
1889
- let server = null;
1890
- let timeout = null;
1891
- let finish = null;
1892
- const waitForCallback = new Promise((resolve, reject) => {
1893
- let settled = false;
1894
- finish = (value, error = null) => {
1895
- if (settled) return;
1896
- settled = true;
1897
- if (timeout) clearTimeout(timeout);
1898
- try { server?.close(); } catch { /* already closed */ }
1899
- if (error) reject(error);
1900
- else resolve(value);
1901
- };
1902
- server = createServer(async (req, res) => {
1903
- const u = new URL(req.url || '/', `http://${CALLBACK_HOST}:${CALLBACK_PORT}`);
1904
- if (u.pathname !== CALLBACK_PATH) {
1905
- res.writeHead(404);
1906
- res.end();
1907
- return;
1908
- }
1909
- const code = u.searchParams.get('code');
1910
- if (!code || u.searchParams.get('state') !== state) {
1911
- res.writeHead(400);
1912
- res.end('Invalid');
1913
- finish(null);
1914
- return;
1915
- }
1916
- res.writeHead(200, { 'Content-Type': 'text/html' });
1917
- res.end('<html><body><h2>OpenAI OAuth login successful! You can close this tab.</h2></body></html>');
1918
- try {
1919
- const tokens = await exchangeAuthorizationCode({ pkce, code });
1920
- finish(tokens);
1921
- } catch (err) {
1922
- finish(null, err instanceof Error ? err : new Error(String(err)));
1923
- }
1924
- });
1925
- timeout = setTimeout(() => finish(null), LOGIN_TIMEOUT_MS);
1926
- server.listen(CALLBACK_PORT, CALLBACK_HOST, async () => {
1927
- process.stderr.write(`\n[openai-oauth] Open this URL to log in to ChatGPT (OpenAI OAuth):\n${url.toString()}\n\n`);
1928
- try {
1929
- const { openInBrowser } = await import('../../../shared/open-url.mjs');
1930
- openInBrowser(url.toString());
1931
- } catch (err) {
1932
- process.stderr.write(`[openai-oauth] browser open failed: ${String(err?.message || err).slice(0, 200)}\n`);
1933
- }
1934
- });
1935
- server.on('error', (err) => finish(null, new Error(`[openai-oauth] callback server failed on ${CALLBACK_HOST}:${CALLBACK_PORT}: ${err?.message || err}`)));
1936
- });
1937
-
1938
- return {
1939
- provider: 'openai-oauth',
1940
- url: url.toString(),
1941
- waitForCallback,
1942
- completeCode: async (input) => {
1943
- const parsed = _parseOAuthCodeInput(input);
1944
- if (parsed.state && parsed.state !== state) throw new Error('[openai-oauth] OAuth state mismatch');
1945
- const tokens = await exchangeAuthorizationCode({ pkce, code: parsed.code });
1946
- finish?.(tokens);
1947
- return tokens;
1948
- },
1949
- cancel: () => {
1950
- finish?.(null);
1951
- },
1952
- };
1953
- }
1954
-
1955
- export async function loginOAuth() {
1956
- const login = await beginOAuthLogin();
1957
- return await login.waitForCallback;
1958
- }
1078
+ // --- OAuth PKCE login flow: extracted to openai-oauth-login.mjs ---
1079
+ const { beginOAuthLogin, loginOAuth } = createOpenAIOAuthLogin({
1080
+ clientId: CLIENT_ID,
1081
+ originator: CODEX_OAUTH_ORIGINATOR,
1082
+ extractAccountId,
1083
+ expiryFromAccessToken: _expiryFromAccessToken,
1084
+ saveTokens,
1085
+ });
1086
+ export { beginOAuthLogin, loginOAuth };