@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,516 @@
1
+ import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs";
2
+ import { mkdir, writeFile, open, unlink } from "node:fs/promises";
3
+ import { basename, join, resolve, sep } from "node:path";
4
+ import { getConfigDir } from "../config";
5
+ import { assessUrlDestination, resolvePublicAddresses } from "../lib/destination-policy";
6
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
7
+ import { pinnedHttpGet, type PinnedAddress } from "../lib/pinned-http";
8
+
9
+ export type { PinnedAddress } from "../lib/pinned-http";
10
+
11
+ const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024;
12
+ const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024;
13
+ /** Hard cap for remote image downloads (also enforced inside pinnedHttpsGet). */
14
+ export const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB
15
+ /** Idle timeout for pinned HTTPS connect/headers/body when no AbortSignal is provided. */
16
+ export const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
17
+
18
+ /**
19
+ * Upper bound on the raw base64 string length before it is decoded. Base64
20
+ * encoding expands 3 decoded bytes to 4 encoded chars, so this corresponds to
21
+ * MAX_DECODED_BYTES_PER_IMAGE. Checking this in the adapter (before calling
22
+ * materializeInlineImage) rejects oversized payloads before normalization
23
+ * copies them — see Wibias R4 finding 5.
24
+ */
25
+ export const MAX_ENCODED_BYTES_PER_IMAGE = Math.ceil(MAX_DECODED_BYTES_PER_IMAGE * 4 / 3);
26
+
27
+ /** Default cap on files retained under artifacts/. Oldest files are pruned when exceeded. */
28
+ export const DEFAULT_ARTIFACT_KEEP_COUNT = 200;
29
+
30
+ /** Opaque artifact HTTP path prefix (data-plane, API-auth gated). */
31
+ export const ARTIFACT_HTTP_PREFIX = "/v1/opencodex/artifacts";
32
+
33
+ const ARTIFACT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}\.(png|jpe?g|webp|gif)$/i;
34
+
35
+ // Strict alphabet check: Buffer.from(..., "base64") silently ignores invalid
36
+ // characters, so malformed payloads would otherwise decode to garbage bytes.
37
+ const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
38
+
39
+ export interface ImageBudget {
40
+ spent: number;
41
+ }
42
+
43
+ /** Test seam / custom transport: must connect to `pinned`, not re-resolve `url`'s hostname. */
44
+ export type PinnedDownloadFn = (
45
+ url: string,
46
+ pinned: PinnedAddress,
47
+ signal?: AbortSignal,
48
+ ) => Promise<Response>;
49
+
50
+ export function createImageBudget(): ImageBudget {
51
+ return { spent: 0 };
52
+ }
53
+
54
+ /** Atomically reserve `bytes` against the per-response budget (no await between check and charge). */
55
+ export function chargeImageBudget(budget: ImageBudget | undefined, bytes: number): void {
56
+ if (!budget) return;
57
+ if (budget.spent + bytes > MAX_DECODED_BYTES_PER_RESPONSE) {
58
+ throw new Error(`image response exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response cap`);
59
+ }
60
+ budget.spent += bytes;
61
+ }
62
+
63
+ export function getArtifactsDir(): string {
64
+ return join(getConfigDir(), "artifacts");
65
+ }
66
+
67
+ /**
68
+ * Markdown-safe relative URL for a materialized artifact. Opaque filename only —
69
+ * never expose host filesystem paths to model-visible content.
70
+ */
71
+ export function artifactHttpUrl(filePath: string): string {
72
+ const name = basename(filePath);
73
+ if (!ARTIFACT_ID_RE.test(name)) {
74
+ throw new Error("artifact filename is not a valid opaque id");
75
+ }
76
+ return `${ARTIFACT_HTTP_PREFIX}/${name}`;
77
+ }
78
+
79
+ /**
80
+ * Resolve an opaque artifact id to an absolute path under the artifacts dir.
81
+ * Rejects traversal (`..`, absolute paths, separators).
82
+ */
83
+ export function resolveArtifactPath(id: string): string | null {
84
+ if (!ARTIFACT_ID_RE.test(id)) return null;
85
+ const dir = resolve(getArtifactsDir());
86
+ const candidate = resolve(dir, id);
87
+ if (candidate !== dir && !candidate.startsWith(dir + sep)) return null;
88
+ if (!existsSync(candidate)) return null;
89
+ try {
90
+ if (!statSync(candidate).isFile()) return null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ return candidate;
95
+ }
96
+
97
+ export function readArtifactBytes(id: string): { bytes: Buffer; contentType: string } | null {
98
+ const path = resolveArtifactPath(id);
99
+ if (!path) return null;
100
+ const bytes = readFileSync(path);
101
+ const ext = path.split(".").pop()?.toLowerCase();
102
+ const contentType =
103
+ ext === "png" ? "image/png"
104
+ : ext === "jpg" || ext === "jpeg" ? "image/jpeg"
105
+ : ext === "webp" ? "image/webp"
106
+ : ext === "gif" ? "image/gif"
107
+ : "application/octet-stream";
108
+ return { bytes, contentType };
109
+ }
110
+
111
+ /**
112
+ * Decode + validate base64 image bytes (alphabet, size, magic). Used by CCA
113
+ * Images fallback before returning b64_json and by materializeInlineImage.
114
+ */
115
+ export function decodeValidatedImageBase64(base64Data: string): Buffer {
116
+ const normalized = base64Data.replace(/\s+/g, "");
117
+ if (!BASE64_RE.test(normalized) || normalized.length % 4 !== 0) {
118
+ throw new Error("inline image data is not valid base64");
119
+ }
120
+ if (normalized.length > MAX_ENCODED_BYTES_PER_IMAGE) {
121
+ throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`);
122
+ }
123
+ const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
124
+ const decodedBytes = (normalized.length / 4) * 3 - padding;
125
+ if (decodedBytes === 0) throw new Error("inline image data is empty after base64 decode");
126
+ if (decodedBytes > MAX_DECODED_BYTES_PER_IMAGE) {
127
+ throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`);
128
+ }
129
+ const buf = Buffer.from(normalized, "base64");
130
+ guessExtFromMagic(buf);
131
+ return buf;
132
+ }
133
+
134
+ /**
135
+ * Best-effort retention cap: when the artifact directory holds more than `maxFiles`,
136
+ * delete the oldest (by mtime) until the count is back under the limit. Synchronous
137
+ * on purpose — it runs right after each successful write and touches at most a handful
138
+ * of files. All errors are swallowed and logged so a prune failure never breaks an image write.
139
+ */
140
+ export function pruneOldArtifacts(dir: string, maxFiles: number): void {
141
+ // A non-positive maxFiles disables pruning entirely (do not delete everything).
142
+ if (maxFiles <= 0) return;
143
+ let entries: string[];
144
+ try {
145
+ entries = readdirSync(dir);
146
+ } catch (e) {
147
+ console.warn(`[images] prune: could not read ${dir}:`, e instanceof Error ? e.message : e);
148
+ return;
149
+ }
150
+ if (entries.length <= maxFiles) return;
151
+
152
+ let stats: Array<{ name: string; mtime: number }>;
153
+ try {
154
+ stats = entries.map(name => {
155
+ const st = statSync(join(dir, name));
156
+ return { name, mtime: st.mtimeMs };
157
+ });
158
+ } catch (e) {
159
+ console.warn(`[images] prune: could not stat files in ${dir}:`, e instanceof Error ? e.message : e);
160
+ return;
161
+ }
162
+
163
+ // Sort oldest-first, delete the excess.
164
+ stats.sort((a, b) => a.mtime - b.mtime);
165
+ const toDelete = stats.slice(0, stats.length - maxFiles);
166
+ for (const { name } of toDelete) {
167
+ try {
168
+ unlinkSync(join(dir, name));
169
+ } catch (e) {
170
+ console.warn(`[images] prune: could not delete ${name}:`, e instanceof Error ? e.message : e);
171
+ }
172
+ }
173
+ }
174
+
175
+ function timestampPrefix(): string {
176
+ const now = new Date();
177
+ return [
178
+ now.getFullYear(),
179
+ String(now.getMonth() + 1).padStart(2, "0"),
180
+ String(now.getDate()).padStart(2, "0"),
181
+ "-",
182
+ String(now.getHours()).padStart(2, "0"),
183
+ String(now.getMinutes()).padStart(2, "0"),
184
+ String(now.getSeconds()).padStart(2, "0"),
185
+ "-",
186
+ String(now.getMilliseconds()).padStart(3, "0"),
187
+ ].join("");
188
+ }
189
+
190
+ /**
191
+ * Write a buffer to a unique artifact file using `flag: "wx"` (exclusive create).
192
+ * Collisions on the random UUID suffix are astronomically unlikely, but `wx`
193
+ * would surface them as EEXIST; retry a few times with a fresh UUID before
194
+ * giving up so a fluke name clash can never fail an image write.
195
+ */
196
+ async function writeArtifactUnique(
197
+ dir: string,
198
+ prefix: string,
199
+ buf: Uint8Array,
200
+ ext: string,
201
+ ): Promise<string> {
202
+ for (let attempt = 0; ; attempt++) {
203
+ const suffix = attempt === 0 ? crypto.randomUUID() : `${crypto.randomUUID()}-${attempt}`;
204
+ const filePath = join(dir, `${prefix}${timestampPrefix()}-${suffix}.${ext}`);
205
+ try {
206
+ await writeFile(filePath, buf, { mode: 0o600, flag: "wx" });
207
+ return filePath;
208
+ } catch (e) {
209
+ if (e instanceof Error && "code" in e && (e as { code: string }).code === "EEXIST" && attempt < 3) continue;
210
+ throw e;
211
+ }
212
+ }
213
+ }
214
+
215
+ /** Sniff a recognized image extension, or null when the payload is empty/non-image. */
216
+ export function sniffImageExtension(bytes: Uint8Array): "png" | "jpg" | "webp" | "gif" | null {
217
+ if (bytes.byteLength === 0) return null;
218
+ const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1");
219
+ // Full 8-byte PNG signature (89 50 4E 47 0D 0A 1A 0A) — reject truncated/malformed prefixes.
220
+ if (sig.startsWith("\x89PNG\r\n\x1a\n")) return "png";
221
+ if (sig.startsWith("\xff\xd8\xff")) return "jpg";
222
+ if (sig.startsWith("RIFF") && sig.slice(8, 12) === "WEBP") return "webp";
223
+ if (sig.startsWith("GIF8")) return "gif";
224
+ return null;
225
+ }
226
+
227
+ export function guessExtFromMagic(bytes: Uint8Array): string {
228
+ const ext = sniffImageExtension(bytes);
229
+ if (!ext) {
230
+ throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF");
231
+ }
232
+ return ext;
233
+ }
234
+
235
+ /** Prune `OPENCODEX_HOME/artifacts` after a full image batch has been written. */
236
+ export function pruneArtifacts(keepCount?: number): void {
237
+ pruneOldArtifacts(getArtifactsDir(), keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT);
238
+ }
239
+
240
+ export async function materializeInlineImage(
241
+ base64Data: string,
242
+ budget?: ImageBudget,
243
+ ): Promise<string> {
244
+ const dir = getArtifactsDir();
245
+ recordOwnedConfigPath(getConfigDir(), dir);
246
+ await mkdir(dir, { recursive: true, mode: 0o700 });
247
+
248
+ const buf = decodeValidatedImageBase64(base64Data);
249
+ chargeImageBudget(budget, buf.length);
250
+
251
+ // Sniff actual format from decoded bytes rather than trusting the declared mimeType.
252
+ const ext = sniffImageExtension(buf);
253
+ if (!ext) throw new Error("inline image data is not a recognized image");
254
+ // Retention is post-batch via pruneArtifacts (see fulfill.ts) so a tight keepCount
255
+ // cannot delete earlier images from the same call before their paths are returned.
256
+ return writeArtifactUnique(dir, "img-", buf, ext);
257
+ }
258
+
259
+ /**
260
+ * HTTPS GET that connects to a previously validated address while keeping the
261
+ * original hostname for SNI / Host. The custom `lookup` never asks the OS
262
+ * resolver again, so a rebinding answer cannot redirect the TCP peer.
263
+ *
264
+ * Returns a streaming Response so callers can enforce byte caps while reading;
265
+ * the transport also destroys the request if `maxBytes` is exceeded mid-stream.
266
+ */
267
+ export function pinnedHttpsGet(
268
+ url: string,
269
+ pinned: PinnedAddress,
270
+ signal?: AbortSignal,
271
+ options?: {
272
+ maxBytes?: number;
273
+ idleTimeoutMs?: number;
274
+ rejectUnauthorized?: boolean;
275
+ },
276
+ ): Promise<Response> {
277
+ const parsed = new URL(url);
278
+ if (parsed.protocol !== "https:") {
279
+ throw new Error(`image URL must use HTTPS, got ${parsed.protocol}`);
280
+ }
281
+ const maxBytes = options?.maxBytes ?? MAX_DOWNLOAD_BYTES;
282
+ const idleTimeoutMs = options?.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS;
283
+ return pinnedHttpGet(url, pinned, signal, {
284
+ maxBytes,
285
+ idleTimeoutMs,
286
+ rejectUnauthorized: options?.rejectUnauthorized,
287
+ context: "image download",
288
+ }).then(response => {
289
+ if (!response.ok) throw new Error("image download failed: " + response.status);
290
+ return response;
291
+ });
292
+ }
293
+
294
+ function pickPinnedAddress(addresses: PinnedAddress[]): PinnedAddress {
295
+ return addresses.find(a => a.family === 4) ?? addresses[0]!;
296
+ }
297
+
298
+ export async function downloadImageToArtifact(
299
+ url: string,
300
+ budget?: ImageBudget,
301
+ signal?: AbortSignal,
302
+ options?: { pinnedDownload?: PinnedDownloadFn },
303
+ ): Promise<string> {
304
+ if (url.startsWith("data:")) {
305
+ const m = /^data:([^;]+);base64,(.+)$/.exec(url);
306
+ if (!m) throw new Error("data URL is not a valid base64 image");
307
+ return materializeInlineImage(m[2], budget);
308
+ }
309
+
310
+ // SSRF protection: validate the provider-returned URL before fetching.
311
+ // Require HTTPS strictly — plain HTTP and all other schemes (ftp, file, …) are rejected.
312
+ // Resolve DNS once, then pin that public address for the HTTPS connect (SNI/Host keep
313
+ // the original hostname) so a rebinding answer cannot retarget the TCP peer.
314
+ let parsedUrl: URL;
315
+ try { parsedUrl = new URL(url); } catch { throw new Error("image URL is not valid"); }
316
+ if (parsedUrl.protocol !== "https:") {
317
+ throw new Error(`image URL must use HTTPS, got ${parsedUrl.protocol}`);
318
+ }
319
+ // Reject literal private/loopback/link-local/metadata addresses.
320
+ const assessment = assessUrlDestination(url);
321
+ if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") {
322
+ throw new Error(`image URL targets ${assessment.detail}`);
323
+ }
324
+ const resolved = await resolvePublicAddresses(url);
325
+ const pinned = pickPinnedAddress(resolved.addresses);
326
+ const download = options?.pinnedDownload ?? pinnedHttpsGet;
327
+ const resp = await download(url, pinned, signal);
328
+ if (!resp.ok) {
329
+ // Custom `pinnedDownload` seams may still return a failed Response with a
330
+ // live body; cancel it so unread error payloads cannot keep the socket warm.
331
+ try { await resp.body?.cancel(); } catch { /* ignore */ }
332
+ throw new Error("image download failed: " + resp.status);
333
+ }
334
+
335
+ // Stream the body with a hard byte cap so a missing/lying Content-Length or a
336
+ // compromised CDN URL cannot exhaust memory before the size check runs.
337
+ if (!resp.body) throw new Error("image download returned no body");
338
+ const reader = resp.body.getReader();
339
+ const chunks: Uint8Array[] = [];
340
+ let total = 0;
341
+ try {
342
+ for (;;) {
343
+ const { value, done } = await reader.read();
344
+ if (done) break;
345
+ total += value.byteLength;
346
+ if (total > MAX_DOWNLOAD_BYTES) {
347
+ throw new Error(`image download exceeds ${MAX_DOWNLOAD_BYTES} byte cap`);
348
+ }
349
+ chunks.push(value);
350
+ }
351
+ } finally {
352
+ try { await reader.cancel(); } catch { /* ignore cancel errors */ }
353
+ reader.releaseLock();
354
+ }
355
+
356
+ const bytes = new Uint8Array(total);
357
+ let offset = 0;
358
+ for (const c of chunks) { bytes.set(c, offset); offset += c.byteLength; }
359
+
360
+ if (bytes.byteLength === 0) throw new Error("image download returned empty body");
361
+ const ext = sniffImageExtension(bytes);
362
+ if (!ext) throw new Error("image download did not contain a recognized image");
363
+
364
+ chargeImageBudget(budget, bytes.length);
365
+
366
+ const dir = getArtifactsDir();
367
+ recordOwnedConfigPath(getConfigDir(), dir);
368
+ await mkdir(dir, { recursive: true, mode: 0o700 });
369
+
370
+ // Retention is post-batch via pruneArtifacts (see fulfill.ts).
371
+ return writeArtifactUnique(dir, "dl-", bytes, ext);
372
+ }
373
+
374
+ const MAX_VIDEO_DOWNLOAD_BYTES = 200 * 1024 * 1024; // 200 MiB
375
+ /** Aggregate per-turn video download cap (600 MiB = 3 × max single download). */
376
+ const MAX_VIDEO_BYTES_PER_TURN = MAX_VIDEO_DOWNLOAD_BYTES * 3;
377
+
378
+ export interface VideoBudget {
379
+ spent: number;
380
+ /** Ceiling on total bytes across all downloads this turn. */
381
+ cap: number;
382
+ }
383
+
384
+ export function createVideoBudget(): VideoBudget {
385
+ return { spent: 0, cap: MAX_VIDEO_BYTES_PER_TURN };
386
+ }
387
+
388
+ /** Charge bytes to the budget; returns false if the ceiling would be exceeded. */
389
+ export function chargeVideoBudget(budget: VideoBudget, bytes: number): boolean {
390
+ if (budget.spent + bytes > budget.cap) return false;
391
+ budget.spent += bytes;
392
+ return true;
393
+ }
394
+
395
+ export function guessVideoExtFromMagic(bytes: Uint8Array): string {
396
+ if (bytes.byteLength < 12) throw new Error("video data too short for magic byte sniffing");
397
+ const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1");
398
+ // MP4/QuickTime/MOV: bytes 4-7 == "ftyp" (ISO BMFF)
399
+ if (sig.slice(4, 8) === "ftyp") return "mp4";
400
+ // WebM/Matroska: \x1a\x45\xdf\xa3
401
+ if (sig.startsWith("\x1a\x45\xdf\xa3")) return "webm";
402
+ throw new Error("unrecognized video format — magic bytes do not match MP4 or WebM");
403
+ }
404
+
405
+ /**
406
+ * Download a video from a URL to an artifact file with a 200 MiB hard cap, streaming the body
407
+ * to disk to avoid buffering. SSRF protection reuses the same destination policy + pinned HTTPS
408
+ * as image downloads. Format is sniffed from magic bytes.
409
+ */
410
+ export async function downloadVideoToArtifact(
411
+ url: string,
412
+ budget?: VideoBudget,
413
+ signal?: AbortSignal,
414
+ ): Promise<string> {
415
+ // For data: URLs, handle inline (unlikely for video but keep parity)
416
+ if (url.startsWith("data:")) {
417
+ const commaIdx = url.indexOf(",");
418
+ const meta = url.slice(0, commaIdx);
419
+ const data = url.slice(commaIdx + 1);
420
+ const isBase64 = meta.includes(";base64");
421
+ if (!isBase64) throw new Error("non-base64 data URI for video is not supported");
422
+ const buf = Buffer.from(data, "base64");
423
+ if (budget && !chargeVideoBudget(budget, buf.byteLength)) {
424
+ throw new Error("video data URI exceeds per-turn download budget");
425
+ }
426
+ if (buf.byteLength > MAX_VIDEO_DOWNLOAD_BYTES) throw new Error("video data URI exceeds size cap");
427
+ const ext = guessVideoExtFromMagic(buf);
428
+ const dir = getArtifactsDir();
429
+ await mkdir(dir, { recursive: true, mode: 0o700 });
430
+ const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`;
431
+ const dest = join(dir, name);
432
+ await writeFile(dest, buf, { mode: 0o600 });
433
+ return dest;
434
+ }
435
+
436
+ // SSRF protection: same validation as downloadImageToArtifact
437
+ let parsedUrl: URL;
438
+ try { parsedUrl = new URL(url); } catch { throw new Error("video URL is not valid"); }
439
+ if (parsedUrl.protocol !== "https:") {
440
+ throw new Error(`video URL must use HTTPS, got ${parsedUrl.protocol}`);
441
+ }
442
+ const assessment = assessUrlDestination(url);
443
+ if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") {
444
+ throw new Error(`video URL targets ${assessment.detail}`);
445
+ }
446
+ const resolved = await resolvePublicAddresses(url, "video");
447
+ const pinned = pickPinnedAddress(resolved.addresses);
448
+ const resp = await pinnedHttpsGet(url, pinned, signal, { maxBytes: MAX_VIDEO_DOWNLOAD_BYTES });
449
+ if (!resp.ok) {
450
+ try { await resp.body?.cancel(); } catch { /* ignore */ }
451
+ throw new Error("video download failed: " + resp.status);
452
+ }
453
+
454
+ const dir = getArtifactsDir();
455
+ await mkdir(dir, { recursive: true, mode: 0o700 });
456
+
457
+ const reader = resp.body?.getReader();
458
+ if (!reader) throw new Error("video download returned no body");
459
+
460
+ // Buffer at least 12 bytes for magic-byte sniffing before opening the file.
461
+ // A single read() can return fewer bytes; accumulate until we have enough.
462
+ let dest: string | undefined;
463
+ let fh: { close(): Promise<void>; writeFile(data: Uint8Array): Promise<void> } | undefined;
464
+ try {
465
+ const sniffChunks: Uint8Array[] = [];
466
+ let sniffLen = 0;
467
+ while (sniffLen < 12) {
468
+ const { value, done } = await reader.read();
469
+ if (done) break;
470
+ if (!value) continue;
471
+ sniffChunks.push(value);
472
+ sniffLen += value.byteLength;
473
+ }
474
+ if (sniffLen === 0) {
475
+ throw new Error("video download returned empty body");
476
+ }
477
+ const sniffBuf = Buffer.concat(sniffChunks);
478
+ const ext = guessVideoExtFromMagic(new Uint8Array(sniffBuf));
479
+ const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`;
480
+ dest = join(dir, name);
481
+ fh = await open(dest, "w", 0o600);
482
+ // Write all buffered chunks and set up accounting
483
+ let totalBytes = sniffBuf.byteLength;
484
+ if (budget && !chargeVideoBudget(budget, totalBytes)) {
485
+ throw new Error("video download exceeds per-turn budget");
486
+ }
487
+ if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) {
488
+ throw new Error("video download exceeds size cap");
489
+ }
490
+ await fh.writeFile(new Uint8Array(sniffBuf));
491
+ for (;;) {
492
+ const { value, done } = await reader.read();
493
+ if (done) break;
494
+ totalBytes += value.byteLength;
495
+ if (budget && !chargeVideoBudget(budget, value.byteLength)) {
496
+ throw new Error("video download exceeds per-turn budget");
497
+ }
498
+ if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) {
499
+ throw new Error("video download exceeds size cap");
500
+ }
501
+ await fh.writeFile(value);
502
+ }
503
+ await fh.close();
504
+ try { await reader.cancel(); } catch { /* ignore */ }
505
+ reader.releaseLock();
506
+ return dest;
507
+ } catch (err) {
508
+ try { await reader.cancel(); } catch { /* ignore */ }
509
+ reader.releaseLock();
510
+ if (fh) {
511
+ try { await fh.close(); } catch { /* ignore */ }
512
+ }
513
+ if (dest) await unlink(dest).catch(() => {});
514
+ throw err;
515
+ }
516
+ }
@@ -0,0 +1,163 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import type { VideoBridgePlan, VideoCallResult } from "./types";
3
+ import { submitVideoJob, pollVideoJob } from "./xai-video-client";
4
+ import { downloadVideoToArtifact, createVideoBudget, type VideoBudget } from "./artifacts";
5
+
6
+ /** Parsed arguments from the model's video_gen tool call. */
7
+ export interface ParsedVideoArgs {
8
+ ok: true;
9
+ prompt: string;
10
+ duration?: number;
11
+ resolution?: string;
12
+ aspectRatio?: string;
13
+ }
14
+
15
+ const INITIAL_POLL_INTERVAL_MS = 5_000;
16
+ const MAX_POLL_INTERVAL_MS = 15_000;
17
+ const POLL_BACKOFF = 1.5;
18
+ const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; // 5 min
19
+
20
+ export type SleepFn = (ms: number, signal?: AbortSignal) => Promise<void>;
21
+ export type PollVideoJobFn = (
22
+ requestId: string,
23
+ auth: { baseUrl: string; token: string },
24
+ signal?: AbortSignal,
25
+ ) => ReturnType<typeof pollVideoJob>;
26
+
27
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
28
+ if (signal?.aborted) return Promise.reject(new Error("aborted"));
29
+ return new Promise((resolve, reject) => {
30
+ const onAbort = (): void => { clearTimeout(timer); reject(new Error("aborted")); };
31
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
32
+ signal?.addEventListener("abort", onAbort, { once: true });
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Parse the JSON arguments string from a video_gen tool call. Mirrors fulfillImageCall's
38
+ * defensive parsing: invalid JSON or missing prompt returns an error result.
39
+ */
40
+ export function parseVideoCallArgs(raw: string):
41
+ | ParsedVideoArgs
42
+ | { ok: false; error: string } {
43
+ let args: unknown;
44
+ try {
45
+ args = JSON.parse(raw || "{}");
46
+ } catch {
47
+ return { ok: false, error: "invalid arguments JSON" };
48
+ }
49
+ if (typeof args !== "object" || args === null) {
50
+ return { ok: false, error: "invalid arguments JSON" };
51
+ }
52
+ const obj = args as Record<string, unknown>;
53
+
54
+ const prompt =
55
+ typeof obj.prompt === "string" ? obj.prompt : typeof obj.input === "string" ? obj.input : "";
56
+ if (!prompt) {
57
+ return { ok: false, error: "missing prompt" };
58
+ }
59
+
60
+ const result: ParsedVideoArgs = { ok: true, prompt };
61
+
62
+ // duration: clamp to 1-15
63
+ if (typeof obj.duration === "number") {
64
+ result.duration = Math.max(1, Math.min(15, Math.floor(obj.duration)));
65
+ }
66
+
67
+ if (typeof obj.resolution === "string" && (obj.resolution === "480p" || obj.resolution === "720p")) {
68
+ result.resolution = obj.resolution;
69
+ }
70
+ if (typeof obj.aspect_ratio === "string") {
71
+ const VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"];
72
+ if (VALID_RATIOS.includes(obj.aspect_ratio)) result.aspectRatio = obj.aspect_ratio;
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Poll a video generation job, yielding heartbeats with elapsed-time messages so the
80
+ * caller can forward them to the SSE stream. Returns the final result when the job
81
+ * completes or fails/times out.
82
+ */
83
+ export async function* pollVideoWithHeartbeats(
84
+ requestId: string,
85
+ auth: { baseUrl: string; token: string },
86
+ signal: AbortSignal,
87
+ timeoutMs: number = DEFAULT_VIDEO_TIMEOUT_MS,
88
+ /** Test seams — avoid mock.module / global setTimeout (both poison parallel Bun CI). */
89
+ sleepFn: SleepFn = sleep,
90
+ pollFn: PollVideoJobFn = pollVideoJob,
91
+ ): AsyncGenerator<
92
+ { type: "heartbeat"; message: string },
93
+ { ok: true; videoUrl: string } | { ok: false; error: string }
94
+ > {
95
+ const start = Date.now();
96
+ let interval = INITIAL_POLL_INTERVAL_MS;
97
+ const timeoutStr = `video generation timed out after ${Math.floor(timeoutMs / 1000)}s`;
98
+
99
+ for (;;) {
100
+ if (Date.now() - start >= timeoutMs) {
101
+ return { ok: false, error: timeoutStr };
102
+ }
103
+
104
+ try {
105
+ const poll = await pollFn(requestId, auth, signal);
106
+ if (poll.status === "done") {
107
+ if (poll.videoUrl) {
108
+ return { ok: true, videoUrl: poll.videoUrl };
109
+ }
110
+ return { ok: false, error: "video generation completed but no video URL was returned" };
111
+ }
112
+ if (poll.status === "failed") {
113
+ return { ok: false, error: "video generation failed" };
114
+ }
115
+ if (poll.status === "expired") {
116
+ return { ok: false, error: "video generation expired" };
117
+ }
118
+ // still processing — continue with backoff
119
+ } catch (e) {
120
+ // Distinguish deadline-expiry from client-cancel.
121
+ // AbortSignal.timeout sets signal.reason to a TimeoutError.
122
+ const isDeadline = signal.aborted && e instanceof Error && e.name === "TimeoutError";
123
+ if (isDeadline) return { ok: false, error: timeoutStr };
124
+ if (signal.aborted) return { ok: false, error: "client closed request during video generation" };
125
+ const status = (e as Error & { status?: number }).status;
126
+ if (status && status >= 400 && status < 500 && status !== 429) {
127
+ return { ok: false, error: `video poll failed permanently: HTTP ${status}` };
128
+ }
129
+ console.warn(`[videos] poll error (will retry): ${e instanceof Error ? e.message : String(e)}`);
130
+ }
131
+
132
+ const elapsed = Math.floor((Date.now() - start) / 1000);
133
+ yield { type: "heartbeat", message: `Generating video... ${elapsed}s` };
134
+
135
+ try {
136
+ await sleepFn(interval, signal);
137
+ } catch {
138
+ const isDeadline = signal.aborted && signal.reason instanceof Error && signal.reason.name === "TimeoutError";
139
+ if (isDeadline) return { ok: false, error: timeoutStr };
140
+ return { ok: false, error: "client closed request during video generation" };
141
+ }
142
+
143
+ interval = Math.min(MAX_POLL_INTERVAL_MS, Math.floor(interval * POLL_BACKOFF));
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Build a success VideoCallResult after the video has been downloaded to disk.
149
+ */
150
+ export function buildVideoResult(path: string, prompt: string, model: string): VideoCallResult {
151
+ return {
152
+ ok: true,
153
+ model,
154
+ prompt,
155
+ path,
156
+ files: [path],
157
+ count: 1,
158
+ markdown: `[video](${pathToFileURL(path).href})`,
159
+ };
160
+ }
161
+
162
+ export type { VideoBudget };
163
+ export { createVideoBudget };