@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -7,6 +7,12 @@
7
7
  * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
8
8
  */
9
9
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
+ import {
11
+ admissionModelDeniedResponse,
12
+ AdmissionModelDeniedError,
13
+ assertRouteAllowedByScope,
14
+ resolveAdmissionModelScope,
15
+ } from "./admission-model-scope";
10
16
  import { jsonUtf8Bytes } from "../lib/json-byte-size";
11
17
  import { sseFieldValue } from "../lib/sse-decoder";
12
18
  import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard";
@@ -18,7 +24,14 @@ import { recordDesktopRequest } from "../claude/desktop-health";
18
24
  import { stripOneMillionMarker } from "../claude/context-windows";
19
25
  import { captureClaudeInbound } from "../claude/inbound-debug";
20
26
  import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
21
- import { isTransientUpstreamStatus } from "../lib/upstream-retry";
27
+ import {
28
+ applyReplayRefusalClientHeaders,
29
+ carryReplayRefusal,
30
+ isReplayRefusalResponse,
31
+ isTransientUpstreamStatus,
32
+ REPLAY_REFUSED_STATUS,
33
+ UPSTREAM_RESET_REPLAY_REFUSED_CODE,
34
+ } from "../lib/upstream-retry";
22
35
  import { resolveClientRetryAfter } from "../lib/retry-after";
23
36
  import {
24
37
  anthropicErrorBody,
@@ -29,7 +42,7 @@ import {
29
42
  } from "../claude/outbound";
30
43
  import { clearableDeadline, idleDeadline } from "../lib/abort";
31
44
  import { estimateTokens } from "../lib/token-estimate";
32
- import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
45
+ import { captureRouteStaticPolicy, NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
33
46
  import { evidenceFromBody } from "../routing/request-evidence";
34
47
  import { resolveWireProtocolOverride } from "./adapter-resolve";
35
48
  import type { OcxConfig } from "../types";
@@ -420,7 +433,7 @@ async function anthropicNativePassthrough(
420
433
  // count_tokens too — counts must match what the real send will contain, and the 32MB
421
434
  // body cap applies to it equally. Non-message bodies pass through untouched.
422
435
  if (Array.isArray(body.messages)) {
423
- await normalizeAnthropicImages(body.messages);
436
+ await normalizeAnthropicImages(body.messages, { abortSignal: req.signal });
424
437
  enforceAnthropicImageLimits(body.messages);
425
438
  }
426
439
  const headers = new Headers();
@@ -815,9 +828,19 @@ async function handleClaudeMessagesWithBudget(
815
828
  // verified live 2026-07-11). Strip them for that route; routed providers keep them.
816
829
  try {
817
830
  const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
831
+ // Same reason as the native Chat lane: this route can be sent from here, so
832
+ // the key's scope is applied before the wire is settled.
833
+ assertRouteAllowedByScope(
834
+ resolveAdmissionModelScope(config, logIds?.admission),
835
+ String(internalBody.model ?? ""),
836
+ route,
837
+ );
818
838
  // Settle the wire once so the sampling decision below reads the effective
819
839
  // adapter rather than the provider-wide default (#404).
820
- route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
840
+ route.staticPolicy = captureRouteStaticPolicy(
841
+ route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic",
842
+ );
843
+ route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy);
821
844
  logCtx.routeDecision = route.routeDecision;
822
845
  if (route.provider.adapter === "openai-responses") {
823
846
  delete internalBody.max_output_tokens;
@@ -844,6 +867,11 @@ async function handleClaudeMessagesWithBudget(
844
867
  if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning;
845
868
  }
846
869
  } catch (err) {
870
+ if (err instanceof AdmissionModelDeniedError) {
871
+ logCtx.requestedModel = requestedModel;
872
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" });
873
+ return admissionModelDeniedResponse(err);
874
+ }
847
875
  if (err instanceof UnknownRoutingPolicyError) {
848
876
  logCtx.requestedModel = requestedModel;
849
877
  if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" });
@@ -958,6 +986,9 @@ async function handleClaudeMessagesWithBudget(
958
986
  const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream;
959
987
 
960
988
  if (!response.ok) {
989
+ // Read the shared provenance verdict before consuming and re-wrapping the body. A refusal
990
+ // and an ordinary provider rate limit are both 429, so the status cannot distinguish them.
991
+ const replayRefusal = isReplayRefusalResponse(response);
961
992
  // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status.
962
993
  let message = `upstream error (${response.status})`;
963
994
  try {
@@ -972,14 +1003,16 @@ async function handleClaudeMessagesWithBudget(
972
1003
  }
973
1004
  } catch { /* keep fallback message */ }
974
1005
  const upstreamRetryAfter = response.headers.get("retry-after");
975
- const retryAfter = resolveClientRetryAfter({
976
- status: response.status,
977
- message,
978
- upstreamRetryAfter,
979
- })
980
- // Instant-retry "0" is a valid client directive but rejected by cooldown parsers.
981
- // Preserve it so it still wins over the transient "2" fallback (claude-529 mapping).
982
- ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined);
1006
+ const retryAfter = replayRefusal
1007
+ ? undefined
1008
+ : resolveClientRetryAfter({
1009
+ status: response.status,
1010
+ message,
1011
+ upstreamRetryAfter,
1012
+ })
1013
+ // Instant-retry "0" is a valid client directive but rejected by cooldown parsers.
1014
+ // Preserve it so it still wins over the transient "2" fallback (claude-529 mapping).
1015
+ ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined);
983
1016
  // Transient upstream 5xx (already retried pre-stream, 010): reclassify as Anthropic
984
1017
  // 529 overloaded_error so the Claude Code client applies its built-in backoff retry
985
1018
  // instead of dying on a fatal api_error (260716 sol-builder incident). The request
@@ -990,16 +1023,24 @@ async function handleClaudeMessagesWithBudget(
990
1023
  const nativeMainFence = response.status === 503
991
1024
  && upstreamRetryAfter?.trim() === "1"
992
1025
  && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE;
993
- const transient = !nativeMainFence && isTransientUpstreamStatus(response.status);
994
- const outStatus = nativeMainFence ? 503 : transient ? 529 : response.status;
995
- const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), {
1026
+ const transient = !replayRefusal && !nativeMainFence && isTransientUpstreamStatus(response.status);
1027
+ const outStatus = replayRefusal
1028
+ ? REPLAY_REFUSED_STATUS
1029
+ : nativeMainFence ? 503 : transient ? 529 : response.status;
1030
+ const outHeaders = new Headers({ "Content-Type": "application/json" });
1031
+ if (retryAfter) outHeaders.set("Retry-After", retryAfter);
1032
+ else if (transient) outHeaders.set("Retry-After", "2");
1033
+ if (replayRefusal) applyReplayRefusalClientHeaders(outHeaders);
1034
+ const out = new Response(JSON.stringify(anthropicErrorBody(
1035
+ outStatus,
1036
+ message,
1037
+ undefined,
1038
+ replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined,
1039
+ )), {
996
1040
  status: outStatus,
997
- headers: {
998
- "Content-Type": "application/json",
999
- ...(retryAfter ? { "Retry-After": retryAfter } : (transient ? { "Retry-After": "2" } : {})),
1000
- },
1041
+ headers: outHeaders,
1001
1042
  });
1002
- return out;
1043
+ return carryReplayRefusal(response, out);
1003
1044
  }
1004
1045
 
1005
1046
  const contentType = response.headers.get("content-type") ?? "";
@@ -30,7 +30,9 @@ export interface EffortRowOptions {
30
30
  }
31
31
 
32
32
  export function isKnownId(knownIds: EffortRowKnownIds | undefined, id: string): boolean {
33
- return typeof knownIds === "function" ? knownIds(id) : knownIds?.has(id) === true;
33
+ return typeof knownIds === "function"
34
+ ? knownIds(id)
35
+ : knownIds?.has(id) === true || knownIds?.has(id.toLowerCase()) === true;
34
36
  }
35
37
 
36
38
  export function effortRowId(baseId: string, effort: string): string {
@@ -43,6 +45,12 @@ export function effortRowId(baseId: string, effort: string): string {
43
45
  */
44
46
  export function knownEffortRowIds(config: OcxConfig): Set<string> {
45
47
  const ids = new Set<string>();
48
+ const addAlias = (id: string): void => {
49
+ ids.add(id);
50
+ // Model aliases are resolved case-insensitively. Preserve that contract here so an
51
+ // alias cannot be mistaken for a synthetic effort row before ordinary routing runs.
52
+ ids.add(id.toLowerCase());
53
+ };
46
54
  for (const [providerName, provider] of Object.entries(config.providers)) {
47
55
  const known = knownModelIdsForProvider(providerName, provider, config);
48
56
  const namespaces = [providerName, provider.alias].filter((value): value is string => (
@@ -54,8 +62,8 @@ export function knownEffortRowIds(config: OcxConfig): Set<string> {
54
62
  for (const namespace of namespaces) ids.add(`${namespace}/${id}`);
55
63
  }
56
64
  for (const alias of Object.values(provider.modelAliases ?? {})) {
57
- ids.add(alias);
58
- for (const namespace of namespaces) ids.add(`${namespace}/${alias}`);
65
+ addAlias(alias);
66
+ for (const namespace of namespaces) addAlias(`${namespace}/${alias}`);
59
67
  }
60
68
  }
61
69
  for (const [id, combo] of Object.entries(config.combos ?? {})) {
@@ -1,10 +1,137 @@
1
- import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
1
+ import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
2
2
 
3
3
  const GROK_CONTROL_FRAME_TYPES: Record<string, true> = {
4
4
  "codex.rate_limits": true,
5
5
  "codex.response.metadata": true,
6
6
  };
7
7
 
8
+ const GROK_RESPONSE_INTEGER_TIMESTAMP_FIELDS = ["created_at", "completed_at"] as const;
9
+
10
+ type JsonRange = { start: number; end: number };
11
+
12
+ function skipJsonWhitespace(payload: string, index: number): number {
13
+ while (index < payload.length && /[\t\n\r ]/.test(payload[index] ?? "")) index += 1;
14
+ return index;
15
+ }
16
+
17
+ function findJsonStringEnd(payload: string, start: number): number | undefined {
18
+ for (let index = start + 1; index < payload.length; index += 1) {
19
+ if (payload[index] === "\\") index += 1;
20
+ else if (payload[index] === '"') return index + 1;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function findJsonValueEnd(payload: string, start: number): number | undefined {
26
+ if (payload[start] === '"') return findJsonStringEnd(payload, start);
27
+ if (payload[start] !== "{" && payload[start] !== "[") {
28
+ let index = start;
29
+ while (index < payload.length && !",}]".includes(payload[index] ?? "")) index += 1;
30
+ return index;
31
+ }
32
+
33
+ let depth = 0;
34
+ let inString = false;
35
+ for (let index = start; index < payload.length; index += 1) {
36
+ const char = payload[index];
37
+ if (inString) {
38
+ if (char === "\\") index += 1;
39
+ else if (char === '"') inString = false;
40
+ continue;
41
+ }
42
+ if (char === '"') inString = true;
43
+ else if (char === "{" || char === "[") depth += 1;
44
+ else if (char === "}" || char === "]") {
45
+ depth -= 1;
46
+ if (depth === 0) return index + 1;
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function findResponseObjectRange(payload: string): JsonRange | null {
53
+ let cursor = skipJsonWhitespace(payload, 0);
54
+ if (payload[cursor] !== "{") return null;
55
+ cursor += 1;
56
+ let responseRange: JsonRange | null = null;
57
+
58
+ while (cursor < payload.length) {
59
+ cursor = skipJsonWhitespace(payload, cursor);
60
+ if (payload[cursor] === "}") return responseRange;
61
+ if (payload[cursor] !== '"') return null;
62
+ const keyEnd = findJsonStringEnd(payload, cursor);
63
+ if (keyEnd === undefined) return null;
64
+ let key: unknown;
65
+ try {
66
+ key = JSON.parse(payload.slice(cursor, keyEnd));
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ cursor = skipJsonWhitespace(payload, keyEnd);
72
+ if (payload[cursor] !== ":") return null;
73
+ const valueStart = skipJsonWhitespace(payload, cursor + 1);
74
+ const valueEnd = findJsonValueEnd(payload, valueStart);
75
+ if (valueEnd === undefined) return null;
76
+ if (key === "response" && payload[valueStart] === "{") {
77
+ responseRange = { start: valueStart, end: valueEnd };
78
+ }
79
+
80
+ cursor = skipJsonWhitespace(payload, valueEnd);
81
+ if (payload[cursor] === "}") return responseRange;
82
+ if (payload[cursor] !== ",") return null;
83
+ cursor += 1;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function rewriteIntegralTimestamp(payload: string, field: string, value: number, event: unknown): string | null {
89
+ const responseRange = findResponseObjectRange(payload);
90
+ if (responseRange === null) return null;
91
+ let cursor = responseRange.start + 1;
92
+ let candidate: { start: number; length: number } | null = null;
93
+ while (cursor < responseRange.end) {
94
+ cursor = skipJsonWhitespace(payload, cursor);
95
+ if (payload[cursor] === "}") break;
96
+ if (payload[cursor] !== '"') return null;
97
+ const keyEnd = findJsonStringEnd(payload, cursor);
98
+ if (keyEnd === undefined) return null;
99
+ let key: unknown;
100
+ try {
101
+ key = JSON.parse(payload.slice(cursor, keyEnd));
102
+ } catch {
103
+ return null;
104
+ }
105
+ cursor = skipJsonWhitespace(payload, keyEnd);
106
+ if (payload[cursor] !== ":") return null;
107
+ const valueStart = skipJsonWhitespace(payload, cursor + 1);
108
+ const valueEnd = findJsonValueEnd(payload, valueStart);
109
+ if (valueEnd === undefined || valueEnd > responseRange.end) return null;
110
+ const valueToken = payload.slice(valueStart, valueEnd).trimEnd();
111
+ if (
112
+ key === field &&
113
+ /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(valueToken) &&
114
+ valueToken !== String(value) &&
115
+ Number(valueToken) === value
116
+ ) {
117
+ if (candidate !== null) return null;
118
+ candidate = { start: valueStart, length: valueToken.length };
119
+ }
120
+ cursor = skipJsonWhitespace(payload, valueEnd);
121
+ if (payload[cursor] === "}") break;
122
+ if (payload[cursor] !== ",") return null;
123
+ cursor += 1;
124
+ }
125
+ if (candidate === null) return payload;
126
+
127
+ const rewritten = `${payload.slice(0, candidate.start)}${value}${payload.slice(candidate.start + candidate.length)}`;
128
+ try {
129
+ return JSON.stringify(JSON.parse(rewritten)) === JSON.stringify(event) ? rewritten : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
8
135
  /**
9
136
  * Hide Codex-only control frames from Grok's strict Responses decoder.
10
137
  *
@@ -41,3 +168,35 @@ export function createGrokResponsesControlFrameBlockRewrite(): SseBlockRewrite {
41
168
  : [block];
42
169
  };
43
170
  }
171
+
172
+ /** Normalize safe integer response timestamps for Grok's strict Responses decoder. */
173
+ export function createGrokResponsesTimestampBlockRewrite(): SseBlockRewrite {
174
+ return (block) => {
175
+ const payload = sseDataPayload(block);
176
+ if (payload === null || payload === "[DONE]") return [block];
177
+
178
+ let event: unknown;
179
+ try {
180
+ event = JSON.parse(payload);
181
+ } catch {
182
+ return [block];
183
+ }
184
+ if (!event || typeof event !== "object" || Array.isArray(event)) return [block];
185
+ const eventRecord = event as Record<string, unknown>;
186
+ if (typeof eventRecord.type !== "string" || !eventRecord.type.startsWith("response.")) return [block];
187
+ if (!eventRecord.response || typeof eventRecord.response !== "object" || Array.isArray(eventRecord.response)) {
188
+ return [block];
189
+ }
190
+ const response = eventRecord.response as Record<string, unknown>;
191
+ let rewrittenPayload = payload;
192
+ for (const field of GROK_RESPONSE_INTEGER_TIMESTAMP_FIELDS) {
193
+ const value = response[field];
194
+ if (value === undefined) continue;
195
+ if (typeof value !== "number" || value < 0 || !Number.isSafeInteger(value)) return [block];
196
+ const rewritten = rewriteIntegralTimestamp(rewrittenPayload, field, value, event);
197
+ if (rewritten === null) return [block];
198
+ rewrittenPayload = rewritten;
199
+ }
200
+ return rewrittenPayload === payload ? [block] : [replaceSseDataPayload(block, rewrittenPayload)];
201
+ };
202
+ }
@@ -1,8 +1,13 @@
1
1
  /** Strict terminal reconstruction selected by the Grok compatibility marker. */
2
2
  import type { TranslatorBudget } from "../lib/translator-budget";
3
3
  import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay";
4
- import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
4
+ import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
5
5
  import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec";
6
+ import {
7
+ requestToolScope,
8
+ type RequestToolScope,
9
+ type RequestToolScopeCorrespondence,
10
+ } from "./responses-request-tool-scope";
6
11
 
7
12
  type SparseTerminalOpenItem = {
8
13
  type: string;
@@ -16,6 +21,20 @@ type SparseTerminalCompletedItem = RetainedOutputItem & {
16
21
 
17
22
  const MAX_GROK_OPEN_ITEM_IDENTITY_BYTES = MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES;
18
23
 
24
+ /** Terminal event this repair publishes when it refused to reconstruct a call faithfully. */
25
+ export const GROK_REFUSED_TERMINAL_EVENT_TYPE = "response.incomplete";
26
+
27
+ /** `incomplete_details.reason` carried by that terminal. */
28
+ export const GROK_FORBIDDEN_TOOL_CALL_REASON = "forbidden_tool_call";
29
+
30
+ /** An upstream-supplied name reaches the terminal message; keep it bounded. */
31
+ const MAX_REPORTED_TOOL_NAME_CHARS = 100;
32
+
33
+ export function forbiddenToolCallMessage(name: string): string {
34
+ return `routed provider called "${name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS)}", `
35
+ + "which this request's tool selection excludes; the reconstructed output omits that call";
36
+ }
37
+
19
38
  const GROK_TERMINAL_OUTPUT_ITEM_TYPES = new Set([
20
39
  "message",
21
40
  "reasoning",
@@ -167,6 +186,45 @@ function plausibleGrokOpenItem(
167
186
  };
168
187
  }
169
188
 
189
+ /**
190
+ * Publish the refusal on the terminal itself rather than as a silent omission.
191
+ *
192
+ * The ordinary reconstruction replaces only the data payload's `output`, so its event name still
193
+ * describes the payload. A refusal does not: the turn no longer completed the way the upstream
194
+ * said it did, so the event line moves with the status instead of leaving a client to read a
195
+ * clean finish off an unchanged `event: response.completed`.
196
+ */
197
+ function refusedTerminalBlock(
198
+ block: string,
199
+ parsed: Record<string, unknown>,
200
+ response: Record<string, unknown>,
201
+ output: readonly Record<string, unknown>[],
202
+ refusedName: string | undefined,
203
+ ): string {
204
+ const payload = JSON.stringify({
205
+ ...parsed,
206
+ type: GROK_REFUSED_TERMINAL_EVENT_TYPE,
207
+ response: {
208
+ ...response,
209
+ status: "incomplete",
210
+ output,
211
+ incomplete_details: {
212
+ reason: GROK_FORBIDDEN_TOOL_CALL_REASON,
213
+ ...(refusedName === undefined ? {} : { message: forbiddenToolCallMessage(refusedName) }),
214
+ },
215
+ },
216
+ });
217
+ const rewritten = replaceSseDataPayload(block, payload);
218
+ const newline = block.includes("\r\n") ? "\r\n" : "\n";
219
+ let eventRewritten = false;
220
+ const lines = rewritten.split(/\r?\n/).map(line => {
221
+ if (eventRewritten || !line.startsWith("event:")) return line;
222
+ eventRewritten = true;
223
+ return `event: ${GROK_REFUSED_TERMINAL_EVENT_TYPE}`;
224
+ });
225
+ return lines.join(newline);
226
+ }
227
+
170
228
  /**
171
229
  * Narrow client repair for grok-build's Responses consumer.
172
230
  *
@@ -176,12 +234,26 @@ function plausibleGrokOpenItem(
176
234
  * empty output. Reconstruct only from real, unique, contiguous, bounded done
177
235
  * events whose raw semantics are already valid. Any ambiguity stays byte-level
178
236
  * fail-closed; the provider-opt-in snapshot repair above is unchanged.
237
+ *
238
+ * The terminal this publishes is one the upstream never sent, so it carries only what the final
239
+ * outbound request still authorized. A client call outside that request's tool selection is left
240
+ * out and the terminal says so explicitly. The declaration guard downstream answers the other
241
+ * half of the question — whether a name was declared at all — and keeps policing the raw stream,
242
+ * which this rewrite never edits.
179
243
  */
180
244
  export function createGrokResponsesSparseTerminalBlockRewrite(
181
245
  budget?: TranslatorBudget,
246
+ outboundRequestBody?: unknown,
247
+ toolIdentityCorrespondence?: RequestToolScopeCorrespondence,
182
248
  ): SseBlockRewrite {
249
+ const toolScope: RequestToolScope | undefined = requestToolScope(
250
+ outboundRequestBody,
251
+ toolIdentityCorrespondence,
252
+ );
183
253
  const openItems = new Map<number, SparseTerminalOpenItem>();
184
254
  const completedItems = new Map<number, SparseTerminalCompletedItem>();
255
+ const withheldIndices = new Set<number>();
256
+ let withheldToolName: string | undefined;
185
257
  let aggregateItemBytes = 0;
186
258
  let aggregateOpenItemBytes = 0;
187
259
  let tainted = false;
@@ -194,6 +266,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
194
266
  }
195
267
  openItems.clear();
196
268
  completedItems.clear();
269
+ withheldIndices.clear();
270
+ withheldToolName = undefined;
197
271
  aggregateItemBytes = 0;
198
272
  aggregateOpenItemBytes = 0;
199
273
  hasVisibleOutput = false;
@@ -217,7 +291,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
217
291
  if (tainted) return;
218
292
  const sourceBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
219
293
  if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES
220
- || completedItems.size >= MAX_COMPLETED_OUTPUT_ITEMS
294
+ || completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS
221
295
  || aggregateItemBytes + sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) {
222
296
  taintAndRelease();
223
297
  return;
@@ -228,6 +302,24 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
228
302
  hasVisibleOutput = hasVisibleOutput || visibleToGrok;
229
303
  };
230
304
 
305
+ /**
306
+ * Record the position of a call this request forbade without retaining the item.
307
+ *
308
+ * Only the offending item is dropped. Tainting here instead would discard the assistant text
309
+ * that arrived in the same turn and leave the client the empty terminal this repair exists to
310
+ * fix, which punishes the caller for the provider's overreach. The index is kept so the
311
+ * contiguity proof below still covers the whole output.
312
+ */
313
+ const withholdForbiddenCall = (index: number, name: string): void => {
314
+ if (tainted) return;
315
+ if (completedItems.size + withheldIndices.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
316
+ taintAndRelease();
317
+ return;
318
+ }
319
+ withheldIndices.add(index);
320
+ withheldToolName ??= name.slice(0, MAX_REPORTED_TOOL_NAME_CHARS);
321
+ };
322
+
231
323
  const closeOpenItem = (index: number): void => {
232
324
  const open = openItems.get(index);
233
325
  if (!open) return;
@@ -265,6 +357,7 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
265
357
  const open = isPlainObject(parsed.item) ? plausibleGrokOpenItem(parsed.item) : null;
266
358
  if (outputIndex === undefined || !open
267
359
  || openItems.has(outputIndex) || completedItems.has(outputIndex)
360
+ || withheldIndices.has(outputIndex)
268
361
  || openItems.size >= MAX_COMPLETED_OUTPUT_ITEMS) {
269
362
  taintAndRelease();
270
363
  } else if (!tainted) {
@@ -284,7 +377,8 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
284
377
  if (type === "response.output_item.done") {
285
378
  const item = isPlainObject(parsed.item) ? parsed.item : null;
286
379
  const proof = item ? trustedGrokCompletedItem(item) : null;
287
- if (outputIndex === undefined || !proof || completedItems.has(outputIndex)) {
380
+ if (outputIndex === undefined || !proof
381
+ || completedItems.has(outputIndex) || withheldIndices.has(outputIndex)) {
288
382
  taintAndRelease();
289
383
  return [block];
290
384
  }
@@ -295,7 +389,12 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
295
389
  return [block];
296
390
  }
297
391
  closeOpenItem(outputIndex);
298
- retainCompletedItem(outputIndex, item!, proof.visibleToGrok);
392
+ const forbidden = toolScope?.forbiddenClientToolCallName(item!);
393
+ if (forbidden === undefined) {
394
+ retainCompletedItem(outputIndex, item!, proof.visibleToGrok);
395
+ } else {
396
+ withholdForbiddenCall(outputIndex, forbidden);
397
+ }
299
398
  return [block];
300
399
  }
301
400
 
@@ -312,14 +411,18 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
312
411
  const outputIsAuthoritative = Array.isArray(output) && output.length > 0;
313
412
  const outputIsSparse = !("output" in response)
314
413
  || (Array.isArray(output) && output.length === 0);
414
+ // A withheld call is a reason to publish on its own: the refusal has to reach the client
415
+ // even when nothing visible survived it, or the turn ends as an ordinary empty finish.
416
+ const refused = withheldIndices.size > 0;
315
417
  if (!outputIsAuthoritative && outputIsSparse && terminalStatusConsistent
316
- && completedItems.size > 0 && openItems.size === 0 && hasVisibleOutput) {
418
+ && openItems.size === 0 && (hasVisibleOutput || refused)) {
317
419
  const ordered = [...completedItems.entries()].sort(([left], [right]) => left - right);
318
- if (ordered.every(([index], position) => index === position)) {
319
- out = jsonBlock({
320
- ...parsed,
321
- response: { ...response, output: ordered.map(([, retained]) => retained.item) },
322
- });
420
+ const positions = [...completedItems.keys(), ...withheldIndices].sort((left, right) => left - right);
421
+ if (positions.length > 0 && positions.every((index, position) => index === position)) {
422
+ const rebuilt = ordered.map(([, retained]) => retained.item);
423
+ out = refused
424
+ ? refusedTerminalBlock(block, parsed, response, rebuilt, withheldToolName)
425
+ : jsonBlock({ ...parsed, response: { ...response, output: rebuilt } });
323
426
  }
324
427
  }
325
428
  }
@@ -330,4 +433,3 @@ export function createGrokResponsesSparseTerminalBlockRewrite(
330
433
  rewrite.dispose = reset;
331
434
  return rewrite;
332
435
  }
333
-
@@ -0,0 +1,103 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Whether the served dashboard bundle predates the sources it was built from.
6
+ *
7
+ * The dashboard is a build artifact: `AGENTS.md` records that `gui/` is served from `gui/dist`,
8
+ * so a checkout that moves forward without `bun run build:gui` keeps serving the previous bundle.
9
+ * Nothing fails when that happens. The proxy answers, the page loads, and every feature added since
10
+ * the last build is simply absent — which reads as the feature being broken rather than unbuilt.
11
+ * A five-day-old bundle hid the entire menu-bar and widget section of the Usage page this way.
12
+ *
13
+ * This reports; it never rebuilds. A proxy that compiled a frontend while starting would trade a
14
+ * silent staleness for a slow, surprising start, and the rebuild belongs to whoever moved the
15
+ * checkout.
16
+ */
17
+ export interface GuiBundleFreshness {
18
+ /** Absolute path of the served bundle, or null when no bundle was found. */
19
+ bundlePath: string | null;
20
+ /** Newest mtime under the bundle, in epoch milliseconds. */
21
+ bundleModifiedMs: number | null;
22
+ /** Newest mtime under the GUI sources, in epoch milliseconds. */
23
+ sourceModifiedMs: number | null;
24
+ /** True only when both sides are known and the sources are strictly newer. */
25
+ stale: boolean;
26
+ }
27
+
28
+ /** Directory entries that never carry meaningful build input or output timestamps. */
29
+ const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", ".vite", ".cache"]);
30
+
31
+ /**
32
+ * Newest mtime beneath `root`, or null when the tree is missing or empty.
33
+ *
34
+ * Walking is bounded by `maxEntries` because this runs on a status path: a pathological tree must
35
+ * cost a predictable amount rather than stalling the command that reports on it. Hitting the bound
36
+ * yields the newest value seen so far, which can only make the comparison more conservative.
37
+ */
38
+ export function newestModifiedMs(root: string, maxEntries = 20_000): number | null {
39
+ if (!existsSync(root)) return null;
40
+ let newest: number | null = null;
41
+ let seen = 0;
42
+ const queue: string[] = [root];
43
+ while (queue.length > 0) {
44
+ const current = queue.pop()!;
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(current, { withFileTypes: true });
48
+ } catch {
49
+ continue;
50
+ }
51
+ for (const entry of entries) {
52
+ if (seen >= maxEntries) return newest;
53
+ if (entry.isSymbolicLink()) continue;
54
+ const full = join(current, entry.name);
55
+ if (entry.isDirectory()) {
56
+ if (IGNORED_DIRECTORIES.has(entry.name)) continue;
57
+ queue.push(full);
58
+ continue;
59
+ }
60
+ if (!entry.isFile()) continue;
61
+ seen += 1;
62
+ try {
63
+ const mtime = statSync(full).mtimeMs;
64
+ if (newest === null || mtime > newest) newest = mtime;
65
+ } catch {
66
+ // A file that vanished mid-walk cannot make the bundle look fresher than it is.
67
+ }
68
+ }
69
+ }
70
+ return newest;
71
+ }
72
+
73
+ /**
74
+ * Compare the built bundle against its sources.
75
+ *
76
+ * Unknown is not stale. A packaged install has no `gui/src` beside it, and a missing bundle is a
77
+ * different condition with its own message elsewhere; neither should produce a rebuild warning.
78
+ */
79
+ export function inspectGuiBundleFreshness(input: {
80
+ bundlePath: string | null;
81
+ sourcePath: string;
82
+ }): GuiBundleFreshness {
83
+ const bundleModifiedMs = input.bundlePath === null ? null : newestModifiedMs(input.bundlePath);
84
+ const sourceModifiedMs = newestModifiedMs(input.sourcePath);
85
+ const stale = bundleModifiedMs !== null
86
+ && sourceModifiedMs !== null
87
+ && sourceModifiedMs > bundleModifiedMs;
88
+ return {
89
+ bundlePath: input.bundlePath,
90
+ bundleModifiedMs,
91
+ sourceModifiedMs,
92
+ stale,
93
+ };
94
+ }
95
+
96
+ /** Operator-facing lines for a stale bundle; empty when the bundle is current or unknown. */
97
+ export function staleGuiBundleLines(freshness: GuiBundleFreshness): string[] {
98
+ if (!freshness.stale) return [];
99
+ return [
100
+ "Dashboard bundle is older than the GUI sources, so the page is missing everything built since.",
101
+ "Rebuild it with: bun run build:gui",
102
+ ];
103
+ }