@iislee/opencodex 2.11.0 → 2.31.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 (675) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +114 -19
  3. package/bin/ocx.mjs +164 -36
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-BF38heuV.js +104 -0
  6. package/gui/dist/assets/index-DMiI18Kv.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +19 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +376 -52
  44. package/src/adapters/base.ts +54 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +601 -0
  48. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  49. package/src/adapters/cursor/cursor-errors.ts +129 -6
  50. package/src/adapters/cursor/discovery.ts +61 -4
  51. package/src/adapters/cursor/effort-map.ts +27 -3
  52. package/src/adapters/cursor/framing.ts +39 -0
  53. package/src/adapters/cursor/h2-pool.ts +123 -0
  54. package/src/adapters/cursor/http1-bidi.ts +361 -0
  55. package/src/adapters/cursor/images.ts +704 -0
  56. package/src/adapters/cursor/live-models.ts +174 -58
  57. package/src/adapters/cursor/live-transport.ts +609 -170
  58. package/src/adapters/cursor/native-exec-common.ts +23 -2
  59. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  60. package/src/adapters/cursor/native-exec-network.ts +1 -1
  61. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  62. package/src/adapters/cursor/native-exec.ts +101 -14
  63. package/src/adapters/cursor/protobuf-events.ts +829 -11
  64. package/src/adapters/cursor/protobuf-request.ts +383 -65
  65. package/src/adapters/cursor/request-builder.ts +237 -31
  66. package/src/adapters/cursor/tool-definitions.ts +242 -12
  67. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  68. package/src/adapters/cursor/transport.ts +22 -0
  69. package/src/adapters/cursor/types.ts +28 -1
  70. package/src/adapters/cursor.ts +133 -7
  71. package/src/adapters/google-antigravity-replay.ts +558 -34
  72. package/src/adapters/google-antigravity-wire.ts +43 -10
  73. package/src/adapters/google-http.ts +38 -10
  74. package/src/adapters/google-tool-schema.ts +80 -15
  75. package/src/adapters/google-truncation.ts +11 -0
  76. package/src/adapters/google.ts +618 -74
  77. package/src/adapters/identity.ts +39 -6
  78. package/src/adapters/kiro-errors.ts +11 -0
  79. package/src/adapters/kiro-events.ts +19 -1
  80. package/src/adapters/kiro-thinking.ts +18 -2
  81. package/src/adapters/kiro-tools.ts +10 -1
  82. package/src/adapters/kiro.ts +92 -54
  83. package/src/adapters/mimo-free.ts +17 -0
  84. package/src/adapters/openai-chat-url.ts +11 -0
  85. package/src/adapters/openai-chat.ts +1311 -336
  86. package/src/adapters/openai-responses-url.ts +16 -0
  87. package/src/adapters/openai-responses.ts +830 -56
  88. package/src/adapters/registry.ts +175 -0
  89. package/src/adapters/responses-tool-schema.ts +67 -0
  90. package/src/adapters/tool-call-id.ts +119 -0
  91. package/src/adapters/tool-catalog-nudge.ts +104 -21
  92. package/src/adapters/xai-web-search.ts +185 -0
  93. package/src/bridge.ts +428 -61
  94. package/src/chat/inbound.ts +43 -19
  95. package/src/chat/outbound.ts +82 -26
  96. package/src/claude/agents-inject.ts +32 -9
  97. package/src/claude/context-windows.ts +21 -5
  98. package/src/claude/desktop-3p.ts +243 -9
  99. package/src/claude/gateway-cache.ts +41 -4
  100. package/src/claude/inbound.ts +72 -3
  101. package/src/claude/model-info.ts +38 -15
  102. package/src/claude/outbound.ts +70 -16
  103. package/src/cli/account-api.ts +35 -1
  104. package/src/cli/account-auth.ts +33 -6
  105. package/src/cli/account-catalog-refresh.ts +14 -0
  106. package/src/cli/account-extended.ts +389 -2
  107. package/src/cli/account-main.ts +317 -0
  108. package/src/cli/account.ts +30 -6
  109. package/src/cli/agent.ts +45 -1
  110. package/src/cli/claude-agent-startup-sync.ts +73 -0
  111. package/src/cli/claude-desktop.ts +29 -4
  112. package/src/cli/claude.ts +103 -34
  113. package/src/cli/codex-log-guard-doctor.ts +103 -0
  114. package/src/cli/codex-shim-autorestore.ts +2 -0
  115. package/src/cli/codex-shim-readiness.ts +76 -0
  116. package/src/cli/combo.ts +8 -0
  117. package/src/cli/config-command.ts +74 -10
  118. package/src/cli/dispatch.ts +593 -0
  119. package/src/cli/doctor.ts +315 -43
  120. package/src/cli/ensure-desired-integrations.ts +152 -0
  121. package/src/cli/export-command.ts +46 -20
  122. package/src/cli/help.ts +20 -271
  123. package/src/cli/index.ts +378 -519
  124. package/src/cli/init.ts +4 -17
  125. package/src/cli/integrations.ts +120 -2
  126. package/src/cli/lab.ts +607 -0
  127. package/src/cli/launcher-context.ts +77 -0
  128. package/src/cli/minimax.ts +497 -0
  129. package/src/cli/models-runtime.ts +35 -2
  130. package/src/cli/models.ts +100 -14
  131. package/src/cli/observe.ts +92 -3
  132. package/src/cli/opencode.ts +4 -2
  133. package/src/cli/provider-runtime.ts +18 -1
  134. package/src/cli/provider.ts +24 -3
  135. package/src/cli/ready.ts +301 -0
  136. package/src/cli/registry.ts +437 -0
  137. package/src/cli/root.ts +86 -0
  138. package/src/cli/route-policy.ts +92 -0
  139. package/src/cli/runtime-api.ts +6 -3
  140. package/src/cli/star-prompt.ts +71 -15
  141. package/src/cli/status.ts +10 -3
  142. package/src/cli/system-restart-client.ts +146 -0
  143. package/src/cli/tray-proxy.ts +153 -6
  144. package/src/cli/v2.ts +105 -10
  145. package/src/cli.ts +1 -1
  146. package/src/clients/config-export.ts +1358 -21
  147. package/src/codex/account-label.ts +14 -1
  148. package/src/codex/account-lifecycle.ts +130 -13
  149. package/src/codex/account-namespaces.ts +49 -3
  150. package/src/codex/account-priority.ts +83 -0
  151. package/src/codex/account-store.ts +29 -2
  152. package/src/codex/account-usability.ts +25 -2
  153. package/src/codex/admission.ts +256 -0
  154. package/src/codex/affinity-debug.ts +162 -0
  155. package/src/codex/app-server-processes.ts +493 -106
  156. package/src/codex/app-server-restart-service.ts +232 -0
  157. package/src/codex/auth-api.ts +849 -242
  158. package/src/codex/auth-collision.ts +5 -3
  159. package/src/codex/auth-context.ts +345 -32
  160. package/src/codex/autostart-health.ts +8 -1
  161. package/src/codex/catalog/account-models.ts +67 -0
  162. package/src/codex/catalog/aggregation.ts +68 -10
  163. package/src/codex/catalog/bundled.ts +331 -33
  164. package/src/codex/catalog/effort.ts +121 -30
  165. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  166. package/src/codex/catalog/kinds.ts +2 -0
  167. package/src/codex/catalog/metadata.ts +529 -45
  168. package/src/codex/catalog/native-models.ts +72 -0
  169. package/src/codex/catalog/parsing.ts +224 -30
  170. package/src/codex/catalog/provider-fetch.ts +1460 -134
  171. package/src/codex/catalog/sync.ts +1449 -186
  172. package/src/codex/catalog-admission.ts +199 -0
  173. package/src/codex/catalog-refresh-status.ts +105 -0
  174. package/src/codex/catalog-write-serialization.ts +242 -0
  175. package/src/codex/catalog.ts +6 -3
  176. package/src/codex/codex-write-lock.ts +384 -0
  177. package/src/codex/convergence-types.ts +614 -0
  178. package/src/codex/convergence.ts +651 -0
  179. package/src/codex/coordinator-doctor.ts +332 -0
  180. package/src/codex/custom-model-catalog-migration.ts +176 -0
  181. package/src/codex/desired-state.ts +230 -0
  182. package/src/codex/features.ts +636 -39
  183. package/src/codex/generation.ts +202 -0
  184. package/src/codex/history-job.ts +407 -0
  185. package/src/codex/history-lock.ts +242 -0
  186. package/src/codex/history-migration-guardian.ts +26 -20
  187. package/src/codex/history-provider.ts +231 -28
  188. package/src/codex/history-transition.ts +105 -0
  189. package/src/codex/history-worker.ts +220 -0
  190. package/src/codex/inject-coordination.ts +290 -0
  191. package/src/codex/inject.ts +1073 -152
  192. package/src/codex/injected-marker.ts +37 -3
  193. package/src/codex/integration-record.ts +266 -0
  194. package/src/codex/internal/catalog-writer.ts +203 -0
  195. package/src/codex/internal/history-writer.ts +80 -0
  196. package/src/codex/journal.ts +66 -4
  197. package/src/codex/log-guard/inspect.ts +506 -0
  198. package/src/codex/log-guard/lock.ts +150 -0
  199. package/src/codex/log-guard/maintenance.ts +403 -0
  200. package/src/codex/log-guard/path-safety.ts +88 -0
  201. package/src/codex/log-guard/policy.ts +44 -0
  202. package/src/codex/log-guard/processes.ts +205 -0
  203. package/src/codex/log-guard/protection.ts +489 -0
  204. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  205. package/src/codex/main-account-cache.ts +24 -0
  206. package/src/codex/main-account.ts +29 -1
  207. package/src/codex/management-convergence.ts +167 -0
  208. package/src/codex/model-cache.ts +56 -10
  209. package/src/codex/model-entitlements.ts +353 -0
  210. package/src/codex/native-main-admission.ts +47 -0
  211. package/src/codex/native-main-auth-temp.ts +187 -0
  212. package/src/codex/native-main-claim.ts +178 -0
  213. package/src/codex/native-main-lock-file.ts +162 -0
  214. package/src/codex/native-main-owner.ts +329 -0
  215. package/src/codex/native-profile-api.ts +247 -0
  216. package/src/codex/native-profile-manager.ts +1531 -0
  217. package/src/codex/native-profile-processes.ts +121 -0
  218. package/src/codex/native-profile-recovery.ts +99 -0
  219. package/src/codex/native-profile-stage-store.ts +387 -0
  220. package/src/codex/native-profile-startup.ts +492 -0
  221. package/src/codex/native-profile-store.ts +855 -0
  222. package/src/codex/native-profile-types.ts +120 -0
  223. package/src/codex/native-residue.ts +682 -0
  224. package/src/codex/paths.ts +80 -1
  225. package/src/codex/plan-from-token.ts +140 -0
  226. package/src/codex/plan.ts +40 -0
  227. package/src/codex/plugins-doctor.ts +1 -1
  228. package/src/codex/pool-rotation.ts +74 -4
  229. package/src/codex/project-config-warnings.ts +20 -6
  230. package/src/codex/prompt-journal.ts +352 -0
  231. package/src/codex/prompt-layers.ts +967 -0
  232. package/src/codex/prompt-lock.ts +143 -0
  233. package/src/codex/quota-rejection.ts +298 -0
  234. package/src/codex/quota.ts +175 -13
  235. package/src/codex/refresh.ts +11 -2
  236. package/src/codex/reset-credit-recovery.ts +1044 -0
  237. package/src/codex/routing.ts +505 -94
  238. package/src/codex/runtime.ts +159 -38
  239. package/src/codex/shim.ts +1009 -28
  240. package/src/codex/subagent-model-fallback.ts +350 -35
  241. package/src/codex/sync.ts +191 -2
  242. package/src/codex/transition-state.ts +612 -0
  243. package/src/codex/upstream-host-health.ts +368 -0
  244. package/src/codex/user-identity.ts +557 -0
  245. package/src/codex/warmup.ts +187 -81
  246. package/src/codex/write-coordination.ts +114 -0
  247. package/src/combos/failover.ts +20 -0
  248. package/src/combos/index.ts +4 -0
  249. package/src/combos/request.ts +32 -0
  250. package/src/combos/types.ts +81 -9
  251. package/src/config/provider-name.ts +24 -0
  252. package/src/config.ts +1762 -140
  253. package/src/generated/compatibility-version.json +3164 -0
  254. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  255. package/src/grok/inject.ts +16 -5
  256. package/src/grok/inspect.ts +45 -0
  257. package/src/grok/sync.ts +2 -2
  258. package/src/images/loop.ts +152 -29
  259. package/src/images/plan.ts +23 -13
  260. package/src/integrations/config-io.ts +269 -0
  261. package/src/integrations/journal.ts +315 -0
  262. package/src/integrations/merge.ts +135 -0
  263. package/src/integrations/mutation-flight.ts +71 -0
  264. package/src/integrations/native/ownership-preflight.ts +202 -0
  265. package/src/integrations/omp-yaml-source.ts +358 -0
  266. package/src/integrations/owned-refresh.ts +74 -0
  267. package/src/integrations/ownership.ts +111 -0
  268. package/src/integrations/registry.ts +159 -0
  269. package/src/integrations/serialize.ts +314 -0
  270. package/src/integrations/state.ts +361 -0
  271. package/src/integrations/store.ts +103 -0
  272. package/src/integrations/writer-lock.ts +98 -0
  273. package/src/integrations/writer.ts +691 -0
  274. package/src/lab/artifacts/sanitize.ts +586 -0
  275. package/src/lab/artifacts/secure-fs.ts +475 -0
  276. package/src/lab/artifacts/store.ts +310 -0
  277. package/src/lab/automation/budgets.ts +78 -0
  278. package/src/lab/automation/config-persistence.ts +256 -0
  279. package/src/lab/automation/constants.ts +39 -0
  280. package/src/lab/automation/cooldown.ts +103 -0
  281. package/src/lab/automation/dispatch.ts +211 -0
  282. package/src/lab/automation/index.ts +13 -0
  283. package/src/lab/automation/orchestrator.ts +499 -0
  284. package/src/lab/automation/persistence.ts +512 -0
  285. package/src/lab/automation/planner.ts +371 -0
  286. package/src/lab/automation/policy.ts +136 -0
  287. package/src/lab/automation/queue.ts +191 -0
  288. package/src/lab/automation/recovery.ts +24 -0
  289. package/src/lab/automation/route-context.ts +21 -0
  290. package/src/lab/automation/run-key.ts +44 -0
  291. package/src/lab/automation/runs-query.ts +34 -0
  292. package/src/lab/automation/types.ts +160 -0
  293. package/src/lab/conformance/assertion.ts +325 -0
  294. package/src/lab/conformance/digest.ts +22 -0
  295. package/src/lab/conformance/executor.ts +741 -0
  296. package/src/lab/conformance/fixture-provider.ts +27 -0
  297. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  298. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  299. package/src/lab/conformance/harness-budget.ts +47 -0
  300. package/src/lab/conformance/index.ts +5 -0
  301. package/src/lab/conformance/jcs.ts +64 -0
  302. package/src/lab/conformance/json-pointer.ts +39 -0
  303. package/src/lab/conformance/manifest.ts +180 -0
  304. package/src/lab/conformance/mcp-stub.ts +179 -0
  305. package/src/lab/conformance/negative-controls.ts +164 -0
  306. package/src/lab/conformance/observation.ts +355 -0
  307. package/src/lab/conformance/runner.ts +68 -0
  308. package/src/lab/conformance/sse-normalize.ts +59 -0
  309. package/src/lab/conformance/suite-manifest.ts +78 -0
  310. package/src/lab/conformance/types.ts +214 -0
  311. package/src/lab/constants.ts +126 -0
  312. package/src/lab/digest.ts +64 -0
  313. package/src/lab/events/errors.ts +9 -0
  314. package/src/lab/events/limits.ts +117 -0
  315. package/src/lab/events/types.ts +229 -0
  316. package/src/lab/events/validate.ts +781 -0
  317. package/src/lab/fabric/constants.ts +40 -0
  318. package/src/lab/fabric/executor.ts +492 -0
  319. package/src/lab/fabric/index.ts +80 -0
  320. package/src/lab/fabric/manifest.ts +222 -0
  321. package/src/lab/fabric/observe.ts +489 -0
  322. package/src/lab/fabric/patch.ts +79 -0
  323. package/src/lab/fabric/producer-child.ts +139 -0
  324. package/src/lab/fabric/producer-isolate.ts +276 -0
  325. package/src/lab/fabric/producer-protocol.ts +61 -0
  326. package/src/lab/fabric/scratch.ts +439 -0
  327. package/src/lab/fabric/subject.ts +106 -0
  328. package/src/lab/fabric/types.ts +134 -0
  329. package/src/lab/fabric/verifier.ts +98 -0
  330. package/src/lab/index.ts +54 -0
  331. package/src/lab/ledger/artifact-refs.ts +127 -0
  332. package/src/lab/ledger/invalidation.ts +136 -0
  333. package/src/lab/ledger/purge.ts +310 -0
  334. package/src/lab/ledger/store.ts +532 -0
  335. package/src/lab/live/credential-lease.ts +53 -0
  336. package/src/lab/live/destination.ts +155 -0
  337. package/src/lab/live/executor.ts +336 -0
  338. package/src/lab/live/inert-tools.ts +56 -0
  339. package/src/lab/live/manifest.ts +85 -0
  340. package/src/lab/live/mcp-loopback.ts +57 -0
  341. package/src/lab/live/runner.ts +19 -0
  342. package/src/lab/live/sandbox.ts +61 -0
  343. package/src/lab/live/suite-manifest.ts +41 -0
  344. package/src/lab/live/transport.ts +118 -0
  345. package/src/lab/live/types.ts +197 -0
  346. package/src/lab/observe/from-conformance.ts +301 -0
  347. package/src/lab/observe/from-live.ts +117 -0
  348. package/src/lab/paths.ts +153 -0
  349. package/src/lab/projection/rebuild.ts +495 -0
  350. package/src/lab/projection/schema.ts +135 -0
  351. package/src/lab/projection/verdicts.ts +474 -0
  352. package/src/lab/projection/verification.ts +412 -0
  353. package/src/lab/public/bundle.ts +217 -0
  354. package/src/lab/public/community-authority.ts +175 -0
  355. package/src/lab/public/community-files.ts +29 -0
  356. package/src/lab/public/community.ts +479 -0
  357. package/src/lab/public/file-safety.ts +155 -0
  358. package/src/lab/public/ids.ts +26 -0
  359. package/src/lab/public/index.ts +16 -0
  360. package/src/lab/public/mutation-lock.ts +424 -0
  361. package/src/lab/public/operator.ts +353 -0
  362. package/src/lab/public/origin-purge.ts +79 -0
  363. package/src/lab/public/origin.ts +203 -0
  364. package/src/lab/public/privacy.ts +143 -0
  365. package/src/lab/public/private-file.ts +261 -0
  366. package/src/lab/public/project.ts +124 -0
  367. package/src/lab/public/purge-test-fault.ts +21 -0
  368. package/src/lab/public/purge.ts +223 -0
  369. package/src/lab/public/registry.ts +44 -0
  370. package/src/lab/public/revocation.ts +252 -0
  371. package/src/lab/public/signature.ts +243 -0
  372. package/src/lab/public/storage.ts +105 -0
  373. package/src/lab/public/strict-json.ts +206 -0
  374. package/src/lab/public/time.ts +26 -0
  375. package/src/lab/public/types.ts +172 -0
  376. package/src/lab/public/validate.ts +391 -0
  377. package/src/lab/query/catalog.ts +101 -0
  378. package/src/lab/query/connection.ts +107 -0
  379. package/src/lab/query/constants.ts +4 -0
  380. package/src/lab/query/cursor.ts +132 -0
  381. package/src/lab/query/dto-map.ts +277 -0
  382. package/src/lab/query/errors.ts +22 -0
  383. package/src/lab/query/freshness.ts +53 -0
  384. package/src/lab/query/index.ts +45 -0
  385. package/src/lab/query/latest-observation.ts +59 -0
  386. package/src/lab/query/passive-production.ts +159 -0
  387. package/src/lab/query/queries.ts +444 -0
  388. package/src/lab/query/types.ts +266 -0
  389. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  390. package/src/lab/subject/installation-salt.ts +112 -0
  391. package/src/lab/subject/protocol-subject.ts +80 -0
  392. package/src/lab/subject/route-subject.ts +74 -0
  393. package/src/lib/app-owned-memory-stores.ts +22 -0
  394. package/src/lib/bounded-body.ts +153 -9
  395. package/src/lib/bun-runtime.ts +125 -12
  396. package/src/lib/bun-stream-caps.ts +13 -9
  397. package/src/lib/codex-restart-contract.ts +120 -0
  398. package/src/lib/config-ownership.ts +6 -2
  399. package/src/lib/destination-policy.ts +65 -1
  400. package/src/lib/errors.ts +44 -2
  401. package/src/lib/fabric-task-execution-authority.ts +7 -0
  402. package/src/lib/fabric-task-host.ts +29 -0
  403. package/src/lib/lab-activation.ts +223 -0
  404. package/src/lib/lab-live-execution-authority.ts +13 -0
  405. package/src/lib/lab-live-host.ts +30 -0
  406. package/src/lib/lab-live-pinned-sender.ts +56 -0
  407. package/src/lib/lab-live-route-production.ts +130 -0
  408. package/src/lib/lab-passive-linker-registration.ts +26 -0
  409. package/src/lib/local-management-attestation.ts +51 -0
  410. package/src/lib/local-management-capability.ts +100 -0
  411. package/src/lib/local-provider-reload-contract.ts +100 -0
  412. package/src/lib/optional-shutdown-hooks.ts +57 -0
  413. package/src/lib/pinned-http.ts +145 -26
  414. package/src/lib/process-control.ts +4 -1
  415. package/src/lib/provider-outbound.ts +49 -9
  416. package/src/lib/redact.ts +419 -3
  417. package/src/lib/self-launch-argv.ts +15 -0
  418. package/src/lib/server-resource-ownership.ts +71 -0
  419. package/src/lib/shadow-call.ts +35 -4
  420. package/src/lib/sse-decoder.ts +41 -0
  421. package/src/lib/state-store-registrations.ts +10 -2
  422. package/src/lib/system-restart-contract.ts +73 -0
  423. package/src/lib/token-estimate.ts +19 -2
  424. package/src/lib/tool-argument-integers.ts +202 -0
  425. package/src/lib/translator-budget.ts +44 -0
  426. package/src/lib/upstream-http-version.ts +57 -0
  427. package/src/lib/upstream-reachability.ts +95 -0
  428. package/src/lib/upstream-retry.ts +156 -3
  429. package/src/lib/windows-atomic-replace.ts +155 -0
  430. package/src/lib/windows-elevation.ts +70 -2
  431. package/src/lib/windows-secret-acl.ts +409 -69
  432. package/src/lib/windows-service-wrappers.ts +72 -0
  433. package/src/lib/windows-text.ts +106 -0
  434. package/src/lib/windows-user-principal.ts +341 -0
  435. package/src/lib/winsw.ts +33 -5
  436. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  437. package/src/oauth/account-import/index.ts +15 -0
  438. package/src/oauth/account-import/parser.ts +83 -0
  439. package/src/oauth/account-import/registry.ts +18 -0
  440. package/src/oauth/account-import/service.ts +75 -0
  441. package/src/oauth/account-import/types.ts +91 -0
  442. package/src/oauth/anthropic.ts +12 -1
  443. package/src/oauth/callback-server.ts +8 -2
  444. package/src/oauth/chatgpt.ts +12 -1
  445. package/src/oauth/command-code.ts +239 -0
  446. package/src/oauth/cursor.ts +46 -5
  447. package/src/oauth/google-antigravity.ts +35 -3
  448. package/src/oauth/health.ts +20 -12
  449. package/src/oauth/index.ts +398 -66
  450. package/src/oauth/key-providers.ts +16 -0
  451. package/src/oauth/kimi.ts +16 -2
  452. package/src/oauth/kiro.ts +50 -6
  453. package/src/oauth/local-token-detect.ts +11 -2
  454. package/src/oauth/log.ts +3 -1
  455. package/src/oauth/login-cli.ts +88 -28
  456. package/src/oauth/nous.ts +798 -0
  457. package/src/oauth/store.ts +119 -21
  458. package/src/oauth/token-guardian.ts +9 -3
  459. package/src/pi/models.ts +2 -2
  460. package/src/providers/alibaba-region-migration.ts +1 -1
  461. package/src/providers/antigravity-models.ts +521 -31
  462. package/src/providers/base-url-choices.ts +10 -0
  463. package/src/providers/codex-capacity.ts +292 -0
  464. package/src/providers/command-code-efforts.ts +144 -0
  465. package/src/providers/context-cap.ts +22 -5
  466. package/src/providers/cursor-pool.ts +72 -0
  467. package/src/providers/derive.ts +253 -6
  468. package/src/providers/fastwire.ts +501 -0
  469. package/src/providers/free-directory.ts +10 -7
  470. package/src/providers/google-vertex-location.ts +14 -0
  471. package/src/providers/key-failover.ts +71 -3
  472. package/src/providers/label.ts +1 -1
  473. package/src/providers/model-discovery-limits.ts +16 -0
  474. package/src/providers/model-discovery.ts +115 -22
  475. package/src/providers/model-rename-migration.ts +255 -0
  476. package/src/providers/model-rename-startup.ts +28 -0
  477. package/src/providers/openai-sidecar.ts +72 -4
  478. package/src/providers/openai-tier-startup.ts +31 -2
  479. package/src/providers/openai-tiers.ts +119 -4
  480. package/src/providers/openai-virtual-models.ts +1 -0
  481. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  482. package/src/providers/provider-id-rewrite.ts +29 -0
  483. package/src/providers/quota.ts +1319 -38
  484. package/src/providers/registry.ts +1429 -111
  485. package/src/providers/request-pacing.ts +310 -0
  486. package/src/providers/service-tier.ts +277 -0
  487. package/src/providers/slug-codec.ts +42 -6
  488. package/src/providers/static-model-discovery.ts +86 -0
  489. package/src/providers/xai-responses-opt-in.ts +15 -0
  490. package/src/providers/xai-transport.ts +11 -4
  491. package/src/reasoning-effort.ts +49 -1
  492. package/src/responses/compaction.ts +26 -1
  493. package/src/responses/custom-tool-compat.ts +266 -0
  494. package/src/responses/hosted-tool-policy.ts +9 -0
  495. package/src/responses/namespace-tool-compat.ts +355 -0
  496. package/src/responses/parser.ts +220 -38
  497. package/src/responses/provider-continuation.ts +98 -0
  498. package/src/responses/provider-opaque-metadata.ts +73 -0
  499. package/src/responses/reasoning-envelope.ts +9 -1
  500. package/src/responses/reasoning-replay-cache.ts +426 -0
  501. package/src/responses/schema.ts +7 -1
  502. package/src/responses/spill-store.ts +75 -10
  503. package/src/responses/state.ts +565 -27
  504. package/src/responses/thought-signature-replay.ts +347 -0
  505. package/src/responses/tool-search-compat.ts +301 -0
  506. package/src/responses/truncated-stop-reason.ts +60 -0
  507. package/src/router.ts +366 -30
  508. package/src/routing/analytics.ts +378 -0
  509. package/src/routing/capability.ts +244 -0
  510. package/src/routing/compatibility/assemble.ts +73 -0
  511. package/src/routing/compatibility/behavior.ts +278 -0
  512. package/src/routing/compatibility/catalog.ts +99 -0
  513. package/src/routing/compatibility/endpoint.ts +52 -0
  514. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  515. package/src/routing/compatibility/policy.ts +181 -0
  516. package/src/routing/compatibility/provider-slot.ts +56 -0
  517. package/src/routing/compatibility/reader.ts +110 -0
  518. package/src/routing/compatibility/subject.ts +191 -0
  519. package/src/routing/compatibility/types.ts +64 -0
  520. package/src/routing/compatibility/version.ts +104 -0
  521. package/src/routing/cost.ts +77 -0
  522. package/src/routing/evaluator.ts +495 -0
  523. package/src/routing/health.ts +412 -0
  524. package/src/routing/history/cursor.ts +43 -0
  525. package/src/routing/history/indexer.ts +605 -0
  526. package/src/routing/history/schema.ts +72 -0
  527. package/src/routing/profile-namespace.ts +15 -0
  528. package/src/routing/profile.ts +547 -0
  529. package/src/routing/quota.ts +145 -0
  530. package/src/routing/request-evidence.ts +45 -0
  531. package/src/routing/trace.ts +776 -0
  532. package/src/server/adapter-resolve.ts +2 -29
  533. package/src/server/auth-cors.ts +267 -46
  534. package/src/server/background-lifecycle.ts +182 -0
  535. package/src/server/chat-completions.ts +130 -56
  536. package/src/server/chat-native-sse.ts +331 -0
  537. package/src/server/chat-native.ts +426 -0
  538. package/src/server/claude-messages.ts +159 -43
  539. package/src/server/direct-local-http.ts +347 -0
  540. package/src/server/effort-policy.ts +18 -0
  541. package/src/server/github-copilot-responses-repair.ts +338 -0
  542. package/src/server/gui-static.ts +39 -10
  543. package/src/server/images.ts +94 -12
  544. package/src/server/index.ts +865 -181
  545. package/src/server/lifecycle.ts +284 -13
  546. package/src/server/live.ts +136 -17
  547. package/src/server/local-management-read-client.ts +90 -0
  548. package/src/server/local-provider-reload-client.ts +137 -0
  549. package/src/server/management/agent-settings-routes.ts +398 -103
  550. package/src/server/management/api-key-usage.ts +31 -5
  551. package/src/server/management/body.ts +6 -0
  552. package/src/server/management/combo-routes.ts +62 -24
  553. package/src/server/management/config-routes.ts +464 -51
  554. package/src/server/management/context.ts +80 -2
  555. package/src/server/management/integration-routes.ts +498 -0
  556. package/src/server/management/lab-automation-routes.ts +206 -0
  557. package/src/server/management/lab-routes.ts +563 -0
  558. package/src/server/management/logs-usage-routes.ts +101 -32
  559. package/src/server/management/model-routes.ts +189 -131
  560. package/src/server/management/model-rows.ts +163 -0
  561. package/src/server/management/native-integration-routes.ts +769 -0
  562. package/src/server/management/oauth-account-routes.ts +80 -4
  563. package/src/server/management/provider-capability-config.ts +48 -0
  564. package/src/server/management/provider-routes.ts +764 -157
  565. package/src/server/management/request-history-routes.ts +191 -0
  566. package/src/server/management/routing-analytics-routes.ts +74 -0
  567. package/src/server/management/routing-profile-routes.ts +380 -0
  568. package/src/server/management/shared.ts +27 -11
  569. package/src/server/management/sidebar-routes.ts +47 -31
  570. package/src/server/management/storage-log-guard-routes.ts +186 -0
  571. package/src/server/management/sync-response.ts +69 -0
  572. package/src/server/management/system-restart.ts +288 -32
  573. package/src/server/management/system-routes.ts +77 -0
  574. package/src/server/management/usage-summary-cache.ts +9 -1
  575. package/src/server/management/vision-sidecar-options.ts +167 -0
  576. package/src/server/management/web-search-sidecar-options.ts +120 -0
  577. package/src/server/management-api.ts +115 -14
  578. package/src/server/management-auth.ts +220 -5
  579. package/src/server/passive-route-linker.ts +66 -0
  580. package/src/server/ports.ts +41 -1
  581. package/src/server/proxy-liveness.ts +132 -5
  582. package/src/server/readiness.ts +99 -0
  583. package/src/server/relay-eager.ts +82 -42
  584. package/src/server/relay.ts +236 -76
  585. package/src/server/request-decompress.ts +113 -6
  586. package/src/server/request-log.ts +235 -22
  587. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  588. package/src/server/responses/agent-task-recovery.ts +465 -0
  589. package/src/server/responses/collaboration.ts +204 -35
  590. package/src/server/responses/compact.ts +442 -55
  591. package/src/server/responses/core.ts +2872 -331
  592. package/src/server/responses/empty-completion-guard.ts +276 -0
  593. package/src/server/responses/encrypted-payload.ts +62 -39
  594. package/src/server/responses/fetch-helpers.ts +79 -4
  595. package/src/server/responses/input-admission.ts +185 -0
  596. package/src/server/responses/pacing-overload.ts +13 -0
  597. package/src/server/responses/policy-fallback.ts +178 -0
  598. package/src/server/responses/responses-field-backfill.ts +251 -0
  599. package/src/server/responses/terminal-guard.ts +26 -5
  600. package/src/server/responses/upstream-error.ts +5 -0
  601. package/src/server/responses/ws-upstream.ts +308 -0
  602. package/src/server/responses-custom-tool-repair.ts +282 -0
  603. package/src/server/responses-item-id-repair.ts +54 -6
  604. package/src/server/responses-json-events.ts +90 -0
  605. package/src/server/responses-model-rewrite.ts +29 -0
  606. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  607. package/src/server/responses-snapshot-repair.ts +621 -0
  608. package/src/server/responses-terminal-repair.ts +342 -0
  609. package/src/server/responses-tool-search-repair.ts +267 -0
  610. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  611. package/src/server/responses.ts +18 -2
  612. package/src/server/search.ts +78 -13
  613. package/src/server/sse-frame-buffer.ts +292 -0
  614. package/src/server/sse-payload-rewrite.ts +110 -22
  615. package/src/server/startup-action-control.ts +8 -1
  616. package/src/server/startup-health-cache.ts +19 -1
  617. package/src/server/system-env.ts +80 -9
  618. package/src/server/ws-bridge.ts +39 -38
  619. package/src/service-manager-probe.ts +892 -0
  620. package/src/service.ts +1111 -90
  621. package/src/sidecar/auth.ts +92 -0
  622. package/src/sidecar/candidates.ts +83 -0
  623. package/src/storage/cleanup.ts +2 -2
  624. package/src/storage/scanner.ts +1 -1
  625. package/src/storage/worker-lifecycle.ts +14 -14
  626. package/src/tray/windows-tray.ps1 +83 -9
  627. package/src/tray/windows.ts +43 -16
  628. package/src/types/accounts.ts +37 -0
  629. package/src/types/config.ts +845 -0
  630. package/src/types/provider.ts +545 -0
  631. package/src/types/request.ts +384 -0
  632. package/src/types/tools.ts +131 -0
  633. package/src/types/wire.ts +80 -0
  634. package/src/types.ts +104 -1236
  635. package/src/update/index.ts +32 -19
  636. package/src/update/job.ts +442 -67
  637. package/src/update/notify.ts +12 -6
  638. package/src/update/npm-cache-preflight.d.mts +47 -0
  639. package/src/update/npm-cache-preflight.mjs +201 -0
  640. package/src/update/transactional-install.d.mts +22 -0
  641. package/src/update/transactional-install.mjs +259 -0
  642. package/src/usage/cost.ts +0 -0
  643. package/src/usage/expected-prices.ts +268 -16
  644. package/src/usage/log.ts +606 -41
  645. package/src/usage/summary.ts +177 -9
  646. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  647. package/src/usage/user-cost-overlays.ts +314 -0
  648. package/src/vision/anthropic-describe.ts +10 -6
  649. package/src/vision/backends.ts +97 -0
  650. package/src/vision/describe.ts +9 -3
  651. package/src/vision/eligibility.ts +250 -0
  652. package/src/vision/index.ts +238 -24
  653. package/src/vision/reasoning.ts +55 -0
  654. package/src/vision/routed-describe.ts +175 -0
  655. package/src/vision/timeout-bounds.ts +9 -0
  656. package/src/web-search/anthropic-executor.ts +13 -7
  657. package/src/web-search/backends.ts +108 -0
  658. package/src/web-search/exa-executor.ts +88 -0
  659. package/src/web-search/executor.ts +11 -3
  660. package/src/web-search/gemini-executor.ts +141 -0
  661. package/src/web-search/index.ts +150 -15
  662. package/src/web-search/loop.ts +279 -50
  663. package/src/web-search/parse.ts +125 -30
  664. package/src/web-search/sources.ts +60 -0
  665. package/src/web-search/xai-executor.ts +219 -0
  666. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  667. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  668. package/gui/dist/provider-icons/antigravity.svg +0 -1
  669. package/gui/dist/provider-icons/claude.svg +0 -1
  670. package/gui/dist/provider-icons/copilot.svg +0 -1
  671. package/gui/dist/provider-icons/cursor.svg +0 -2
  672. package/gui/dist/provider-icons/gemini.svg +0 -1
  673. package/gui/dist/provider-icons/grok-color.svg +0 -1
  674. package/gui/dist/provider-icons/kiro.svg +0 -14
  675. package/src/cli/internal-dispatch.ts +0 -20
@@ -1,15 +1,31 @@
1
1
  import type { AdapterRequest, ProviderAdapter } from "./base";
2
2
  import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
3
- import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
3
+ import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
4
4
  import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
5
5
  import { debugProviderDiagnostic } from "../lib/debug";
6
+ import { sseFieldValue } from "../lib/sse-decoder";
6
7
  import { isDebugEnabled } from "../lib/debug-settings";
7
8
  import { isCyberPolicyCode } from "../lib/errors";
8
9
  import { redactSecretString } from "../lib/redact";
9
10
  import { contentPartsToText } from "./image";
10
- import { neutralizeIdentity } from "./identity";
11
+ import { identifyRoutedModel } from "./identity";
12
+ import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
11
13
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
12
14
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
15
+ import {
16
+ canForwardForeignServiceTierForChatModel,
17
+ fastPolicyForModel,
18
+ supportsServiceTierForModel,
19
+ } from "../providers/service-tier";
20
+ import {
21
+ canonicalFastTierMarker,
22
+ createAdapterTierMetadata,
23
+ decideTier,
24
+ type AdapterTierMetadata,
25
+ type ResolvedFastPolicy,
26
+ } from "../providers/fastwire";
27
+ import { openaiChatCompletionsUrl } from "./openai-chat-url";
28
+ import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
13
29
  import {
14
30
  isTranslatorBudgetExceededError,
15
31
  retainTranslatedEventBatch,
@@ -21,7 +37,148 @@ import {
21
37
  // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
22
38
  // unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim.
23
39
  export function stripBracketedModelSuffix(modelId: string): string {
24
- return modelId.replace(/\[[^\]]*\]\s*$/, "");
40
+ const suffixEnd = modelId.trimEnd().length;
41
+ if (suffixEnd === 0 || modelId[suffixEnd - 1] !== "]") return modelId;
42
+
43
+ let suffixStart = -1;
44
+ for (let i = suffixEnd - 2; i >= 0 && modelId[i] !== "]"; i--) {
45
+ if (modelId[i] === "[") suffixStart = i;
46
+ }
47
+ return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart);
48
+ }
49
+
50
+ const CHAT_PASSTHROUGH_FIELDS = [
51
+ "audio",
52
+ "frequency_penalty",
53
+ "logit_bias",
54
+ "logprobs",
55
+ "max_completion_tokens",
56
+ "max_tokens",
57
+ "metadata",
58
+ "modalities",
59
+ "n",
60
+ "prediction",
61
+ "presence_penalty",
62
+ "reasoning_effort",
63
+ "response_format",
64
+ "seed",
65
+ "stop",
66
+ "store",
67
+ "temperature",
68
+ "tool_choice",
69
+ "tools",
70
+ "top_logprobs",
71
+ "top_p",
72
+ "user",
73
+ "web_search_options",
74
+ ] as const;
75
+
76
+ function openAIChatTransport(provider: OcxProviderConfig): {
77
+ url: string;
78
+ headers: Record<string, string>;
79
+ hasCredential: boolean;
80
+ } {
81
+ const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
82
+ if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
83
+ throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
84
+ }
85
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
86
+ if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
87
+ if (provider.headers) Object.assign(headers, provider.headers);
88
+ return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential };
89
+ }
90
+
91
+ /**
92
+ * Build a provider request from an inbound Chat Completions body without translating it
93
+ * through the Responses contract. This is deliberately a whitelist: Chat-only caller
94
+ * fields retain their exact wire representation, while provider capability gates remain
95
+ * centralized beside the ordinary openai-chat adapter.
96
+ */
97
+ export function buildOpenAIChatPassthroughRequest(
98
+ provider: OcxProviderConfig,
99
+ rawBody: Record<string, unknown>,
100
+ modelId: string,
101
+ stream: boolean,
102
+ fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"),
103
+ fastMode?: boolean,
104
+ ): AdapterRequest {
105
+ const { url, headers, hasCredential } = openAIChatTransport(provider);
106
+
107
+ const body: Record<string, unknown> = {
108
+ model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
109
+ messages: rawBody.messages,
110
+ stream,
111
+ };
112
+ for (const field of CHAT_PASSTHROUGH_FIELDS) {
113
+ if (rawBody[field] !== undefined) body[field] = rawBody[field];
114
+ }
115
+
116
+ const openRouterRouting = resolveOpenRouterRouting(provider, modelId);
117
+ if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
118
+
119
+ if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature;
120
+ if (modelInList(provider.noTopPModels, modelId)) delete body.top_p;
121
+ if (modelInList(provider.noPenaltyModels, modelId)) {
122
+ delete body.presence_penalty;
123
+ delete body.frequency_penalty;
124
+ }
125
+ // Exact match, unlike the gates above: `noStructuredOutputModels` is documented as
126
+ // "only an exact requested-model match omits the field" (#1424), and the Responses
127
+ // ingress enforces exactly that. A prefix match here would strip response_format from
128
+ // `<listed>:<tag>` siblings the operator never opted out, silently returning prose.
129
+ if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;
130
+
131
+ // Run the same complete Fast policy as the translated Chat path, including explicit
132
+ // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still
133
+ // retains the caller's exact spelling; forced Fast uses the policy-owned wire value.
134
+ const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined;
135
+ const tierDecision = decideTier(fastPolicy, fastMode, callerTier);
136
+ if (tierDecision.kind === "set") {
137
+ body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined
138
+ ? callerTier
139
+ : tierDecision.value;
140
+ } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) {
141
+ body.service_tier = rawBody.service_tier;
142
+ }
143
+ if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
144
+ body.prompt_cache_key = rawBody.prompt_cache_key;
145
+ }
146
+ if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) {
147
+ if (provider.parallelToolCalls === true) {
148
+ body.parallel_tool_calls = rawBody.parallel_tool_calls !== false;
149
+ } else if (provider.parallelToolCalls === false
150
+ && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) {
151
+ body.parallel_tool_calls = false;
152
+ }
153
+ }
154
+ if (stream) {
155
+ const callerOptions = rawBody.stream_options !== null
156
+ && typeof rawBody.stream_options === "object"
157
+ && !Array.isArray(rawBody.stream_options)
158
+ ? rawBody.stream_options as Record<string, unknown>
159
+ : {};
160
+ body.stream_options = { ...callerOptions, include_usage: true };
161
+ } else if (rawBody.stream_options !== undefined) {
162
+ body.stream_options = rawBody.stream_options;
163
+ }
164
+
165
+ const bodyJson = JSON.stringify(body);
166
+
167
+ if (isDebugEnabled()) {
168
+ let host = "upstream";
169
+ try { host = new URL(url).host; } catch { /* keep fallback */ }
170
+ debugProviderDiagnostic("openai-chat", "passthrough-request", {
171
+ host,
172
+ model: body.model,
173
+ stream,
174
+ messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
175
+ toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
176
+ hasCredential,
177
+ bodyBytes: new TextEncoder().encode(bodyJson).length,
178
+ });
179
+ }
180
+
181
+ return { url, method: "POST", headers, body: bodyJson };
25
182
  }
26
183
 
27
184
  // 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
@@ -46,14 +203,12 @@ function extractErrorDetail(parsed: unknown): string | undefined {
46
203
  if (typeof parsed === "string") return parsed.trim() || undefined;
47
204
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
48
205
  const obj = parsed as Record<string, unknown>;
49
- // OpenAI shape: { error: { message } } or { error: "..." }
50
206
  const err = obj.error;
51
207
  if (typeof err === "string" && err.trim()) return err.trim();
52
208
  if (err !== null && typeof err === "object" && !Array.isArray(err)) {
53
209
  const msg = (err as Record<string, unknown>).message;
54
210
  if (typeof msg === "string" && msg.trim()) return msg.trim();
55
211
  }
56
- // FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
57
212
  const det = obj.detail;
58
213
  if (typeof det === "string" && det.trim()) return det.trim();
59
214
  if (Array.isArray(det)) {
@@ -64,12 +219,349 @@ function extractErrorDetail(parsed: unknown): string | undefined {
64
219
  .filter(m => m.length > 0);
65
220
  if (msgs.length > 0) return msgs.join("; ");
66
221
  }
67
- // Generic fallbacks: { message } / RFC7807 { title }
68
222
  if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
69
223
  if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
70
224
  return undefined;
71
225
  }
72
226
 
227
+ function unwrapChatCompletionPayload(json: Record<string, unknown>): Record<string, unknown> {
228
+ if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json;
229
+ const data = json.data;
230
+ return data !== null && typeof data === "object" && !Array.isArray(data)
231
+ ? data as Record<string, unknown>
232
+ : json;
233
+ }
234
+
235
+ interface OpenAIChatError {
236
+ message?: unknown;
237
+ code?: unknown;
238
+ type?: unknown;
239
+ status?: unknown;
240
+ metadata?: unknown;
241
+ }
242
+
243
+ function safeUpstreamRequestId(metadata: unknown): string | undefined {
244
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
245
+ const record = metadata as Record<string, unknown>;
246
+ const value = record.request_id ?? record.requestId;
247
+ if (typeof value !== "string") return undefined;
248
+ const requestId = value.trim();
249
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId)
250
+ && redactSecretString(requestId) === requestId
251
+ ? requestId
252
+ : undefined;
253
+ }
254
+
255
+ function upstreamErrorEvent(
256
+ error: unknown,
257
+ usage?: OcxUsage,
258
+ ): Extract<AdapterEvent, { type: "error" }> {
259
+ const details = error !== null && typeof error === "object" && !Array.isArray(error)
260
+ ? error as OpenAIChatError
261
+ : undefined;
262
+ const rawMessage = typeof error === "string"
263
+ ? error.trim() || "upstream error"
264
+ : typeof details?.message === "string" ? details.message : "upstream error";
265
+ const safeMessage = redactSecretString(rawMessage);
266
+ const requestId = safeUpstreamRequestId(details?.metadata);
267
+ const message = requestId !== undefined && !safeMessage.includes(requestId)
268
+ ? `${safeMessage} (request ID: ${requestId})`
269
+ : safeMessage;
270
+ const code = typeof details?.code === "string"
271
+ ? details.code
272
+ : typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code)
273
+ ? String(details.code)
274
+ : undefined;
275
+ const errorType = typeof details?.type === "string" ? details.type : undefined;
276
+ const codeStatus = typeof details?.code === "number"
277
+ && Number.isInteger(details.code)
278
+ && details.code >= 100
279
+ && details.code <= 599
280
+ ? details.code
281
+ : undefined;
282
+ const status = isCyberPolicyCode(code)
283
+ ? 400
284
+ : typeof details?.status === "number" && Number.isInteger(details.status)
285
+ ? details.status
286
+ : codeStatus;
287
+ return {
288
+ type: "error",
289
+ message,
290
+ ...(usage !== undefined ? { usage } : {}),
291
+ ...(code !== undefined ? { code } : {}),
292
+ ...(errorType !== undefined ? { errorType } : {}),
293
+ ...(status !== undefined ? { status } : {}),
294
+ };
295
+ }
296
+
297
+ function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined {
298
+ return finishReason === "length"
299
+ ? "max_tokens"
300
+ : finishReason === "content_filter"
301
+ ? "content_filter"
302
+ : undefined;
303
+ }
304
+
305
+ function reasoningTextFrom(record: Record<string, unknown>): string | undefined {
306
+ return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0
307
+ ? record.reasoning_content
308
+ : typeof record.reasoning === "string" && record.reasoning.length > 0
309
+ ? record.reasoning
310
+ : undefined;
311
+ }
312
+
313
+ function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
314
+ return {
315
+ type: "error",
316
+ message: "upstream response contained invalid choices",
317
+ ...(usage !== undefined ? { usage } : {}),
318
+ };
319
+ }
320
+
321
+ function invalidToolCallsEvent(
322
+ rawToolCalls: unknown,
323
+ mode: "stream" | "response",
324
+ usage?: OcxUsage,
325
+ diagnosticOverride?: InvalidToolCallDiagnostic,
326
+ ): Extract<AdapterEvent, { type: "error" }> {
327
+ // The streamed accumulator knows things a rescan cannot: which field on which pending call
328
+ // was actually rejected. Without the override, a stream carrying accepted padding on call 0
329
+ // and a real defect on call 1 blames call 0, because the stateless scan stops at the first
330
+ // structurally odd value it sees.
331
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
332
+ const detail = diagnostic
333
+ ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
334
+ : "";
335
+ return {
336
+ type: "error",
337
+ status: 502,
338
+ errorType: "upstream_error",
339
+ message: `upstream response contained invalid tool calls${detail}`,
340
+ ...(usage !== undefined ? { usage } : {}),
341
+ };
342
+ }
343
+
344
+ /**
345
+ * A streamed tool call is only dispatchable once the upstream has named the function.
346
+ *
347
+ * The OpenAI streaming convention puts `function.name` in the first chunk for a tool-call
348
+ * index and leaves later chunks carrying only `arguments` deltas, so a stream that never
349
+ * sends a name is non-conforming for every provider rather than quirky for one. The
350
+ * reference implementations accumulate such a call with an empty name and let the caller
351
+ * fail; we sit at the boundary where it would become a Codex tool-call contract event, so
352
+ * the equivalent is to refuse to emit it.
353
+ *
354
+ * Failing closed rather than dropping is deliberate, and matches #1325: a claimed tool call
355
+ * that silently disappears can leave the matching result orphaned on the next turn. Naming
356
+ * it ourselves is worse still — the id is synthesizable because it is an opaque correlation
357
+ * handle, but a function name is a guess at intent.
358
+ */
359
+ function unnamedToolCallEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
360
+ return {
361
+ type: "error",
362
+ message: "upstream streamed a tool call without a function name — cannot dispatch",
363
+ ...(usage !== undefined ? { usage } : {}),
364
+ };
365
+ }
366
+
367
+ function isRecord(value: unknown): value is Record<string, unknown> {
368
+ return value !== null && typeof value === "object" && !Array.isArray(value);
369
+ }
370
+
371
+ type InvalidToolCallReason =
372
+ | "tool_calls_not_array"
373
+ | "tool_call_not_object"
374
+ | "tool_call_id_invalid"
375
+ | "tool_call_function_not_object"
376
+ | "tool_call_function_name_invalid"
377
+ | "tool_call_function_name_blank"
378
+ | "tool_call_function_arguments_invalid";
379
+
380
+ type InvalidToolCallDiagnostic = {
381
+ reason: InvalidToolCallReason;
382
+ callIndex?: number;
383
+ valueType: string;
384
+ };
385
+
386
+ type InvalidFieldShape =
387
+ | {
388
+ kind: "object";
389
+ knownKeys: string[];
390
+ knownFieldTypes: Record<string, string>;
391
+ hasUnknownKeys: boolean;
392
+ }
393
+ | {
394
+ kind: "array";
395
+ length: number;
396
+ };
397
+
398
+ const SAFE_TOOL_CALL_SHAPE_KEYS = [
399
+ "name",
400
+ "type",
401
+ "value",
402
+ "function",
403
+ "arguments",
404
+ "id",
405
+ "index",
406
+ ] as const;
407
+ const SAFE_TOOL_CALL_SHAPE_KEY_SET = new Set<string>(SAFE_TOOL_CALL_SHAPE_KEYS);
408
+
409
+ function structuralValueType(value: unknown): string {
410
+ return value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
411
+ }
412
+
413
+ function invalidToolCallField(rawToolCalls: unknown, diagnostic: InvalidToolCallDiagnostic): unknown {
414
+ if (diagnostic.reason === "tool_calls_not_array") return rawToolCalls;
415
+ if (!Array.isArray(rawToolCalls) || diagnostic.callIndex === undefined) return undefined;
416
+
417
+ const rawToolCall = rawToolCalls[diagnostic.callIndex];
418
+ if (diagnostic.reason === "tool_call_not_object") return rawToolCall;
419
+ if (!isRecord(rawToolCall)) return undefined;
420
+ if (diagnostic.reason === "tool_call_function_not_object") return rawToolCall.function;
421
+
422
+ const rawFunction = rawToolCall.function;
423
+ switch (diagnostic.reason) {
424
+ case "tool_call_id_invalid":
425
+ return rawToolCall.id;
426
+ case "tool_call_function_name_invalid":
427
+ return isRecord(rawFunction) ? rawFunction.name : undefined;
428
+ case "tool_call_function_arguments_invalid":
429
+ return isRecord(rawFunction) ? rawFunction.arguments : undefined;
430
+ default:
431
+ return undefined;
432
+ }
433
+ }
434
+
435
+ function fingerprintInvalidField(value: unknown): InvalidFieldShape | undefined {
436
+ if (Array.isArray(value)) return { kind: "array", length: value.length };
437
+ if (!isRecord(value)) return undefined;
438
+
439
+ const knownKeys: string[] = [];
440
+ const knownFieldTypes: Record<string, string> = {};
441
+ for (const key of SAFE_TOOL_CALL_SHAPE_KEYS) {
442
+ if (!Object.hasOwn(value, key)) continue;
443
+ knownKeys.push(key);
444
+ knownFieldTypes[key] = structuralValueType(value[key]);
445
+ }
446
+
447
+ let hasUnknownKeys = false;
448
+ for (const key of Object.keys(value)) {
449
+ if (!SAFE_TOOL_CALL_SHAPE_KEY_SET.has(key)) {
450
+ hasUnknownKeys = true;
451
+ break;
452
+ }
453
+ }
454
+ return { kind: "object", knownKeys, knownFieldTypes, hasUnknownKeys };
455
+ }
456
+
457
+ /**
458
+ * Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible
459
+ * streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas.
460
+ * The accumulator and this diagnostic share this predicate so they cannot disagree about
461
+ * which delta was the invalid one.
462
+ */
463
+ function isInvalidStreamStringField(value: unknown): boolean {
464
+ return value != null && typeof value !== "string";
465
+ }
466
+
467
+ /**
468
+ * Explain only the rejected wire shape, never its values. This diagnostic exists so provider
469
+ * compatibility can be tightened from evidence without retaining tool arguments or credentials.
470
+ */
471
+ function diagnoseInvalidToolCalls(
472
+ rawToolCalls: unknown,
473
+ mode: "stream" | "response",
474
+ ): InvalidToolCallDiagnostic | undefined {
475
+ if (!Array.isArray(rawToolCalls)) {
476
+ return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls };
477
+ }
478
+ for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
479
+ const rawToolCall = rawToolCalls[callIndex];
480
+ if (!isRecord(rawToolCall)) {
481
+ return {
482
+ reason: "tool_call_not_object",
483
+ callIndex,
484
+ valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
485
+ };
486
+ }
487
+ if (mode === "stream") {
488
+ // The streamed path validates the pieces it is about to store (#1531): a present
489
+ // `function` must be a record, and a present `name`/`arguments`/`id` must be a string.
490
+ // Blank names are caught later at flush, not here, so they are not diagnosed on this
491
+ // branch. Describe exactly that boundary rather than tightening compatibility in a
492
+ // diagnostic change.
493
+ // #1731: "present" means the same thing here as in the accumulator — null and undefined
494
+ // are both absent, because some OpenAI-compatible streamers repeat already-sent fields
495
+ // as null on continuation deltas. A separate predicate here would diagnose accepted
496
+ // padding as the failure and point compatibility work at the wrong delta.
497
+ const streamFunction = (rawToolCall as { function?: unknown }).function;
498
+ if (streamFunction !== undefined && streamFunction !== null) {
499
+ if (!isRecord(streamFunction)) {
500
+ return {
501
+ reason: "tool_call_function_not_object",
502
+ callIndex,
503
+ valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction,
504
+ };
505
+ }
506
+ if (isInvalidStreamStringField(streamFunction.name)) {
507
+ return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name };
508
+ }
509
+ if (isInvalidStreamStringField(streamFunction.arguments)) {
510
+ return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments };
511
+ }
512
+ }
513
+ if (isInvalidStreamStringField(rawToolCall.id)) {
514
+ return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
515
+ }
516
+ continue;
517
+ }
518
+ // Precedence must mirror the buffered validator below, or a payload with more than one
519
+ // problem is reported under the wrong reason and sends compatibility work after the wrong
520
+ // shape. That validator checks the `function` container first (`!isRecord(rawToolCall) ||
521
+ // !isRecord(rawToolCall.function)`), then id/name/arguments types together, and only then
522
+ // the blank name.
523
+ if (!isRecord(rawToolCall.function)) {
524
+ return {
525
+ reason: "tool_call_function_not_object",
526
+ callIndex,
527
+ valueType: rawToolCall.function === null ? "null" : Array.isArray(rawToolCall.function) ? "array" : typeof rawToolCall.function,
528
+ };
529
+ }
530
+ if (typeof rawToolCall.id !== "string") {
531
+ return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
532
+ }
533
+ if (typeof rawToolCall.function.name !== "string") {
534
+ return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawToolCall.function.name };
535
+ }
536
+ if (typeof rawToolCall.function.arguments !== "string") {
537
+ return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawToolCall.function.arguments };
538
+ }
539
+ // Last, matching the validator: #1531 also rejects a blank or whitespace-only name here,
540
+ // because such a call cannot select a dispatch target. Reporting it as `name_invalid`
541
+ // would claim a type problem for a correctly-typed value, so it gets its own code.
542
+ if (rawToolCall.function.name.trim().length === 0) {
543
+ return { reason: "tool_call_function_name_blank", callIndex, valueType: "string" };
544
+ }
545
+ }
546
+ return undefined;
547
+ }
548
+
549
+ function logInvalidToolCalls(
550
+ mode: "stream" | "response",
551
+ rawToolCalls: unknown,
552
+ diagnosticOverride?: InvalidToolCallDiagnostic,
553
+ ): void {
554
+ if (!isDebugEnabled()) return;
555
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
556
+ if (!diagnostic) return;
557
+ const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic));
558
+ debugProviderDiagnostic("openai-chat", "invalid-tool-calls", {
559
+ mode,
560
+ ...diagnostic,
561
+ ...(fieldShape ? { fieldShape } : {}),
562
+ });
563
+ }
564
+
73
565
  function developerSystemText(message: OcxMessage): string | undefined {
74
566
  if (message.role !== "developer") return undefined;
75
567
  if (typeof message.content === "string") return message.content;
@@ -77,20 +569,49 @@ function developerSystemText(message: OcxMessage): string | undefined {
77
569
  return message.content.map(part => (part as OcxTextContent).text).join("");
78
570
  }
79
571
 
572
+ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
573
+ try {
574
+ return new URL(provider.baseUrl).hostname === "api.openai.com";
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
582
+ * content is text-only on every chat provider, so these ride in a follow-up user message instead of
583
+ * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
584
+ * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
585
+ */
586
+ function toolResultTextForWire(content: string | OcxContentPart[]): string {
587
+ if (typeof content === "string") return content;
588
+ const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
589
+ if (text) {
590
+ const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
591
+ return `${text}${"[image]".repeat(untransportableImages)}`;
592
+ }
593
+ return contentPartsToText(content);
594
+ }
595
+
596
+ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
597
+ if (typeof content === "string") return [];
598
+ const parts: unknown[] = [];
599
+ for (const p of content) {
600
+ if (p.type !== "image" || !p.imageUrl) continue;
601
+ parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
602
+ }
603
+ return parts;
604
+ }
605
+
80
606
  function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
81
607
  const out: unknown[] = [];
82
608
  const { context, options } = parsed;
609
+ const replayCacheScope = parsed._reasoningReplayScope;
83
610
 
84
- // 260718 dangling tool_calls hardening (devlog/_plan/260718_dangling_toolcall_hardening):
85
- // strict chat providers (Kimi/Moonshot) 400 when an assistant tool_call is not answered
86
- // immediately by role:"tool" messages. Repair order: (1) reattach a real result to its
87
- // original call (barrier messages are DEFERRED until the open tool round closes),
88
- // (2) synthesize an explicit unavailable-result only when no real result exists,
89
- // (3) manufacture an orphan assistant call only when no call occurrence matches at all.
90
- // Occurrences are kept as an ordered list (never a Map) so duplicated ids survive.
91
611
  interface PendingToolCall { id: string; name: string }
92
612
  let pendingToolCalls: PendingToolCall[] = [];
93
613
  let deferredBarrierMessages: unknown[] = [];
614
+ let pendingToolResultImageParts: unknown[] = [];
94
615
  let mintedIdSeq = 0;
95
616
  const seenWireCallIds = new Set<string>();
96
617
 
@@ -109,9 +630,18 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
109
630
  deferredBarrierMessages = [];
110
631
  };
111
632
 
112
- // Close an unresolved tool round with explicit unavailable-result messages. The wording
113
- // must not claim interruption, success, failure, or user intent: execution status is
114
- // UNKNOWN, and for user-input tools this must not read as an answer.
633
+ const flushToolResultImages = (): void => {
634
+ if (pendingToolResultImageParts.length === 0) return;
635
+ out.push({
636
+ role: "user",
637
+ content: [
638
+ { type: "text", text: "[ocx] image output from the preceding tool result(s):" },
639
+ ...pendingToolResultImageParts,
640
+ ],
641
+ });
642
+ pendingToolResultImageParts = [];
643
+ };
644
+
115
645
  const flushPendingToolCalls = (): void => {
116
646
  if (pendingToolCalls.length === 0) return;
117
647
  for (const call of pendingToolCalls) {
@@ -122,32 +652,29 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
122
652
  });
123
653
  }
124
654
  pendingToolCalls = [];
655
+ flushToolResultImages();
125
656
  releaseDeferredBarriers();
126
657
  };
127
658
 
659
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
128
660
  const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
129
661
  ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
130
662
  : undefined;
131
- // Chat templates used by LM Studio, llama.cpp, and other strict OpenAI-compatible
132
- // backends require every system instruction to precede conversation history. Codex can
133
- // append developer reminders after user turns, so fold text-only developer messages into
134
- // the single leading system message instead of emitting role:"system" in place. Developer
135
- // messages with images cannot be represented as system content and remain user-compatible
136
- // vision messages at their original position below.
137
- const developerSystemParts = context.messages
138
- .map(developerSystemText)
139
- .filter((part): part is string => part !== undefined && part.length > 0);
663
+ const developerSystemParts = nativeOpenAI
664
+ ? []
665
+ : context.messages
666
+ .map(developerSystemText)
667
+ .filter((part): part is string => part !== undefined && part.length > 0);
140
668
  const systemParts = [
141
669
  ...(context.systemPrompt ?? []),
142
670
  ...developerSystemParts,
143
671
  ...(toolCatalogNudge ? [toolCatalogNudge] : []),
144
672
  ];
145
673
  if (systemParts.length > 0) {
146
- // Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
147
- // base_instructions is ignored at request time). Neutralize that one identity line
148
- // so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI — without
149
- // leaking the proxy identity into the payload.
150
- const sys = neutralizeIdentity(systemParts.join("\n\n"));
674
+ const wireModelId = provider.modelSuffixBracketStrip
675
+ ? stripBracketedModelSuffix(parsed.modelId)
676
+ : parsed.modelId;
677
+ const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId);
151
678
  out.push({ role: "system", content: sys });
152
679
  }
153
680
 
@@ -157,24 +684,23 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
157
684
  case "developer": {
158
685
  const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
159
686
  const hasImages = parts?.some(p => p.type === "image") ?? false;
160
- if (msg.role === "developer" && !hasImages) break;
161
687
  let chatMsg: Record<string, unknown>;
162
- if (typeof msg.content === "string") {
688
+ if (msg.role === "developer" && !hasImages) {
689
+ if (!nativeOpenAI) break;
690
+ const text = typeof msg.content === "string"
691
+ ? msg.content
692
+ : parts!.map(p => (p as OcxTextContent).text).join("");
693
+ chatMsg = { role: "developer", content: text };
694
+ } else if (typeof msg.content === "string") {
163
695
  chatMsg = { role: "user", content: msg.content };
696
+ } else if (!hasImages) {
697
+ chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
164
698
  } else {
165
- if (!hasImages) {
166
- chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
167
- } else {
168
- // Vision: chat-completions content-parts array. Images are only valid on the user role,
169
- // and the data URL goes straight into image_url.url (never the token-exploding text path).
170
- const chatParts = parts!.map(p => p.type === "image"
171
- ? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
172
- : { type: "text", text: (p as OcxTextContent).text });
173
- chatMsg = { role: "user", content: chatParts };
174
- }
699
+ const chatParts = parts!.map(p => p.type === "image"
700
+ ? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
701
+ : { type: "text", text: (p as OcxTextContent).text });
702
+ chatMsg = { role: "user", content: chatParts };
175
703
  }
176
- // A barrier must not split an open tool round: defer it until the round closes
177
- // (real result arrives) or the round is synthesized shut.
178
704
  if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
179
705
  else out.push(chatMsg);
180
706
  break;
@@ -185,18 +711,38 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
185
711
  const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
186
712
  const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
187
713
  const chatMsg: Record<string, unknown> = { role: "assistant" };
188
- if (textParts.length > 0) {
189
- chatMsg.content = textParts.map(p => p.text).join("");
714
+ if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join("");
715
+ let reasoningContent = thinkingParts.map(p => p.thinking).join("");
716
+ if (
717
+ reasoningContent.length === 0
718
+ && toolCalls.length > 0
719
+ && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
720
+ ) {
721
+ const cached = toolCalls
722
+ .map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined))
723
+ .filter((text): text is string => typeof text === "string" && text.length > 0);
724
+ // Parallel calls share one preceding reasoning block, which is
725
+ // recorded under every call id — join unique texts only.
726
+ if (cached.length > 0) {
727
+ reasoningContent = [...new Set(cached)].join("\n");
728
+ } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) {
729
+ // Fallback (extends #950, closes #1193): the replay cache is
730
+ // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on
731
+ // long sessions, and some tool rounds carry no recorded reasoning
732
+ // at all. DeepSeek thinking mode rejects ANY tool_call assistant
733
+ // message missing reasoning_content with HTTP 400, so inject a
734
+ // minimal placeholder rather than emit a bare continuation the
735
+ // upstream will reject. Scoped to requiresReasoningPlaceholderModels
736
+ // (defaulting to the preserve list): preserve-listed providers with
737
+ // toggleable thinking (MiniMax low effort) opt out with `[]` so
738
+ // non-thinking histories are never given a fabricated placeholder.
739
+ reasoningContent = " ";
740
+ }
190
741
  }
191
- const reasoningContent = thinkingParts.map(p => p.thinking).join("");
192
742
  if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
193
743
  chatMsg.reasoning_content = reasoningContent;
194
744
  }
195
- // Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message
196
- // with neither content, tool calls, nor a provider-supported reasoning_content field.
197
745
  if (chatMsg.content === undefined && toolCalls.length === 0 && chatMsg.reasoning_content === undefined) break;
198
- // A new assistant starts while a previous round is still open: close the previous
199
- // round synthetically first so its tool_calls are never left dangling.
200
746
  flushPendingToolCalls();
201
747
  const wireToolCalls = toolCalls.map(tc => {
202
748
  let id = tc.id;
@@ -210,8 +756,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
210
756
  type: "function",
211
757
  function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
212
758
  }));
213
- // "" instead of null: strict validators (xAI: "Each message must have at least one
214
- // content element", langchain#34140) reject content-less assistant history entries.
215
759
  if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
216
760
  }
217
761
  if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
@@ -225,26 +769,44 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
225
769
  let toolCallId = msg.toolCallId;
226
770
  const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
227
771
  if (matchIdx >= 0 && toolCallId) {
228
- // Real result reattached to its original call. Barriers were deferred, so the
229
- // tool message lands immediately inside the open round.
230
772
  out.push({
231
773
  role: "tool",
232
774
  tool_call_id: toolCallId,
233
- content: contentPartsToText(msg.content),
775
+ content: toolResultTextForWire(msg.content),
234
776
  });
777
+ pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
235
778
  pendingToolCalls.splice(matchIdx, 1);
236
- if (pendingToolCalls.length === 0) releaseDeferredBarriers();
779
+ if (pendingToolCalls.length === 0) {
780
+ flushToolResultImages();
781
+ releaseDeferredBarriers();
782
+ }
237
783
  } else {
238
784
  if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
239
- // No matching call in the open round. Close any unresolved round first so the
240
- // synthesized orphan pair never splits it, then keep the historical repair:
241
- // WS turns can arrive with only tool outputs; chat-completions providers reject a bare
242
- // role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
243
785
  flushPendingToolCalls();
244
786
  const name = safeToolName(msg.toolName);
787
+ const cachedReasoning =
788
+ toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
789
+ ? peekReasoningForCall(toolCallId, replayCacheScope)
790
+ : undefined;
791
+ // Same fallback as the main-assistant path: never emit a bare orphan
792
+ // tool_call continuation on a thinking-mode provider — inject a
793
+ // placeholder when the replay cache missed (the bounded cache can
794
+ // always miss on long sessions), or DeepSeek thinking mode 400s.
795
+ // Gate on the preserve list too: reasoning_content is only ever
796
+ // serialized for preserve-listed models, so a requires-only custom
797
+ // entry must not fabricate it on this path (P2 on #1205).
798
+ // `||` (not `??`): the cache never stores empty strings, but treat a
799
+ // falsy hit as a miss so the placeholder still fires.
800
+ const orphanReasoning =
801
+ cachedReasoning
802
+ || (modelInList(provider.preserveReasoningContentModels, parsed.modelId)
803
+ && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)
804
+ ? " "
805
+ : undefined);
245
806
  out.push({
246
807
  role: "assistant",
247
808
  content: emptyAssistantContent(provider),
809
+ ...(orphanReasoning ? { reasoning_content: orphanReasoning } : {}),
248
810
  tool_calls: [{
249
811
  id: toolCallId,
250
812
  type: "function",
@@ -255,16 +817,16 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
255
817
  out.push({
256
818
  role: "tool",
257
819
  tool_call_id: toolCallId,
258
- content: contentPartsToText(msg.content),
820
+ content: toolResultTextForWire(msg.content),
259
821
  });
822
+ pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
823
+ flushToolResultImages();
260
824
  }
261
825
  break;
262
826
  }
263
827
  }
264
828
  }
265
829
 
266
- // Trailing dangle: a turn interrupted after the assistant requested tools leaves the
267
- // round open; close it synthetically (then release any deferred barriers in order).
268
830
  flushPendingToolCalls();
269
831
  releaseDeferredBarriers();
270
832
  return out;
@@ -360,67 +922,36 @@ function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
360
922
  || baseUrl === "https://opencode.ai/zen/go/v1";
361
923
  }
362
924
 
363
- const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
364
-
365
925
  function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
366
926
  try {
367
- return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
368
- } catch {
369
- return false;
370
- }
371
- }
372
-
373
- function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
374
- try {
375
- return new URL(provider.baseUrl).hostname === "api.kimi.com";
927
+ // Public api.x.ai accepts native root object unions. Only the Grok CLI proxy
928
+ // 400s on a root oneOf/anyOf, so flattening/omitting is scoped to that host.
929
+ return new URL(provider.baseUrl).hostname === "cli-chat-proxy.grok.com";
376
930
  } catch {
377
931
  return false;
378
932
  }
379
933
  }
380
934
 
381
- // Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
382
- // REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
383
- // `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
384
- // "", and xAI actively requires it ("Each message must have at least one content element"), so
385
- // the two contracts are in direct conflict and this cannot be a global change.
386
935
  const VOLCENGINE_ARK_HOSTNAMES = new Set([
387
936
  "ark.cn-beijing.volces.com",
388
937
  "ark.ap-southeast.volces.com",
389
938
  ]);
390
939
 
391
- function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
940
+ function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean {
392
941
  try {
393
- return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
942
+ const url = new URL(provider.baseUrl);
943
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
944
+ return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3";
394
945
  } catch {
395
946
  return false;
396
947
  }
397
948
  }
398
949
 
399
- /**
400
- * Placeholder content for an assistant history entry carrying only tool calls or reasoning.
401
- *
402
- * UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
403
- * which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
404
- * bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
405
- * published examples only show array content for MULTIMODAL USER input, never for an assistant
406
- * history entry, so this shape is inferred from the error message and not confirmed by the docs
407
- * or by a live request. The empty inner text at least adds no tokens either way.
408
- *
409
- * Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
410
- *
411
- * Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
412
- * message must have at least one content element"), so this cannot be applied globally.
413
- */
414
950
  function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
415
- return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
951
+ return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : "";
416
952
  }
417
953
 
418
- /**
419
- * Kimi requires function.parameters.type to be exactly "object" at the root.
420
- * Codex tools with oneOf/anyOf schemas omit the root type, causing 400 errors.
421
- * Add type: "object" at the root while preserving oneOf, $defs, and other schema keys.
422
- */
423
- function ensureKimiRootObjectType(parameters: unknown): Record<string, unknown> {
954
+ function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
424
955
  if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
425
956
  return { type: "object", properties: {} };
426
957
  }
@@ -429,64 +960,288 @@ function ensureKimiRootObjectType(parameters: unknown): Record<string, unknown>
429
960
  return { ...obj, type: "object" };
430
961
  }
431
962
 
963
+ function isXaiObjectSchema(value: unknown): value is Record<string, unknown> {
964
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
965
+ }
966
+
967
+ function stringRequiredFields(value: unknown): string[] {
968
+ return Array.isArray(value)
969
+ ? value.filter((item): item is string => typeof item === "string")
970
+ : [];
971
+ }
972
+
973
+ /** Variant keys the merger can keep. Anything else is refused, not silently dropped. */
974
+ const XAI_VARIANT_MERGE_KEYS = new Set([
975
+ "type",
976
+ "properties",
977
+ "required",
978
+ "additionalProperties",
979
+ "description",
980
+ "title",
981
+ "$comment",
982
+ "$defs",
983
+ "definitions",
984
+ ]);
985
+
986
+ function decodeJsonPointerToken(token: string): string {
987
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
988
+ }
989
+
990
+ function lookupLocalJsonPointer(root: unknown, ref: string): unknown {
991
+ if (ref === "#" || ref === "#/") return root;
992
+ if (!ref.startsWith("#/")) return undefined;
993
+ let current: unknown = root;
994
+ for (const token of ref.slice(2).split("/").map(decodeJsonPointerToken)) {
995
+ if (!isXaiObjectSchema(current) || !Object.hasOwn(current, token)) return undefined;
996
+ current = current[token];
997
+ }
998
+ return current;
999
+ }
1000
+
1001
+ /** Resolve local `#/` `$ref`s. Unresolvable or cyclic refs return undefined. */
1002
+ function resolveXaiSchemaRefs(
1003
+ schema: unknown,
1004
+ root: Record<string, unknown>,
1005
+ stack: Set<string> = new Set(),
1006
+ ): unknown | undefined {
1007
+ if (!isXaiObjectSchema(schema)) return schema;
1008
+ if (typeof schema.$ref === "string") {
1009
+ const ref = schema.$ref;
1010
+ if (stack.has(ref)) return undefined;
1011
+ const target = lookupLocalJsonPointer(root, ref);
1012
+ if (target === undefined) return undefined;
1013
+ stack.add(ref);
1014
+ const resolvedTarget = resolveXaiSchemaRefs(target, root, stack);
1015
+ stack.delete(ref);
1016
+ if (resolvedTarget === undefined) return undefined;
1017
+ const rest: Record<string, unknown> = { ...schema };
1018
+ delete rest.$ref;
1019
+ if (Object.keys(rest).length === 0) return resolvedTarget;
1020
+ const resolvedRest = resolveXaiSchemaRefs(rest, root, stack);
1021
+ if (resolvedRest === undefined || !isXaiObjectSchema(resolvedTarget) || !isXaiObjectSchema(resolvedRest)) {
1022
+ return undefined;
1023
+ }
1024
+ return composeXaiObjectSchemas(resolvedTarget, resolvedRest);
1025
+ }
1026
+
1027
+ const resolved: Record<string, unknown> = {};
1028
+ for (const [key, value] of Object.entries(schema)) {
1029
+ if ((key === "oneOf" || key === "anyOf") && Array.isArray(value)) {
1030
+ const items: unknown[] = [];
1031
+ for (const item of value) {
1032
+ const next = resolveXaiSchemaRefs(item, root, stack);
1033
+ if (next === undefined) return undefined;
1034
+ items.push(next);
1035
+ }
1036
+ resolved[key] = items;
1037
+ continue;
1038
+ }
1039
+ if (key === "properties" && isXaiObjectSchema(value)) {
1040
+ const properties: Record<string, unknown> = {};
1041
+ for (const [name, property] of Object.entries(value)) {
1042
+ const next = resolveXaiSchemaRefs(property, root, stack);
1043
+ if (next === undefined) return undefined;
1044
+ properties[name] = next;
1045
+ }
1046
+ resolved[key] = properties;
1047
+ continue;
1048
+ }
1049
+ resolved[key] = value;
1050
+ }
1051
+ return resolved;
1052
+ }
1053
+
1054
+ function xaiVariantIsConcreteObject(variant: Record<string, unknown>): boolean {
1055
+ if (variant.type !== undefined && variant.type !== "object") return false;
1056
+ return Object.keys(variant).every(key => XAI_VARIANT_MERGE_KEYS.has(key));
1057
+ }
1058
+
1059
+ function variantProperties(variant: Record<string, unknown>): Record<string, unknown> {
1060
+ return isXaiObjectSchema(variant.properties) ? variant.properties : {};
1061
+ }
1062
+
1063
+ /**
1064
+ * Independent per-property anyOf is lossless only when every property name exists
1065
+ * on every variant (absence is meaningful under xAI's default additionalProperties:
1066
+ * false, and promoting a branch-local key also tightens explicit-true variants)
1067
+ * and at most one of those shared properties has a conflicting schema.
1068
+ */
1069
+ function xaiPropertyMergeIsLossless(variants: Record<string, unknown>[]): boolean {
1070
+ const names = new Set<string>();
1071
+ const props = variants.map(variant => {
1072
+ const properties = variantProperties(variant);
1073
+ for (const name of Object.keys(properties)) names.add(name);
1074
+ return properties;
1075
+ });
1076
+ let schemaConflicts = 0;
1077
+ for (const name of names) {
1078
+ const values = props.map(property => property[name]);
1079
+ if (values.some(value => value === undefined)) return false;
1080
+ if (values.some(value => JSON.stringify(value) !== JSON.stringify(values[0]))) schemaConflicts += 1;
1081
+ }
1082
+ return schemaConflicts <= 1;
1083
+ }
1084
+
1085
+ function xaiRequiredSetsMatch(variants: Record<string, unknown>[]): boolean {
1086
+ const serialized = variants.map(variant => [...stringRequiredFields(variant.required)].sort().join("\0"));
1087
+ return serialized.every(value => value === serialized[0]);
1088
+ }
1089
+
1090
+ function mergeXaiAdditionalProperties(
1091
+ variants: Record<string, unknown>[],
1092
+ ): { ok: true; value?: unknown } | { ok: false } {
1093
+ const values = variants.map(variant => variant.additionalProperties);
1094
+ const explicit = values.filter(value => value !== undefined);
1095
+ if (explicit.length === 0) return { ok: true };
1096
+ if (explicit.length !== values.length) return { ok: false };
1097
+ const hasFalse = explicit.some(value => value === false);
1098
+ const permissive = explicit.filter(value => value !== false);
1099
+ if (hasFalse && permissive.length > 0) return { ok: false };
1100
+ if (hasFalse) return { ok: true, value: false };
1101
+ const unique: unknown[] = [];
1102
+ const seen = new Set<string>();
1103
+ for (const value of permissive) {
1104
+ const key = JSON.stringify(value);
1105
+ if (seen.has(key)) continue;
1106
+ seen.add(key);
1107
+ unique.push(value);
1108
+ }
1109
+ if (unique.length !== 1) return { ok: false };
1110
+ return { ok: true, value: unique[0] };
1111
+ }
1112
+
1113
+ /** Compose root siblings into a branch so properties/required are not overwritten. */
1114
+ function composeXaiObjectSchemas(
1115
+ inherited: Record<string, unknown>,
1116
+ branch: Record<string, unknown>,
1117
+ ): Record<string, unknown> {
1118
+ const composed: Record<string, unknown> = { ...inherited, ...branch };
1119
+ const inheritedProps = isXaiObjectSchema(inherited.properties) ? inherited.properties : undefined;
1120
+ const branchProps = isXaiObjectSchema(branch.properties) ? branch.properties : undefined;
1121
+ if (inheritedProps || branchProps) {
1122
+ const properties: Record<string, unknown> = { ...(inheritedProps ?? {}) };
1123
+ for (const [name, value] of Object.entries(branchProps ?? {})) {
1124
+ const inheritedValue = inheritedProps?.[name];
1125
+ properties[name] = inheritedValue !== undefined && JSON.stringify(inheritedValue) !== JSON.stringify(value)
1126
+ ? { allOf: [inheritedValue, value] }
1127
+ : value;
1128
+ }
1129
+ composed.properties = properties;
1130
+ }
1131
+ const required = [...new Set([
1132
+ ...stringRequiredFields(inherited.required),
1133
+ ...stringRequiredFields(branch.required),
1134
+ ])];
1135
+ if (required.length > 0) composed.required = required;
1136
+ else delete composed.required;
1137
+ return composed;
1138
+ }
1139
+
432
1140
  function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
433
- if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
434
- const obj = schema as Record<string, unknown>;
435
- const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(obj[key]));
1141
+ if (!isXaiObjectSchema(schema)) return undefined;
1142
+ const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(schema[key]));
436
1143
  if (!compositionKey) {
437
- if (obj.type !== undefined && obj.type !== "object") return undefined;
438
- return [{ ...obj, type: "object" }];
1144
+ if (schema.type !== undefined && schema.type !== "object") return undefined;
1145
+ return [{ ...schema, type: "object" }];
439
1146
  }
440
1147
 
441
- const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
442
- const branches = obj[compositionKey];
1148
+ const siblings = Object.fromEntries(Object.entries(schema).filter(([key]) => key !== compositionKey));
1149
+ const branches = schema[compositionKey];
443
1150
  if (!Array.isArray(branches)) return undefined;
444
1151
  const expanded: Record<string, unknown>[] = [];
445
1152
  for (const branch of branches) {
446
1153
  const variants = expandXaiRootObjectSchemas(branch);
447
1154
  if (!variants) return undefined;
448
- for (const variant of variants) expanded.push({ ...siblings, ...variant });
1155
+ for (const variant of variants) expanded.push(composeXaiObjectSchemas(siblings, variant));
449
1156
  }
450
1157
  return expanded.length > 0 ? expanded : undefined;
451
1158
  }
452
1159
 
1160
+ function mergeXaiPropertySchemas(values: unknown[]): unknown {
1161
+ const unique: unknown[] = [];
1162
+ const serialized = new Set<string>();
1163
+ for (const value of values) {
1164
+ const key = JSON.stringify(value);
1165
+ if (serialized.has(key)) continue;
1166
+ serialized.add(key);
1167
+ unique.push(value);
1168
+ }
1169
+ return unique.length === 1 ? unique[0] : { anyOf: unique };
1170
+ }
1171
+
1172
+ /**
1173
+ * The Grok CLI proxy rejects a function parameter schema whose root remains oneOf/anyOf.
1174
+ * Flatten only when the merge is lossless: local $refs resolve, every variant is a concrete
1175
+ * object whose keys we can preserve, required sets match, additionalProperties does not change
1176
+ * meaning, every property name exists on every variant, and at most one property schema
1177
+ * differs. Otherwise omit the tool rather than emit a weaker schema.
1178
+ */
453
1179
  function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
454
- const variants = expandXaiRootObjectSchemas(parameters);
1180
+ if (!isXaiObjectSchema(parameters)) return undefined;
1181
+ const resolved = resolveXaiSchemaRefs(parameters, parameters);
1182
+ if (!isXaiObjectSchema(resolved)) return undefined;
1183
+
1184
+ const normalizedRoot = { ...resolved };
1185
+ delete normalizedRoot.$schema;
1186
+
1187
+ const variants = expandXaiRootObjectSchemas(normalizedRoot);
455
1188
  if (!variants) return undefined;
456
- if (variants.length === 1) return variants[0];
457
- const root = parameters && typeof parameters === "object" && !Array.isArray(parameters)
458
- ? parameters as Record<string, unknown>
459
- : {};
460
- const metadata = Object.fromEntries(Object.entries(root).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
461
- return { ...metadata, oneOf: variants };
1189
+ if (variants.length === 1) {
1190
+ return xaiVariantIsConcreteObject(variants[0]) ? variants[0] : undefined;
1191
+ }
1192
+ if (!variants.every(xaiVariantIsConcreteObject) || !xaiRequiredSetsMatch(variants)) return undefined;
1193
+ const additionalProperties = mergeXaiAdditionalProperties(variants);
1194
+ if (!additionalProperties.ok) return undefined;
1195
+ if (!xaiPropertyMergeIsLossless(variants)) return undefined;
1196
+
1197
+ const metadata = Object.fromEntries(Object.entries(normalizedRoot).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
1198
+ delete metadata.properties;
1199
+ delete metadata.required;
1200
+ delete metadata.additionalProperties;
1201
+
1202
+ const propertyValues = new Map<string, unknown[]>();
1203
+ for (const variant of variants) {
1204
+ if (!variant.properties || typeof variant.properties !== "object" || Array.isArray(variant.properties)) continue;
1205
+ for (const [name, value] of Object.entries(variant.properties as Record<string, unknown>)) {
1206
+ const values = propertyValues.get(name) ?? [];
1207
+ values.push(value);
1208
+ propertyValues.set(name, values);
1209
+ }
1210
+ }
1211
+
1212
+ const properties = Object.fromEntries(
1213
+ [...propertyValues].map(([name, values]) => [name, mergeXaiPropertySchemas(values)]),
1214
+ );
1215
+ const required = stringRequiredFields(variants[0]?.required);
1216
+
1217
+ return {
1218
+ ...metadata,
1219
+ type: "object",
1220
+ properties,
1221
+ ...(required.length > 0 ? { required } : {}),
1222
+ ...("value" in additionalProperties ? { additionalProperties: additionalProperties.value } : {}),
1223
+ };
462
1224
  }
463
1225
 
464
1226
  function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
465
1227
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
466
- const allowed = isAllowedToolChoice(parsed.options.toolChoice)
467
- ? new Set(parsed.options.toolChoice.allowedTools)
468
- : undefined;
469
- const tools = allowed
470
- ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
471
- : parsed.context.tools;
1228
+ const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
472
1229
  if (tools.length === 0) return undefined;
473
1230
  const xaiTarget = isXaiSchemaTarget(provider);
474
- const kimiTarget = isKimiSchemaTarget(provider);
475
1231
  const formatted = tools.flatMap(t => {
476
- const parameters = xaiTarget
1232
+ const parameters = stripResponsesOnlyEncryptedMarker(xaiTarget
477
1233
  ? normalizeXaiToolParameters(t.parameters)
478
- : kimiTarget
479
- ? ensureKimiRootObjectType(t.parameters)
480
- : t.parameters;
1234
+ : ensureRootObjectType(t.parameters));
1235
+
481
1236
  if (parameters === undefined) return [];
482
1237
  return [{
483
- type: "function",
484
- function: {
485
- name: namespacedToolName(t.namespace, t.name),
486
- description: t.description,
487
- parameters,
488
- ...(t.strict !== undefined ? { strict: t.strict } : {}),
489
- },
1238
+ type: "function",
1239
+ function: {
1240
+ name: namespacedToolName(t.namespace, t.name),
1241
+ ...(t.description ? { description: t.description } : {}),
1242
+ parameters,
1243
+ ...(t.strict !== undefined ? { strict: t.strict } : {}),
1244
+ },
490
1245
  }];
491
1246
  });
492
1247
  return formatted.length > 0 ? formatted : undefined;
@@ -509,9 +1264,18 @@ function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxPro
509
1264
  });
510
1265
  }
511
1266
 
512
- function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
1267
+ function toolChoiceToChatFormat(
1268
+ tc: OcxParsedRequest["options"]["toolChoice"],
1269
+ tools: OcxParsedRequest["context"]["tools"],
1270
+ provider: OcxProviderConfig,
1271
+ ): unknown {
513
1272
  if (!tc) return undefined;
514
- if (isAllowedToolChoice(tc)) return tc.mode === "required" ? "required" : "auto";
1273
+ if (isAllowedToolChoice(tc)) {
1274
+ if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) {
1275
+ return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } };
1276
+ }
1277
+ return tc.mode === "required" ? "required" : "auto";
1278
+ }
515
1279
  if (tc === "auto" || tc === "none" || tc === "required") return tc;
516
1280
  if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } };
517
1281
  return undefined;
@@ -549,6 +1313,26 @@ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: stri
549
1313
  return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction));
550
1314
  }
551
1315
 
1316
+ function canSerializeOpenAIChatServiceTier(
1317
+ provider: OcxProviderConfig,
1318
+ modelId: string,
1319
+ serviceTier: unknown,
1320
+ tierDecision?: OcxParsedRequest["options"]["tierDecision"],
1321
+ ): boolean {
1322
+ if (serviceTier === undefined) return false;
1323
+ if (tierDecision !== undefined) {
1324
+ return tierDecision.kind === "set" || tierDecision.kind === "forward-caller";
1325
+ }
1326
+ // No decision from the router means this call did not go through the tier state machine, so
1327
+ // ask that machine rather than re-deriving a looser answer beside it. The previous fallback
1328
+ // returned true whenever foreign forwarding was allowed at all, which let a caller tier
1329
+ // reach the wire in cases `decideTier` would have dropped — the two paths disagreeing is
1330
+ // precisely the bug, so there is now only one authority.
1331
+ const callerTier = typeof serviceTier === "string" ? serviceTier : undefined;
1332
+ const decision = decideTier(fastPolicyForModel(provider, modelId, undefined, "chat"), undefined, callerTier);
1333
+ return decision.kind === "set" || decision.kind === "forward-caller";
1334
+ }
1335
+
552
1336
  export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
553
1337
  return {
554
1338
  name: "openai-chat",
@@ -556,20 +1340,30 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
556
1340
  formatErrorBody: formatOpenAIChatErrorBody,
557
1341
 
558
1342
  buildRequest(parsed: OcxParsedRequest) {
559
- const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
560
- if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
561
- throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
562
- }
563
-
1343
+ const { url, headers, hasCredential } = openAIChatTransport(provider);
564
1344
  const messages = messagesToChatFormat(parsed, provider);
565
1345
  const tools = toolsToChatFormatForProvider(parsed, provider);
566
- const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
1346
+ const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
567
1347
 
568
1348
  const body: Record<string, unknown> = {
569
1349
  model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
570
1350
  messages,
571
1351
  stream: parsed.stream,
572
1352
  };
1353
+ // A policy-produced canonical decision has already passed capability validation. Without
1354
+ // that decision, a canonical caller value still requires an explicit true capability;
1355
+ // unclassified Chat routes remain behind the caller-forwarding opt-in.
1356
+ const serviceTier = parsed.options.serviceTier;
1357
+ const tierDecision = parsed.options.tierDecision;
1358
+ const canSerializeServiceTier = canSerializeOpenAIChatServiceTier(
1359
+ provider,
1360
+ parsed.modelId,
1361
+ serviceTier,
1362
+ tierDecision,
1363
+ );
1364
+ if (canSerializeServiceTier && serviceTier !== undefined) {
1365
+ body.service_tier = serviceTier;
1366
+ }
573
1367
  if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
574
1368
  const maxTokens = resolveMaxTokens(provider, parsed);
575
1369
  const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
@@ -588,10 +1382,44 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
588
1382
  body.top_p = parsed.options.topP;
589
1383
  }
590
1384
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
1385
+ const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId);
591
1386
  const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
1387
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
592
1388
  let reasoningLog: AdapterRequest["reasoningLog"];
593
- if (reasoningEffort !== undefined) {
594
- if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
1389
+ if (!reasoningDisabled && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") {
1390
+ if (nativeOpenAI) {
1391
+ body.reasoning_effort = "none";
1392
+ reasoningLog = {
1393
+ effectiveEffort: "none",
1394
+ wireField: "reasoning_effort",
1395
+ wireValue: "none",
1396
+ };
1397
+ } else {
1398
+ body.reasoning = { enabled: false };
1399
+ reasoningLog = {
1400
+ effectiveEffort: "none",
1401
+ wireField: "reasoning.enabled",
1402
+ wireValue: false,
1403
+ };
1404
+ }
1405
+ } else if (reasoningEffort !== undefined) {
1406
+ if (provider.reasoningWireFormat === "gateway-object") {
1407
+ if (nativeOpenAI) {
1408
+ body.reasoning_effort = reasoningEffort;
1409
+ reasoningLog = {
1410
+ effectiveEffort: reasoningEffort,
1411
+ wireField: "reasoning_effort",
1412
+ wireValue: reasoningEffort,
1413
+ };
1414
+ } else {
1415
+ body.reasoning = { enabled: true, effort: reasoningEffort };
1416
+ reasoningLog = {
1417
+ effectiveEffort: reasoningEffort,
1418
+ wireField: "reasoning.effort",
1419
+ wireValue: reasoningEffort,
1420
+ };
1421
+ }
1422
+ } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
595
1423
  const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
596
1424
  if (budget !== undefined) {
597
1425
  body.thinking_budget = budget;
@@ -602,9 +1430,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
602
1430
  };
603
1431
  }
604
1432
  } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
605
- // Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
606
- // these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
607
- // uses adaptive/disabled.
608
1433
  if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
609
1434
  body.thinking = { type: reasoningEffort };
610
1435
  reasoningLog = {
@@ -628,37 +1453,55 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
628
1453
  if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
629
1454
  body.frequency_penalty = parsed.options.frequencyPenalty;
630
1455
  }
631
- // prompt_cache_key is an OpenAI-specific chat extension; strict backends (Groq,
632
- // Cerebras, etc.) reject unknown fields. Only forward when the provider opts in.
633
1456
  if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
634
1457
  body.prompt_cache_key = parsed.options.promptCacheKey;
635
1458
  }
1459
+ // Structured-output support varies by the physical upstream model even when one
1460
+ // gateway exposes a uniform OpenAI-compatible endpoint. Keep the #1137 translation
1461
+ // as the default, but let an exact model opt out instead of forcing a provider-wide
1462
+ // rollback that would silently return prose for siblings that support JSON Schema.
1463
+ if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) {
1464
+ const textFormat = parsed.options.textFormat;
1465
+ if (textFormat?.type === "json_object") {
1466
+ body.response_format = { type: "json_object" };
1467
+ } else if (textFormat?.type === "json_schema") {
1468
+ body.response_format = {
1469
+ type: "json_schema",
1470
+ json_schema: {
1471
+ name: textFormat.name ?? "response",
1472
+ ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
1473
+ ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
1474
+ ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
1475
+ },
1476
+ };
1477
+ }
1478
+ }
636
1479
 
637
1480
  if (tools) {
638
- // Default-ON for chat-completions providers (user decision 260709): the buffered
639
- // parser assembles multi-call streams safely, so `parallelToolCalls: false` is the
640
- // only per-provider opt-out; Codex's request bit can still force false per request.
641
- // Rationale + provider evidence: devlog/_plan/260709_parallel_tool_calls.
642
- body.parallel_tool_calls = provider.parallelToolCalls === false
643
- ? false
644
- : parsed.options.parallelToolCalls !== false;
645
- }
646
- if (parsed.stream) {
647
- body.stream_options = { include_usage: true };
1481
+ if (provider.parallelToolCalls === false) {
1482
+ // NIM documents the Boolean defaulting to false and kimi rejects true; pin the
1483
+ // wire bit so Codex cannot opt in via request.options. Other opted-out providers
1484
+ // omit the field by default so strict OpenAI-compatible hosts never see an
1485
+ // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps
1486
+ // emitting parallel calls without it can opt in via pinParallelToolCallsFalse.
1487
+ if (provider.baseUrl === "https://integrate.api.nvidia.com/v1"
1488
+ || provider.pinParallelToolCallsFalse === true) {
1489
+ body.parallel_tool_calls = false;
1490
+ }
1491
+ } else if (provider.parallelToolCalls === true) {
1492
+ body.parallel_tool_calls = parsed.options.parallelToolCalls !== false;
1493
+ }
648
1494
  }
649
-
650
- const url = `${provider.baseUrl}/chat/completions`;
651
- const headers: Record<string, string> = { "Content-Type": "application/json" };
652
- // Precedence preserved from pre-#128 behavior: apiKey Authorization first, then
653
- // provider.headers may override (user/registry-configured headers win). Registry
654
- // staticHeaders (e.g. opencode-free x-opencode-client) flow in via derive.ts and
655
- // never carry Authorization, so keyless providers are unaffected.
656
- if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
657
- if (provider.headers) Object.assign(headers, provider.headers);
1495
+ if (parsed.stream) body.stream_options = { include_usage: true };
658
1496
 
659
1497
  const bodyJson = JSON.stringify(body);
660
- // Never log pathname/query tenant-scoped hosts (e.g. Cloudflare
661
- // /accounts/<account_id>/ai/v1) would otherwise leak account identifiers (#452).
1498
+ const actualServiceTier = typeof body.service_tier === "string" ? body.service_tier : null;
1499
+ const tierLog = createAdapterTierMetadata(
1500
+ parsed.options.tierObservation,
1501
+ parsed.options.tierDecision,
1502
+ actualServiceTier === null ? null : "service-tier",
1503
+ actualServiceTier,
1504
+ );
662
1505
  if (isDebugEnabled()) {
663
1506
  let host = "upstream";
664
1507
  try { host = new URL(url).host; } catch { /* keep fallback */ }
@@ -679,10 +1522,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
679
1522
  headers,
680
1523
  body: bodyJson,
681
1524
  ...(reasoningLog ? { reasoningLog } : {}),
1525
+ ...(tierLog ? { tierLog } : {}),
682
1526
  };
683
1527
  },
684
1528
 
685
- async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
1529
+ async *parseStream(
1530
+ response: Response,
1531
+ budget: TranslatorBudget,
1532
+ tierMetadata?: AdapterTierMetadata,
1533
+ ): AsyncGenerator<AdapterEvent> {
686
1534
  if (!response.body) {
687
1535
  yield { type: "error", message: "No response body" };
688
1536
  return;
@@ -693,138 +1541,240 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
693
1541
  const budgetEncoder = new TextEncoder();
694
1542
  let buffer = "";
695
1543
  let bufferBytes = 0;
696
- // Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
697
- // start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
698
- // close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
699
- // emitting calls incrementally would orphan later argument deltas whenever a provider
700
- // interleaves content — and parallel tool calls (multiple ids, index-keyed continuation
701
- // chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
702
- // Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
703
- // last-seen call for providers that omit both on continuation chunks.
704
- interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
1544
+ interface PendingToolCall {
1545
+ key: string;
1546
+ id: string;
1547
+ name: string;
1548
+ args: string;
1549
+ argsBytes: number;
1550
+ /**
1551
+ * Whether this call has ever received `arguments` as an actual string, empty included.
1552
+ * An empty string still counts: it proves the upstream sent the field with the right
1553
+ * wire type, which is what a later malformed repeat of that field would be padding for.
1554
+ * A canonical NAME is not evidence about the ARGUMENTS field and must not stand in.
1555
+ */
1556
+ sawArgumentsString: boolean;
1557
+ }
705
1558
  const pendingToolCalls: PendingToolCall[] = [];
706
1559
  let toolCallSeq = 0;
707
- const flushToolCalls = function* (): Generator<AdapterEvent> {
708
- // Do not treat flushed tool calls as user-facing output for the finish-less EOF
709
- // fallback incomplete tool args must stay on the truncation path.
710
- for (const call of pendingToolCalls) {
1560
+ const closeToolCalls = (): PendingToolCall[] => {
1561
+ const calls = [...pendingToolCalls];
1562
+ for (const call of calls) budget.closeCall(call.key);
1563
+ pendingToolCalls.length = 0;
1564
+ return calls;
1565
+ };
1566
+ const pendingToolCallsAreCompleteJsonObjects = (): boolean =>
1567
+ pendingToolCalls.length > 0 && pendingToolCalls.every(call => {
1568
+ if (call.name.trim().length === 0 || !call.sawArgumentsString || call.args.length === 0) return false;
1569
+ try {
1570
+ const parsed = JSON.parse(call.args) as unknown;
1571
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
1572
+ } catch {
1573
+ return false;
1574
+ }
1575
+ });
1576
+ // Returns "terminate" when a pending call cannot be dispatched, so every flush site
1577
+ // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
1578
+ // so budget reservations are released for every pending call even on the early return.
1579
+ const flushToolCalls = function* (): Generator<AdapterEvent, "continue" | "terminate"> {
1580
+ for (const call of closeToolCalls()) {
1581
+ // Ingest already proved `name` is a string; the typeof guard keeps this branch
1582
+ // total so a future ingest change cannot turn a malformed name into a throw.
1583
+ if (typeof call.name !== "string" || call.name.trim().length === 0) {
1584
+ debugProviderDiagnostic("openai-chat", "tool-call-unnamed", {
1585
+ hadId: call.id.length > 0,
1586
+ argsBytes: call.argsBytes,
1587
+ });
1588
+ yield unnamedToolCallEvent(pendingUsage);
1589
+ return "terminate";
1590
+ }
711
1591
  if (!call.id) call.id = `call_${++toolCallSeq}`;
712
1592
  yield { type: "tool_call_start", id: call.id, name: call.name };
713
1593
  if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
714
1594
  yield { type: "tool_call_end" };
715
- budget.closeCall(call.key);
716
1595
  }
717
- pendingToolCalls.length = 0;
1596
+ return "continue";
1597
+ };
1598
+ const terminateWithError = function* (
1599
+ event: Extract<AdapterEvent, { type: "error" }>,
1600
+ ): Generator<AdapterEvent, "terminate"> {
1601
+ closeToolCalls();
1602
+ yield event;
1603
+ return "terminate";
718
1604
  };
719
1605
  let pendingUsage: OcxUsage | undefined;
720
- // Track terminal signals so a socket EOF without any terminator can fail closed instead of
721
- // being reported as a clean completion (silent truncation). A graceful close is either an
722
- // explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
723
- // OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
724
1606
  let finishReason: string | undefined;
725
- // Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
726
- // suppressed by hideThinkingSummary and must not complete as empty successful turns.
727
1607
  let sawUserFacingOutput = false;
728
1608
 
729
- // Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
730
- // a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
731
- // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
732
- // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
733
1609
  const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
734
- if (!line.startsWith("data: ")) return "continue";
735
- const payload = line.slice(6).trim();
1610
+ const rawPayload = sseFieldValue(line, "data");
1611
+ if (rawPayload === null) return "continue";
1612
+ const payload = rawPayload.trim();
1613
+ if (payload.length === 0) return "continue";
736
1614
  if (payload === "[DONE]") {
737
- yield* flushToolCalls();
738
- const stopReason = finishReason === "length"
739
- ? "max_tokens"
740
- : finishReason === "content_filter"
741
- ? "content_filter"
742
- : undefined;
1615
+ if ((yield* flushToolCalls()) === "terminate") return "terminate";
1616
+ const stopReason = stopReasonFor(finishReason);
743
1617
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
744
1618
  return "terminate";
745
1619
  }
746
1620
 
747
- let chunk: Record<string, unknown>;
1621
+ let parsed: unknown;
748
1622
  try {
749
- chunk = JSON.parse(payload) as Record<string, unknown>;
1623
+ parsed = JSON.parse(payload);
750
1624
  } catch {
1625
+ tierMetadata?.markResponseUnparseable();
751
1626
  yield { type: "error", message: "malformed upstream SSE data frame" };
752
1627
  return "terminate";
753
1628
  }
754
-
755
- // A 200/OK chat-completions stream may carry an inline provider error envelope
756
- // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
757
- // classified response.failed (bridge case "error") — never a truncated completion.
758
- if (chunk.error) {
759
- const err = chunk.error as { message?: string; code?: string; type?: string; status?: number } | undefined;
760
- const message = err?.message ?? "upstream error";
761
- debugProviderDiagnostic("openai-chat", "stream-error", { message });
762
- yield* flushToolCalls();
763
- yield {
764
- type: "error",
765
- message,
766
- ...(typeof err?.code === "string" ? { code: err.code } : {}),
767
- ...(typeof err?.type === "string" ? { errorType: err.type } : {}),
768
- ...(isCyberPolicyCode(err?.code)
769
- ? { status: 400 }
770
- : typeof err?.status === "number" && Number.isInteger(err.status)
771
- ? { status: err.status }
772
- : {}),
773
- };
774
- return "terminate";
1629
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue";
1630
+ const chunk = parsed as Record<string, unknown>;
1631
+ if (Object.hasOwn(chunk, "service_tier")) {
1632
+ tierMetadata?.observeResponseServiceTier(chunk.service_tier);
775
1633
  }
776
1634
 
777
- if (chunk.usage) {
778
- // Record usage but keep parsing: some providers send usage and the final content
779
- // delta in the SAME chunk; a bail here would drop that content. The choices
780
- // guard below no-ops a usage-only chunk.
781
- pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
1635
+ if (chunk.error !== undefined && chunk.error !== null) {
1636
+ const event = upstreamErrorEvent(chunk.error, pendingUsage);
1637
+ debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
1638
+ return yield* terminateWithError(event);
782
1639
  }
783
1640
 
784
- const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
785
- if (!choices || choices.length === 0) return "continue";
786
- // Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
787
- // no delta) is a graceful close and must record finishReason even though we skip it below.
788
- if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
789
- finishReason = choices[0].finish_reason;
1641
+ if (chunk.usage) pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
1642
+
1643
+ const choices = chunk.choices;
1644
+ if (choices === undefined) return "continue";
1645
+ if (!Array.isArray(choices)) return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
1646
+ if (choices.length === 0) return "continue";
1647
+ const rawChoice = choices[0];
1648
+ if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
1649
+ return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
790
1650
  }
791
- const delta = choices[0].delta;
1651
+ const choice = rawChoice as {
1652
+ delta?: Record<string, unknown>;
1653
+ finish_reason?: string;
1654
+ error?: unknown;
1655
+ };
1656
+ if (choice.finish_reason === "error") {
1657
+ const event = upstreamErrorEvent(choice.error, pendingUsage);
1658
+ debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
1659
+ return yield* terminateWithError(event);
1660
+ }
1661
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) finishReason = choice.finish_reason;
1662
+ const delta = choice.delta;
792
1663
  if (delta) {
793
- if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
794
- yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
795
- }
1664
+ const reasoningText = reasoningTextFrom(delta);
1665
+ if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText };
796
1666
  if (typeof delta.content === "string" && delta.content.length > 0) {
797
1667
  sawUserFacingOutput = true;
798
1668
  yield { type: "text_delta", text: delta.content };
799
1669
  }
800
1670
 
801
- const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
802
- if (toolCalls) {
803
- for (const tc of toolCalls) {
804
- const key = typeof tc.index === "number"
805
- ? `i:${tc.index}`
806
- : tc.id
807
- ? `id:${tc.id}`
808
- : pendingToolCalls[pendingToolCalls.length - 1]?.key;
1671
+ const rawToolCalls = delta.tool_calls;
1672
+ if (rawToolCalls !== undefined && rawToolCalls !== null) {
1673
+ // A non-null claimed tool-call payload is not benign padding. Dropping it can leave the
1674
+ // matching result permanently orphaned, so malformed nested shapes fail closed
1675
+ // through the adapter error channel instead of escaping as TypeError (#1325). Null is
1676
+ // tolerated as absent because OpenAI-compatible providers may emit it as stream padding.
1677
+ if (!Array.isArray(rawToolCalls)) {
1678
+ logInvalidToolCalls("stream", rawToolCalls);
1679
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1680
+ }
1681
+ for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
1682
+ const rawToolCall: unknown = rawToolCalls[callIndex];
1683
+ if (!isRecord(rawToolCall)) {
1684
+ const diagnostic: InvalidToolCallDiagnostic = {
1685
+ reason: "tool_call_not_object",
1686
+ callIndex,
1687
+ valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
1688
+ };
1689
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1690
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1691
+ }
1692
+ // This is upstream JSON, so every field is validated before it is stored: a
1693
+ // malformed value must fail closed through the #1325 channel here rather than
1694
+ // escaping later as a TypeError from string handling at flush time.
1695
+ const rawFunction = rawToolCall.function;
1696
+ if (rawFunction !== undefined && rawFunction !== null && !isRecord(rawFunction)) {
1697
+ const diagnostic: InvalidToolCallDiagnostic = {
1698
+ reason: "tool_call_function_not_object",
1699
+ callIndex,
1700
+ valueType: Array.isArray(rawFunction) ? "array" : typeof rawFunction,
1701
+ };
1702
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1703
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1704
+ }
1705
+ const fnRecord = isRecord(rawFunction) ? rawFunction : undefined;
1706
+ const rawName = fnRecord?.name;
1707
+ const rawArguments = fnRecord?.arguments;
1708
+ const rawId = rawToolCall.id;
1709
+ const idDelta = typeof rawId === "string" ? rawId : "";
1710
+ const rawIndex = rawToolCall.index;
1711
+
1712
+ // Resolve the pending call BEFORE judging the fields. Some OpenAI-compatible
1713
+ // streamers repeat an already-sent field as a non-string placeholder on a
1714
+ // continuation delta; judging first meant the whole stream died with a 502 even
1715
+ // though the value being repeated was already held in canonical form.
1716
+ const key = typeof rawIndex === "number"
1717
+ ? `i:${rawIndex}`
1718
+ : idDelta
1719
+ ? `id:${idDelta}`
1720
+ : pendingToolCalls[pendingToolCalls.length - 1]?.key;
809
1721
  let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
810
- // Mixed keying rescue: a call opened under an index key must still absorb an
811
- // id-only continuation for the same provider id (and vice versa) instead of
812
- // splitting into two calls that share one call_id downstream.
813
- if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
1722
+ if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta);
814
1723
  if (!call) {
815
- call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
1724
+ call = {
1725
+ key: key ?? `seq:${pendingToolCalls.length}`,
1726
+ id: "",
1727
+ name: "",
1728
+ args: "",
1729
+ argsBytes: 0,
1730
+ sawArgumentsString: false,
1731
+ };
816
1732
  pendingToolCalls.push(call);
817
1733
  budget.openCall(call.key);
818
1734
  }
819
- if (tc.id && !call.id) call.id = tc.id;
820
- if (tc.function?.name && !call.name) call.name = tc.function.name;
821
- if (tc.function?.arguments) {
1735
+
1736
+ // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name
1737
+ // says nothing about whether `arguments` was ever sent as a string, so it cannot
1738
+ // authorize a malformed arguments value — that would silently drop a real
1739
+ // argument payload the model intended to send.
1740
+ const rejection: InvalidToolCallDiagnostic | undefined =
1741
+ isInvalidStreamStringField(rawName) && call.name.trim() === ""
1742
+ ? { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawName }
1743
+ : isInvalidStreamStringField(rawArguments) && !call.sawArgumentsString
1744
+ ? { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawArguments }
1745
+ : isInvalidStreamStringField(rawId) && call.id === ""
1746
+ ? { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawId }
1747
+ : undefined;
1748
+ if (rejection) {
1749
+ logInvalidToolCalls("stream", rawToolCalls, rejection);
1750
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, rejection));
1751
+ }
1752
+
1753
+ if (idDelta && !call.id) call.id = idDelta;
1754
+ if (typeof rawName === "string" && rawName && !call.name) call.name = rawName;
1755
+ if (typeof rawArguments === "string") call.sawArgumentsString = true;
1756
+ // Tool-call deltas are BUFFERED until a terminal signal, so this adapter can
1757
+ // consume upstream frames for a long time while yielding nothing. The Responses
1758
+ // bridge reads adapter activity, not socket activity, so a model that streams a
1759
+ // large argument payload looks identical to a hung upstream and the stall
1760
+ // watchdog can abort a turn that was progressing normally.
1761
+ //
1762
+ // Found while investigating #2156, but it is NOT that bug: a stall abort emits
1763
+ // `response.incomplete` with `upstream_stall_timeout` from the bridge, whereas
1764
+ // that report shows the adapter's own end-of-stream error after `reader.read()`
1765
+ // returned EOF with tool calls still pending. Different path, different frame.
1766
+ //
1767
+ // A heartbeat is invisible downstream — the bridge consumes it to re-arm the
1768
+ // watchdog and emits nothing — which is the same remedy the Cursor, Anthropic,
1769
+ // Google, and Kiro adapters already use for their own silent phases.
1770
+ yield { type: "heartbeat" };
1771
+ if (typeof rawArguments === "string" && rawArguments) {
822
1772
  const previousBytes = call.argsBytes;
823
- const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
1773
+ const nextBytes = previousBytes + budgetEncoder.encode(rawArguments).byteLength;
824
1774
  const scope = { kind: "tool_args" as const, callId: call.key };
825
1775
  const reservation = budget.reserveTransient(nextBytes, scope);
826
1776
  try {
827
- call.args += tc.function.arguments;
1777
+ call.args += rawArguments;
828
1778
  reservation.commitRetained();
829
1779
  budget.releaseRetained(previousBytes, scope);
830
1780
  call.argsBytes = nextBytes;
@@ -837,10 +1787,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
837
1787
  }
838
1788
  }
839
1789
 
840
- // Any non-empty finish_reason ends the generation: flush assembled tool calls as
841
- // atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop").
842
- if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
843
- yield* flushToolCalls();
1790
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) {
1791
+ if ((yield* flushToolCalls()) === "terminate") return "terminate";
844
1792
  }
845
1793
  return "continue";
846
1794
  };
@@ -880,23 +1828,20 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
880
1828
  }
881
1829
  }
882
1830
 
883
- // Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
884
- // and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
885
- // the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
886
- // content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
887
- // complete stream loses its last frame and may be falsely failed below.
888
1831
  if (buffer.length > 0) {
889
1832
  if ((yield* handleDataLine(buffer)) === "terminate") return;
890
1833
  }
891
- // Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
892
- // never sees a fabricated tool_call_end on a truncated mid-assembly stream.
893
- //
894
- // Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
895
- // taking it back: a half-assembled argument string would reach the client as a completed
896
- // call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
897
- // fragments live), so this adapter can still decide.
898
1834
  const sawFinish = finishReason !== undefined;
899
1835
  if (!sawFinish && pendingToolCalls.length > 0) {
1836
+ // Some OpenAI-compatible gateways close immediately after a complete function-call
1837
+ // delta and omit both terminal conventions. Keep the default fail-closed policy, and
1838
+ // let an opted-in provider recover only calls whose assembled argument payload is a
1839
+ // complete JSON object. A partial JSON prefix still takes the truncation path below.
1840
+ if (provider.openaiChatEofTolerance === true && pendingToolCallsAreCompleteJsonObjects()) {
1841
+ if ((yield* flushToolCalls()) === "terminate") return;
1842
+ yield { type: "done", usage: pendingUsage };
1843
+ return;
1844
+ }
900
1845
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
901
1846
  finishReason: null,
902
1847
  hadUsage: pendingUsage !== undefined,
@@ -905,9 +1850,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
905
1850
  yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
906
1851
  return;
907
1852
  }
908
- // Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
909
- // truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
910
- // Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
911
1853
  if (!sawFinish && !sawUserFacingOutput) {
912
1854
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
913
1855
  finishReason: finishReason ?? null,
@@ -916,13 +1858,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
916
1858
  yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
917
1859
  return;
918
1860
  }
919
- yield* flushToolCalls();
920
- // Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
921
- const stopReason = finishReason === "length"
922
- ? "max_tokens"
923
- : finishReason === "content_filter"
924
- ? "content_filter"
925
- : undefined;
1861
+ if ((yield* flushToolCalls()) === "terminate") return;
1862
+ const stopReason = stopReasonFor(finishReason);
926
1863
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
927
1864
  } catch (error) {
928
1865
  if (isTranslatorBudgetExceededError(error)
@@ -940,63 +1877,101 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
940
1877
  throw error;
941
1878
  } finally {
942
1879
  budget.releaseRetained(bufferBytes, { kind: "live_transient" });
943
- for (const call of pendingToolCalls) budget.closeCall(call.key);
1880
+ closeToolCalls();
944
1881
  reader.releaseLock();
945
1882
  }
946
1883
  },
947
1884
 
948
- async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
949
- const json = await response.json() as Record<string, unknown>;
1885
+ async parseResponse(
1886
+ response: Response,
1887
+ budget: TranslatorBudget,
1888
+ tierMetadata?: AdapterTierMetadata,
1889
+ ): Promise<AdapterEvent[]> {
1890
+ let parsed: unknown;
1891
+ try {
1892
+ parsed = await response.json();
1893
+ } catch (error) {
1894
+ tierMetadata?.markResponseUnparseable();
1895
+ throw error;
1896
+ }
1897
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1898
+ tierMetadata?.markResponseUnparseable();
1899
+ throw new Error("upstream response was not a JSON object");
1900
+ }
1901
+ const json = parsed as Record<string, unknown>;
1902
+ if (Object.hasOwn(json, "service_tier")) {
1903
+ tierMetadata?.observeResponseServiceTier(json.service_tier);
1904
+ }
950
1905
  const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
951
1906
  budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
952
1907
  try {
953
- if (json.error) {
954
- const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown };
955
- const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error";
956
- const code = typeof upstreamError.code === "string" ? upstreamError.code : undefined;
957
- const errorType = typeof upstreamError.type === "string" ? upstreamError.type : undefined;
958
- const status = isCyberPolicyCode(code)
959
- ? 400
960
- : typeof upstreamError.status === "number" && Number.isInteger(upstreamError.status)
961
- ? upstreamError.status
962
- : undefined;
963
- return [{
964
- type: "error",
965
- message,
966
- ...(code !== undefined ? { code } : {}),
967
- ...(errorType !== undefined ? { errorType } : {}),
968
- ...(status !== undefined ? { status } : {}),
969
- }];
970
- }
1908
+ const payload = unwrapChatCompletionPayload(json);
1909
+ const usage = usageFromOpenAIChat(payload.usage as Record<string, unknown> | undefined);
1910
+ if (json.success === false && payload.error === undefined) {
1911
+ return [{
1912
+ type: "error",
1913
+ message: "upstream reported failure without an error payload",
1914
+ ...(usage ? { usage } : {}),
1915
+ }];
1916
+ }
1917
+ if (payload.error !== undefined && payload.error !== null) return [upstreamErrorEvent(payload.error, usage)];
971
1918
 
972
- const events: AdapterEvent[] = [];
973
- const choices = json.choices as { message?: Record<string, unknown> }[] | undefined;
974
- if (!Array.isArray(choices) || choices.length === 0 || !choices[0].message) {
975
- return [{ type: "error", message: "upstream response contained no choices" }];
976
- }
1919
+ const events: AdapterEvent[] = [];
1920
+ const choices = payload.choices as {
1921
+ message?: Record<string, unknown>;
1922
+ finish_reason?: unknown;
1923
+ error?: OpenAIChatError;
1924
+ }[] | undefined;
1925
+ if (!Array.isArray(choices) || choices.length === 0) {
1926
+ return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
1927
+ }
1928
+ const rawChoice = choices[0];
1929
+ if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
1930
+ return [invalidChoicesEvent(usage)];
1931
+ }
1932
+ const choice = rawChoice;
1933
+ if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)];
1934
+ if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
977
1935
 
978
- const msg = choices[0].message;
979
- if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
980
- events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
981
- }
982
- if (typeof msg.content === "string") {
983
- events.push({ type: "text_delta", text: msg.content });
984
- }
985
- const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
986
- if (toolCalls) {
987
- for (const tc of toolCalls) {
988
- events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
989
- events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
990
- events.push({ type: "tool_call_end" });
1936
+ const msg = choice.message;
1937
+ const reasoningText = reasoningTextFrom(msg);
1938
+ if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText });
1939
+ if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content });
1940
+ const rawToolCalls = msg.tool_calls;
1941
+ if (rawToolCalls !== undefined && rawToolCalls !== null) {
1942
+ if (!Array.isArray(rawToolCalls)) {
1943
+ logInvalidToolCalls("response", rawToolCalls);
1944
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
1945
+ }
1946
+ for (const rawToolCall of rawToolCalls) {
1947
+ if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {
1948
+ logInvalidToolCalls("response", rawToolCalls);
1949
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
1950
+ }
1951
+ const id = rawToolCall.id;
1952
+ const name = rawToolCall.function.name;
1953
+ const args = rawToolCall.function.arguments;
1954
+ // A blank name is as undispatchable as a missing one, so it fails closed here
1955
+ // for the same reason the streamed path refuses it. Trimmed length, not `!name`:
1956
+ // a whitespace-only function name is not a legitimate tool-call shape either.
1957
+ if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string"
1958
+ || name.trim().length === 0) {
1959
+ logInvalidToolCalls("response", rawToolCalls);
1960
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
1961
+ }
1962
+ events.push({ type: "tool_call_start", id, name });
1963
+ events.push({ type: "tool_call_delta", arguments: args });
1964
+ events.push({ type: "tool_call_end" });
1965
+ }
991
1966
  }
992
- }
993
- const usage = json.usage as Record<string, unknown> | undefined;
994
- events.push({
995
- type: "done",
996
- usage: usageFromOpenAIChat(usage),
997
- });
998
- retainTranslatedEventBatch(events, budget);
999
- return events;
1967
+ const stopReason = stopReasonFor(choice.finish_reason);
1968
+ events.push({
1969
+ type: "done",
1970
+ usage,
1971
+ ...(stopReason ? { stopReason } : {}),
1972
+ });
1973
+ retainTranslatedEventBatch(events, budget);
1974
+ return events;
1000
1975
  } finally {
1001
1976
  budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
1002
1977
  }