@iislee/opencodex 2.11.0 → 2.35.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 (733) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +117 -22
  3. package/bin/ocx.mjs +245 -88
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  6. package/gui/dist/assets/index-D4OKgUV4.js +114 -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 +21 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +475 -62
  44. package/src/adapters/base.ts +82 -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 +637 -0
  48. package/src/adapters/cursor/call-id.ts +44 -0
  49. package/src/adapters/cursor/catalog.ts +541 -0
  50. package/src/adapters/cursor/checkpoint-store.ts +308 -0
  51. package/src/adapters/cursor/cursor-errors.ts +144 -6
  52. package/src/adapters/cursor/discovery.ts +122 -14
  53. package/src/adapters/cursor/effort-map.ts +106 -4
  54. package/src/adapters/cursor/envelope-echo.ts +290 -0
  55. package/src/adapters/cursor/framing.ts +39 -0
  56. package/src/adapters/cursor/h2-pool.ts +123 -0
  57. package/src/adapters/cursor/http1-bidi.ts +361 -0
  58. package/src/adapters/cursor/images.ts +704 -0
  59. package/src/adapters/cursor/live-models.ts +180 -59
  60. package/src/adapters/cursor/live-transport.ts +623 -170
  61. package/src/adapters/cursor/message-mapper.ts +4 -1
  62. package/src/adapters/cursor/native-exec-common.ts +23 -2
  63. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  64. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  65. package/src/adapters/cursor/native-exec-network.ts +1 -1
  66. package/src/adapters/cursor/native-exec-shell.ts +5 -3
  67. package/src/adapters/cursor/native-exec.ts +121 -14
  68. package/src/adapters/cursor/protobuf-events.ts +829 -11
  69. package/src/adapters/cursor/protobuf-request.ts +447 -75
  70. package/src/adapters/cursor/request-builder.ts +271 -35
  71. package/src/adapters/cursor/tool-definitions.ts +252 -12
  72. package/src/adapters/cursor/tool-result-normalize.ts +115 -0
  73. package/src/adapters/cursor/transport.ts +22 -0
  74. package/src/adapters/cursor/types.ts +48 -1
  75. package/src/adapters/cursor.ts +336 -39
  76. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  77. package/src/adapters/google-antigravity-replay.ts +713 -41
  78. package/src/adapters/google-antigravity-wire.ts +48 -10
  79. package/src/adapters/google-errors.ts +44 -12
  80. package/src/adapters/google-http.ts +38 -10
  81. package/src/adapters/google-tool-schema.ts +80 -15
  82. package/src/adapters/google-truncation.ts +11 -0
  83. package/src/adapters/google.ts +742 -86
  84. package/src/adapters/identity.ts +39 -6
  85. package/src/adapters/image.ts +1 -1
  86. package/src/adapters/kiro-constants.ts +27 -0
  87. package/src/adapters/kiro-errors.ts +11 -0
  88. package/src/adapters/kiro-events.ts +19 -1
  89. package/src/adapters/kiro-thinking.ts +18 -2
  90. package/src/adapters/kiro-tools.ts +65 -17
  91. package/src/adapters/kiro.ts +270 -73
  92. package/src/adapters/mimo-free.ts +17 -0
  93. package/src/adapters/openai-chat-url.ts +11 -0
  94. package/src/adapters/openai-chat.ts +1377 -344
  95. package/src/adapters/openai-responses-url.ts +16 -0
  96. package/src/adapters/openai-responses.ts +1164 -64
  97. package/src/adapters/registry.ts +175 -0
  98. package/src/adapters/responses-tool-schema.ts +67 -0
  99. package/src/adapters/run-turn-queue.ts +36 -1
  100. package/src/adapters/tool-call-id.ts +119 -0
  101. package/src/adapters/tool-catalog-nudge.ts +105 -21
  102. package/src/adapters/xai-tool-schema.ts +436 -0
  103. package/src/adapters/xai-web-search.ts +186 -0
  104. package/src/bridge.ts +512 -92
  105. package/src/chat/inbound.ts +57 -20
  106. package/src/chat/outbound.ts +97 -33
  107. package/src/claude/agents-inject.ts +40 -10
  108. package/src/claude/context-windows.ts +37 -14
  109. package/src/claude/desktop-3p.ts +243 -9
  110. package/src/claude/gateway-cache.ts +41 -4
  111. package/src/claude/inbound.ts +72 -3
  112. package/src/claude/model-info.ts +38 -15
  113. package/src/claude/outbound.ts +88 -21
  114. package/src/cli/access.ts +46 -3
  115. package/src/cli/account-api.ts +150 -19
  116. package/src/cli/account-auth.ts +33 -6
  117. package/src/cli/account-catalog-refresh.ts +14 -0
  118. package/src/cli/account-extended.ts +648 -28
  119. package/src/cli/account-main.ts +317 -0
  120. package/src/cli/account.ts +97 -19
  121. package/src/cli/agent.ts +53 -2
  122. package/src/cli/alias.ts +66 -0
  123. package/src/cli/capabilities-command.ts +94 -0
  124. package/src/cli/capabilities.ts +496 -0
  125. package/src/cli/claude-agent-startup-sync.ts +73 -0
  126. package/src/cli/claude-desktop.ts +60 -15
  127. package/src/cli/claude.ts +129 -35
  128. package/src/cli/codex-log-guard-doctor.ts +103 -0
  129. package/src/cli/codex-shim-autorestore.ts +2 -0
  130. package/src/cli/codex-shim-readiness.ts +76 -0
  131. package/src/cli/combo.ts +8 -0
  132. package/src/cli/config-command.ts +74 -10
  133. package/src/cli/dispatch.ts +821 -0
  134. package/src/cli/doctor.ts +440 -56
  135. package/src/cli/ensure-desired-integrations.ts +152 -0
  136. package/src/cli/export-command.ts +46 -20
  137. package/src/cli/help.ts +34 -274
  138. package/src/cli/index.ts +417 -528
  139. package/src/cli/init.ts +5 -17
  140. package/src/cli/inspect.ts +230 -0
  141. package/src/cli/integrations.ts +120 -2
  142. package/src/cli/lab.ts +607 -0
  143. package/src/cli/launcher-context.ts +77 -0
  144. package/src/cli/minimax.ts +497 -0
  145. package/src/cli/models-runtime.ts +130 -2
  146. package/src/cli/models.ts +118 -16
  147. package/src/cli/observe.ts +123 -11
  148. package/src/cli/opencode.ts +4 -2
  149. package/src/cli/provider-runtime.ts +34 -3
  150. package/src/cli/provider.ts +25 -3
  151. package/src/cli/ready.ts +301 -0
  152. package/src/cli/registry.ts +476 -0
  153. package/src/cli/root.ts +86 -0
  154. package/src/cli/route-policy.ts +92 -0
  155. package/src/cli/runtime-api.ts +57 -10
  156. package/src/cli/star-prompt.ts +71 -15
  157. package/src/cli/status.ts +51 -4
  158. package/src/cli/storage.ts +234 -0
  159. package/src/cli/system-command.ts +16 -0
  160. package/src/cli/system-restart-client.ts +146 -0
  161. package/src/cli/tray-proxy.ts +153 -6
  162. package/src/cli/usage-report.ts +184 -0
  163. package/src/cli/v2.ts +137 -18
  164. package/src/cli/version-skew.ts +46 -0
  165. package/src/cli.ts +1 -1
  166. package/src/clients/config-export.ts +1442 -23
  167. package/src/clients/effective-status.ts +30 -2
  168. package/src/clients/probes/cline.ts +81 -0
  169. package/src/codex/account-label.ts +35 -1
  170. package/src/codex/account-lifecycle.ts +130 -13
  171. package/src/codex/account-namespaces.ts +49 -3
  172. package/src/codex/account-pause.ts +2 -1
  173. package/src/codex/account-priority.ts +84 -0
  174. package/src/codex/account-store.ts +29 -2
  175. package/src/codex/account-usability.ts +25 -2
  176. package/src/codex/admission.ts +256 -0
  177. package/src/codex/affinity-debug.ts +162 -0
  178. package/src/codex/app-server-processes.ts +570 -107
  179. package/src/codex/app-server-restart-service.ts +232 -0
  180. package/src/codex/auth-api.ts +893 -246
  181. package/src/codex/auth-collision.ts +5 -3
  182. package/src/codex/auth-context.ts +348 -32
  183. package/src/codex/autostart-health.ts +24 -1
  184. package/src/codex/catalog/account-models.ts +67 -0
  185. package/src/codex/catalog/aggregation.ts +86 -10
  186. package/src/codex/catalog/bundled.ts +331 -33
  187. package/src/codex/catalog/effort.ts +138 -32
  188. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  189. package/src/codex/catalog/kinds.ts +2 -0
  190. package/src/codex/catalog/metadata.ts +555 -45
  191. package/src/codex/catalog/native-models.ts +75 -0
  192. package/src/codex/catalog/parsing.ts +269 -30
  193. package/src/codex/catalog/provider-fetch.ts +1626 -144
  194. package/src/codex/catalog/sync.ts +1576 -187
  195. package/src/codex/catalog-admission.ts +199 -0
  196. package/src/codex/catalog-refresh-status.ts +105 -0
  197. package/src/codex/catalog-write-serialization.ts +242 -0
  198. package/src/codex/catalog.ts +6 -3
  199. package/src/codex/codex-write-lock.ts +386 -0
  200. package/src/codex/convergence-types.ts +614 -0
  201. package/src/codex/convergence.ts +676 -0
  202. package/src/codex/coordinator-doctor.ts +332 -0
  203. package/src/codex/custom-model-catalog-migration.ts +176 -0
  204. package/src/codex/desired-state.ts +230 -0
  205. package/src/codex/desktop-app-restart.ts +355 -0
  206. package/src/codex/features.ts +636 -39
  207. package/src/codex/generation.ts +202 -0
  208. package/src/codex/history-job.ts +436 -0
  209. package/src/codex/history-lock.ts +242 -0
  210. package/src/codex/history-manifest.ts +112 -0
  211. package/src/codex/history-migration-guardian.ts +30 -24
  212. package/src/codex/history-provider.ts +1016 -235
  213. package/src/codex/history-transition.ts +105 -0
  214. package/src/codex/history-worker.ts +223 -0
  215. package/src/codex/inject-coordination.ts +373 -0
  216. package/src/codex/inject.ts +1114 -152
  217. package/src/codex/injected-marker.ts +37 -3
  218. package/src/codex/integration-record.ts +266 -0
  219. package/src/codex/internal/catalog-writer.ts +203 -0
  220. package/src/codex/internal/history-writer.ts +81 -0
  221. package/src/codex/journal.ts +66 -4
  222. package/src/codex/log-guard/inspect.ts +524 -0
  223. package/src/codex/log-guard/lock.ts +150 -0
  224. package/src/codex/log-guard/maintenance.ts +403 -0
  225. package/src/codex/log-guard/path-safety.ts +88 -0
  226. package/src/codex/log-guard/policy.ts +44 -0
  227. package/src/codex/log-guard/processes.ts +205 -0
  228. package/src/codex/log-guard/protection.ts +489 -0
  229. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  230. package/src/codex/main-account-cache.ts +24 -0
  231. package/src/codex/main-account.ts +29 -1
  232. package/src/codex/management-convergence.ts +167 -0
  233. package/src/codex/model-cache.ts +56 -10
  234. package/src/codex/model-entitlements.ts +360 -0
  235. package/src/codex/native-main-admission.ts +47 -0
  236. package/src/codex/native-main-auth-temp.ts +187 -0
  237. package/src/codex/native-main-claim.ts +178 -0
  238. package/src/codex/native-main-lock-file.ts +162 -0
  239. package/src/codex/native-main-owner.ts +329 -0
  240. package/src/codex/native-profile-api.ts +247 -0
  241. package/src/codex/native-profile-manager.ts +1531 -0
  242. package/src/codex/native-profile-processes.ts +121 -0
  243. package/src/codex/native-profile-recovery.ts +99 -0
  244. package/src/codex/native-profile-stage-store.ts +387 -0
  245. package/src/codex/native-profile-startup.ts +622 -0
  246. package/src/codex/native-profile-store.ts +855 -0
  247. package/src/codex/native-profile-types.ts +120 -0
  248. package/src/codex/native-residue.ts +675 -0
  249. package/src/codex/paths.ts +80 -1
  250. package/src/codex/plan-from-token.ts +140 -0
  251. package/src/codex/plan.ts +40 -0
  252. package/src/codex/plugins-doctor.ts +1 -1
  253. package/src/codex/pool-rotation.ts +74 -4
  254. package/src/codex/project-config-warnings.ts +20 -6
  255. package/src/codex/prompt-journal.ts +352 -0
  256. package/src/codex/prompt-layers.ts +1313 -0
  257. package/src/codex/prompt-lock.ts +143 -0
  258. package/src/codex/prompt-text-probe.ts +238 -0
  259. package/src/codex/quota-rejection.ts +298 -0
  260. package/src/codex/quota.ts +264 -21
  261. package/src/codex/refresh.ts +11 -2
  262. package/src/codex/reset-credit-recovery.ts +1044 -0
  263. package/src/codex/routing.ts +514 -94
  264. package/src/codex/runtime.ts +159 -38
  265. package/src/codex/shim.ts +1065 -31
  266. package/src/codex/subagent-model-fallback.ts +437 -43
  267. package/src/codex/sync.ts +191 -2
  268. package/src/codex/transition-state.ts +720 -0
  269. package/src/codex/upstream-host-health.ts +368 -0
  270. package/src/codex/user-identity.ts +557 -0
  271. package/src/codex/warmup.ts +187 -81
  272. package/src/codex/write-coordination.ts +114 -0
  273. package/src/combos/failover.ts +47 -0
  274. package/src/combos/index.ts +4 -0
  275. package/src/combos/request.ts +32 -0
  276. package/src/combos/types.ts +91 -9
  277. package/src/compatibility/index.ts +26 -0
  278. package/src/compatibility/manifest.ts +253 -0
  279. package/src/compatibility/openai-responses.ts +121 -0
  280. package/src/config/atomic-write.ts +219 -0
  281. package/src/config/paths.ts +40 -0
  282. package/src/config/process-state.ts +309 -0
  283. package/src/config/provider-name.ts +24 -0
  284. package/src/config/provider-validation.ts +177 -0
  285. package/src/config/rebase-provenance.ts +68 -0
  286. package/src/config.ts +1713 -709
  287. package/src/generated/compatibility-version.json +3324 -0
  288. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  289. package/src/grok/inject.ts +16 -5
  290. package/src/grok/inspect.ts +45 -0
  291. package/src/grok/sync.ts +2 -2
  292. package/src/images/loop.ts +157 -33
  293. package/src/images/plan.ts +24 -13
  294. package/src/integrations/config-io.ts +269 -0
  295. package/src/integrations/journal.ts +315 -0
  296. package/src/integrations/merge.ts +135 -0
  297. package/src/integrations/mutation-flight.ts +71 -0
  298. package/src/integrations/native/ownership-preflight.ts +202 -0
  299. package/src/integrations/omp-yaml-source.ts +358 -0
  300. package/src/integrations/owned-refresh.ts +74 -0
  301. package/src/integrations/ownership-policy.ts +160 -0
  302. package/src/integrations/ownership.ts +155 -0
  303. package/src/integrations/registry.ts +166 -0
  304. package/src/integrations/serialize.ts +314 -0
  305. package/src/integrations/state.ts +433 -0
  306. package/src/integrations/store.ts +103 -0
  307. package/src/integrations/writer-lock.ts +98 -0
  308. package/src/integrations/writer.ts +715 -0
  309. package/src/lab/artifacts/sanitize.ts +586 -0
  310. package/src/lab/artifacts/secure-fs.ts +475 -0
  311. package/src/lab/artifacts/store.ts +310 -0
  312. package/src/lab/automation/budgets.ts +78 -0
  313. package/src/lab/automation/config-persistence.ts +256 -0
  314. package/src/lab/automation/constants.ts +39 -0
  315. package/src/lab/automation/cooldown.ts +103 -0
  316. package/src/lab/automation/dispatch.ts +211 -0
  317. package/src/lab/automation/index.ts +13 -0
  318. package/src/lab/automation/orchestrator.ts +499 -0
  319. package/src/lab/automation/persistence.ts +512 -0
  320. package/src/lab/automation/planner.ts +371 -0
  321. package/src/lab/automation/policy.ts +136 -0
  322. package/src/lab/automation/queue.ts +191 -0
  323. package/src/lab/automation/recovery.ts +24 -0
  324. package/src/lab/automation/route-context.ts +21 -0
  325. package/src/lab/automation/run-key.ts +44 -0
  326. package/src/lab/automation/runs-query.ts +34 -0
  327. package/src/lab/automation/types.ts +160 -0
  328. package/src/lab/conformance/assertion.ts +325 -0
  329. package/src/lab/conformance/digest.ts +22 -0
  330. package/src/lab/conformance/executor.ts +741 -0
  331. package/src/lab/conformance/fixture-provider.ts +27 -0
  332. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  333. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  334. package/src/lab/conformance/harness-budget.ts +47 -0
  335. package/src/lab/conformance/index.ts +5 -0
  336. package/src/lab/conformance/jcs.ts +64 -0
  337. package/src/lab/conformance/json-pointer.ts +39 -0
  338. package/src/lab/conformance/manifest.ts +180 -0
  339. package/src/lab/conformance/mcp-stub.ts +179 -0
  340. package/src/lab/conformance/negative-controls.ts +164 -0
  341. package/src/lab/conformance/observation.ts +355 -0
  342. package/src/lab/conformance/runner.ts +68 -0
  343. package/src/lab/conformance/sse-normalize.ts +59 -0
  344. package/src/lab/conformance/suite-manifest.ts +78 -0
  345. package/src/lab/conformance/types.ts +214 -0
  346. package/src/lab/constants.ts +126 -0
  347. package/src/lab/digest.ts +64 -0
  348. package/src/lab/events/errors.ts +9 -0
  349. package/src/lab/events/limits.ts +117 -0
  350. package/src/lab/events/types.ts +229 -0
  351. package/src/lab/events/validate.ts +781 -0
  352. package/src/lab/fabric/constants.ts +40 -0
  353. package/src/lab/fabric/executor.ts +492 -0
  354. package/src/lab/fabric/index.ts +80 -0
  355. package/src/lab/fabric/manifest.ts +222 -0
  356. package/src/lab/fabric/observe.ts +489 -0
  357. package/src/lab/fabric/patch.ts +79 -0
  358. package/src/lab/fabric/producer-child.ts +139 -0
  359. package/src/lab/fabric/producer-isolate.ts +276 -0
  360. package/src/lab/fabric/producer-protocol.ts +61 -0
  361. package/src/lab/fabric/scratch.ts +439 -0
  362. package/src/lab/fabric/subject.ts +106 -0
  363. package/src/lab/fabric/types.ts +134 -0
  364. package/src/lab/fabric/verifier.ts +98 -0
  365. package/src/lab/index.ts +54 -0
  366. package/src/lab/ledger/artifact-refs.ts +127 -0
  367. package/src/lab/ledger/invalidation.ts +136 -0
  368. package/src/lab/ledger/purge.ts +310 -0
  369. package/src/lab/ledger/store.ts +532 -0
  370. package/src/lab/live/credential-lease.ts +53 -0
  371. package/src/lab/live/destination.ts +155 -0
  372. package/src/lab/live/executor.ts +336 -0
  373. package/src/lab/live/inert-tools.ts +56 -0
  374. package/src/lab/live/manifest.ts +85 -0
  375. package/src/lab/live/mcp-loopback.ts +57 -0
  376. package/src/lab/live/runner.ts +19 -0
  377. package/src/lab/live/sandbox.ts +61 -0
  378. package/src/lab/live/suite-manifest.ts +41 -0
  379. package/src/lab/live/transport.ts +118 -0
  380. package/src/lab/live/types.ts +197 -0
  381. package/src/lab/observe/from-conformance.ts +301 -0
  382. package/src/lab/observe/from-live.ts +117 -0
  383. package/src/lab/paths.ts +153 -0
  384. package/src/lab/projection/rebuild.ts +495 -0
  385. package/src/lab/projection/schema.ts +135 -0
  386. package/src/lab/projection/verdicts.ts +474 -0
  387. package/src/lab/projection/verification.ts +412 -0
  388. package/src/lab/public/bundle.ts +217 -0
  389. package/src/lab/public/community-authority.ts +175 -0
  390. package/src/lab/public/community-files.ts +29 -0
  391. package/src/lab/public/community.ts +479 -0
  392. package/src/lab/public/file-safety.ts +155 -0
  393. package/src/lab/public/ids.ts +26 -0
  394. package/src/lab/public/index.ts +16 -0
  395. package/src/lab/public/mutation-lock.ts +424 -0
  396. package/src/lab/public/operator.ts +353 -0
  397. package/src/lab/public/origin-purge.ts +79 -0
  398. package/src/lab/public/origin.ts +203 -0
  399. package/src/lab/public/privacy.ts +143 -0
  400. package/src/lab/public/private-file.ts +261 -0
  401. package/src/lab/public/project.ts +124 -0
  402. package/src/lab/public/purge-test-fault.ts +21 -0
  403. package/src/lab/public/purge.ts +223 -0
  404. package/src/lab/public/registry.ts +44 -0
  405. package/src/lab/public/revocation.ts +252 -0
  406. package/src/lab/public/signature.ts +243 -0
  407. package/src/lab/public/storage.ts +105 -0
  408. package/src/lab/public/strict-json.ts +206 -0
  409. package/src/lab/public/time.ts +26 -0
  410. package/src/lab/public/types.ts +172 -0
  411. package/src/lab/public/validate.ts +391 -0
  412. package/src/lab/query/catalog.ts +101 -0
  413. package/src/lab/query/connection.ts +107 -0
  414. package/src/lab/query/constants.ts +4 -0
  415. package/src/lab/query/cursor.ts +132 -0
  416. package/src/lab/query/dto-map.ts +277 -0
  417. package/src/lab/query/errors.ts +22 -0
  418. package/src/lab/query/freshness.ts +53 -0
  419. package/src/lab/query/index.ts +45 -0
  420. package/src/lab/query/latest-observation.ts +59 -0
  421. package/src/lab/query/passive-production.ts +159 -0
  422. package/src/lab/query/queries.ts +444 -0
  423. package/src/lab/query/types.ts +266 -0
  424. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  425. package/src/lab/subject/installation-salt.ts +112 -0
  426. package/src/lab/subject/protocol-subject.ts +80 -0
  427. package/src/lab/subject/route-subject.ts +74 -0
  428. package/src/lib/admin-secrets.ts +24 -0
  429. package/src/lib/app-owned-memory-stores.ts +22 -0
  430. package/src/lib/bounded-body.ts +167 -11
  431. package/src/lib/bun-runtime.ts +125 -12
  432. package/src/lib/bun-stream-caps.ts +13 -9
  433. package/src/lib/codex-restart-contract.ts +120 -0
  434. package/src/lib/config-ownership.ts +26 -2
  435. package/src/lib/destination-policy.ts +65 -1
  436. package/src/lib/errors.ts +80 -5
  437. package/src/lib/fabric-task-execution-authority.ts +7 -0
  438. package/src/lib/fabric-task-host.ts +29 -0
  439. package/src/lib/lab-activation.ts +223 -0
  440. package/src/lib/lab-live-execution-authority.ts +13 -0
  441. package/src/lib/lab-live-host.ts +30 -0
  442. package/src/lib/lab-live-pinned-sender.ts +56 -0
  443. package/src/lib/lab-live-route-production.ts +130 -0
  444. package/src/lib/lab-passive-linker-registration.ts +26 -0
  445. package/src/lib/local-management-attestation.ts +51 -0
  446. package/src/lib/local-management-capability.ts +100 -0
  447. package/src/lib/local-provider-reload-contract.ts +100 -0
  448. package/src/lib/optional-shutdown-hooks.ts +57 -0
  449. package/src/lib/package-tree-integrity.ts +101 -0
  450. package/src/lib/pinned-http.ts +145 -26
  451. package/src/lib/process-control.ts +6 -2
  452. package/src/lib/provider-outbound.ts +49 -9
  453. package/src/lib/redact.ts +419 -3
  454. package/src/lib/self-launch-argv.ts +15 -0
  455. package/src/lib/server-resource-ownership.ts +71 -0
  456. package/src/lib/service-secrets.ts +15 -0
  457. package/src/lib/shadow-call.ts +35 -4
  458. package/src/lib/sse-decoder.ts +41 -0
  459. package/src/lib/state-store-registrations.ts +10 -2
  460. package/src/lib/system-restart-contract.ts +73 -0
  461. package/src/lib/token-estimate.ts +19 -2
  462. package/src/lib/tool-argument-integers.ts +253 -0
  463. package/src/lib/translator-budget.ts +44 -0
  464. package/src/lib/upstream-http-version.ts +57 -0
  465. package/src/lib/upstream-reachability.ts +95 -0
  466. package/src/lib/upstream-retry.ts +156 -3
  467. package/src/lib/windows-atomic-replace.ts +155 -0
  468. package/src/lib/windows-elevation.ts +70 -2
  469. package/src/lib/windows-secret-acl.ts +409 -69
  470. package/src/lib/windows-service-wrappers.ts +72 -0
  471. package/src/lib/windows-text.ts +106 -0
  472. package/src/lib/windows-user-principal.ts +341 -0
  473. package/src/lib/winsw.ts +33 -5
  474. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  475. package/src/oauth/account-import/index.ts +15 -0
  476. package/src/oauth/account-import/parser.ts +83 -0
  477. package/src/oauth/account-import/registry.ts +18 -0
  478. package/src/oauth/account-import/service.ts +75 -0
  479. package/src/oauth/account-import/types.ts +91 -0
  480. package/src/oauth/anthropic.ts +12 -1
  481. package/src/oauth/callback-server.ts +30 -4
  482. package/src/oauth/chatgpt.ts +12 -1
  483. package/src/oauth/cline.ts +203 -0
  484. package/src/oauth/command-code.ts +239 -0
  485. package/src/oauth/cursor.ts +46 -5
  486. package/src/oauth/generic-account-failover.ts +231 -0
  487. package/src/oauth/google-antigravity.ts +35 -3
  488. package/src/oauth/health.ts +20 -12
  489. package/src/oauth/index.ts +462 -71
  490. package/src/oauth/key-providers.ts +25 -0
  491. package/src/oauth/kimi.ts +25 -3
  492. package/src/oauth/kiro.ts +95 -6
  493. package/src/oauth/local-token-detect.ts +13 -2
  494. package/src/oauth/log.ts +3 -1
  495. package/src/oauth/login-cli.ts +88 -28
  496. package/src/oauth/nous.ts +798 -0
  497. package/src/oauth/open-browser-choice.ts +26 -0
  498. package/src/oauth/store.ts +133 -26
  499. package/src/oauth/token-guardian.ts +9 -3
  500. package/src/oauth/types.ts +15 -0
  501. package/src/pi/models.ts +2 -2
  502. package/src/providers/alibaba-region-migration.ts +1 -1
  503. package/src/providers/antigravity-models.ts +521 -31
  504. package/src/providers/auto-compact-budget.ts +65 -0
  505. package/src/providers/base-url-choices.ts +10 -0
  506. package/src/providers/codex-capacity.ts +292 -0
  507. package/src/providers/command-code-efforts.ts +176 -0
  508. package/src/providers/context-cap.ts +26 -8
  509. package/src/providers/cursor-pool.ts +72 -0
  510. package/src/providers/default-aliases.ts +65 -0
  511. package/src/providers/derive.ts +281 -6
  512. package/src/providers/fastwire.ts +507 -0
  513. package/src/providers/free-directory.ts +10 -7
  514. package/src/providers/google-vertex-location.ts +14 -0
  515. package/src/providers/key-failover.ts +71 -3
  516. package/src/providers/label.ts +35 -2
  517. package/src/providers/model-discovery-limits.ts +16 -0
  518. package/src/providers/model-discovery.ts +115 -22
  519. package/src/providers/model-presets.ts +119 -0
  520. package/src/providers/model-rename-migration.ts +255 -0
  521. package/src/providers/model-rename-startup.ts +28 -0
  522. package/src/providers/new-model-policy.ts +146 -0
  523. package/src/providers/openai-sidecar.ts +72 -4
  524. package/src/providers/openai-tier-startup.ts +31 -2
  525. package/src/providers/openai-tiers.ts +119 -4
  526. package/src/providers/openai-virtual-models.ts +1 -0
  527. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  528. package/src/providers/provider-id-rewrite.ts +30 -0
  529. package/src/providers/quota.ts +1379 -40
  530. package/src/providers/registry.ts +1545 -112
  531. package/src/providers/request-pacing.ts +310 -0
  532. package/src/providers/service-tier.ts +277 -0
  533. package/src/providers/slug-codec.ts +94 -6
  534. package/src/providers/static-model-discovery.ts +86 -0
  535. package/src/providers/xai-responses-opt-in.ts +15 -0
  536. package/src/providers/xai-transport.ts +32 -4
  537. package/src/reasoning-effort.ts +68 -3
  538. package/src/responses/apply-patch-envelope.ts +63 -0
  539. package/src/responses/code-mode-helper-compat.ts +50 -0
  540. package/src/responses/compaction.ts +26 -1
  541. package/src/responses/custom-tool-compat.ts +384 -0
  542. package/src/responses/hosted-tool-policy.ts +9 -0
  543. package/src/responses/namespace-tool-compat.ts +435 -0
  544. package/src/responses/parser.ts +225 -38
  545. package/src/responses/provider-continuation.ts +98 -0
  546. package/src/responses/provider-opaque-metadata.ts +73 -0
  547. package/src/responses/reasoning-envelope.ts +9 -1
  548. package/src/responses/reasoning-replay-cache.ts +504 -0
  549. package/src/responses/schema.ts +12 -2
  550. package/src/responses/spill-store.ts +75 -10
  551. package/src/responses/state.ts +659 -31
  552. package/src/responses/thought-signature-replay.ts +364 -0
  553. package/src/responses/tool-search-compat.ts +301 -0
  554. package/src/responses/truncated-stop-reason.ts +60 -0
  555. package/src/responses/turn-termination.ts +107 -0
  556. package/src/router.ts +405 -31
  557. package/src/routing/analytics.ts +379 -0
  558. package/src/routing/capability.ts +244 -0
  559. package/src/routing/compatibility/assemble.ts +73 -0
  560. package/src/routing/compatibility/behavior.ts +278 -0
  561. package/src/routing/compatibility/catalog.ts +99 -0
  562. package/src/routing/compatibility/endpoint.ts +52 -0
  563. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  564. package/src/routing/compatibility/policy.ts +181 -0
  565. package/src/routing/compatibility/provider-slot.ts +56 -0
  566. package/src/routing/compatibility/reader.ts +110 -0
  567. package/src/routing/compatibility/subject.ts +191 -0
  568. package/src/routing/compatibility/types.ts +64 -0
  569. package/src/routing/compatibility/version.ts +104 -0
  570. package/src/routing/cost.ts +77 -0
  571. package/src/routing/evaluator.ts +495 -0
  572. package/src/routing/health.ts +412 -0
  573. package/src/routing/history/cursor.ts +43 -0
  574. package/src/routing/history/indexer.ts +605 -0
  575. package/src/routing/history/schema.ts +72 -0
  576. package/src/routing/profile-namespace.ts +15 -0
  577. package/src/routing/profile.ts +547 -0
  578. package/src/routing/quota.ts +155 -0
  579. package/src/routing/request-evidence.ts +45 -0
  580. package/src/routing/trace.ts +776 -0
  581. package/src/server/adapter-resolve.ts +2 -29
  582. package/src/server/auth-cors.ts +289 -48
  583. package/src/server/background-lifecycle.ts +182 -0
  584. package/src/server/chat-completions.ts +154 -72
  585. package/src/server/chat-native-sse.ts +331 -0
  586. package/src/server/chat-native.ts +445 -0
  587. package/src/server/claude-messages.ts +160 -44
  588. package/src/server/direct-local-http.ts +347 -0
  589. package/src/server/effort-policy.ts +18 -0
  590. package/src/server/github-copilot-responses-repair.ts +338 -0
  591. package/src/server/gui-static.ts +39 -10
  592. package/src/server/images.ts +94 -12
  593. package/src/server/index.ts +1021 -190
  594. package/src/server/lifecycle.ts +336 -14
  595. package/src/server/live.ts +136 -17
  596. package/src/server/local-management-read-client.ts +90 -0
  597. package/src/server/local-provider-reload-client.ts +137 -0
  598. package/src/server/management/agent-settings-routes.ts +433 -116
  599. package/src/server/management/api-key-usage.ts +31 -5
  600. package/src/server/management/body.ts +6 -0
  601. package/src/server/management/codex-prompt-routes.ts +570 -0
  602. package/src/server/management/combo-routes.ts +64 -25
  603. package/src/server/management/config-routes.ts +511 -51
  604. package/src/server/management/context.ts +89 -2
  605. package/src/server/management/integration-routes.ts +498 -0
  606. package/src/server/management/lab-automation-routes.ts +206 -0
  607. package/src/server/management/lab-routes.ts +563 -0
  608. package/src/server/management/logs-usage-routes.ts +127 -52
  609. package/src/server/management/model-routes.ts +461 -133
  610. package/src/server/management/model-rows.ts +167 -0
  611. package/src/server/management/native-integration-routes.ts +770 -0
  612. package/src/server/management/oauth-account-routes.ts +90 -8
  613. package/src/server/management/provider-capability-config.ts +48 -0
  614. package/src/server/management/provider-routes.ts +836 -159
  615. package/src/server/management/request-history-routes.ts +191 -0
  616. package/src/server/management/route-registry.ts +332 -0
  617. package/src/server/management/routing-analytics-routes.ts +74 -0
  618. package/src/server/management/routing-profile-routes.ts +380 -0
  619. package/src/server/management/shared.ts +27 -11
  620. package/src/server/management/sidebar-routes.ts +47 -31
  621. package/src/server/management/storage-log-guard-routes.ts +186 -0
  622. package/src/server/management/sync-response.ts +69 -0
  623. package/src/server/management/system-restart.ts +289 -33
  624. package/src/server/management/system-routes.ts +77 -0
  625. package/src/server/management/usage-summary-cache.ts +9 -1
  626. package/src/server/management/vision-sidecar-options.ts +167 -0
  627. package/src/server/management/web-search-sidecar-options.ts +120 -0
  628. package/src/server/management-api.ts +126 -23
  629. package/src/server/management-auth.ts +220 -5
  630. package/src/server/passive-route-linker.ts +66 -0
  631. package/src/server/port-reclaim.ts +1 -1
  632. package/src/server/ports.ts +41 -1
  633. package/src/server/proxy-liveness.ts +161 -10
  634. package/src/server/readiness.ts +99 -0
  635. package/src/server/relay-eager.ts +221 -54
  636. package/src/server/relay.ts +479 -87
  637. package/src/server/request-decompress.ts +113 -6
  638. package/src/server/request-log-conversation.ts +51 -0
  639. package/src/server/request-log.ts +298 -31
  640. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  641. package/src/server/responses/agent-task-recovery.ts +465 -0
  642. package/src/server/responses/codex-auth-error.ts +55 -0
  643. package/src/server/responses/collaboration.ts +239 -36
  644. package/src/server/responses/combo-stream-preflight.ts +178 -0
  645. package/src/server/responses/compact.ts +444 -72
  646. package/src/server/responses/core.ts +3579 -436
  647. package/src/server/responses/empty-completion-guard.ts +333 -0
  648. package/src/server/responses/encrypted-payload.ts +62 -39
  649. package/src/server/responses/fetch-helpers.ts +89 -99
  650. package/src/server/responses/input-admission.ts +187 -0
  651. package/src/server/responses/pacing-overload.ts +13 -0
  652. package/src/server/responses/passthrough-error.ts +33 -9
  653. package/src/server/responses/policy-fallback.ts +178 -0
  654. package/src/server/responses/responses-field-backfill.ts +343 -0
  655. package/src/server/responses/terminal-guard.ts +26 -5
  656. package/src/server/responses/upstream-error.ts +5 -0
  657. package/src/server/responses/ws-upstream.ts +381 -0
  658. package/src/server/responses-custom-tool-repair.ts +363 -0
  659. package/src/server/responses-item-id-repair.ts +54 -6
  660. package/src/server/responses-json-events.ts +90 -0
  661. package/src/server/responses-model-rewrite.ts +29 -0
  662. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  663. package/src/server/responses-snapshot-repair.ts +621 -0
  664. package/src/server/responses-terminal-repair.ts +363 -0
  665. package/src/server/responses-tool-search-repair.ts +267 -0
  666. package/src/server/responses-undeclared-tool-guard.ts +376 -0
  667. package/src/server/responses.ts +18 -2
  668. package/src/server/search.ts +78 -13
  669. package/src/server/sse-frame-buffer.ts +319 -0
  670. package/src/server/sse-payload-rewrite.ts +110 -22
  671. package/src/server/startup-action-control.ts +8 -1
  672. package/src/server/startup-health-cache.ts +19 -1
  673. package/src/server/system-env.ts +80 -9
  674. package/src/server/ws-bridge.ts +53 -40
  675. package/src/service-manager-probe.ts +892 -0
  676. package/src/service.ts +1153 -94
  677. package/src/sidecar/auth.ts +92 -0
  678. package/src/sidecar/candidates.ts +83 -0
  679. package/src/storage/cleanup.ts +2 -2
  680. package/src/storage/policy-job.ts +14 -4
  681. package/src/storage/policy.ts +88 -23
  682. package/src/storage/scanner.ts +1 -1
  683. package/src/storage/worker-lifecycle.ts +14 -14
  684. package/src/tray/windows-tray.ps1 +83 -9
  685. package/src/tray/windows.ts +43 -16
  686. package/src/types/accounts.ts +37 -0
  687. package/src/types/config.ts +911 -0
  688. package/src/types/provider.ts +614 -0
  689. package/src/types/request.ts +397 -0
  690. package/src/types/tools.ts +236 -0
  691. package/src/types/wire.ts +80 -0
  692. package/src/types.ts +107 -1237
  693. package/src/update/index.ts +37 -23
  694. package/src/update/job.ts +445 -68
  695. package/src/update/notify.ts +12 -6
  696. package/src/update/npm-cache-preflight.d.mts +47 -0
  697. package/src/update/npm-cache-preflight.mjs +201 -0
  698. package/src/update/transactional-install.d.mts +22 -0
  699. package/src/update/transactional-install.mjs +266 -0
  700. package/src/usage/cost.ts +0 -0
  701. package/src/usage/expected-prices.ts +287 -16
  702. package/src/usage/log.ts +666 -50
  703. package/src/usage/summary.ts +388 -17
  704. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  705. package/src/usage/user-cost-overlays.ts +314 -0
  706. package/src/vision/anthropic-describe.ts +10 -6
  707. package/src/vision/backends.ts +97 -0
  708. package/src/vision/describe.ts +27 -16
  709. package/src/vision/eligibility.ts +250 -0
  710. package/src/vision/index.ts +238 -24
  711. package/src/vision/reasoning.ts +55 -0
  712. package/src/vision/routed-describe.ts +175 -0
  713. package/src/vision/timeout-bounds.ts +9 -0
  714. package/src/web-search/anthropic-executor.ts +13 -7
  715. package/src/web-search/backends.ts +108 -0
  716. package/src/web-search/exa-executor.ts +119 -0
  717. package/src/web-search/executor.ts +21 -6
  718. package/src/web-search/gemini-executor.ts +141 -0
  719. package/src/web-search/index.ts +150 -15
  720. package/src/web-search/loop.ts +284 -54
  721. package/src/web-search/parse.ts +125 -30
  722. package/src/web-search/sources.ts +60 -0
  723. package/src/web-search/xai-executor.ts +219 -0
  724. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  725. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  726. package/gui/dist/provider-icons/antigravity.svg +0 -1
  727. package/gui/dist/provider-icons/claude.svg +0 -1
  728. package/gui/dist/provider-icons/copilot.svg +0 -1
  729. package/gui/dist/provider-icons/cursor.svg +0 -2
  730. package/gui/dist/provider-icons/gemini.svg +0 -1
  731. package/gui/dist/provider-icons/grok-color.svg +0 -1
  732. package/gui/dist/provider-icons/kiro.svg +0 -14
  733. package/src/cli/internal-dispatch.ts +0 -20
@@ -1,15 +1,36 @@
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";
29
+ import {
30
+ isXaiSchemaTarget,
31
+ lookupLocalJsonPointer,
32
+ normalizeXaiToolParameters,
33
+ } from "./xai-tool-schema";
13
34
  import {
14
35
  isTranslatorBudgetExceededError,
15
36
  retainTranslatedEventBatch,
@@ -21,7 +42,148 @@ import {
21
42
  // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
22
43
  // unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim.
23
44
  export function stripBracketedModelSuffix(modelId: string): string {
24
- return modelId.replace(/\[[^\]]*\]\s*$/, "");
45
+ const suffixEnd = modelId.trimEnd().length;
46
+ if (suffixEnd === 0 || modelId[suffixEnd - 1] !== "]") return modelId;
47
+
48
+ let suffixStart = -1;
49
+ for (let i = suffixEnd - 2; i >= 0 && modelId[i] !== "]"; i--) {
50
+ if (modelId[i] === "[") suffixStart = i;
51
+ }
52
+ return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart);
53
+ }
54
+
55
+ const CHAT_PASSTHROUGH_FIELDS = [
56
+ "audio",
57
+ "frequency_penalty",
58
+ "logit_bias",
59
+ "logprobs",
60
+ "max_completion_tokens",
61
+ "max_tokens",
62
+ "metadata",
63
+ "modalities",
64
+ "n",
65
+ "prediction",
66
+ "presence_penalty",
67
+ "reasoning_effort",
68
+ "response_format",
69
+ "seed",
70
+ "stop",
71
+ "store",
72
+ "temperature",
73
+ "tool_choice",
74
+ "tools",
75
+ "top_logprobs",
76
+ "top_p",
77
+ "user",
78
+ "web_search_options",
79
+ ] as const;
80
+
81
+ function openAIChatTransport(provider: OcxProviderConfig): {
82
+ url: string;
83
+ headers: Record<string, string>;
84
+ hasCredential: boolean;
85
+ } {
86
+ const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
87
+ if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
88
+ throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
89
+ }
90
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
91
+ if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
92
+ if (provider.headers) Object.assign(headers, provider.headers);
93
+ return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential };
94
+ }
95
+
96
+ /**
97
+ * Build a provider request from an inbound Chat Completions body without translating it
98
+ * through the Responses contract. This is deliberately a whitelist: Chat-only caller
99
+ * fields retain their exact wire representation, while provider capability gates remain
100
+ * centralized beside the ordinary openai-chat adapter.
101
+ */
102
+ export function buildOpenAIChatPassthroughRequest(
103
+ provider: OcxProviderConfig,
104
+ rawBody: Record<string, unknown>,
105
+ modelId: string,
106
+ stream: boolean,
107
+ fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"),
108
+ fastMode?: boolean,
109
+ ): AdapterRequest {
110
+ const { url, headers, hasCredential } = openAIChatTransport(provider);
111
+
112
+ const body: Record<string, unknown> = {
113
+ model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
114
+ messages: rawBody.messages,
115
+ stream,
116
+ };
117
+ for (const field of CHAT_PASSTHROUGH_FIELDS) {
118
+ if (rawBody[field] !== undefined) body[field] = rawBody[field];
119
+ }
120
+
121
+ const openRouterRouting = resolveOpenRouterRouting(provider, modelId);
122
+ if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
123
+
124
+ if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature;
125
+ if (modelInList(provider.noTopPModels, modelId)) delete body.top_p;
126
+ if (modelInList(provider.noPenaltyModels, modelId)) {
127
+ delete body.presence_penalty;
128
+ delete body.frequency_penalty;
129
+ }
130
+ // Exact match, unlike the gates above: `noStructuredOutputModels` is documented as
131
+ // "only an exact requested-model match omits the field" (#1424), and the Responses
132
+ // ingress enforces exactly that. A prefix match here would strip response_format from
133
+ // `<listed>:<tag>` siblings the operator never opted out, silently returning prose.
134
+ if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;
135
+
136
+ // Run the same complete Fast policy as the translated Chat path, including explicit
137
+ // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still
138
+ // retains the caller's exact spelling; forced Fast uses the policy-owned wire value.
139
+ const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined;
140
+ const tierDecision = decideTier(fastPolicy, fastMode, callerTier);
141
+ if (tierDecision.kind === "set") {
142
+ body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined
143
+ ? callerTier
144
+ : tierDecision.value;
145
+ } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) {
146
+ body.service_tier = rawBody.service_tier;
147
+ }
148
+ if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
149
+ body.prompt_cache_key = rawBody.prompt_cache_key;
150
+ }
151
+ if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) {
152
+ if (provider.parallelToolCalls === true) {
153
+ body.parallel_tool_calls = rawBody.parallel_tool_calls !== false;
154
+ } else if (provider.parallelToolCalls === false
155
+ && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) {
156
+ body.parallel_tool_calls = false;
157
+ }
158
+ }
159
+ if (stream) {
160
+ const callerOptions = rawBody.stream_options !== null
161
+ && typeof rawBody.stream_options === "object"
162
+ && !Array.isArray(rawBody.stream_options)
163
+ ? rawBody.stream_options as Record<string, unknown>
164
+ : {};
165
+ body.stream_options = { ...callerOptions, include_usage: true };
166
+ } else if (rawBody.stream_options !== undefined) {
167
+ body.stream_options = rawBody.stream_options;
168
+ }
169
+
170
+ const bodyJson = JSON.stringify(body);
171
+
172
+ if (isDebugEnabled()) {
173
+ let host = "upstream";
174
+ try { host = new URL(url).host; } catch { /* keep fallback */ }
175
+ debugProviderDiagnostic("openai-chat", "passthrough-request", {
176
+ host,
177
+ model: body.model,
178
+ stream,
179
+ messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
180
+ toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
181
+ hasCredential,
182
+ bodyBytes: new TextEncoder().encode(bodyJson).length,
183
+ });
184
+ }
185
+
186
+ return { url, method: "POST", headers, body: bodyJson };
25
187
  }
26
188
 
27
189
  // 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
@@ -46,14 +208,12 @@ function extractErrorDetail(parsed: unknown): string | undefined {
46
208
  if (typeof parsed === "string") return parsed.trim() || undefined;
47
209
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
48
210
  const obj = parsed as Record<string, unknown>;
49
- // OpenAI shape: { error: { message } } or { error: "..." }
50
211
  const err = obj.error;
51
212
  if (typeof err === "string" && err.trim()) return err.trim();
52
213
  if (err !== null && typeof err === "object" && !Array.isArray(err)) {
53
214
  const msg = (err as Record<string, unknown>).message;
54
215
  if (typeof msg === "string" && msg.trim()) return msg.trim();
55
216
  }
56
- // FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
57
217
  const det = obj.detail;
58
218
  if (typeof det === "string" && det.trim()) return det.trim();
59
219
  if (Array.isArray(det)) {
@@ -64,12 +224,349 @@ function extractErrorDetail(parsed: unknown): string | undefined {
64
224
  .filter(m => m.length > 0);
65
225
  if (msgs.length > 0) return msgs.join("; ");
66
226
  }
67
- // Generic fallbacks: { message } / RFC7807 { title }
68
227
  if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
69
228
  if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
70
229
  return undefined;
71
230
  }
72
231
 
232
+ function unwrapChatCompletionPayload(json: Record<string, unknown>): Record<string, unknown> {
233
+ if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json;
234
+ const data = json.data;
235
+ return data !== null && typeof data === "object" && !Array.isArray(data)
236
+ ? data as Record<string, unknown>
237
+ : json;
238
+ }
239
+
240
+ interface OpenAIChatError {
241
+ message?: unknown;
242
+ code?: unknown;
243
+ type?: unknown;
244
+ status?: unknown;
245
+ metadata?: unknown;
246
+ }
247
+
248
+ function safeUpstreamRequestId(metadata: unknown): string | undefined {
249
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
250
+ const record = metadata as Record<string, unknown>;
251
+ const value = record.request_id ?? record.requestId;
252
+ if (typeof value !== "string") return undefined;
253
+ const requestId = value.trim();
254
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId)
255
+ && redactSecretString(requestId) === requestId
256
+ ? requestId
257
+ : undefined;
258
+ }
259
+
260
+ function upstreamErrorEvent(
261
+ error: unknown,
262
+ usage?: OcxUsage,
263
+ ): Extract<AdapterEvent, { type: "error" }> {
264
+ const details = error !== null && typeof error === "object" && !Array.isArray(error)
265
+ ? error as OpenAIChatError
266
+ : undefined;
267
+ const rawMessage = typeof error === "string"
268
+ ? error.trim() || "upstream error"
269
+ : typeof details?.message === "string" ? details.message : "upstream error";
270
+ const safeMessage = redactSecretString(rawMessage);
271
+ const requestId = safeUpstreamRequestId(details?.metadata);
272
+ const message = requestId !== undefined && !safeMessage.includes(requestId)
273
+ ? `${safeMessage} (request ID: ${requestId})`
274
+ : safeMessage;
275
+ const code = typeof details?.code === "string"
276
+ ? details.code
277
+ : typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code)
278
+ ? String(details.code)
279
+ : undefined;
280
+ const errorType = typeof details?.type === "string" ? details.type : undefined;
281
+ const codeStatus = typeof details?.code === "number"
282
+ && Number.isInteger(details.code)
283
+ && details.code >= 100
284
+ && details.code <= 599
285
+ ? details.code
286
+ : undefined;
287
+ const status = isCyberPolicyCode(code)
288
+ ? 400
289
+ : typeof details?.status === "number" && Number.isInteger(details.status)
290
+ ? details.status
291
+ : codeStatus;
292
+ return {
293
+ type: "error",
294
+ message,
295
+ ...(usage !== undefined ? { usage } : {}),
296
+ ...(code !== undefined ? { code } : {}),
297
+ ...(errorType !== undefined ? { errorType } : {}),
298
+ ...(status !== undefined ? { status } : {}),
299
+ };
300
+ }
301
+
302
+ function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined {
303
+ return finishReason === "length"
304
+ ? "max_tokens"
305
+ : finishReason === "content_filter"
306
+ ? "content_filter"
307
+ : undefined;
308
+ }
309
+
310
+ function reasoningTextFrom(record: Record<string, unknown>): string | undefined {
311
+ return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0
312
+ ? record.reasoning_content
313
+ : typeof record.reasoning === "string" && record.reasoning.length > 0
314
+ ? record.reasoning
315
+ : undefined;
316
+ }
317
+
318
+ function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
319
+ return {
320
+ type: "error",
321
+ message: "upstream response contained invalid choices",
322
+ ...(usage !== undefined ? { usage } : {}),
323
+ };
324
+ }
325
+
326
+ function invalidToolCallsEvent(
327
+ rawToolCalls: unknown,
328
+ mode: "stream" | "response",
329
+ usage?: OcxUsage,
330
+ diagnosticOverride?: InvalidToolCallDiagnostic,
331
+ ): Extract<AdapterEvent, { type: "error" }> {
332
+ // The streamed accumulator knows things a rescan cannot: which field on which pending call
333
+ // was actually rejected. Without the override, a stream carrying accepted padding on call 0
334
+ // and a real defect on call 1 blames call 0, because the stateless scan stops at the first
335
+ // structurally odd value it sees.
336
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
337
+ const detail = diagnostic
338
+ ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
339
+ : "";
340
+ return {
341
+ type: "error",
342
+ status: 502,
343
+ errorType: "upstream_error",
344
+ message: `upstream response contained invalid tool calls${detail}`,
345
+ ...(usage !== undefined ? { usage } : {}),
346
+ };
347
+ }
348
+
349
+ /**
350
+ * A streamed tool call is only dispatchable once the upstream has named the function.
351
+ *
352
+ * The OpenAI streaming convention puts `function.name` in the first chunk for a tool-call
353
+ * index and leaves later chunks carrying only `arguments` deltas, so a stream that never
354
+ * sends a name is non-conforming for every provider rather than quirky for one. The
355
+ * reference implementations accumulate such a call with an empty name and let the caller
356
+ * fail; we sit at the boundary where it would become a Codex tool-call contract event, so
357
+ * the equivalent is to refuse to emit it.
358
+ *
359
+ * Failing closed rather than dropping is deliberate, and matches #1325: a claimed tool call
360
+ * that silently disappears can leave the matching result orphaned on the next turn. Naming
361
+ * it ourselves is worse still — the id is synthesizable because it is an opaque correlation
362
+ * handle, but a function name is a guess at intent.
363
+ */
364
+ function unnamedToolCallEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
365
+ return {
366
+ type: "error",
367
+ message: "upstream streamed a tool call without a function name — cannot dispatch",
368
+ ...(usage !== undefined ? { usage } : {}),
369
+ };
370
+ }
371
+
372
+ function isRecord(value: unknown): value is Record<string, unknown> {
373
+ return value !== null && typeof value === "object" && !Array.isArray(value);
374
+ }
375
+
376
+ type InvalidToolCallReason =
377
+ | "tool_calls_not_array"
378
+ | "tool_call_not_object"
379
+ | "tool_call_id_invalid"
380
+ | "tool_call_function_not_object"
381
+ | "tool_call_function_name_invalid"
382
+ | "tool_call_function_name_blank"
383
+ | "tool_call_function_arguments_invalid";
384
+
385
+ type InvalidToolCallDiagnostic = {
386
+ reason: InvalidToolCallReason;
387
+ callIndex?: number;
388
+ valueType: string;
389
+ };
390
+
391
+ type InvalidFieldShape =
392
+ | {
393
+ kind: "object";
394
+ knownKeys: string[];
395
+ knownFieldTypes: Record<string, string>;
396
+ hasUnknownKeys: boolean;
397
+ }
398
+ | {
399
+ kind: "array";
400
+ length: number;
401
+ };
402
+
403
+ const SAFE_TOOL_CALL_SHAPE_KEYS = [
404
+ "name",
405
+ "type",
406
+ "value",
407
+ "function",
408
+ "arguments",
409
+ "id",
410
+ "index",
411
+ ] as const;
412
+ const SAFE_TOOL_CALL_SHAPE_KEY_SET = new Set<string>(SAFE_TOOL_CALL_SHAPE_KEYS);
413
+
414
+ function structuralValueType(value: unknown): string {
415
+ return value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
416
+ }
417
+
418
+ function invalidToolCallField(rawToolCalls: unknown, diagnostic: InvalidToolCallDiagnostic): unknown {
419
+ if (diagnostic.reason === "tool_calls_not_array") return rawToolCalls;
420
+ if (!Array.isArray(rawToolCalls) || diagnostic.callIndex === undefined) return undefined;
421
+
422
+ const rawToolCall = rawToolCalls[diagnostic.callIndex];
423
+ if (diagnostic.reason === "tool_call_not_object") return rawToolCall;
424
+ if (!isRecord(rawToolCall)) return undefined;
425
+ if (diagnostic.reason === "tool_call_function_not_object") return rawToolCall.function;
426
+
427
+ const rawFunction = rawToolCall.function;
428
+ switch (diagnostic.reason) {
429
+ case "tool_call_id_invalid":
430
+ return rawToolCall.id;
431
+ case "tool_call_function_name_invalid":
432
+ return isRecord(rawFunction) ? rawFunction.name : undefined;
433
+ case "tool_call_function_arguments_invalid":
434
+ return isRecord(rawFunction) ? rawFunction.arguments : undefined;
435
+ default:
436
+ return undefined;
437
+ }
438
+ }
439
+
440
+ function fingerprintInvalidField(value: unknown): InvalidFieldShape | undefined {
441
+ if (Array.isArray(value)) return { kind: "array", length: value.length };
442
+ if (!isRecord(value)) return undefined;
443
+
444
+ const knownKeys: string[] = [];
445
+ const knownFieldTypes: Record<string, string> = {};
446
+ for (const key of SAFE_TOOL_CALL_SHAPE_KEYS) {
447
+ if (!Object.hasOwn(value, key)) continue;
448
+ knownKeys.push(key);
449
+ knownFieldTypes[key] = structuralValueType(value[key]);
450
+ }
451
+
452
+ let hasUnknownKeys = false;
453
+ for (const key of Object.keys(value)) {
454
+ if (!SAFE_TOOL_CALL_SHAPE_KEY_SET.has(key)) {
455
+ hasUnknownKeys = true;
456
+ break;
457
+ }
458
+ }
459
+ return { kind: "object", knownKeys, knownFieldTypes, hasUnknownKeys };
460
+ }
461
+
462
+ /**
463
+ * Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible
464
+ * streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas.
465
+ * The accumulator and this diagnostic share this predicate so they cannot disagree about
466
+ * which delta was the invalid one.
467
+ */
468
+ function isInvalidStreamStringField(value: unknown): boolean {
469
+ return value != null && typeof value !== "string";
470
+ }
471
+
472
+ /**
473
+ * Explain only the rejected wire shape, never its values. This diagnostic exists so provider
474
+ * compatibility can be tightened from evidence without retaining tool arguments or credentials.
475
+ */
476
+ function diagnoseInvalidToolCalls(
477
+ rawToolCalls: unknown,
478
+ mode: "stream" | "response",
479
+ ): InvalidToolCallDiagnostic | undefined {
480
+ if (!Array.isArray(rawToolCalls)) {
481
+ return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls };
482
+ }
483
+ for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
484
+ const rawToolCall = rawToolCalls[callIndex];
485
+ if (!isRecord(rawToolCall)) {
486
+ return {
487
+ reason: "tool_call_not_object",
488
+ callIndex,
489
+ valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
490
+ };
491
+ }
492
+ if (mode === "stream") {
493
+ // The streamed path validates the pieces it is about to store (#1531): a present
494
+ // `function` must be a record, and a present `name`/`arguments`/`id` must be a string.
495
+ // Blank names are caught later at flush, not here, so they are not diagnosed on this
496
+ // branch. Describe exactly that boundary rather than tightening compatibility in a
497
+ // diagnostic change.
498
+ // #1731: "present" means the same thing here as in the accumulator — null and undefined
499
+ // are both absent, because some OpenAI-compatible streamers repeat already-sent fields
500
+ // as null on continuation deltas. A separate predicate here would diagnose accepted
501
+ // padding as the failure and point compatibility work at the wrong delta.
502
+ const streamFunction = (rawToolCall as { function?: unknown }).function;
503
+ if (streamFunction !== undefined && streamFunction !== null) {
504
+ if (!isRecord(streamFunction)) {
505
+ return {
506
+ reason: "tool_call_function_not_object",
507
+ callIndex,
508
+ valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction,
509
+ };
510
+ }
511
+ if (isInvalidStreamStringField(streamFunction.name)) {
512
+ return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name };
513
+ }
514
+ if (isInvalidStreamStringField(streamFunction.arguments)) {
515
+ return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments };
516
+ }
517
+ }
518
+ if (isInvalidStreamStringField(rawToolCall.id)) {
519
+ return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
520
+ }
521
+ continue;
522
+ }
523
+ // Precedence must mirror the buffered validator below, or a payload with more than one
524
+ // problem is reported under the wrong reason and sends compatibility work after the wrong
525
+ // shape. That validator checks the `function` container first (`!isRecord(rawToolCall) ||
526
+ // !isRecord(rawToolCall.function)`), then id/name/arguments types together, and only then
527
+ // the blank name.
528
+ if (!isRecord(rawToolCall.function)) {
529
+ return {
530
+ reason: "tool_call_function_not_object",
531
+ callIndex,
532
+ valueType: rawToolCall.function === null ? "null" : Array.isArray(rawToolCall.function) ? "array" : typeof rawToolCall.function,
533
+ };
534
+ }
535
+ if (typeof rawToolCall.id !== "string") {
536
+ return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
537
+ }
538
+ if (typeof rawToolCall.function.name !== "string") {
539
+ return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawToolCall.function.name };
540
+ }
541
+ if (typeof rawToolCall.function.arguments !== "string") {
542
+ return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawToolCall.function.arguments };
543
+ }
544
+ // Last, matching the validator: #1531 also rejects a blank or whitespace-only name here,
545
+ // because such a call cannot select a dispatch target. Reporting it as `name_invalid`
546
+ // would claim a type problem for a correctly-typed value, so it gets its own code.
547
+ if (rawToolCall.function.name.trim().length === 0) {
548
+ return { reason: "tool_call_function_name_blank", callIndex, valueType: "string" };
549
+ }
550
+ }
551
+ return undefined;
552
+ }
553
+
554
+ function logInvalidToolCalls(
555
+ mode: "stream" | "response",
556
+ rawToolCalls: unknown,
557
+ diagnosticOverride?: InvalidToolCallDiagnostic,
558
+ ): void {
559
+ if (!isDebugEnabled()) return;
560
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
561
+ if (!diagnostic) return;
562
+ const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic));
563
+ debugProviderDiagnostic("openai-chat", "invalid-tool-calls", {
564
+ mode,
565
+ ...diagnostic,
566
+ ...(fieldShape ? { fieldShape } : {}),
567
+ });
568
+ }
569
+
73
570
  function developerSystemText(message: OcxMessage): string | undefined {
74
571
  if (message.role !== "developer") return undefined;
75
572
  if (typeof message.content === "string") return message.content;
@@ -77,20 +574,49 @@ function developerSystemText(message: OcxMessage): string | undefined {
77
574
  return message.content.map(part => (part as OcxTextContent).text).join("");
78
575
  }
79
576
 
577
+ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
578
+ try {
579
+ return new URL(provider.baseUrl).hostname === "api.openai.com";
580
+ } catch {
581
+ return false;
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
587
+ * content is text-only on every chat provider, so these ride in a follow-up user message instead of
588
+ * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
589
+ * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
590
+ */
591
+ function toolResultTextForWire(content: string | OcxContentPart[]): string {
592
+ if (typeof content === "string") return content;
593
+ const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
594
+ if (text) {
595
+ const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
596
+ return `${text}${"[image]".repeat(untransportableImages)}`;
597
+ }
598
+ return contentPartsToText(content);
599
+ }
600
+
601
+ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
602
+ if (typeof content === "string") return [];
603
+ const parts: unknown[] = [];
604
+ for (const p of content) {
605
+ if (p.type !== "image" || !p.imageUrl) continue;
606
+ parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
607
+ }
608
+ return parts;
609
+ }
610
+
80
611
  function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
81
612
  const out: unknown[] = [];
82
613
  const { context, options } = parsed;
614
+ const replayCacheScope = parsed._reasoningReplayScope;
83
615
 
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
616
  interface PendingToolCall { id: string; name: string }
92
617
  let pendingToolCalls: PendingToolCall[] = [];
93
618
  let deferredBarrierMessages: unknown[] = [];
619
+ let pendingToolResultImageParts: unknown[] = [];
94
620
  let mintedIdSeq = 0;
95
621
  const seenWireCallIds = new Set<string>();
96
622
 
@@ -109,9 +635,18 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
109
635
  deferredBarrierMessages = [];
110
636
  };
111
637
 
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.
638
+ const flushToolResultImages = (): void => {
639
+ if (pendingToolResultImageParts.length === 0) return;
640
+ out.push({
641
+ role: "user",
642
+ content: [
643
+ { type: "text", text: "[ocx] image output from the preceding tool result(s):" },
644
+ ...pendingToolResultImageParts,
645
+ ],
646
+ });
647
+ pendingToolResultImageParts = [];
648
+ };
649
+
115
650
  const flushPendingToolCalls = (): void => {
116
651
  if (pendingToolCalls.length === 0) return;
117
652
  for (const call of pendingToolCalls) {
@@ -122,32 +657,29 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
122
657
  });
123
658
  }
124
659
  pendingToolCalls = [];
660
+ flushToolResultImages();
125
661
  releaseDeferredBarriers();
126
662
  };
127
663
 
664
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
128
665
  const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
129
666
  ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
130
667
  : 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);
668
+ const developerSystemParts = nativeOpenAI
669
+ ? []
670
+ : context.messages
671
+ .map(developerSystemText)
672
+ .filter((part): part is string => part !== undefined && part.length > 0);
140
673
  const systemParts = [
141
674
  ...(context.systemPrompt ?? []),
142
675
  ...developerSystemParts,
143
676
  ...(toolCatalogNudge ? [toolCatalogNudge] : []),
144
677
  ];
145
678
  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"));
679
+ const wireModelId = provider.modelSuffixBracketStrip
680
+ ? stripBracketedModelSuffix(parsed.modelId)
681
+ : parsed.modelId;
682
+ const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId);
151
683
  out.push({ role: "system", content: sys });
152
684
  }
153
685
 
@@ -157,24 +689,23 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
157
689
  case "developer": {
158
690
  const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
159
691
  const hasImages = parts?.some(p => p.type === "image") ?? false;
160
- if (msg.role === "developer" && !hasImages) break;
161
692
  let chatMsg: Record<string, unknown>;
162
- if (typeof msg.content === "string") {
693
+ if (msg.role === "developer" && !hasImages) {
694
+ if (!nativeOpenAI) break;
695
+ const text = typeof msg.content === "string"
696
+ ? msg.content
697
+ : parts!.map(p => (p as OcxTextContent).text).join("");
698
+ chatMsg = { role: "developer", content: text };
699
+ } else if (typeof msg.content === "string") {
163
700
  chatMsg = { role: "user", content: msg.content };
701
+ } else if (!hasImages) {
702
+ chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
164
703
  } 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
- }
704
+ const chatParts = parts!.map(p => p.type === "image"
705
+ ? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
706
+ : { type: "text", text: (p as OcxTextContent).text });
707
+ chatMsg = { role: "user", content: chatParts };
175
708
  }
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
709
  if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
179
710
  else out.push(chatMsg);
180
711
  break;
@@ -185,18 +716,38 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
185
716
  const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
186
717
  const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
187
718
  const chatMsg: Record<string, unknown> = { role: "assistant" };
188
- if (textParts.length > 0) {
189
- chatMsg.content = textParts.map(p => p.text).join("");
719
+ if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join("");
720
+ let reasoningContent = thinkingParts.map(p => p.thinking).join("");
721
+ if (
722
+ reasoningContent.length === 0
723
+ && toolCalls.length > 0
724
+ && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
725
+ ) {
726
+ const cached = toolCalls
727
+ .map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined))
728
+ .filter((text): text is string => typeof text === "string" && text.length > 0);
729
+ // Parallel calls share one preceding reasoning block, which is
730
+ // recorded under every call id — join unique texts only.
731
+ if (cached.length > 0) {
732
+ reasoningContent = [...new Set(cached)].join("\n");
733
+ } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) {
734
+ // Fallback (extends #950, closes #1193): the replay cache is
735
+ // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on
736
+ // long sessions, and some tool rounds carry no recorded reasoning
737
+ // at all. DeepSeek thinking mode rejects ANY tool_call assistant
738
+ // message missing reasoning_content with HTTP 400, so inject a
739
+ // minimal placeholder rather than emit a bare continuation the
740
+ // upstream will reject. Scoped to requiresReasoningPlaceholderModels
741
+ // (defaulting to the preserve list): preserve-listed providers with
742
+ // toggleable thinking (MiniMax low effort) opt out with `[]` so
743
+ // non-thinking histories are never given a fabricated placeholder.
744
+ reasoningContent = " ";
745
+ }
190
746
  }
191
- const reasoningContent = thinkingParts.map(p => p.thinking).join("");
192
747
  if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
193
748
  chatMsg.reasoning_content = reasoningContent;
194
749
  }
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
750
  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
751
  flushPendingToolCalls();
201
752
  const wireToolCalls = toolCalls.map(tc => {
202
753
  let id = tc.id;
@@ -210,8 +761,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
210
761
  type: "function",
211
762
  function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
212
763
  }));
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
764
  if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
216
765
  }
217
766
  if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
@@ -225,26 +774,44 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
225
774
  let toolCallId = msg.toolCallId;
226
775
  const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
227
776
  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
777
  out.push({
231
778
  role: "tool",
232
779
  tool_call_id: toolCallId,
233
- content: contentPartsToText(msg.content),
780
+ content: toolResultTextForWire(msg.content),
234
781
  });
782
+ pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
235
783
  pendingToolCalls.splice(matchIdx, 1);
236
- if (pendingToolCalls.length === 0) releaseDeferredBarriers();
784
+ if (pendingToolCalls.length === 0) {
785
+ flushToolResultImages();
786
+ releaseDeferredBarriers();
787
+ }
237
788
  } else {
238
789
  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
790
  flushPendingToolCalls();
244
791
  const name = safeToolName(msg.toolName);
792
+ const cachedReasoning =
793
+ toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
794
+ ? peekReasoningForCall(toolCallId, replayCacheScope)
795
+ : undefined;
796
+ // Same fallback as the main-assistant path: never emit a bare orphan
797
+ // tool_call continuation on a thinking-mode provider — inject a
798
+ // placeholder when the replay cache missed (the bounded cache can
799
+ // always miss on long sessions), or DeepSeek thinking mode 400s.
800
+ // Gate on the preserve list too: reasoning_content is only ever
801
+ // serialized for preserve-listed models, so a requires-only custom
802
+ // entry must not fabricate it on this path (P2 on #1205).
803
+ // `||` (not `??`): the cache never stores empty strings, but treat a
804
+ // falsy hit as a miss so the placeholder still fires.
805
+ const orphanReasoning =
806
+ cachedReasoning
807
+ || (modelInList(provider.preserveReasoningContentModels, parsed.modelId)
808
+ && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)
809
+ ? " "
810
+ : undefined);
245
811
  out.push({
246
812
  role: "assistant",
247
813
  content: emptyAssistantContent(provider),
814
+ ...(orphanReasoning ? { reasoning_content: orphanReasoning } : {}),
248
815
  tool_calls: [{
249
816
  id: toolCallId,
250
817
  type: "function",
@@ -255,16 +822,16 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
255
822
  out.push({
256
823
  role: "tool",
257
824
  tool_call_id: toolCallId,
258
- content: contentPartsToText(msg.content),
825
+ content: toolResultTextForWire(msg.content),
259
826
  });
827
+ pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
828
+ flushToolResultImages();
260
829
  }
261
830
  break;
262
831
  }
263
832
  }
264
833
  }
265
834
 
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
835
  flushPendingToolCalls();
269
836
  releaseDeferredBarriers();
270
837
  return out;
@@ -360,133 +927,354 @@ function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
360
927
  || baseUrl === "https://opencode.ai/zen/go/v1";
361
928
  }
362
929
 
363
- const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
930
+ /** Azure Model Router (and Gemini-in-the-pool) 400s Codex MCP schemas whose root is a union. */
931
+ const AZURE_CHAT_FORBIDDEN_ROOT_KEYS = ["oneOf", "anyOf", "allOf", "enum", "const", "not"] as const;
364
932
 
365
- function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
933
+ function isAzureOpenAiChatTarget(provider: OcxProviderConfig): boolean {
366
934
  try {
367
- return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
935
+ const host = new URL(provider.baseUrl).hostname.toLowerCase();
936
+ return host.endsWith(".openai.azure.com")
937
+ || host.endsWith(".cognitiveservices.azure.com")
938
+ || host.endsWith(".services.ai.azure.com")
939
+ || host.endsWith(".ai.azure.com");
368
940
  } catch {
369
941
  return false;
370
942
  }
371
943
  }
372
944
 
373
- function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
945
+ /**
946
+ * Azure Foundry Model Router validates every function schema against the strictest model in
947
+ * the pool (Gemini-shaped): root must be {type:"object"} with no oneOf/anyOf/allOf/enum/
948
+ * const/not. Codex App MCP tools such as mcp__codex_app__automation_update ship a root
949
+ * union, which 400s the whole turn. Flatten like Zen, then strip leftover forbidden keys.
950
+ */
951
+ function sanitizeAzureChatToolParameters(parameters: unknown): Record<string, unknown> {
952
+ const root = ensureZenRootObjectSchema(parameters);
953
+ for (const key of AZURE_CHAT_FORBIDDEN_ROOT_KEYS) delete root[key];
954
+ root.type = "object";
955
+ if (!root.properties || typeof root.properties !== "object" || Array.isArray(root.properties)) {
956
+ root.properties = {};
957
+ }
958
+ return root;
959
+ }
960
+
961
+ // Moonshot validates function schemas against a draft-07 reading of `$ref`, where the
962
+ // keyword stands alone and siblings are ignored. It rejects the whole request rather
963
+ // than ignoring them: "not a valid moonshot flavored json schema ... when using $ref,
964
+ // type should be defined in the referenced schema instead of the parent schema".
965
+ const MOONSHOT_SCHEMA_HOSTNAMES = new Set([
966
+ "api.kimi.com",
967
+ "api.moonshot.ai",
968
+ "api.moonshot.cn",
969
+ ]);
970
+
971
+ function isMoonshotSchemaTarget(provider: OcxProviderConfig): boolean {
374
972
  try {
375
- return new URL(provider.baseUrl).hostname === "api.kimi.com";
973
+ return MOONSHOT_SCHEMA_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
376
974
  } catch {
377
975
  return false;
378
976
  }
379
977
  }
380
978
 
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
979
  const VOLCENGINE_ARK_HOSTNAMES = new Set([
387
980
  "ark.cn-beijing.volces.com",
388
981
  "ark.ap-southeast.volces.com",
389
982
  ]);
390
983
 
391
- function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
984
+ function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean {
392
985
  try {
393
- return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
986
+ const url = new URL(provider.baseUrl);
987
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
988
+ return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3";
394
989
  } catch {
395
990
  return false;
396
991
  }
397
992
  }
398
993
 
994
+ function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
995
+ return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : "";
996
+ }
997
+
998
+ function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
999
+ if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
1000
+ return { type: "object", properties: {} };
1001
+ }
1002
+ const obj = parameters as Record<string, unknown>;
1003
+ if (obj.type === "object") return obj;
1004
+ return { ...obj, type: "object" };
1005
+ }
1006
+
1007
+ function isXaiObjectSchema(value: unknown): value is Record<string, unknown> {
1008
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1009
+ }
1010
+
1011
+ /**
1012
+ * JSON Schema 2020-12 makes `$ref` an in-place applicator: siblings stay in force and are
1013
+ * combined with the referenced schema. Moonshot enforces the older draft-07 reading where
1014
+ * `$ref` must stand alone, and 400s the entire request when a node carries both. Codex's own
1015
+ * deferred tool catalog emits exactly that shape (zod-to-json-schema deduplicates into
1016
+ * `$defs.__schema*` nodes that keep `type`/`minLength`/`format` beside the `$ref`), so the
1017
+ * schema is not something a user can fix from configuration — see issue #2673.
1018
+ *
1019
+ * Inline the referenced schema underneath the node's own keywords, which is what 2020-12 says
1020
+ * the node means, then drop `$ref`. Constraints reach the model instead of being stripped.
1021
+ * The `$defs` bag is preserved: a bare `$ref` (no siblings) is already legal for Moonshot and
1022
+ * is left pointing at its definition rather than expanded, which keeps recursive schemas finite.
1023
+ */
1024
+ function moonshotRefTargetKeys(node: Record<string, unknown>): string[] {
1025
+ return Object.keys(node).filter(key => key !== "$ref");
1026
+ }
1027
+
1028
+ /**
1029
+ * Inlining duplicates the target, so a schema referencing one large definition from many
1030
+ * sibling-carrying nodes can multiply. Bound the total expansions and fall back to a bare
1031
+ * `$ref` once the budget is spent: still valid for Moonshot, just without the node's own
1032
+ * narrowing keywords. Mirrors the node budget in google-tool-schema.ts.
1033
+ */
1034
+ const MOONSHOT_MAX_REF_EXPANSIONS = 512;
1035
+
1036
+ /**
1037
+ * Expansion count alone does not bound the walk: a deeply nested ref-free schema, or one
1038
+ * large definition repeated across many nodes, still recurses to exhaustion or amplifies the
1039
+ * emitted output. Depth and node budgets close both, and mirror google-tool-schema.ts.
1040
+ */
1041
+ const MOONSHOT_MAX_SCHEMA_DEPTH = 64;
1042
+ const MOONSHOT_MAX_SCHEMA_NODES = 4_096;
1043
+
1044
+ /**
1045
+ * Assertion keywords whose meaning under a `$ref` is CONJUNCTION, not replacement. A node
1046
+ * carrying `required: ["b"]` beside a target requiring `["a"]` means both are required;
1047
+ * letting the sibling win emitted a schema that no longer described the tool.
1048
+ */
1049
+ function unionRequired(target: unknown, sibling: unknown): unknown {
1050
+ if (!Array.isArray(target) || !Array.isArray(sibling)) return sibling;
1051
+ const seen = new Set<unknown>();
1052
+ const out: unknown[] = [];
1053
+ for (const name of [...target, ...sibling]) {
1054
+ if (seen.has(name)) continue;
1055
+ seen.add(name);
1056
+ out.push(name);
1057
+ }
1058
+ return out;
1059
+ }
1060
+
399
1061
  /**
400
- * Placeholder content for an assistant history entry carrying only tool calls or reasoning.
1062
+ * Keywords whose values are DATA, not schemas.
401
1063
  *
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.
1064
+ * Recursing into them rewrote user data: an `enum` listing a literal object that happens
1065
+ * to carry a `"$ref"` string had that key stripped as if it were a schema reference, so a
1066
+ * value the tool declared as legal silently changed shape. These are copied through.
1067
+ */
1068
+ const MOONSHOT_DATA_VALUED_KEYWORDS = new Set(["enum", "const", "default", "examples"]);
1069
+
1070
+ /**
1071
+ * Numeric assertions whose intersection is a bound, and which direction tightens.
408
1072
  *
409
- * Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
1073
+ * `$ref` under 2020-12 is an in-place applicator: the node and its target BOTH apply, so
1074
+ * the emitted schema must be their INTERSECTION. The previous code overwrote the target
1075
+ * with the node and called that "the narrower reading", which holds only when the node
1076
+ * happens to be narrower. A node declaring `minLength: 1` beside a target declaring
1077
+ * `minLength: 5` shipped `minLength: 1` - a contract weaker than either side asked for,
1078
+ * emitted silently, which is the same failure mode the `required` composition fixed for
1079
+ * set-valued keywords.
410
1080
  *
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.
1081
+ * "max" means the surviving value is the larger of the two (lower bounds), "min" the
1082
+ * smaller (upper bounds). A keyword absent from this table keeps the overwrite: for
1083
+ * `type`, `format`, `description` and friends there is no ordering to intersect along,
1084
+ * and the node is the more specific statement.
413
1085
  */
414
- function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
415
- return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
1086
+ const MOONSHOT_BOUND_KEYWORDS: Record<string, "max" | "min"> = {
1087
+ minLength: "max",
1088
+ minItems: "max",
1089
+ minProperties: "max",
1090
+ minimum: "max",
1091
+ exclusiveMinimum: "max",
1092
+ maxLength: "min",
1093
+ maxItems: "min",
1094
+ maxProperties: "min",
1095
+ maximum: "min",
1096
+ exclusiveMaximum: "min",
1097
+ };
1098
+
1099
+ /**
1100
+ * Intersect one numeric bound. Either side being absent or non-finite yields the other,
1101
+ * because an unstated bound constrains nothing - returning `undefined` there would drop
1102
+ * a constraint the remaining side genuinely made.
1103
+ */
1104
+ function intersectBound(target: unknown, sibling: unknown, direction: "max" | "min"): unknown {
1105
+ const a = typeof target === "number" && Number.isFinite(target) ? target : null;
1106
+ const b = typeof sibling === "number" && Number.isFinite(sibling) ? sibling : null;
1107
+ if (a === null) return b === null ? sibling : sibling;
1108
+ if (b === null) return target;
1109
+ return direction === "max" ? Math.max(a, b) : Math.min(a, b);
416
1110
  }
417
1111
 
418
1112
  /**
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.
1113
+ * Compose two `properties` maps. A property named in BOTH the referenced target and the
1114
+ * node is the same conjunction problem `required` had: letting the sibling win discards
1115
+ * the target's constraints for that member. Merge the two member schemas so neither side
1116
+ * loses its keywords. Shared member bounds are the same conjunction one level down,
1117
+ * and nested object members recurse through this helper instead of replacing the target.
422
1118
  */
423
- function ensureKimiRootObjectType(parameters: unknown): Record<string, unknown> {
424
- if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
425
- return { type: "object", properties: {} };
1119
+ function composeProperties(
1120
+ target: Record<string, unknown>,
1121
+ sibling: Record<string, unknown>,
1122
+ ): Record<string, unknown> {
1123
+ const combined: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
1124
+ for (const [name, sub] of Object.entries(target)) combined[name] = sub;
1125
+ for (const [name, sub] of Object.entries(sibling)) {
1126
+ const existing = combined[name];
1127
+ if (isXaiObjectSchema(existing) && isXaiObjectSchema(sub)) {
1128
+ const member: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
1129
+ for (const [k, v] of Object.entries(existing)) member[k] = v;
1130
+ for (const [k, v] of Object.entries(sub)) {
1131
+ if (k === "required") {
1132
+ member[k] = unionRequired(member[k], v);
1133
+ continue;
1134
+ }
1135
+ if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) {
1136
+ member[k] = composeProperties(member[k] as Record<string, unknown>, v);
1137
+ continue;
1138
+ }
1139
+ const boundDirection = MOONSHOT_BOUND_KEYWORDS[k];
1140
+ if (boundDirection && k in member) {
1141
+ member[k] = intersectBound(member[k], v, boundDirection);
1142
+ continue;
1143
+ }
1144
+ member[k] = v;
1145
+ }
1146
+ combined[name] = member;
1147
+ continue;
1148
+ }
1149
+ combined[name] = sub;
426
1150
  }
427
- const obj = parameters as Record<string, unknown>;
428
- if (obj.type === "object") return obj;
429
- return { ...obj, type: "object" };
1151
+ return combined;
1152
+ }
1153
+
1154
+ interface MoonshotNormalizeState {
1155
+ activeRefs: Set<string>;
1156
+ remainingExpansions: number;
1157
+ remainingNodes: number;
430
1158
  }
431
1159
 
432
- 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]));
436
- if (!compositionKey) {
437
- if (obj.type !== undefined && obj.type !== "object") return undefined;
438
- return [{ ...obj, type: "object" }];
1160
+ function normalizeMoonshotSchemaNode(
1161
+ node: unknown,
1162
+ root: Record<string, unknown>,
1163
+ state: MoonshotNormalizeState,
1164
+ depth = 0,
1165
+ ): unknown {
1166
+ if (Array.isArray(node)) {
1167
+ if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH) return [];
1168
+ return node.map(item => normalizeMoonshotSchemaNode(item, root, state, depth + 1));
439
1169
  }
1170
+ if (!isXaiObjectSchema(node)) return node;
440
1171
 
441
- const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
442
- const branches = obj[compositionKey];
443
- if (!Array.isArray(branches)) return undefined;
444
- const expanded: Record<string, unknown>[] = [];
445
- for (const branch of branches) {
446
- const variants = expandXaiRootObjectSchemas(branch);
447
- if (!variants) return undefined;
448
- for (const variant of variants) expanded.push({ ...siblings, ...variant });
1172
+ // Fail closed for this node rather than emitting a partially weakened schema: an empty
1173
+ // object is the one shape that asserts nothing it cannot back up.
1174
+ if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH || state.remainingNodes <= 0) return {};
1175
+ state.remainingNodes -= 1;
1176
+
1177
+ const ref = node.$ref;
1178
+ const hasSiblings = moonshotRefTargetKeys(node).length > 0;
1179
+
1180
+ if (typeof ref === "string" && hasSiblings) {
1181
+ // A cycle cannot be inlined. Keeping the bare `$ref` is the lossy-but-valid fallback:
1182
+ // Moonshot accepts it, and the alternative (dropping the ref) would erase the recursion.
1183
+ if (state.activeRefs.has(ref) || state.remainingExpansions <= 0) return { $ref: ref };
1184
+
1185
+ const target = lookupLocalJsonPointer(root, ref);
1186
+ if (isXaiObjectSchema(target)) {
1187
+ state.remainingExpansions -= 1;
1188
+ state.activeRefs.add(ref);
1189
+ const resolvedTarget = normalizeMoonshotSchemaNode(target, root, state, depth + 1);
1190
+ state.activeRefs.delete(ref);
1191
+ const merged: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
1192
+ if (isXaiObjectSchema(resolvedTarget)) {
1193
+ for (const [key, value] of Object.entries(resolvedTarget)) merged[key] = value;
1194
+ }
1195
+ // "Alongside the target" is conjunction, not replacement. For most keywords the node
1196
+ // narrows the target and overwriting is the narrower reading, but `required` and
1197
+ // `properties` are set-valued: letting the sibling win DROPPED the target's own
1198
+ // members, so a tool requiring `a` beside a node requiring `b` shipped requiring only
1199
+ // `b`. Those two compose; everything else keeps the narrowing overwrite.
1200
+ for (const [key, value] of Object.entries(node)) {
1201
+ if (key === "$ref") continue;
1202
+ if (MOONSHOT_DATA_VALUED_KEYWORDS.has(key)) {
1203
+ merged[key] = value;
1204
+ continue;
1205
+ }
1206
+ const normalized = normalizeMoonshotSchemaNode(value, root, state, depth + 1);
1207
+ if (key === "required") {
1208
+ merged[key] = unionRequired(merged[key], normalized);
1209
+ continue;
1210
+ }
1211
+ if (key === "properties" && isXaiObjectSchema(merged[key]) && isXaiObjectSchema(normalized)) {
1212
+ merged[key] = composeProperties(merged[key] as Record<string, unknown>, normalized);
1213
+ continue;
1214
+ }
1215
+ // Numeric bounds intersect rather than overwrite: both the node and its target
1216
+ // apply, so the surviving bound is the stricter of the two in whichever direction
1217
+ // that keyword tightens.
1218
+ const boundDirection = MOONSHOT_BOUND_KEYWORDS[key];
1219
+ if (boundDirection && key in merged) {
1220
+ merged[key] = intersectBound(merged[key], normalized, boundDirection);
1221
+ continue;
1222
+ }
1223
+ merged[key] = normalized;
1224
+ }
1225
+ return merged;
1226
+ }
1227
+
1228
+ // Unresolvable pointer: a remote ref, a malformed path, or a non-object target. Dropping
1229
+ // the ref and keeping the siblings silently discards whatever the reference constrained,
1230
+ // which is the one outcome we cannot detect downstream. A bare `$ref` is lossy in the
1231
+ // other direction - it loses the node's own keywords - but it preserves the identity of
1232
+ // what was asked for, and Moonshot accepts it.
1233
+ return { $ref: ref };
1234
+ }
1235
+
1236
+ const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
1237
+ for (const [key, value] of Object.entries(node)) {
1238
+ out[key] = key === "$ref" || MOONSHOT_DATA_VALUED_KEYWORDS.has(key)
1239
+ ? value
1240
+ : normalizeMoonshotSchemaNode(value, root, state, depth + 1);
449
1241
  }
450
- return expanded.length > 0 ? expanded : undefined;
1242
+ return out;
451
1243
  }
452
1244
 
453
- function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
454
- const variants = expandXaiRootObjectSchemas(parameters);
455
- 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 };
1245
+ function normalizeMoonshotToolParameters(parameters: unknown): Record<string, unknown> {
1246
+ const rooted = ensureRootObjectType(parameters);
1247
+ const normalized = normalizeMoonshotSchemaNode(rooted, rooted, {
1248
+ activeRefs: new Set<string>(),
1249
+ remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS,
1250
+ remainingNodes: MOONSHOT_MAX_SCHEMA_NODES,
1251
+ });
1252
+ return isXaiObjectSchema(normalized) ? normalized : rooted;
462
1253
  }
463
1254
 
464
1255
  function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
465
1256
  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;
1257
+ const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
472
1258
  if (tools.length === 0) return undefined;
473
1259
  const xaiTarget = isXaiSchemaTarget(provider);
474
- const kimiTarget = isKimiSchemaTarget(provider);
1260
+ const moonshotTarget = !xaiTarget && isMoonshotSchemaTarget(provider);
475
1261
  const formatted = tools.flatMap(t => {
476
- const parameters = xaiTarget
1262
+ const normalized = xaiTarget
477
1263
  ? normalizeXaiToolParameters(t.parameters)
478
- : kimiTarget
479
- ? ensureKimiRootObjectType(t.parameters)
480
- : t.parameters;
1264
+ : moonshotTarget
1265
+ ? normalizeMoonshotToolParameters(t.parameters)
1266
+ : ensureRootObjectType(t.parameters);
1267
+ const parameters = stripResponsesOnlyEncryptedMarker(normalized);
1268
+
481
1269
  if (parameters === undefined) return [];
482
1270
  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
- },
1271
+ type: "function",
1272
+ function: {
1273
+ name: namespacedToolName(t.namespace, t.name),
1274
+ ...(t.description ? { description: t.description } : {}),
1275
+ parameters,
1276
+ ...(t.strict !== undefined ? { strict: t.strict } : {}),
1277
+ },
490
1278
  }];
491
1279
  });
492
1280
  return formatted.length > 0 ? formatted : undefined;
@@ -494,24 +1282,38 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
494
1282
 
495
1283
  function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
496
1284
  const base = toolsToChatFormat(parsed, provider);
497
- if (!base || !shouldSanitizeZenToolParameters(provider)) return base;
1285
+ const azureChat = isAzureOpenAiChatTarget(provider);
1286
+ const zenChat = shouldSanitizeZenToolParameters(provider);
1287
+ if (!base || (!zenChat && !azureChat)) return base;
498
1288
  return base.map(tool => {
499
1289
  if (!tool || typeof tool !== "object") return tool;
500
1290
  const functionDef = (tool as { function?: Record<string, unknown> }).function;
501
1291
  if (!functionDef || typeof functionDef !== "object") return tool;
1292
+ const parameters = azureChat
1293
+ ? sanitizeAzureChatToolParameters(functionDef.parameters ?? {})
1294
+ : ensureZenRootObjectSchema(functionDef.parameters ?? {});
1295
+ const nextFunction: Record<string, unknown> = { ...functionDef, parameters };
1296
+ // strict: true plus a flattened schema is rejected by Gemini-in-the-pool routers.
1297
+ if (azureChat) delete nextFunction.strict;
502
1298
  return {
503
1299
  ...tool,
504
- function: {
505
- ...functionDef,
506
- parameters: ensureZenRootObjectSchema(functionDef.parameters ?? {}),
507
- },
1300
+ function: nextFunction,
508
1301
  };
509
1302
  });
510
1303
  }
511
1304
 
512
- function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
1305
+ function toolChoiceToChatFormat(
1306
+ tc: OcxParsedRequest["options"]["toolChoice"],
1307
+ tools: OcxParsedRequest["context"]["tools"],
1308
+ provider: OcxProviderConfig,
1309
+ ): unknown {
513
1310
  if (!tc) return undefined;
514
- if (isAllowedToolChoice(tc)) return tc.mode === "required" ? "required" : "auto";
1311
+ if (isAllowedToolChoice(tc)) {
1312
+ if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) {
1313
+ return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } };
1314
+ }
1315
+ return tc.mode === "required" ? "required" : "auto";
1316
+ }
515
1317
  if (tc === "auto" || tc === "none" || tc === "required") return tc;
516
1318
  if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } };
517
1319
  return undefined;
@@ -549,6 +1351,26 @@ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: stri
549
1351
  return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction));
550
1352
  }
551
1353
 
1354
+ function canSerializeOpenAIChatServiceTier(
1355
+ provider: OcxProviderConfig,
1356
+ modelId: string,
1357
+ serviceTier: unknown,
1358
+ tierDecision?: OcxParsedRequest["options"]["tierDecision"],
1359
+ ): boolean {
1360
+ if (serviceTier === undefined) return false;
1361
+ if (tierDecision !== undefined) {
1362
+ return tierDecision.kind === "set" || tierDecision.kind === "forward-caller";
1363
+ }
1364
+ // No decision from the router means this call did not go through the tier state machine, so
1365
+ // ask that machine rather than re-deriving a looser answer beside it. The previous fallback
1366
+ // returned true whenever foreign forwarding was allowed at all, which let a caller tier
1367
+ // reach the wire in cases `decideTier` would have dropped — the two paths disagreeing is
1368
+ // precisely the bug, so there is now only one authority.
1369
+ const callerTier = typeof serviceTier === "string" ? serviceTier : undefined;
1370
+ const decision = decideTier(fastPolicyForModel(provider, modelId, undefined, "chat"), undefined, callerTier);
1371
+ return decision.kind === "set" || decision.kind === "forward-caller";
1372
+ }
1373
+
552
1374
  export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
553
1375
  return {
554
1376
  name: "openai-chat",
@@ -556,20 +1378,30 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
556
1378
  formatErrorBody: formatOpenAIChatErrorBody,
557
1379
 
558
1380
  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
-
1381
+ const { url, headers, hasCredential } = openAIChatTransport(provider);
564
1382
  const messages = messagesToChatFormat(parsed, provider);
565
1383
  const tools = toolsToChatFormatForProvider(parsed, provider);
566
- const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
1384
+ const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
567
1385
 
568
1386
  const body: Record<string, unknown> = {
569
1387
  model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
570
1388
  messages,
571
1389
  stream: parsed.stream,
572
1390
  };
1391
+ // A policy-produced canonical decision has already passed capability validation. Without
1392
+ // that decision, a canonical caller value still requires an explicit true capability;
1393
+ // unclassified Chat routes remain behind the caller-forwarding opt-in.
1394
+ const serviceTier = parsed.options.serviceTier;
1395
+ const tierDecision = parsed.options.tierDecision;
1396
+ const canSerializeServiceTier = canSerializeOpenAIChatServiceTier(
1397
+ provider,
1398
+ parsed.modelId,
1399
+ serviceTier,
1400
+ tierDecision,
1401
+ );
1402
+ if (canSerializeServiceTier && serviceTier !== undefined) {
1403
+ body.service_tier = serviceTier;
1404
+ }
573
1405
  if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
574
1406
  const maxTokens = resolveMaxTokens(provider, parsed);
575
1407
  const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
@@ -588,10 +1420,44 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
588
1420
  body.top_p = parsed.options.topP;
589
1421
  }
590
1422
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
1423
+ const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId);
591
1424
  const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
1425
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
592
1426
  let reasoningLog: AdapterRequest["reasoningLog"];
593
- if (reasoningEffort !== undefined) {
594
- if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
1427
+ if (!reasoningDisabled && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") {
1428
+ if (nativeOpenAI) {
1429
+ body.reasoning_effort = "none";
1430
+ reasoningLog = {
1431
+ effectiveEffort: "none",
1432
+ wireField: "reasoning_effort",
1433
+ wireValue: "none",
1434
+ };
1435
+ } else {
1436
+ body.reasoning = { enabled: false };
1437
+ reasoningLog = {
1438
+ effectiveEffort: "none",
1439
+ wireField: "reasoning.enabled",
1440
+ wireValue: false,
1441
+ };
1442
+ }
1443
+ } else if (reasoningEffort !== undefined) {
1444
+ if (provider.reasoningWireFormat === "gateway-object") {
1445
+ if (nativeOpenAI) {
1446
+ body.reasoning_effort = reasoningEffort;
1447
+ reasoningLog = {
1448
+ effectiveEffort: reasoningEffort,
1449
+ wireField: "reasoning_effort",
1450
+ wireValue: reasoningEffort,
1451
+ };
1452
+ } else {
1453
+ body.reasoning = { enabled: true, effort: reasoningEffort };
1454
+ reasoningLog = {
1455
+ effectiveEffort: reasoningEffort,
1456
+ wireField: "reasoning.effort",
1457
+ wireValue: reasoningEffort,
1458
+ };
1459
+ }
1460
+ } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
595
1461
  const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
596
1462
  if (budget !== undefined) {
597
1463
  body.thinking_budget = budget;
@@ -602,9 +1468,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
602
1468
  };
603
1469
  }
604
1470
  } 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
1471
  if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
609
1472
  body.thinking = { type: reasoningEffort };
610
1473
  reasoningLog = {
@@ -628,37 +1491,55 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
628
1491
  if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
629
1492
  body.frequency_penalty = parsed.options.frequencyPenalty;
630
1493
  }
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
1494
  if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
634
1495
  body.prompt_cache_key = parsed.options.promptCacheKey;
635
1496
  }
1497
+ // Structured-output support varies by the physical upstream model even when one
1498
+ // gateway exposes a uniform OpenAI-compatible endpoint. Keep the #1137 translation
1499
+ // as the default, but let an exact model opt out instead of forcing a provider-wide
1500
+ // rollback that would silently return prose for siblings that support JSON Schema.
1501
+ if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) {
1502
+ const textFormat = parsed.options.textFormat;
1503
+ if (textFormat?.type === "json_object") {
1504
+ body.response_format = { type: "json_object" };
1505
+ } else if (textFormat?.type === "json_schema") {
1506
+ body.response_format = {
1507
+ type: "json_schema",
1508
+ json_schema: {
1509
+ name: textFormat.name ?? "response",
1510
+ ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
1511
+ ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
1512
+ ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
1513
+ },
1514
+ };
1515
+ }
1516
+ }
636
1517
 
637
1518
  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 };
1519
+ if (provider.parallelToolCalls === false) {
1520
+ // NIM documents the Boolean defaulting to false and kimi rejects true; pin the
1521
+ // wire bit so Codex cannot opt in via request.options. Other opted-out providers
1522
+ // omit the field by default so strict OpenAI-compatible hosts never see an
1523
+ // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps
1524
+ // emitting parallel calls without it can opt in via pinParallelToolCallsFalse.
1525
+ if (provider.baseUrl === "https://integrate.api.nvidia.com/v1"
1526
+ || provider.pinParallelToolCallsFalse === true) {
1527
+ body.parallel_tool_calls = false;
1528
+ }
1529
+ } else if (provider.parallelToolCalls === true) {
1530
+ body.parallel_tool_calls = parsed.options.parallelToolCalls !== false;
1531
+ }
648
1532
  }
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);
1533
+ if (parsed.stream) body.stream_options = { include_usage: true };
658
1534
 
659
1535
  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).
1536
+ const actualServiceTier = typeof body.service_tier === "string" ? body.service_tier : null;
1537
+ const tierLog = createAdapterTierMetadata(
1538
+ parsed.options.tierObservation,
1539
+ parsed.options.tierDecision,
1540
+ actualServiceTier === null ? null : "service-tier",
1541
+ actualServiceTier,
1542
+ );
662
1543
  if (isDebugEnabled()) {
663
1544
  let host = "upstream";
664
1545
  try { host = new URL(url).host; } catch { /* keep fallback */ }
@@ -679,10 +1560,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
679
1560
  headers,
680
1561
  body: bodyJson,
681
1562
  ...(reasoningLog ? { reasoningLog } : {}),
1563
+ ...(tierLog ? { tierLog } : {}),
682
1564
  };
683
1565
  },
684
1566
 
685
- async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
1567
+ async *parseStream(
1568
+ response: Response,
1569
+ budget: TranslatorBudget,
1570
+ tierMetadata?: AdapterTierMetadata,
1571
+ ): AsyncGenerator<AdapterEvent> {
686
1572
  if (!response.body) {
687
1573
  yield { type: "error", message: "No response body" };
688
1574
  return;
@@ -693,138 +1579,240 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
693
1579
  const budgetEncoder = new TextEncoder();
694
1580
  let buffer = "";
695
1581
  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 }
1582
+ interface PendingToolCall {
1583
+ key: string;
1584
+ id: string;
1585
+ name: string;
1586
+ args: string;
1587
+ argsBytes: number;
1588
+ /**
1589
+ * Whether this call has ever received `arguments` as an actual string, empty included.
1590
+ * An empty string still counts: it proves the upstream sent the field with the right
1591
+ * wire type, which is what a later malformed repeat of that field would be padding for.
1592
+ * A canonical NAME is not evidence about the ARGUMENTS field and must not stand in.
1593
+ */
1594
+ sawArgumentsString: boolean;
1595
+ }
705
1596
  const pendingToolCalls: PendingToolCall[] = [];
706
1597
  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) {
1598
+ const closeToolCalls = (): PendingToolCall[] => {
1599
+ const calls = [...pendingToolCalls];
1600
+ for (const call of calls) budget.closeCall(call.key);
1601
+ pendingToolCalls.length = 0;
1602
+ return calls;
1603
+ };
1604
+ const pendingToolCallsAreCompleteJsonObjects = (): boolean =>
1605
+ pendingToolCalls.length > 0 && pendingToolCalls.every(call => {
1606
+ if (call.name.trim().length === 0 || !call.sawArgumentsString || call.args.length === 0) return false;
1607
+ try {
1608
+ const parsed = JSON.parse(call.args) as unknown;
1609
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
1610
+ } catch {
1611
+ return false;
1612
+ }
1613
+ });
1614
+ // Returns "terminate" when a pending call cannot be dispatched, so every flush site
1615
+ // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
1616
+ // so budget reservations are released for every pending call even on the early return.
1617
+ const flushToolCalls = function* (): Generator<AdapterEvent, "continue" | "terminate"> {
1618
+ for (const call of closeToolCalls()) {
1619
+ // Ingest already proved `name` is a string; the typeof guard keeps this branch
1620
+ // total so a future ingest change cannot turn a malformed name into a throw.
1621
+ if (typeof call.name !== "string" || call.name.trim().length === 0) {
1622
+ debugProviderDiagnostic("openai-chat", "tool-call-unnamed", {
1623
+ hadId: call.id.length > 0,
1624
+ argsBytes: call.argsBytes,
1625
+ });
1626
+ yield unnamedToolCallEvent(pendingUsage);
1627
+ return "terminate";
1628
+ }
711
1629
  if (!call.id) call.id = `call_${++toolCallSeq}`;
712
1630
  yield { type: "tool_call_start", id: call.id, name: call.name };
713
1631
  if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
714
1632
  yield { type: "tool_call_end" };
715
- budget.closeCall(call.key);
716
1633
  }
717
- pendingToolCalls.length = 0;
1634
+ return "continue";
1635
+ };
1636
+ const terminateWithError = function* (
1637
+ event: Extract<AdapterEvent, { type: "error" }>,
1638
+ ): Generator<AdapterEvent, "terminate"> {
1639
+ closeToolCalls();
1640
+ yield event;
1641
+ return "terminate";
718
1642
  };
719
1643
  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
1644
  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
1645
  let sawUserFacingOutput = false;
728
1646
 
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
1647
  const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
734
- if (!line.startsWith("data: ")) return "continue";
735
- const payload = line.slice(6).trim();
1648
+ const rawPayload = sseFieldValue(line, "data");
1649
+ if (rawPayload === null) return "continue";
1650
+ const payload = rawPayload.trim();
1651
+ if (payload.length === 0) return "continue";
736
1652
  if (payload === "[DONE]") {
737
- yield* flushToolCalls();
738
- const stopReason = finishReason === "length"
739
- ? "max_tokens"
740
- : finishReason === "content_filter"
741
- ? "content_filter"
742
- : undefined;
1653
+ if ((yield* flushToolCalls()) === "terminate") return "terminate";
1654
+ const stopReason = stopReasonFor(finishReason);
743
1655
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
744
1656
  return "terminate";
745
1657
  }
746
1658
 
747
- let chunk: Record<string, unknown>;
1659
+ let parsed: unknown;
748
1660
  try {
749
- chunk = JSON.parse(payload) as Record<string, unknown>;
1661
+ parsed = JSON.parse(payload);
750
1662
  } catch {
1663
+ tierMetadata?.markResponseUnparseable();
751
1664
  yield { type: "error", message: "malformed upstream SSE data frame" };
752
1665
  return "terminate";
753
1666
  }
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";
1667
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue";
1668
+ const chunk = parsed as Record<string, unknown>;
1669
+ if (Object.hasOwn(chunk, "service_tier")) {
1670
+ tierMetadata?.observeResponseServiceTier(chunk.service_tier);
775
1671
  }
776
1672
 
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>);
1673
+ if (chunk.error !== undefined && chunk.error !== null) {
1674
+ const event = upstreamErrorEvent(chunk.error, pendingUsage);
1675
+ debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
1676
+ return yield* terminateWithError(event);
782
1677
  }
783
1678
 
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;
1679
+ if (chunk.usage) pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
1680
+
1681
+ const choices = chunk.choices;
1682
+ if (choices === undefined) return "continue";
1683
+ if (!Array.isArray(choices)) return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
1684
+ if (choices.length === 0) return "continue";
1685
+ const rawChoice = choices[0];
1686
+ if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
1687
+ return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
1688
+ }
1689
+ const choice = rawChoice as {
1690
+ delta?: Record<string, unknown>;
1691
+ finish_reason?: string;
1692
+ error?: unknown;
1693
+ };
1694
+ if (choice.finish_reason === "error") {
1695
+ const event = upstreamErrorEvent(choice.error, pendingUsage);
1696
+ debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
1697
+ return yield* terminateWithError(event);
790
1698
  }
791
- const delta = choices[0].delta;
1699
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) finishReason = choice.finish_reason;
1700
+ const delta = choice.delta;
792
1701
  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
- }
1702
+ const reasoningText = reasoningTextFrom(delta);
1703
+ if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText };
796
1704
  if (typeof delta.content === "string" && delta.content.length > 0) {
797
1705
  sawUserFacingOutput = true;
798
1706
  yield { type: "text_delta", text: delta.content };
799
1707
  }
800
1708
 
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;
1709
+ const rawToolCalls = delta.tool_calls;
1710
+ if (rawToolCalls !== undefined && rawToolCalls !== null) {
1711
+ // A non-null claimed tool-call payload is not benign padding. Dropping it can leave the
1712
+ // matching result permanently orphaned, so malformed nested shapes fail closed
1713
+ // through the adapter error channel instead of escaping as TypeError (#1325). Null is
1714
+ // tolerated as absent because OpenAI-compatible providers may emit it as stream padding.
1715
+ if (!Array.isArray(rawToolCalls)) {
1716
+ logInvalidToolCalls("stream", rawToolCalls);
1717
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1718
+ }
1719
+ for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
1720
+ const rawToolCall: unknown = rawToolCalls[callIndex];
1721
+ if (!isRecord(rawToolCall)) {
1722
+ const diagnostic: InvalidToolCallDiagnostic = {
1723
+ reason: "tool_call_not_object",
1724
+ callIndex,
1725
+ valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
1726
+ };
1727
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1728
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1729
+ }
1730
+ // This is upstream JSON, so every field is validated before it is stored: a
1731
+ // malformed value must fail closed through the #1325 channel here rather than
1732
+ // escaping later as a TypeError from string handling at flush time.
1733
+ const rawFunction = rawToolCall.function;
1734
+ if (rawFunction !== undefined && rawFunction !== null && !isRecord(rawFunction)) {
1735
+ const diagnostic: InvalidToolCallDiagnostic = {
1736
+ reason: "tool_call_function_not_object",
1737
+ callIndex,
1738
+ valueType: Array.isArray(rawFunction) ? "array" : typeof rawFunction,
1739
+ };
1740
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1741
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1742
+ }
1743
+ const fnRecord = isRecord(rawFunction) ? rawFunction : undefined;
1744
+ const rawName = fnRecord?.name;
1745
+ const rawArguments = fnRecord?.arguments;
1746
+ const rawId = rawToolCall.id;
1747
+ const idDelta = typeof rawId === "string" ? rawId : "";
1748
+ const rawIndex = rawToolCall.index;
1749
+
1750
+ // Resolve the pending call BEFORE judging the fields. Some OpenAI-compatible
1751
+ // streamers repeat an already-sent field as a non-string placeholder on a
1752
+ // continuation delta; judging first meant the whole stream died with a 502 even
1753
+ // though the value being repeated was already held in canonical form.
1754
+ const key = typeof rawIndex === "number"
1755
+ ? `i:${rawIndex}`
1756
+ : idDelta
1757
+ ? `id:${idDelta}`
1758
+ : pendingToolCalls[pendingToolCalls.length - 1]?.key;
809
1759
  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);
1760
+ if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta);
814
1761
  if (!call) {
815
- call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
1762
+ call = {
1763
+ key: key ?? `seq:${pendingToolCalls.length}`,
1764
+ id: "",
1765
+ name: "",
1766
+ args: "",
1767
+ argsBytes: 0,
1768
+ sawArgumentsString: false,
1769
+ };
816
1770
  pendingToolCalls.push(call);
817
1771
  budget.openCall(call.key);
818
1772
  }
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) {
1773
+
1774
+ // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name
1775
+ // says nothing about whether `arguments` was ever sent as a string, so it cannot
1776
+ // authorize a malformed arguments value — that would silently drop a real
1777
+ // argument payload the model intended to send.
1778
+ const rejection: InvalidToolCallDiagnostic | undefined =
1779
+ isInvalidStreamStringField(rawName) && call.name.trim() === ""
1780
+ ? { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawName }
1781
+ : isInvalidStreamStringField(rawArguments) && !call.sawArgumentsString
1782
+ ? { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawArguments }
1783
+ : isInvalidStreamStringField(rawId) && call.id === ""
1784
+ ? { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawId }
1785
+ : undefined;
1786
+ if (rejection) {
1787
+ logInvalidToolCalls("stream", rawToolCalls, rejection);
1788
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, rejection));
1789
+ }
1790
+
1791
+ if (idDelta && !call.id) call.id = idDelta;
1792
+ if (typeof rawName === "string" && rawName && !call.name) call.name = rawName;
1793
+ if (typeof rawArguments === "string") call.sawArgumentsString = true;
1794
+ // Tool-call deltas are BUFFERED until a terminal signal, so this adapter can
1795
+ // consume upstream frames for a long time while yielding nothing. The Responses
1796
+ // bridge reads adapter activity, not socket activity, so a model that streams a
1797
+ // large argument payload looks identical to a hung upstream and the stall
1798
+ // watchdog can abort a turn that was progressing normally.
1799
+ //
1800
+ // Found while investigating #2156, but it is NOT that bug: a stall abort emits
1801
+ // `response.incomplete` with `upstream_stall_timeout` from the bridge, whereas
1802
+ // that report shows the adapter's own end-of-stream error after `reader.read()`
1803
+ // returned EOF with tool calls still pending. Different path, different frame.
1804
+ //
1805
+ // A heartbeat is invisible downstream — the bridge consumes it to re-arm the
1806
+ // watchdog and emits nothing — which is the same remedy the Cursor, Anthropic,
1807
+ // Google, and Kiro adapters already use for their own silent phases.
1808
+ yield { type: "heartbeat" };
1809
+ if (typeof rawArguments === "string" && rawArguments) {
822
1810
  const previousBytes = call.argsBytes;
823
- const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
1811
+ const nextBytes = previousBytes + budgetEncoder.encode(rawArguments).byteLength;
824
1812
  const scope = { kind: "tool_args" as const, callId: call.key };
825
1813
  const reservation = budget.reserveTransient(nextBytes, scope);
826
1814
  try {
827
- call.args += tc.function.arguments;
1815
+ call.args += rawArguments;
828
1816
  reservation.commitRetained();
829
1817
  budget.releaseRetained(previousBytes, scope);
830
1818
  call.argsBytes = nextBytes;
@@ -837,10 +1825,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
837
1825
  }
838
1826
  }
839
1827
 
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();
1828
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) {
1829
+ if ((yield* flushToolCalls()) === "terminate") return "terminate";
844
1830
  }
845
1831
  return "continue";
846
1832
  };
@@ -880,23 +1866,20 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
880
1866
  }
881
1867
  }
882
1868
 
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
1869
  if (buffer.length > 0) {
889
1870
  if ((yield* handleDataLine(buffer)) === "terminate") return;
890
1871
  }
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
1872
  const sawFinish = finishReason !== undefined;
899
1873
  if (!sawFinish && pendingToolCalls.length > 0) {
1874
+ // Some OpenAI-compatible gateways close immediately after a complete function-call
1875
+ // delta and omit both terminal conventions. Keep the default fail-closed policy, and
1876
+ // let an opted-in provider recover only calls whose assembled argument payload is a
1877
+ // complete JSON object. A partial JSON prefix still takes the truncation path below.
1878
+ if (provider.openaiChatEofTolerance === true && pendingToolCallsAreCompleteJsonObjects()) {
1879
+ if ((yield* flushToolCalls()) === "terminate") return;
1880
+ yield { type: "done", usage: pendingUsage };
1881
+ return;
1882
+ }
900
1883
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
901
1884
  finishReason: null,
902
1885
  hadUsage: pendingUsage !== undefined,
@@ -905,9 +1888,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
905
1888
  yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
906
1889
  return;
907
1890
  }
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
1891
  if (!sawFinish && !sawUserFacingOutput) {
912
1892
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
913
1893
  finishReason: finishReason ?? null,
@@ -916,13 +1896,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
916
1896
  yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
917
1897
  return;
918
1898
  }
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;
1899
+ if ((yield* flushToolCalls()) === "terminate") return;
1900
+ const stopReason = stopReasonFor(finishReason);
926
1901
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
927
1902
  } catch (error) {
928
1903
  if (isTranslatorBudgetExceededError(error)
@@ -940,63 +1915,121 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
940
1915
  throw error;
941
1916
  } finally {
942
1917
  budget.releaseRetained(bufferBytes, { kind: "live_transient" });
943
- for (const call of pendingToolCalls) budget.closeCall(call.key);
1918
+ closeToolCalls();
944
1919
  reader.releaseLock();
945
1920
  }
946
1921
  },
947
1922
 
948
- async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
949
- const json = await response.json() as Record<string, unknown>;
1923
+ async parseResponse(
1924
+ response: Response,
1925
+ budget: TranslatorBudget,
1926
+ tierMetadata?: AdapterTierMetadata,
1927
+ ): Promise<AdapterEvent[]> {
1928
+ let parsed: unknown;
1929
+ try {
1930
+ parsed = await response.json();
1931
+ } catch (error) {
1932
+ tierMetadata?.markResponseUnparseable();
1933
+ throw error;
1934
+ }
1935
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1936
+ tierMetadata?.markResponseUnparseable();
1937
+ throw new Error("upstream response was not a JSON object");
1938
+ }
1939
+ const json = parsed as Record<string, unknown>;
1940
+ if (Object.hasOwn(json, "service_tier")) {
1941
+ tierMetadata?.observeResponseServiceTier(json.service_tier);
1942
+ }
950
1943
  const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
951
1944
  budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
952
1945
  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
- }
1946
+ const payload = unwrapChatCompletionPayload(json);
1947
+ const usage = usageFromOpenAIChat(payload.usage as Record<string, unknown> | undefined);
1948
+ if (json.success === false && payload.error === undefined) {
1949
+ return [{
1950
+ type: "error",
1951
+ message: "upstream reported failure without an error payload",
1952
+ ...(usage ? { usage } : {}),
1953
+ }];
1954
+ }
1955
+ if (payload.error !== undefined && payload.error !== null) return [upstreamErrorEvent(payload.error, usage)];
971
1956
 
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
- }
1957
+ const events: AdapterEvent[] = [];
1958
+ const choices = payload.choices as {
1959
+ message?: Record<string, unknown>;
1960
+ finish_reason?: unknown;
1961
+ error?: OpenAIChatError;
1962
+ }[] | undefined;
1963
+ if (!Array.isArray(choices) || choices.length === 0) {
1964
+ return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
1965
+ }
1966
+ const rawChoice = choices[0];
1967
+ if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
1968
+ return [invalidChoicesEvent(usage)];
1969
+ }
1970
+ const choice = rawChoice;
1971
+ if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)];
1972
+ if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
1973
+ // `!choice.message` splits this input class on TRUTHINESS, not on shape: `null` and `0` fail
1974
+ // closed here, while `"text"`, `true` and `[{...}]` pass and every property read below yields
1975
+ // `undefined` — so a choice claiming an assistant message completed as a SUCCESSFUL EMPTY
1976
+ // turn, stranding any tool call it claimed. The one line above already validates the choice
1977
+ // container this way; its message was left on a truthiness test.
1978
+ //
1979
+ // Every non-record is rejected, arrays included. The google adapter does carve out `[]` for
1980
+ // `content`, but that carve-out is specific to a protobuf-derived wire where a repeated
1981
+ // field can spell an empty message — and `content` is genuinely an ARRAY of blocks there.
1982
+ // `message` is a record on a plain-JSON wire that already has `{}`, so importing the
1983
+ // exception would be an analogy rather than evidence. `[{"content":"…"}]` is the case that
1984
+ // matters: it discards a complete answer, #2232's `content: [{ parts: [...] }]` one adapter over.
1985
+ //
1986
+ // Read through `unknown` rather than the declared type: `choices` is a cast over wire data,
1987
+ // so its `message?: Record<string, unknown>` is an assertion the upstream never made, and
1988
+ // narrowing against it is what let the missing check look type-safe.
1989
+ const rawMessage: unknown = choice.message;
1990
+ if (!isRecord(rawMessage)) {
1991
+ return [invalidChoicesEvent(usage)];
1992
+ }
977
1993
 
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" });
1994
+ const msg = rawMessage as Record<string, unknown>;
1995
+ const reasoningText = reasoningTextFrom(msg);
1996
+ if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText });
1997
+ if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content });
1998
+ const rawToolCalls = msg.tool_calls;
1999
+ if (rawToolCalls !== undefined && rawToolCalls !== null) {
2000
+ if (!Array.isArray(rawToolCalls)) {
2001
+ logInvalidToolCalls("response", rawToolCalls);
2002
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
2003
+ }
2004
+ for (const rawToolCall of rawToolCalls) {
2005
+ if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {
2006
+ logInvalidToolCalls("response", rawToolCalls);
2007
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
2008
+ }
2009
+ const id = rawToolCall.id;
2010
+ const name = rawToolCall.function.name;
2011
+ const args = rawToolCall.function.arguments;
2012
+ // A blank name is as undispatchable as a missing one, so it fails closed here
2013
+ // for the same reason the streamed path refuses it. Trimmed length, not `!name`:
2014
+ // a whitespace-only function name is not a legitimate tool-call shape either.
2015
+ if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string"
2016
+ || name.trim().length === 0) {
2017
+ logInvalidToolCalls("response", rawToolCalls);
2018
+ return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
2019
+ }
2020
+ events.push({ type: "tool_call_start", id, name });
2021
+ events.push({ type: "tool_call_delta", arguments: args });
2022
+ events.push({ type: "tool_call_end" });
2023
+ }
991
2024
  }
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;
2025
+ const stopReason = stopReasonFor(choice.finish_reason);
2026
+ events.push({
2027
+ type: "done",
2028
+ usage,
2029
+ ...(stopReason ? { stopReason } : {}),
2030
+ });
2031
+ retainTranslatedEventBatch(events, budget);
2032
+ return events;
1000
2033
  } finally {
1001
2034
  budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
1002
2035
  }