@iislee/opencodex 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/assets/architecture.png +0 -0
  4. package/assets/banner.png +0 -0
  5. package/assets/claude-code-models.gif +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +451 -0
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-DTpMHS4F.js +67 -0
  10. package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
  11. package/gui/dist/favicon.png +0 -0
  12. package/gui/dist/icons.svg +24 -0
  13. package/gui/dist/index.html +25 -0
  14. package/gui/dist/logo.png +0 -0
  15. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  16. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/claude.svg +1 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/cursor.svg +2 -0
  25. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  26. package/gui/dist/provider-icons/discord.svg +1 -0
  27. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  28. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  29. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok-color.svg +1 -0
  34. package/gui/dist/provider-icons/grok.svg +1 -0
  35. package/gui/dist/provider-icons/groq-color.svg +1 -0
  36. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  37. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  38. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  39. package/gui/dist/provider-icons/kiro.svg +14 -0
  40. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  41. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  42. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  43. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  44. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  45. package/gui/dist/provider-icons/openai.svg +1 -0
  46. package/gui/dist/provider-icons/opencode.svg +1 -0
  47. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  48. package/gui/dist/provider-icons/pi.svg +21 -0
  49. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  50. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  51. package/gui/dist/provider-icons/telegram.svg +1 -0
  52. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  53. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  54. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  55. package/package.json +102 -0
  56. package/src/AGENTS.md +28 -0
  57. package/src/adapters/anthropic-image-guard.ts +251 -0
  58. package/src/adapters/anthropic-image-normalize.ts +518 -0
  59. package/src/adapters/anthropic.ts +1003 -0
  60. package/src/adapters/azure.ts +36 -0
  61. package/src/adapters/base.ts +72 -0
  62. package/src/adapters/client-fingerprint.ts +59 -0
  63. package/src/adapters/cursor/arg-codec.ts +38 -0
  64. package/src/adapters/cursor/arg-normalize.ts +104 -0
  65. package/src/adapters/cursor/cursor-errors.ts +165 -0
  66. package/src/adapters/cursor/discovery.ts +276 -0
  67. package/src/adapters/cursor/effort-map.ts +127 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +211 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/kv-store.ts +52 -0
  72. package/src/adapters/cursor/live-models.ts +153 -0
  73. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  74. package/src/adapters/cursor/live-transport.ts +1214 -0
  75. package/src/adapters/cursor/mcp-config.ts +42 -0
  76. package/src/adapters/cursor/mcp-manager.ts +333 -0
  77. package/src/adapters/cursor/message-mapper.ts +49 -0
  78. package/src/adapters/cursor/native-exec-common.ts +55 -0
  79. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  80. package/src/adapters/cursor/native-exec-fs.ts +329 -0
  81. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  82. package/src/adapters/cursor/native-exec-network.ts +43 -0
  83. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  84. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  85. package/src/adapters/cursor/native-exec.ts +576 -0
  86. package/src/adapters/cursor/protobuf-events.ts +563 -0
  87. package/src/adapters/cursor/protobuf-request.ts +714 -0
  88. package/src/adapters/cursor/request-builder.ts +255 -0
  89. package/src/adapters/cursor/thread-continuity.ts +67 -0
  90. package/src/adapters/cursor/tool-definitions.ts +505 -0
  91. package/src/adapters/cursor/transport-retry.ts +132 -0
  92. package/src/adapters/cursor/transport.ts +57 -0
  93. package/src/adapters/cursor/types.ts +52 -0
  94. package/src/adapters/cursor.ts +196 -0
  95. package/src/adapters/google-antigravity-replay.ts +303 -0
  96. package/src/adapters/google-antigravity-wire.ts +108 -0
  97. package/src/adapters/google-errors.ts +85 -0
  98. package/src/adapters/google-http.ts +100 -0
  99. package/src/adapters/google-tool-schema.ts +173 -0
  100. package/src/adapters/google-truncation.ts +13 -0
  101. package/src/adapters/google-wire-compiler.ts +232 -0
  102. package/src/adapters/google.ts +758 -0
  103. package/src/adapters/identity.ts +44 -0
  104. package/src/adapters/image.ts +23 -0
  105. package/src/adapters/kiro-constants.ts +16 -0
  106. package/src/adapters/kiro-errors.ts +197 -0
  107. package/src/adapters/kiro-events.ts +179 -0
  108. package/src/adapters/kiro-images.ts +129 -0
  109. package/src/adapters/kiro-retry.ts +312 -0
  110. package/src/adapters/kiro-thinking.ts +96 -0
  111. package/src/adapters/kiro-tool-fallback.ts +36 -0
  112. package/src/adapters/kiro-tools.ts +215 -0
  113. package/src/adapters/kiro-truncation.ts +33 -0
  114. package/src/adapters/kiro-wire.ts +129 -0
  115. package/src/adapters/kiro.ts +1898 -0
  116. package/src/adapters/mimo-free.ts +263 -0
  117. package/src/adapters/openai-chat.ts +1005 -0
  118. package/src/adapters/openai-responses.ts +1137 -0
  119. package/src/adapters/run-turn-queue.ts +114 -0
  120. package/src/adapters/tool-catalog-nudge.ts +71 -0
  121. package/src/adapters/upstream-http-error.ts +48 -0
  122. package/src/bridge.ts +1619 -0
  123. package/src/chat/inbound.ts +295 -0
  124. package/src/chat/outbound.ts +765 -0
  125. package/src/claude/agents-inject.ts +243 -0
  126. package/src/claude/alias.ts +149 -0
  127. package/src/claude/auth-detect.ts +229 -0
  128. package/src/claude/auth-mode-migration.ts +32 -0
  129. package/src/claude/auth-mode.ts +62 -0
  130. package/src/claude/context-windows.ts +189 -0
  131. package/src/claude/desktop-3p-guard.ts +35 -0
  132. package/src/claude/desktop-3p-paths.ts +84 -0
  133. package/src/claude/desktop-3p.ts +381 -0
  134. package/src/claude/desktop-health.ts +26 -0
  135. package/src/claude/desktop-profile.ts +263 -0
  136. package/src/claude/gateway-cache.ts +70 -0
  137. package/src/claude/inbound-debug.ts +163 -0
  138. package/src/claude/inbound.ts +509 -0
  139. package/src/claude/model-info.ts +151 -0
  140. package/src/claude/outbound.ts +872 -0
  141. package/src/cli/access.ts +108 -0
  142. package/src/cli/account-api.ts +268 -0
  143. package/src/cli/account-auth.ts +223 -0
  144. package/src/cli/account-extended.ts +350 -0
  145. package/src/cli/account.ts +275 -0
  146. package/src/cli/agent-driven.ts +70 -0
  147. package/src/cli/agent.ts +184 -0
  148. package/src/cli/catalog-prewarm.ts +27 -0
  149. package/src/cli/claude-desktop.ts +188 -0
  150. package/src/cli/claude.ts +286 -0
  151. package/src/cli/codex-shim-autorestore.ts +45 -0
  152. package/src/cli/combo.ts +119 -0
  153. package/src/cli/config-command.ts +145 -0
  154. package/src/cli/debug.ts +228 -0
  155. package/src/cli/doctor.ts +930 -0
  156. package/src/cli/export-command.ts +187 -0
  157. package/src/cli/help.ts +354 -0
  158. package/src/cli/index.ts +1113 -0
  159. package/src/cli/init.ts +224 -0
  160. package/src/cli/integrations.ts +142 -0
  161. package/src/cli/interactive-confirm.ts +133 -0
  162. package/src/cli/internal-dispatch.ts +20 -0
  163. package/src/cli/models-runtime.ts +212 -0
  164. package/src/cli/models.ts +336 -0
  165. package/src/cli/observe.ts +117 -0
  166. package/src/cli/opencode.ts +586 -0
  167. package/src/cli/pi.ts +188 -0
  168. package/src/cli/provider-runtime.ts +162 -0
  169. package/src/cli/provider.ts +463 -0
  170. package/src/cli/runtime-api.ts +325 -0
  171. package/src/cli/star-prompt.ts +155 -0
  172. package/src/cli/status-oauth.ts +78 -0
  173. package/src/cli/status.ts +321 -0
  174. package/src/cli/sync-cloud.ts +283 -0
  175. package/src/cli/system-command.ts +112 -0
  176. package/src/cli/tray-proxy.ts +52 -0
  177. package/src/cli/v2.ts +173 -0
  178. package/src/cli.ts +10 -0
  179. package/src/clients/config-export.ts +377 -0
  180. package/src/clients/effective-status.ts +385 -0
  181. package/src/clients/probes/agy.ts +55 -0
  182. package/src/clients/probes/cc-switch.ts +110 -0
  183. package/src/clients/probes/claude.ts +90 -0
  184. package/src/clients/probes/codex.ts +125 -0
  185. package/src/clients/probes/grok.ts +29 -0
  186. package/src/clients/probes/opencode.ts +109 -0
  187. package/src/clients/probes/paseo.ts +55 -0
  188. package/src/clients/probes/pi.ts +55 -0
  189. package/src/cloud/onedrive-auth.ts +666 -0
  190. package/src/cloud/onedrive-graph.ts +108 -0
  191. package/src/cloud/settings.ts +75 -0
  192. package/src/cloud/sync.ts +212 -0
  193. package/src/cloud/types.ts +56 -0
  194. package/src/cloud/vault.ts +89 -0
  195. package/src/codex/account-id.ts +34 -0
  196. package/src/codex/account-label.ts +34 -0
  197. package/src/codex/account-lifecycle.ts +55 -0
  198. package/src/codex/account-namespace-match.ts +63 -0
  199. package/src/codex/account-namespaces.ts +149 -0
  200. package/src/codex/account-pause.ts +20 -0
  201. package/src/codex/account-runtime-state.ts +31 -0
  202. package/src/codex/account-store.ts +517 -0
  203. package/src/codex/account-usability.ts +20 -0
  204. package/src/codex/app-server-processes.ts +756 -0
  205. package/src/codex/auth-api.ts +1540 -0
  206. package/src/codex/auth-collision.ts +107 -0
  207. package/src/codex/auth-context.ts +352 -0
  208. package/src/codex/autostart-health.ts +149 -0
  209. package/src/codex/catalog/aggregation.ts +378 -0
  210. package/src/codex/catalog/bundled.ts +251 -0
  211. package/src/codex/catalog/effort.ts +355 -0
  212. package/src/codex/catalog/metadata.ts +180 -0
  213. package/src/codex/catalog/parsing.ts +456 -0
  214. package/src/codex/catalog/provider-fetch.ts +902 -0
  215. package/src/codex/catalog/sync.ts +620 -0
  216. package/src/codex/catalog.ts +12 -0
  217. package/src/codex/data/upstream-models.json +830 -0
  218. package/src/codex/exec-invocation.ts +22 -0
  219. package/src/codex/features.ts +969 -0
  220. package/src/codex/history-migration-guardian.ts +102 -0
  221. package/src/codex/history-provider.ts +776 -0
  222. package/src/codex/home.ts +206 -0
  223. package/src/codex/inject.ts +799 -0
  224. package/src/codex/injected-marker.ts +72 -0
  225. package/src/codex/journal.ts +163 -0
  226. package/src/codex/main-account-cache.ts +32 -0
  227. package/src/codex/main-account.ts +40 -0
  228. package/src/codex/model-cache.ts +227 -0
  229. package/src/codex/paths.ts +65 -0
  230. package/src/codex/plugins-doctor.ts +242 -0
  231. package/src/codex/pool-rotation.ts +225 -0
  232. package/src/codex/project-config-warnings.ts +411 -0
  233. package/src/codex/quota.ts +411 -0
  234. package/src/codex/refresh.ts +53 -0
  235. package/src/codex/routing.ts +1477 -0
  236. package/src/codex/runtime.ts +538 -0
  237. package/src/codex/shim.ts +1189 -0
  238. package/src/codex/subagent-defaults.ts +550 -0
  239. package/src/codex/subagent-model-fallback.ts +469 -0
  240. package/src/codex/sync.ts +130 -0
  241. package/src/codex/warmup.ts +192 -0
  242. package/src/codex/websocket-registry.ts +100 -0
  243. package/src/combos/failover.ts +140 -0
  244. package/src/combos/index.ts +41 -0
  245. package/src/combos/request.ts +62 -0
  246. package/src/combos/resolve.ts +232 -0
  247. package/src/combos/types.ts +326 -0
  248. package/src/config.ts +2356 -0
  249. package/src/generated/jawcode-model-metadata.ts +104 -0
  250. package/src/github/star-state.ts +203 -0
  251. package/src/grok/inject.ts +545 -0
  252. package/src/grok/status.ts +121 -0
  253. package/src/grok/sync.ts +103 -0
  254. package/src/grok/usage-hook/report.mjs +348 -0
  255. package/src/grok/usage-hook.ts +278 -0
  256. package/src/images/artifacts.ts +516 -0
  257. package/src/images/fulfill-video.ts +163 -0
  258. package/src/images/fulfill.ts +149 -0
  259. package/src/images/index.ts +4 -0
  260. package/src/images/loop.ts +829 -0
  261. package/src/images/plan.ts +133 -0
  262. package/src/images/synthetic-tool.ts +133 -0
  263. package/src/images/types.ts +41 -0
  264. package/src/images/xai-client.ts +141 -0
  265. package/src/images/xai-video-client.ts +163 -0
  266. package/src/index.ts +22 -0
  267. package/src/lib/abort.ts +146 -0
  268. package/src/lib/admin-secrets.ts +25 -0
  269. package/src/lib/admission.ts +83 -0
  270. package/src/lib/app-owned-memory-stores.ts +173 -0
  271. package/src/lib/app-owned-memory.ts +265 -0
  272. package/src/lib/bounded-body.ts +202 -0
  273. package/src/lib/bun-binary-validator.d.mts +3 -0
  274. package/src/lib/bun-binary-validator.mjs +18 -0
  275. package/src/lib/bun-runtime.ts +71 -0
  276. package/src/lib/bun-stream-caps.ts +126 -0
  277. package/src/lib/config-ownership.ts +360 -0
  278. package/src/lib/crash-guard.ts +344 -0
  279. package/src/lib/debug-log-buffer.ts +83 -0
  280. package/src/lib/debug-settings.ts +108 -0
  281. package/src/lib/debug.ts +31 -0
  282. package/src/lib/destination-policy.ts +316 -0
  283. package/src/lib/errors.ts +364 -0
  284. package/src/lib/eventstream-decoder.ts +253 -0
  285. package/src/lib/gcp-adc.ts +341 -0
  286. package/src/lib/injection-debug-log.ts +58 -0
  287. package/src/lib/open-url.ts +25 -0
  288. package/src/lib/pinned-http.ts +151 -0
  289. package/src/lib/privacy.ts +20 -0
  290. package/src/lib/process-control.ts +165 -0
  291. package/src/lib/provider-outbound.ts +170 -0
  292. package/src/lib/provider-url.ts +14 -0
  293. package/src/lib/proxy-env.ts +18 -0
  294. package/src/lib/redact.ts +105 -0
  295. package/src/lib/retry-after.ts +55 -0
  296. package/src/lib/service-secrets.ts +25 -0
  297. package/src/lib/shadow-call.ts +30 -0
  298. package/src/lib/sidecar-tracker.ts +52 -0
  299. package/src/lib/sse-decoder.ts +323 -0
  300. package/src/lib/state-store-registrations.ts +109 -0
  301. package/src/lib/state-store-sweeper.ts +184 -0
  302. package/src/lib/test-home-guard.ts +90 -0
  303. package/src/lib/token-estimate.ts +69 -0
  304. package/src/lib/translator-budget.ts +356 -0
  305. package/src/lib/upstream-retry.ts +239 -0
  306. package/src/lib/win-exec.ts +115 -0
  307. package/src/lib/win-paths.ts +68 -0
  308. package/src/lib/windows-elevation.ts +705 -0
  309. package/src/lib/windows-secret-acl.ts +514 -0
  310. package/src/lib/winsw.ts +375 -0
  311. package/src/oauth/anthropic-routing.ts +594 -0
  312. package/src/oauth/anthropic.ts +177 -0
  313. package/src/oauth/callback-server.ts +294 -0
  314. package/src/oauth/chatgpt.ts +150 -0
  315. package/src/oauth/cursor.ts +211 -0
  316. package/src/oauth/github-copilot.ts +428 -0
  317. package/src/oauth/google-antigravity.ts +230 -0
  318. package/src/oauth/health.ts +399 -0
  319. package/src/oauth/index.ts +1174 -0
  320. package/src/oauth/key-providers.ts +108 -0
  321. package/src/oauth/kimi.ts +213 -0
  322. package/src/oauth/kiro-credentials.ts +726 -0
  323. package/src/oauth/kiro.ts +577 -0
  324. package/src/oauth/local-token-detect.ts +121 -0
  325. package/src/oauth/log.ts +48 -0
  326. package/src/oauth/login-cli.ts +163 -0
  327. package/src/oauth/pkce.ts +15 -0
  328. package/src/oauth/store.ts +630 -0
  329. package/src/oauth/token-guardian.ts +303 -0
  330. package/src/oauth/types.ts +62 -0
  331. package/src/oauth/xai.ts +241 -0
  332. package/src/pi/extensions.ts +72 -0
  333. package/src/pi/home.ts +42 -0
  334. package/src/pi/index.ts +40 -0
  335. package/src/pi/models.ts +278 -0
  336. package/src/pi/packages.ts +219 -0
  337. package/src/pi/settings.ts +365 -0
  338. package/src/pi/status.ts +68 -0
  339. package/src/pi/sync.ts +75 -0
  340. package/src/providers/alibaba-region-backup.ts +75 -0
  341. package/src/providers/alibaba-region-migration.ts +156 -0
  342. package/src/providers/alibaba-region-startup.ts +36 -0
  343. package/src/providers/antigravity-models.ts +205 -0
  344. package/src/providers/api-keys.ts +140 -0
  345. package/src/providers/base-url-choices.ts +64 -0
  346. package/src/providers/context-cap.ts +65 -0
  347. package/src/providers/derive.ts +339 -0
  348. package/src/providers/free-directory.ts +184 -0
  349. package/src/providers/github-copilot-transport.ts +56 -0
  350. package/src/providers/key-failover.ts +203 -0
  351. package/src/providers/kiro-models.ts +67 -0
  352. package/src/providers/label.ts +19 -0
  353. package/src/providers/model-discovery.ts +356 -0
  354. package/src/providers/openai-sidecar.ts +175 -0
  355. package/src/providers/openai-tier-startup.ts +27 -0
  356. package/src/providers/openai-tiers.ts +301 -0
  357. package/src/providers/openai-virtual-models.ts +82 -0
  358. package/src/providers/openrouter-routing.ts +102 -0
  359. package/src/providers/provider-id-rewrite.ts +150 -0
  360. package/src/providers/quota.ts +1260 -0
  361. package/src/providers/registry.ts +1600 -0
  362. package/src/providers/slug-codec.ts +67 -0
  363. package/src/providers/xai-transport.ts +141 -0
  364. package/src/reasoning-effort.ts +135 -0
  365. package/src/responses/compaction.ts +117 -0
  366. package/src/responses/parser.ts +656 -0
  367. package/src/responses/reasoning-envelope.ts +52 -0
  368. package/src/responses/schema.ts +159 -0
  369. package/src/responses/spill-store.ts +394 -0
  370. package/src/responses/state.ts +895 -0
  371. package/src/responses/tool-groups.ts +19 -0
  372. package/src/router.ts +425 -0
  373. package/src/server/adapter-resolve.ts +80 -0
  374. package/src/server/auth-cors.ts +530 -0
  375. package/src/server/chat-completions.ts +368 -0
  376. package/src/server/claude-messages.ts +914 -0
  377. package/src/server/effort-policy.ts +172 -0
  378. package/src/server/gui-static.ts +123 -0
  379. package/src/server/image-retry.ts +42 -0
  380. package/src/server/images.ts +476 -0
  381. package/src/server/index.ts +1126 -0
  382. package/src/server/lifecycle.ts +227 -0
  383. package/src/server/live.ts +598 -0
  384. package/src/server/management/agent-settings-routes.ts +1169 -0
  385. package/src/server/management/api-access.ts +141 -0
  386. package/src/server/management/api-key-usage.ts +167 -0
  387. package/src/server/management/body.ts +35 -0
  388. package/src/server/management/clients-routes.ts +63 -0
  389. package/src/server/management/cloud-sync-routes.ts +266 -0
  390. package/src/server/management/combo-routes.ts +220 -0
  391. package/src/server/management/config-routes.ts +422 -0
  392. package/src/server/management/context.ts +31 -0
  393. package/src/server/management/logs-usage-routes.ts +707 -0
  394. package/src/server/management/model-routes.ts +525 -0
  395. package/src/server/management/oauth-account-routes.ts +563 -0
  396. package/src/server/management/provider-routes.ts +556 -0
  397. package/src/server/management/shared.ts +277 -0
  398. package/src/server/management/sidebar-routes.ts +90 -0
  399. package/src/server/management/system-restart.ts +179 -0
  400. package/src/server/management/system-routes.ts +117 -0
  401. package/src/server/management/usage-summary-cache.ts +86 -0
  402. package/src/server/management-api.ts +215 -0
  403. package/src/server/management-auth.ts +267 -0
  404. package/src/server/memory-watchdog.ts +156 -0
  405. package/src/server/port-reclaim.ts +307 -0
  406. package/src/server/ports.ts +116 -0
  407. package/src/server/proxy-liveness.ts +201 -0
  408. package/src/server/relay-eager.ts +313 -0
  409. package/src/server/relay.ts +1049 -0
  410. package/src/server/request-decompress.ts +132 -0
  411. package/src/server/request-log-conversation.ts +168 -0
  412. package/src/server/request-log.ts +1046 -0
  413. package/src/server/responses/collaboration.ts +354 -0
  414. package/src/server/responses/compact.ts +384 -0
  415. package/src/server/responses/core.ts +2758 -0
  416. package/src/server/responses/encrypted-payload.ts +308 -0
  417. package/src/server/responses/fetch-helpers.ts +157 -0
  418. package/src/server/responses/passthrough-error.ts +78 -0
  419. package/src/server/responses/terminal-guard.ts +230 -0
  420. package/src/server/responses/upstream-error.ts +48 -0
  421. package/src/server/responses-image-gen-repair.ts +132 -0
  422. package/src/server/responses-item-id-repair.ts +224 -0
  423. package/src/server/responses.ts +9 -0
  424. package/src/server/search.ts +136 -0
  425. package/src/server/sse-payload-rewrite.ts +175 -0
  426. package/src/server/startup-action-control.ts +308 -0
  427. package/src/server/startup-health-cache.ts +113 -0
  428. package/src/server/system-env.ts +413 -0
  429. package/src/server/windows-tcp-drop.ts +184 -0
  430. package/src/server/windows-tray-control.ts +41 -0
  431. package/src/server/ws-bridge.ts +471 -0
  432. package/src/service.ts +2554 -0
  433. package/src/stall-timeout.ts +20 -0
  434. package/src/storage/cleanup-job.ts +57 -0
  435. package/src/storage/cleanup.ts +3085 -0
  436. package/src/storage/policy-job.ts +457 -0
  437. package/src/storage/policy-scheduler.ts +40 -0
  438. package/src/storage/policy-worker.ts +59 -0
  439. package/src/storage/policy.ts +527 -0
  440. package/src/storage/restore-job.ts +299 -0
  441. package/src/storage/restore-worker.ts +58 -0
  442. package/src/storage/scanner.ts +238 -0
  443. package/src/storage/storage-mutation-coordinator.ts +139 -0
  444. package/src/storage/worker-lifecycle.ts +215 -0
  445. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  446. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  447. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  448. package/src/tray/assets/opencodex-tray.png +0 -0
  449. package/src/tray/windows-tray.ps1 +290 -0
  450. package/src/tray/windows.ts +730 -0
  451. package/src/types.ts +1237 -0
  452. package/src/update/badge.ts +72 -0
  453. package/src/update/index.ts +407 -0
  454. package/src/update/job.ts +1520 -0
  455. package/src/update/notify.ts +257 -0
  456. package/src/update/npm-invocation.d.mts +23 -0
  457. package/src/update/npm-invocation.mjs +94 -0
  458. package/src/update/tray-update-plan.d.mts +18 -0
  459. package/src/update/tray-update-plan.mjs +38 -0
  460. package/src/usage/cost.ts +0 -0
  461. package/src/usage/debug.ts +97 -0
  462. package/src/usage/expected-prices.ts +164 -0
  463. package/src/usage/log.ts +658 -0
  464. package/src/usage/summary.ts +585 -0
  465. package/src/usage/totals.ts +14 -0
  466. package/src/vision/anthropic-describe.ts +185 -0
  467. package/src/vision/describe.ts +125 -0
  468. package/src/vision/index.ts +467 -0
  469. package/src/web-search/anthropic-executor.ts +189 -0
  470. package/src/web-search/executor.ts +105 -0
  471. package/src/web-search/format-result.ts +89 -0
  472. package/src/web-search/index.ts +196 -0
  473. package/src/web-search/loop.ts +664 -0
  474. package/src/web-search/parse.ts +220 -0
  475. package/src/web-search/progress-stream.ts +342 -0
  476. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,1126 @@
1
+ import { markActivity } from "../lib/sidecar-tracker";
2
+ import {
3
+ buildWarmupCompletionFrames,
4
+ buildWsErrorFrame,
5
+ selectForwardHeaders,
6
+ sendJsonFrame,
7
+ buildResponsesWsData,
8
+ sendResponseToWebSocket,
9
+ sendTextFrame,
10
+ type WsData,
11
+ } from "./ws-bridge";
12
+ import type { Server, ServerWebSocket } from "bun";
13
+ import {
14
+ DEFAULT_SUBAGENT_MODELS,
15
+ applyProxyEnv,
16
+ armClaudeCodeBaseline,
17
+ loadConfig,
18
+ saveConfig,
19
+ websocketsEnabled,
20
+ } from "../config";
21
+ import { reconcileOAuthProviders } from "../oauth";
22
+ import { invalidateCodexModelsCache } from "../codex/catalog";
23
+ import { startMemoryWatchdog } from "./memory-watchdog";
24
+ import {
25
+ reconcileLiveStateStores,
26
+ setLiveStateStoreConfig,
27
+ } from "../lib/state-store-registrations";
28
+ import { startStateStoreSweeper } from "../lib/state-store-sweeper";
29
+ import {
30
+ configureAppOwnedMemoryBudget,
31
+ enforceAppOwnedMemoryBudget,
32
+ resolveAppOwnedMemoryBudgetBytes,
33
+ } from "../lib/app-owned-memory";
34
+ import {
35
+ registerAppOwnedMemorySweepFallback,
36
+ registerDefaultAppOwnedMemoryStores,
37
+ registerDefaultAppOwnedObservedBuffers,
38
+ } from "../lib/app-owned-memory-stores";
39
+ import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
40
+ import { setStorageCleanupPolicyJobLiveApply } from "../storage/policy-job";
41
+ import { scheduleStorageCleanupStartupRun, startStorageCleanupScheduler } from "../storage/policy-scheduler";
42
+ import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
43
+ import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup";
44
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
45
+ import { providerCodexAccountMode } from "../providers/registry";
46
+ import type { StorageCleanupPolicy } from "../types";
47
+ import {
48
+ CodexAccountCooldownError,
49
+ cooldownErrorMessage,
50
+ } from "../codex/auth-context";
51
+ export {
52
+ clearThreadAccountMap,
53
+ formatCodexProviderForLog,
54
+ resolveCodexAccountForThread,
55
+ } from "../codex/routing";
56
+ import { formatCodexProviderForLog } from "../codex/routing";
57
+ import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
58
+ import { registerCodexWebSocket, tryReserveCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "../codex/websocket-registry";
59
+ import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile } from "./gui-static";
60
+ export { resolveGuiFilePath, rootFallbackPayload } from "./gui-static";
61
+ export { resolveAdapter } from "./adapter-resolve";
62
+ import { formatErrorResponse, type ResponsesTerminalStatus } from "../bridge";
63
+ import {
64
+ drainAndShutdown,
65
+ getActiveTurnCount,
66
+ isDraining,
67
+ registerTurn,
68
+ setServerRef,
69
+ trackStreamLifetime,
70
+ tryAdmitTurn,
71
+ unregisterTurn,
72
+ type ActiveTurnLease,
73
+ } from "./lifecycle";
74
+ export {
75
+ drainAndShutdown,
76
+ getActiveTurnCount,
77
+ isDraining,
78
+ isRecyclingForExit,
79
+ markRecyclingForExit,
80
+ registerTurn,
81
+ trackStreamLifetime,
82
+ unregisterTurn,
83
+ } from "./lifecycle";
84
+ import {
85
+ addFinalRequestLog,
86
+ hydrateRequestLogsFromDisk,
87
+ httpStatusForRequestLogTerminal,
88
+ httpStatusForTerminalStatus,
89
+ inspectResponseLogSsePayload,
90
+ nextRequestLogId,
91
+ recordFirstOutput,
92
+ type RequestLogContext,
93
+ type RequestLogEntry,
94
+ } from "./request-log";
95
+ export {
96
+ addFinalRequestLog,
97
+ filterRequestLogs,
98
+ hydrateRequestLogsFromDisk,
99
+ httpStatusForTerminalStatus,
100
+ httpStatusFromTerminalError,
101
+ nextRequestLogId,
102
+ requestLogErrorCode,
103
+ requestLogSpeedLabel,
104
+ usageFromResponsesPayload,
105
+ type RequestLogContext,
106
+ type RequestLogEntry,
107
+ } from "./request-log";
108
+ import {
109
+ consumeForInspection,
110
+ relaySseWithHeartbeat,
111
+ relayWithAbort,
112
+ responseWithDeferredRequestLog,
113
+ sanitizePassthroughHeaders,
114
+ } from "./relay";
115
+ export {
116
+ consumeForInspection,
117
+ relaySseWithFailedTail,
118
+ relaySseWithHeartbeat,
119
+ relayWithAbort,
120
+ responseWithDeferredRequestLog,
121
+ sanitizePassthroughHeaders,
122
+ } from "./relay";
123
+ import {
124
+ assertServerAuthConfig,
125
+ corsHeaders,
126
+ managementCorsHeaders,
127
+ isAllowedRequestOrigin,
128
+ isAllowedManagementOrigin,
129
+ isApiAuthRequired,
130
+ isLoopbackHostname,
131
+ jsonResponse,
132
+ admissionFields,
133
+ resolveApiAuth,
134
+ resolveResponsesApiAuth,
135
+ safeConfigDTO,
136
+ setCorsOrigin,
137
+ withCors,
138
+ withManagementCors,
139
+ } from "./auth-cors";
140
+ export {
141
+ assertServerAuthConfig,
142
+ corsHeaders,
143
+ hasValidApiAuth,
144
+ isApiAuthRequired,
145
+ isLoopbackHostname,
146
+ jsonResponse,
147
+ safeConfigDTO,
148
+ } from "./auth-cors";
149
+ import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
150
+ export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
151
+ import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages";
152
+ import { handleChatCompletions } from "./chat-completions";
153
+ import { anthropicErrorResponse } from "../claude/outbound";
154
+ import { buildDesktop3pRegistry } from "../claude/desktop-3p";
155
+ import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration";
156
+ import { handleImages } from "./images";
157
+ import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
158
+ import { handleSearch } from "./search";
159
+ import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
160
+ import { initializeManagementAuthState, issueGuiSession, requireManagementAuth } from "./management-auth";
161
+
162
+ const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
163
+ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
164
+ const LIVE_SIDEBAND_PENDING_MAX = 32;
165
+
166
+ function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""): void {
167
+ try {
168
+ ws.data.liveUpstream?.close(code, reason);
169
+ } catch {
170
+ /* upstream already gone */
171
+ }
172
+ ws.data.liveUpstream = undefined;
173
+ ws.data.livePending = undefined;
174
+ try {
175
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
176
+ ws.close(code, reason);
177
+ }
178
+ } catch {
179
+ /* client already gone */
180
+ }
181
+ }
182
+
183
+ function attachLiveSidebandUpstream(ws: ServerWebSocket<WsData>): void {
184
+ const url = ws.data.liveUpstreamUrl;
185
+ if (!url) {
186
+ closeLiveSideband(ws, 1011, "missing upstream");
187
+ return;
188
+ }
189
+ let upstream: WebSocket;
190
+ try {
191
+ // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
192
+ upstream = new WebSocket(url, { headers: ws.data.liveUpstreamHeaders ?? {} } as unknown as string[]);
193
+ } catch {
194
+ closeLiveSideband(ws, 1011, "upstream connect failed");
195
+ return;
196
+ }
197
+ ws.data.liveUpstream = upstream;
198
+ ws.data.cancel = () => {
199
+ try {
200
+ upstream.close(1000, "client closed");
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ };
205
+
206
+ upstream.addEventListener("open", () => {
207
+ ws.data.liveOpened = true;
208
+ const pending = ws.data.livePending ?? [];
209
+ ws.data.livePending = undefined;
210
+ for (const frame of pending) {
211
+ try {
212
+ upstream.send(frame);
213
+ } catch {
214
+ closeLiveSideband(ws, 1011, "upstream send failed");
215
+ return;
216
+ }
217
+ }
218
+ });
219
+ upstream.addEventListener("message", (event) => {
220
+ try {
221
+ logLiveSidebandFrame("u2c", event.data);
222
+ if (typeof event.data === "string") ws.send(event.data);
223
+ else if (event.data instanceof ArrayBuffer) ws.send(event.data);
224
+ else if (ArrayBuffer.isView(event.data)) {
225
+ ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
226
+ } else ws.send(event.data as Buffer);
227
+ } catch {
228
+ closeLiveSideband(ws, 1011, "client send failed");
229
+ }
230
+ });
231
+ upstream.addEventListener("close", (event) => {
232
+ try {
233
+ ws.close(event.code || 1000, event.reason || "");
234
+ } catch {
235
+ /* ignore */
236
+ }
237
+ ws.data.liveUpstream = undefined;
238
+ });
239
+ upstream.addEventListener("error", () => {
240
+ closeLiveSideband(ws, 1011, "upstream error");
241
+ });
242
+ }
243
+
244
+ // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
245
+ // "../src/server" import surface stable for tests/callers.
246
+
247
+ // Adapter resolution + wire-protocol override extracted to ./server/adapter-resolve.
248
+
249
+ // Source invariant for tests/passthrough-abort.test.ts after the pure module split:
250
+ // if (isEventStream && upstreamResponse.body) {
251
+ // const repairConfig = route.provider.responsesItemIdRepair;
252
+ // const needsClientRewrite = imageGenCallAliases.size > 0
253
+ // #314 gated shape: win32 no-rewrite traffic follows runtime/config policy; darwin no-rewrite
254
+ // traffic requires explicit config-eager opt-in (`auto` always stays tee on darwin). Default OFF
255
+ // on the bundled known-bad runtime; policy lives in 260731_macos_rss_retention phase 100.
256
+ // selectEagerPath(process.platform, needsClientRewrite, config.streamMode ?? "auto")
257
+ // relaySseEagerBounded(upstreamResponse.body, turnAc,
258
+ // new Response(eagerBody,
259
+ // Default shape (tee + background inspection):
260
+ // upstreamResponse.body.tee()
261
+ // const repairedBody = hasResponsesItemIdRepair(repairConfig)
262
+ // process.platform === "win32"
263
+ // && !needsClientRewrite
264
+ // ? nativeBody
265
+ // relaySseWithFailedTail(repairedBody, upstream)
266
+ // new Response(clientBody
267
+ // markNativePassthroughSseResponse
268
+ // const body = relayWithAbort(upstreamResponse.body, upstream);
269
+ // function responseWithDeferredRequestLog
270
+ // isNativePassthroughSseResponse(response)
271
+ // trackSseForRequestLog(
272
+ // export function relaySseWithHeartbeat
273
+
274
+ export function startServer(port?: number) {
275
+ const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()));
276
+ setLiveStateStoreConfig(config);
277
+ applyProxyEnv(config);
278
+ assertServerAuthConfig(config);
279
+ const managementAuth = initializeManagementAuthState(config);
280
+ // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
281
+ // adding/dropping models reaches existing configs on start — not just fresh installs.
282
+ reconcileOAuthProviders(config);
283
+ reconcileLiveStateStores();
284
+ // Seed default featured subagent models on first run only (UNSET → defaults). A user-set list,
285
+ // even [], is left alone so GUI removals persist.
286
+ if (config.subagentModels === undefined) {
287
+ config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
288
+ saveConfig(config);
289
+ }
290
+ // authMode migration (devlog 260726_claude_auth_auto/015): before "auto" existed,
291
+ // choosing Subscription DELETED the key, so a pre-upgrade block with no authMode is
292
+ // indistinguishable from "never chose". Pin those to subscription once so an upgrade
293
+ // never silently moves a deliberate subscriber onto proxy.
294
+ if (runClaudeAuthModeMigration(config)) saveConfig(config);
295
+ // Sidecar model migration (KST 2026-07-10 06:00 = UTC 2026-07-09 21:00): auto-migrate the old
296
+ // gpt-5.4-mini default to gpt-5.6-luna for both search and vision sidecars. Only touches configs
297
+ // still on the old default — explicit user choices are preserved.
298
+ {
299
+ const SIDECAR_MIGRATION_CUTOFF = Date.UTC(2026, 6, 9, 21, 0); // July 9 21:00 UTC = KST July 10 06:00
300
+ if (Date.now() >= SIDECAR_MIGRATION_CUTOFF) {
301
+ let migrated = false;
302
+ if (config.webSearchSidecar?.model === "gpt-5.4-mini") {
303
+ config.webSearchSidecar = { ...config.webSearchSidecar, model: "gpt-5.6-luna" };
304
+ migrated = true;
305
+ }
306
+ if (config.visionSidecar?.model === "gpt-5.4-mini") {
307
+ config.visionSidecar = { ...config.visionSidecar, model: "gpt-5.6-luna" };
308
+ migrated = true;
309
+ }
310
+ if (migrated) saveConfig(config);
311
+ }
312
+ }
313
+ invalidateCodexModelsCache();
314
+ // Arm the `claudeCode` hand-edit guard (devlog 260726_claude_auth_auto/040 H1) BEFORE
315
+ // the server can serve a request, and AFTER the startup migrations above — those run
316
+ // against a config nobody else holds and are the documented exception to the save
317
+ // boundary, so the baseline should reflect what they wrote. Arming is eager on
318
+ // purpose: a lazy "arm on first save" loses exactly the hand edit made before that
319
+ // first save, which is the case the guard exists for.
320
+ armClaudeCodeBaseline(config);
321
+ // usage.jsonl already persists every request; rehydrate the in-memory Logs ring so
322
+ // /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts.
323
+ hydrateRequestLogsFromDisk();
324
+ // #314: warn-only RSS observability (unref'd, idempotent — safe under repeated
325
+ // startServer(0) in tests). Snapshot surfaces via GET /api/system/memory.
326
+ startMemoryWatchdog();
327
+ registerDefaultAppOwnedMemoryStores();
328
+ registerDefaultAppOwnedObservedBuffers();
329
+ registerAppOwnedMemorySweepFallback();
330
+ configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(config.appOwnedMemoryBudgetMb));
331
+ enforceAppOwnedMemoryBudget();
332
+ startStateStoreSweeper();
333
+ // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly
334
+ // tick for daily/weekly; startup evaluation is fire-and-forget after listen.
335
+ // Heavy work runs in a Worker via the single-flight job controller.
336
+ // Keep live config.policy in sync when background runs advance nextRun/lastRun.
337
+ const applyPolicy = (policy: StorageCleanupPolicy) => {
338
+ config.storageCleanupPolicy = policy;
339
+ };
340
+ setStorageCleanupPolicyLiveSink(applyPolicy);
341
+ setStorageCleanupPolicyJobLiveApply(applyPolicy);
342
+ startStorageCleanupScheduler();
343
+
344
+ const listenPort = port ?? config.port ?? 10100;
345
+ setCorsOrigin(listenPort);
346
+
347
+ // Canonicalize an explicit "localhost" bind to IPv4 so it matches the injected base_url (which
348
+ // resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
349
+ // is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
350
+ // (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
351
+ const configuredHost = config.hostname?.trim();
352
+ const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
353
+
354
+ // Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and
355
+ // the server_is_overloaded code so clients can back off, but always return a JSON envelope.
356
+ function drainingResponse(req: Request): Response {
357
+ const response = formatErrorResponse(503, "server_error", "Service shutting down");
358
+ const headers = new Headers(response.headers);
359
+ for (const [name, value] of Object.entries(corsHeaders(req, config))) {
360
+ headers.set(name, value);
361
+ }
362
+ headers.set("Retry-After", "5");
363
+ return new Response(response.body, { status: 503, headers });
364
+ }
365
+
366
+ function serverBusyResponse(req: Request, resource: string): Response {
367
+ return withCors(new Response(JSON.stringify({
368
+ error: { type: "server_error", code: "server_busy", message: `${resource} capacity reached` },
369
+ }), {
370
+ status: 503,
371
+ headers: { "Content-Type": "application/json", "Retry-After": "1" },
372
+ }), req, config);
373
+ }
374
+
375
+ async function runAdmittedHttpTurn(req: Request, work: (lease: ActiveTurnLease) => Promise<Response>): Promise<Response> {
376
+ const lease = tryAdmitTurn();
377
+ if (!lease) return serverBusyResponse(req, "active turns");
378
+ let response: Response;
379
+ try {
380
+ response = await work(lease);
381
+ } catch (error) {
382
+ lease.release();
383
+ throw error;
384
+ }
385
+ if (!lease.isTransferred()) {
386
+ lease.release();
387
+ }
388
+ return response;
389
+ }
390
+
391
+ const server: Server<WsData> = Bun.serve<WsData>({
392
+ port: listenPort,
393
+ hostname: bindHost,
394
+ idleTimeout: 255,
395
+ async fetch(req, requestServer): Promise<Response> {
396
+ const url = new URL(req.url);
397
+ markActivity(`${req.method} ${url.pathname}`);
398
+
399
+ if (req.method === "OPTIONS") {
400
+ const managementPreflight = url.pathname.startsWith("/api/");
401
+ const allowed = managementPreflight
402
+ ? isAllowedManagementOrigin(req, config)
403
+ : isAllowedRequestOrigin(req, config);
404
+ if (!allowed) {
405
+ return new Response(null, { status: 403, headers: corsHeaders() });
406
+ }
407
+ return new Response(null, {
408
+ status: 204,
409
+ headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, config),
410
+ });
411
+ }
412
+
413
+ // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
414
+ // handshake-time only, so capture inbound headers and thread them into the pipeline.
415
+ if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
416
+ if (isDraining()) {
417
+ return drainingResponse(req);
418
+ }
419
+ const admission = resolveResponsesApiAuth(req, config);
420
+ if (!admission) {
421
+ return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
422
+ }
423
+ if (!isAllowedRequestOrigin(req, config)) {
424
+ return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
425
+ }
426
+ // WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true,
427
+ // so under Design B it always tries the WS transport first. When the feature is off, reject
428
+ // the upgrade with 426 — codex-rs maps a connect-time UPGRADE_REQUIRED to a clean
429
+ // session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of
430
+ // surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept.
431
+ if (!websocketsEnabled(config)) {
432
+ return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, config);
433
+ }
434
+ const websocketLease = tryReserveCodexWebSocket();
435
+ if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets");
436
+ if (server.upgrade(req, {
437
+ data: buildResponsesWsData(selectForwardHeaders(req.headers), admission, websocketLease),
438
+ })) return undefined as unknown as Response;
439
+ websocketLease.release();
440
+ return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
441
+ }
442
+
443
+ if (url.pathname === "/healthz" && req.method === "GET") {
444
+ // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
445
+ return jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: listenPort }, 200, req, config);
446
+ }
447
+
448
+ if (url.pathname.startsWith("/api/")) {
449
+ const apiAuthError = requireManagementAuth(req, managementAuth, config);
450
+ if (apiAuthError) return withManagementCors(apiAuthError, req, config);
451
+ const mgmtResponse = await handleManagementAPI(req, url, config);
452
+ if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
453
+ return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
454
+ }
455
+
456
+ if (url.pathname === "/v1/models" && req.method === "GET") {
457
+ // Model discovery never forwards Authorization upstream, so the broader admission
458
+ // set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by
459
+ // remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version).
460
+ const admission = resolveApiAuth(req, config);
461
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
462
+ if (!isAllowedRequestOrigin(req, config)) {
463
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
464
+ }
465
+ let goModels;
466
+ try {
467
+ goModels = await fetchAllModels(config);
468
+ } catch (error) {
469
+ if (error instanceof CatalogGatherBusyError) {
470
+ return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
471
+ status: 503,
472
+ headers: { "content-type": "application/json", "Retry-After": "1" },
473
+ }), req, config);
474
+ }
475
+ throw error;
476
+ }
477
+ const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
478
+ const nativeSlugs = nativeOpenAiSlugs();
479
+ const goEnabled = filterCatalogVisibleModels(goModels, config);
480
+ const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
481
+ // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with
482
+ // Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official
483
+ // ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can
484
+ // only learn capabilities through discovery, and Claude Code 2.1.207 strips the
485
+ // extra fields (backward-safe). Ids are the claude-opus-4-8-{code} Desktop
486
+ // aliases; legacy claude-ocx-* ids keep decoding via resolveAlias. Detection:
487
+ // anthropic-version header (Claude Code sends it) or explicit ?flavor=anthropic.
488
+ // Codex catalog (client_version) and the OpenAI list shape below stay byte-identical.
489
+ const wantsAnthropicList = req.headers.get("anthropic-version") !== null
490
+ || url.searchParams.get("flavor") === "anthropic";
491
+ if (wantsAnthropicList && !url.searchParams.has("client_version")) {
492
+ if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, config);
493
+ // Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
494
+ buildDesktop3pRegistry(
495
+ [...desktopVisibleNativeSlugs(config)],
496
+ goOrdered.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
497
+ config.claudeCode?.desktopProfile,
498
+ );
499
+ const { buildAnthropicModelInfos } = await import("../claude/model-info");
500
+ const { resolveAutoContext } = await import("../claude/context-windows");
501
+ const { activeDesktop3pAlias } = await import("../claude/desktop-3p");
502
+ // Per-surface id family (devlog 050): explicit ?ids= wins; otherwise the
503
+ // Claude Code CLI discovery UA (`claude-code/<version>`, binary n_()) gets
504
+ // readable claude-ocx ids and every other client (Desktop 3P) keeps the
505
+ // hashed family its config was written with. Unknown UA -> hashed (safe).
506
+ const idsParam = url.searchParams.get("ids");
507
+ const idStyle = idsParam === "cli"
508
+ ? "readable" as const
509
+ : idsParam === "desktop"
510
+ ? "desktop3p" as const
511
+ : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
512
+ const data = buildAnthropicModelInfos([...desktopVisibleNativeSlugs(config)], goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias);
513
+ return jsonResponse({ data }, 200, req, config);
514
+ }
515
+ if (url.searchParams.has("client_version")) {
516
+ // Codex client → Codex catalog shape: native gpt + namespaced routed models,
517
+ // cloned from a native template so required fields (base_instructions, etc.) are present.
518
+ // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
519
+ // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the
520
+ // on-disk sync; codex-rs keeps them out of the picker itself).
521
+ const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
522
+ const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config));
523
+ return jsonResponse({ models: applyNativeVisibility(entries, disabledNativeSlugs(config)) }, 200, req, config);
524
+ }
525
+ // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
526
+ // (pure availability list — disabled natives are omitted entirely).
527
+ // Grok Build discovers models through this endpoint too, and its model picker only
528
+ // enables /effort for entries that advertise the reasoning ladder in the Grok model
529
+ // catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog
530
+ // branch above already carries the same ladders, so mirror them here — native rows
531
+ // from the upstream snapshot, routed rows from the configured provider tiers. The
532
+ // default uses the same canonical fallback as the Codex catalog resolver
533
+ // (configured default, then medium, then high, then the first tier). Extra fields
534
+ // are ignored by plain OpenAI clients.
535
+ const grokEffortOption = (value: string, isDefault: boolean) => ({
536
+ value,
537
+ label: `${value[0].toUpperCase()}${value.slice(1)} Effort`,
538
+ ...(isDefault ? { default: true } : {}),
539
+ });
540
+ const grokEffortFields = (efforts: string[], configuredDefault?: string) => {
541
+ if (efforts.length === 0) return {};
542
+ const defaultEffort = configuredDefault && efforts.includes(configuredDefault)
543
+ ? configuredDefault
544
+ : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
545
+ return {
546
+ supports_reasoning_effort: true,
547
+ reasoning_effort: defaultEffort,
548
+ reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
549
+ };
550
+ };
551
+ const data = [
552
+ ...visibleNativeSlugs(config).map(id => ({
553
+ id,
554
+ object: "model",
555
+ created: 0,
556
+ owned_by: "openai",
557
+ ...grokEffortFields(nativeReasoningEfforts(id), nativeDefaultReasoningEffort(id)),
558
+ })),
559
+ ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({
560
+ id: m.alias ?? `${m.provider}/${m.id}`,
561
+ object: "model",
562
+ created: 0,
563
+ owned_by: m.owned_by ?? m.provider,
564
+ ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
565
+ })),
566
+ ];
567
+ return jsonResponse({ object: "list", data }, 200, req, config);
568
+ }
569
+
570
+ // Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default).
571
+ // Must be matched BEFORE the /v1/responses POST branch never sees it (distinct path) and
572
+ // before the /v1/* 404 guard below.
573
+ if (url.pathname === "/v1/responses/compact" && req.method === "POST") {
574
+ if (isDraining()) {
575
+ return drainingResponse(req);
576
+ }
577
+ const admission = resolveResponsesApiAuth(req, config);
578
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
579
+ if (!isAllowedRequestOrigin(req, config)) {
580
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
581
+ }
582
+ const start = Date.now();
583
+ const requestId = nextRequestLogId(start);
584
+ const logCtx: RequestLogContext = {
585
+ model: "unknown",
586
+ provider: "unknown",
587
+ ...admissionFields(admission),
588
+ inboundProtocol: "responses",
589
+ };
590
+ return runAdmittedHttpTurn(req, async () => {
591
+ let response: Response;
592
+ try {
593
+ response = await handleResponsesCompact(req, config, logCtx);
594
+ } catch {
595
+ response = formatErrorResponse(500, "server_error", "Unexpected compact request failure");
596
+ }
597
+ addFinalRequestLog(requestId, start, logCtx, response.status,
598
+ response.status === 499 ? { closeReason: "client_cancel" } : undefined);
599
+ return withCors(response, req, config);
600
+ });
601
+ }
602
+
603
+ if (
604
+ req.method === "POST"
605
+ && (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
606
+ ) {
607
+ disableResponsesRequestTimeout(req, requestServer);
608
+ if (isDraining()) {
609
+ return drainingResponse(req);
610
+ }
611
+ const admission = resolveApiAuth(req, config);
612
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
613
+ if (!isAllowedRequestOrigin(req, config)) {
614
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
615
+ }
616
+ const start = Date.now();
617
+ const requestId = nextRequestLogId(start);
618
+ const logCtx: RequestLogContext = {
619
+ model: "image_gen",
620
+ provider: "unknown",
621
+ ...admissionFields(admission),
622
+ };
623
+ const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
624
+ return runAdmittedHttpTurn(req, async () => {
625
+ const response = await handleImages(req, config, endpoint, logCtx);
626
+ addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
627
+ return withCors(response, req, config);
628
+ });
629
+ }
630
+
631
+ if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) {
632
+ const admission = resolveApiAuth(req, config);
633
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
634
+ if (!isAllowedRequestOrigin(req, config)) {
635
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
636
+ }
637
+ const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length));
638
+ const { resolveArtifactPath } = await import("../images/artifacts");
639
+ const artifactPath = resolveArtifactPath(id);
640
+ if (!artifactPath) {
641
+ return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config);
642
+ }
643
+ const file = Bun.file(artifactPath);
644
+ const ext = artifactPath.split(".").pop()?.toLowerCase();
645
+ const contentType =
646
+ ext === "png" ? "image/png"
647
+ : ext === "jpg" || ext === "jpeg" ? "image/jpeg"
648
+ : ext === "webp" ? "image/webp"
649
+ : ext === "gif" ? "image/gif"
650
+ : "application/octet-stream";
651
+ return withCors(new Response(file, {
652
+ status: 200,
653
+ headers: {
654
+ "content-type": contentType,
655
+ "cache-control": "private, max-age=3600",
656
+ "x-content-type-options": "nosniff",
657
+ },
658
+ }), req, config);
659
+ }
660
+
661
+ if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
662
+ disableResponsesRequestTimeout(req, requestServer);
663
+ if (isDraining()) {
664
+ return drainingResponse(req);
665
+ }
666
+ const admission = resolveApiAuth(req, config);
667
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
668
+ if (!isAllowedRequestOrigin(req, config)) {
669
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
670
+ }
671
+ const start = Date.now();
672
+ const requestId = nextRequestLogId(start);
673
+ const logCtx: RequestLogContext = {
674
+ model: "web_search",
675
+ provider: "unknown",
676
+ ...admissionFields(admission),
677
+ };
678
+ return runAdmittedHttpTurn(req, async () => {
679
+ const response = await handleSearch(req, config, logCtx);
680
+ addFinalRequestLog(requestId, start, logCtx, response.status,
681
+ response.status === 499 ? { closeReason: "client_cancel" } : undefined);
682
+ return withCors(response, req, config);
683
+ });
684
+ }
685
+
686
+ if (url.pathname === "/v1/responses" && req.method === "POST") {
687
+ disableResponsesRequestTimeout(req, requestServer);
688
+ if (isDraining()) {
689
+ return drainingResponse(req);
690
+ }
691
+ const admission = resolveResponsesApiAuth(req, config);
692
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
693
+ if (!isAllowedRequestOrigin(req, config)) {
694
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
695
+ }
696
+ const start = Date.now();
697
+ const requestId = nextRequestLogId(start);
698
+ const logCtx: RequestLogContext = {
699
+ model: "unknown",
700
+ provider: "unknown",
701
+ ...admissionFields(admission),
702
+ inboundProtocol: "responses",
703
+ };
704
+ let logged = false;
705
+ const finalizeNativePassthroughLog = (
706
+ status: number,
707
+ meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" },
708
+ ) => {
709
+ if (logged) return;
710
+ logged = true;
711
+ addFinalRequestLog(requestId, start, logCtx, status, meta);
712
+ };
713
+ return runAdmittedHttpTurn(req, async turnAdmissionLease => {
714
+ const response = await handleResponses(req, config, logCtx, {
715
+ turnAdmissionLease,
716
+ abortSignal: req.signal,
717
+ onFirstOutput: () => recordFirstOutput(logCtx, start),
718
+ onNativePassthroughTerminal: status => {
719
+ finalizeNativePassthroughLog(httpStatusForTerminalStatus(status), {
720
+ terminalStatus: status,
721
+ closeReason: "terminal",
722
+ });
723
+ },
724
+ onNativePassthroughCancel: () => {
725
+ finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
726
+ },
727
+ });
728
+ return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, config);
729
+ });
730
+ }
731
+
732
+ // Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path).
733
+ // Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9).
734
+ if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") {
735
+ if (isDraining()) {
736
+ return drainingResponse(req);
737
+ }
738
+ const admission = resolveApiAuth(req, config);
739
+ if (!admission) {
740
+ return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, config);
741
+ }
742
+ if (!isAllowedRequestOrigin(req, config)) {
743
+ return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, config);
744
+ }
745
+ return runAdmittedHttpTurn(req, async () => withCors(await handleClaudeCountTokens(req, config), req, config));
746
+ }
747
+
748
+ if (url.pathname === "/v1/messages" && req.method === "POST") {
749
+ disableResponsesRequestTimeout(req, requestServer);
750
+ if (isDraining()) {
751
+ return drainingResponse(req);
752
+ }
753
+ const admission = resolveApiAuth(req, config);
754
+ if (!admission) {
755
+ return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, config);
756
+ }
757
+ if (!isAllowedRequestOrigin(req, config)) {
758
+ return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, config);
759
+ }
760
+ const start = Date.now();
761
+ const requestId = nextRequestLogId(start);
762
+ const logCtx: RequestLogContext = {
763
+ model: "unknown",
764
+ provider: "unknown",
765
+ ...admissionFields(admission),
766
+ inboundProtocol: "messages",
767
+ };
768
+ // Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the
769
+ // pre-translation stream + native passthrough callbacks) — do not re-wrap the
770
+ // translated Anthropic stream here.
771
+ return runAdmittedHttpTurn(req, async turnAdmissionLease => withCors(
772
+ await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }),
773
+ req,
774
+ config,
775
+ ));
776
+ }
777
+
778
+
779
+ // OpenAI Chat Completions inbound (GitHub Copilot App / OpenAI-compatible clients).
780
+ if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
781
+ disableResponsesRequestTimeout(req, requestServer);
782
+ if (isDraining()) {
783
+ return drainingResponse(req);
784
+ }
785
+ const admission = resolveResponsesApiAuth(req, config);
786
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
787
+ if (!isAllowedRequestOrigin(req, config)) {
788
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
789
+ }
790
+ const start = Date.now();
791
+ const requestId = nextRequestLogId(start);
792
+ const logCtx: RequestLogContext = {
793
+ model: "unknown",
794
+ provider: "unknown",
795
+ ...admissionFields(admission),
796
+ inboundProtocol: "chat",
797
+ };
798
+ return runAdmittedHttpTurn(req, async turnAdmissionLease => withCors(
799
+ await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease }),
800
+ req,
801
+ config,
802
+ ));
803
+ }
804
+
805
+ // ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create.
806
+ // Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient /
807
+ // public Realtime API). Sideband WS joins are handled just below.
808
+ if (
809
+ req.method === "POST"
810
+ && (url.pathname === "/v1/live" || url.pathname === "/v1/realtime/calls")
811
+ ) {
812
+ disableResponsesRequestTimeout(req, requestServer);
813
+ if (isDraining()) {
814
+ return drainingResponse(req);
815
+ }
816
+ const admission = resolveApiAuth(req, config);
817
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
818
+ if (!isAllowedRequestOrigin(req, config)) {
819
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
820
+ }
821
+ const start = Date.now();
822
+ const requestId = nextRequestLogId(start);
823
+ const logCtx: RequestLogContext = {
824
+ model: "gpt-live",
825
+ provider: "unknown",
826
+ ...admissionFields(admission),
827
+ };
828
+ return runAdmittedHttpTurn(req, async () => {
829
+ const response = await handleLive(req, config, logCtx);
830
+ addFinalRequestLog(
831
+ requestId,
832
+ start,
833
+ logCtx,
834
+ response.status,
835
+ response.status === 499 ? { closeReason: "client_cancel" } : undefined,
836
+ );
837
+ return withCors(response, req, config);
838
+ });
839
+ }
840
+
841
+ // Voice / Realtime sideband WebSocket: Frameless joins /v1/live/{callId}; Realtime v1 joins
842
+ // /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Transparent bidirectional relay.
843
+ const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket"
844
+ ? parseLiveSidebandTarget(url.pathname, url.searchParams)
845
+ : null;
846
+ if (liveSidebandTarget) {
847
+ if (isDraining()) {
848
+ return drainingResponse(req);
849
+ }
850
+ const admission = resolveApiAuth(req, config);
851
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
852
+ if (!isAllowedRequestOrigin(req, config)) {
853
+ return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
854
+ }
855
+ const start = Date.now();
856
+ const requestId = nextRequestLogId(start);
857
+ const logCtx: RequestLogContext = {
858
+ model: "gpt-live",
859
+ provider: "unknown",
860
+ ...admissionFields(admission),
861
+ };
862
+ const resolved = await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget);
863
+ if (resolved instanceof Response) {
864
+ addFinalRequestLog(requestId, start, logCtx, resolved.status);
865
+ return withCors(resolved, req, config);
866
+ }
867
+ addFinalRequestLog(requestId, start, logCtx, 101);
868
+ if (server.upgrade(req, {
869
+ data: {
870
+ kind: "live-sideband",
871
+ liveUpstreamUrl: resolved.upstreamWsUrl,
872
+ liveUpstreamHeaders: resolved.headers,
873
+ livePending: [],
874
+ liveOpened: false,
875
+ } satisfies WsData,
876
+ })) return undefined as unknown as Response;
877
+ return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
878
+ }
879
+
880
+ // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
881
+ // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
882
+ // endpoint clients — memories/*, realtime/* — would surface confusing
883
+ // serde decode errors instead of a clean not-found).
884
+ if (url.pathname.startsWith("/v1/")) {
885
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
886
+ }
887
+
888
+ const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
889
+ ? issueGuiSession(req, config, managementAuth)
890
+ : null;
891
+ const guiFile = serveGuiFile(url.pathname, undefined, guiSessionCandidate ?? undefined);
892
+ if (guiFile) return guiFile;
893
+ if (url.pathname === "/" && req.method === "GET") {
894
+ return jsonResponse(rootFallbackPayload());
895
+ }
896
+
897
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
898
+ },
899
+ websocket: {
900
+ idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,
901
+ // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
902
+ // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
903
+ // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
904
+ // Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead.
905
+ open(ws: ServerWebSocket<WsData>) {
906
+ if (ws.data.kind === "live-sideband") {
907
+ attachLiveSidebandUpstream(ws);
908
+ return;
909
+ }
910
+ if (!ws.data.admissionLease) {
911
+ ws.close(1013, "server busy");
912
+ return;
913
+ }
914
+ ws.data.admissionLease.bind(ws);
915
+ registerCodexWebSocket(ws);
916
+ },
917
+ message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
918
+ if (ws.data.kind === "live-sideband") {
919
+ logLiveSidebandFrame("c2u", raw);
920
+ const upstream = ws.data.liveUpstream;
921
+ if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) {
922
+ const pending = ws.data.livePending ?? (ws.data.livePending = []);
923
+ if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) {
924
+ closeLiveSideband(ws, 1009, "too many pending frames");
925
+ return;
926
+ }
927
+ pending.push(raw);
928
+ return;
929
+ }
930
+ if (upstream.readyState !== WebSocket.OPEN) {
931
+ closeLiveSideband(ws, 1011, "upstream not open");
932
+ return;
933
+ }
934
+ try {
935
+ upstream.send(raw);
936
+ } catch {
937
+ closeLiveSideband(ws, 1011, "upstream send failed");
938
+ }
939
+ return;
940
+ }
941
+ const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
942
+ if (rawBytes > MAX_WS_FRAME_BYTES) {
943
+ sendJsonFrame(ws, buildWsErrorFrame(413, {
944
+ type: "invalid_request_error",
945
+ message: "WebSocket response.create frame is too large",
946
+ }));
947
+ ws.close(1009, "message too large");
948
+ return;
949
+ }
950
+ let frame: Record<string, unknown>;
951
+ try {
952
+ frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
953
+ } catch {
954
+ return; // text-only contract; ignore unparseable frames
955
+ }
956
+ if (frame.type === "response.processed") return; // ack — no-op
957
+ if (frame.type !== "response.create") return;
958
+ markActivity("ws response.create");
959
+
960
+ ws.data.cancel?.();
961
+ const turnId = (ws.data.turnId ?? 0) + 1;
962
+ ws.data.turnId = turnId;
963
+ const isCurrent = () => ws.data.turnId === turnId;
964
+ const turnAbort = new AbortController();
965
+ const cancelTurn = () => {
966
+ turnAbort.abort("websocket turn superseded or closed");
967
+ };
968
+ ws.data.cancel = cancelTurn;
969
+ // A socket may carry several response.create frames. Clear the previous
970
+ // account before resolving this frame so a failed Multi resolution cannot
971
+ // leave stale invalidation ownership behind.
972
+ updateCodexWebSocketAuthContext(ws, undefined);
973
+
974
+ if (frame.generate === false) {
975
+ for (const payload of buildWarmupCompletionFrames(frame)) {
976
+ if (!isCurrent()) return;
977
+ sendTextFrame(ws, payload);
978
+ }
979
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
980
+ return;
981
+ }
982
+
983
+ const turnAdmissionLease = tryAdmitTurn();
984
+ if (!turnAdmissionLease) {
985
+ sendJsonFrame(ws, buildWsErrorFrame(503, {
986
+ type: "server_error",
987
+ code: "server_busy",
988
+ message: "active turns capacity reached",
989
+ retryable: true,
990
+ }, new Headers({ "Retry-After": "1" })));
991
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
992
+ return;
993
+ }
994
+
995
+ const payload: Record<string, unknown> = { ...frame };
996
+ delete payload.type;
997
+ turnAdmissionLease.bindAbortController(turnAbort);
998
+ void (async () => {
999
+ const start = Date.now();
1000
+ const requestId = nextRequestLogId(start);
1001
+ // Resolved once at the handshake — a frame has no request headers left
1002
+ // to re-resolve from. Optional on WsData like every other member, so
1003
+ // narrow rather than assume: an unattributed frame is preferable to a
1004
+ // fabricated attribution.
1005
+ const wsAdmission = ws.data.admission;
1006
+ const logCtx: RequestLogContext = {
1007
+ model: "unknown",
1008
+ provider: "unknown",
1009
+ ...(wsAdmission ? admissionFields(wsAdmission) : {}),
1010
+ inboundProtocol: "responses",
1011
+ };
1012
+ let logged = false;
1013
+ const finalizeLog = (
1014
+ status: number,
1015
+ meta?: Pick<RequestLogEntry, "terminalStatus" | "closeReason">,
1016
+ ) => {
1017
+ if (logged) return;
1018
+ logged = true;
1019
+ addFinalRequestLog(requestId, start, logCtx, status, meta);
1020
+ };
1021
+ const baseHeaders = ws.data.headers ?? new Headers();
1022
+ const fwd = new Headers({ "content-type": "application/json" });
1023
+ baseHeaders.forEach((value, key) => fwd.set(key, value));
1024
+ const req = new Request("http://localhost/v1/responses", {
1025
+ method: "POST",
1026
+ headers: fwd,
1027
+ body: JSON.stringify({ ...payload, stream: true }),
1028
+ });
1029
+ try {
1030
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
1031
+ const response = await handleResponses(req, config, logCtx, {
1032
+ forceEmptyResponseId: true,
1033
+ abortSignal: turnAbort.signal,
1034
+ turnAdmissionLease,
1035
+ onFirstOutput: () => recordFirstOutput(logCtx, start),
1036
+ onCodexAuthContextResolved: context => updateCodexWebSocketAuthContext(ws, context),
1037
+ recordTerminalOutcomes: false,
1038
+ setTerminalOutcomeRecorder: recorder => {
1039
+ terminalRecorder = recorder;
1040
+ },
1041
+ });
1042
+ await sendResponseToWebSocket(ws, response, isCurrent, {
1043
+ onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
1044
+ onTerminal: status => {
1045
+ terminalRecorder?.(status, logCtx.terminalHttpStatus);
1046
+ finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
1047
+ terminalStatus: status,
1048
+ closeReason: "terminal",
1049
+ });
1050
+ },
1051
+ });
1052
+ if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status);
1053
+ } catch (err) {
1054
+ if (!isCurrent()) return;
1055
+ try {
1056
+ if (err instanceof CodexAccountCooldownError) {
1057
+ finalizeLog(429);
1058
+ // Codex Desktop rides this WS transport, so it must carry the same
1059
+ // actionable text as HTTP; a frame has no headers, hence message-only.
1060
+ sendJsonFrame(ws, buildWsErrorFrame(429, {
1061
+ type: "rate_limit_error",
1062
+ message: cooldownErrorMessage(err),
1063
+ }));
1064
+ return;
1065
+ }
1066
+ finalizeLog(502);
1067
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
1068
+ type: "proxy_error",
1069
+ message: err instanceof Error ? err.message : String(err),
1070
+ }));
1071
+ } catch {
1072
+ /* socket already gone or send dropped */
1073
+ }
1074
+ } finally {
1075
+ turnAdmissionLease.release();
1076
+ if (!logged && turnAbort.signal.aborted) finalizeLog(499);
1077
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
1078
+ }
1079
+ })();
1080
+ },
1081
+ close(ws: ServerWebSocket<WsData>) {
1082
+ if (ws.data.kind === "live-sideband") {
1083
+ ws.data.cancel?.();
1084
+ ws.data.liveUpstream = undefined;
1085
+ return;
1086
+ }
1087
+ unregisterCodexWebSocket(ws);
1088
+ ws.data.admissionLease?.release();
1089
+ ws.data.admissionLease = undefined;
1090
+ ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
1091
+ },
1092
+ },
1093
+ });
1094
+
1095
+ setServerRef(server);
1096
+ const actualPort = server.port ?? listenPort;
1097
+ setCorsOrigin(actualPort);
1098
+
1099
+ console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
1100
+ console.log(` POST /v1/responses → provider translation`);
1101
+ console.log(` POST /v1/chat/completions → OpenAI-compatible clients`);
1102
+ console.log(` GET /healthz → health check`);
1103
+ console.log(` GET /api/* → management API`);
1104
+ console.log(` GET / → GUI dashboard`);
1105
+
1106
+ // Prime pool-account quota in the background so the rotation engine has real
1107
+ // usage scores from the first routing decision, even when the dashboard is
1108
+ // never opened (the common CLI/WSL case). Fire-and-forget: never blocks the
1109
+ // listener, and a blocked network silently no-ops (see Phase 30 diagnostics).
1110
+ const openAiProvider = config.providers.openai;
1111
+ if (
1112
+ openAiProvider
1113
+ && openAiProvider.disabled !== true
1114
+ && isCanonicalOpenAiForwardProvider(openAiProvider)
1115
+ && providerCodexAccountMode("openai", openAiProvider) === "pool"
1116
+ ) {
1117
+ import("../codex/auth-api")
1118
+ .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "startup"))
1119
+ .catch(() => {});
1120
+ }
1121
+
1122
+ // Opt-in storage policy (default OFF). Never blocks listen; cancellable on shutdown.
1123
+ scheduleStorageCleanupStartupRun();
1124
+
1125
+ return server;
1126
+ }