@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
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The telemetry vocabulary both the proxy and the dashboard read.
3
+ *
4
+ * This module has NO imports, and that is its entire job. The dashboard is a separate TypeScript
5
+ * project with `erasableSyntaxOnly`, and a type-only import still pulls the imported file's whole
6
+ * import graph into that project. Importing these names from `./log` therefore dragged
7
+ * `node:fs`, `node:crypto` and the config barrel into the browser build, where a parameter
8
+ * property in `src/config/atomic-write.ts` fails to compile. The names below are the ones a
9
+ * browser legitimately needs, so they live where a browser can reach them.
10
+ *
11
+ * Anything added here must stay free of imports. A contract that acquires a dependency stops
12
+ * being a contract.
13
+ */
14
+
15
+ /**
16
+ * Recovery kinds recorded per attempt in the usage log; the dashboard renders localized labels
17
+ * for these wire values.
18
+ *
19
+ * The roster is the single statement of this vocabulary and the type is derived from it. It was
20
+ * written twice once -- as a union and as the read-back whitelist -- and the two are not
21
+ * interchangeable: a member added only to the union compiles, is written to disk, and is dropped
22
+ * on the next read, so the row loses the field that says why it recovered. One declaration cannot
23
+ * drift from itself, and the dashboard now reads this one rather than keeping a third copy.
24
+ */
25
+ export const ATTEMPT_RECOVERY_KIND_ROSTER = Object.freeze([
26
+ "transient-5xx",
27
+ "connection-reset",
28
+ "oauth-401",
29
+ "key-401",
30
+ "key-429",
31
+ "rate-limit-429",
32
+ "anthropic-oauth-429",
33
+ "oauth-account-429",
34
+ "image-413",
35
+ "console-go-upload-retry",
36
+ "opaque-blob-rejection",
37
+ "empty-completion",
38
+ "reasoning-effort-downgrade",
39
+ ] as const);
40
+
41
+ export type AttemptRecoveryKind = typeof ATTEMPT_RECOVERY_KIND_ROSTER[number];
42
+
43
+ /**
44
+ * Why a recovery this request was otherwise willing to make did not happen.
45
+ *
46
+ * Recorded separately from `recoveryKinds` and from `sendCount` because the question it answers
47
+ * is different from either. A log showing one physical send and no recovery kind used to be
48
+ * ambiguous: nothing was eligible, or something was and the send budget withheld it. Those need
49
+ * opposite follow-ups and the second was invisible (#5044).
50
+ *
51
+ * `sendCount` deliberately does not move for these. A refused attempt is not a physical send, and
52
+ * inflating the count to signal the refusal would corrupt the one number that means "requests this
53
+ * proxy actually made".
54
+ */
55
+ export const ATTEMPT_RECOVERY_WITHHELD_ROSTER = Object.freeze([
56
+ "retry-send-budget",
57
+ "rotation-send-budget",
58
+ ] as const);
59
+
60
+ export type AttemptRecoveryWithheld = typeof ATTEMPT_RECOVERY_WITHHELD_ROSTER[number];
61
+
62
+ /**
63
+ * How far a failed exchange got, ordered by how much the DOWNSTREAM CLIENT observed.
64
+ *
65
+ * The order is by client observation rather than by upstream progress, because the question it
66
+ * answers is whether resending can duplicate something the caller already saw. An upstream that
67
+ * completed a turn we never relayed has committed nothing downstream; an upstream that emitted
68
+ * one token has.
69
+ *
70
+ * The roster lives here rather than beside the resend tables for the reason stated at the top of
71
+ * this file: the dashboard renders a label per member, and reaching the table module for the
72
+ * names would drag its import graph into the browser project. `src/lib/request-failure-model.ts`
73
+ * re-exports it, so every existing importer keeps its path and there is still exactly one
74
+ * declaration.
75
+ */
76
+ export const REQUEST_FAILURE_STAGES = Object.freeze([
77
+ /** No response head exists. Whether the origin began the turn is not known from the stage alone. */
78
+ "pre-header",
79
+ /** A status line and headers exist, and no protocol body event has been parsed yet. */
80
+ "headers-only",
81
+ /** The protocol body began with control events only -- `response.created`, quota frames. */
82
+ "protocol-prelude",
83
+ /** At least one output-bearing event reached the caller. */
84
+ "semantic-output",
85
+ /** A tool call or other externally visible effect was emitted. */
86
+ "side-effect",
87
+ /** A terminal event settled the turn after its answer reached the caller. */
88
+ "terminal",
89
+ ] as const);
90
+
91
+ export type RequestFailureStage = typeof REQUEST_FAILURE_STAGES[number];
92
+
93
+ /**
94
+ * Why the request failed, as one closed dictionary for every layer.
95
+ *
96
+ * Bounded on purpose: these are wire values a maintainer reads and a metric labels by, never a
97
+ * credential, an account identifier, an upstream body or prompt content. That bound is what lets
98
+ * the value be a Prometheus label and a grouping key without a masking pass -- a closed roster
99
+ * has nothing to mask.
100
+ */
101
+ export const REQUEST_FAILURE_CAUSES = Object.freeze([
102
+ /** The bytes provably never reached the origin: connect refused, DNS failure, TLS handshake. */
103
+ "transport-unsent",
104
+ /** The bytes left and the connection died before a head. The origin may be running the turn. */
105
+ "transport-ambiguous",
106
+ /** The origin answered that it would not start the turn now: 503, overloaded, backpressure. */
107
+ "upstream-declined",
108
+ /** A 429 rate limit. Capacity is momentarily gone; waiting is the remedy. */
109
+ "rate-limit",
110
+ /** Plan or credit quota is gone. Waiting out a retry window does not help; the account must change. */
111
+ "quota-exhausted",
112
+ /** Credentials were rejected: 401, 403 on identity. */
113
+ "credential-rejected",
114
+ /** The origin evaluated the content and refused it. Identical bytes get the identical refusal. */
115
+ "policy-refusal",
116
+ /**
117
+ * The origin rejected a request PARAMETER rather than the content: an unsupported reasoning
118
+ * effort, an unknown field. Distinct from `policy-refusal` because the remedy is opposite --
119
+ * the same content succeeds once the parameter is adjusted.
120
+ */
121
+ "parameter-rejected",
122
+ /** Opaque replay state was rejected as unverifiable. Only a request without it can succeed. */
123
+ "ciphertext-refusal",
124
+ /**
125
+ * The payload exceeded a size the origin accepts. A smaller rebuild of the same turn can
126
+ * succeed, which is why this is not the same answer as `payload-rejected`.
127
+ */
128
+ "payload-too-large",
129
+ /** The payload was rejected on its merits: unsupported media, malformed part. No repair helps. */
130
+ "payload-rejected",
131
+ /**
132
+ * The origin returned a server-side fault. Whether it had already begun the turn is not
133
+ * knowable from the status, so this is the honest classification for the mixed 5xx set the
134
+ * transient layer retries: 503 really did decline, 500 may not have.
135
+ */
136
+ "upstream-fault",
137
+ /** The turn settled carrying no usable output. */
138
+ "empty-output",
139
+ /** The caller went away. */
140
+ "client-cancelled",
141
+ /** This proxy refused before dispatch: send budget, route policy, replay refusal. */
142
+ "local-refusal",
143
+ ] as const);
144
+
145
+ export type RequestFailureCause = typeof REQUEST_FAILURE_CAUSES[number];
146
+
147
+ /**
148
+ * Whether this proxy may send the request again. Derived at READ time from the stage and the
149
+ * cause and never persisted, so a stored row cannot carry a verdict that the current table
150
+ * would no longer reach.
151
+ *
152
+ * Every refusal names WHY it refused, because the three reasons need different operator
153
+ * responses and used to arrive as one undifferentiated "no retry".
154
+ */
155
+ export const RESEND_PERMISSIONS = Object.freeze([
156
+ /** The same request may be sent again. */
157
+ "permitted",
158
+ /** Only a modified request may be sent: rotated credential, stripped ciphertext. */
159
+ "permitted-after-repair",
160
+ /** Upstream execution state is unknown. No AUTOMATIC resend. */
161
+ "refused-ambiguous",
162
+ /** The caller already observed output or an externally visible effect. */
163
+ "refused-committed",
164
+ /** Identical bytes would get the identical answer. */
165
+ "refused-futile",
166
+ ] as const);
167
+
168
+ export type ResendPermission = typeof RESEND_PERMISSIONS[number];
169
+
170
+ /**
171
+ * Where a terminal or failure was observed on the wire.
172
+ *
173
+ * Declared here because three modules read it as a closed set -- the durable row's validator,
174
+ * the failure attribution and the failure fingerprint -- and a fourth restatement in a test is
175
+ * how a member added later leaves an "exhaustive" cross product green without exercising it.
176
+ */
177
+ export const REQUEST_TRANSPORT_PHASES = Object.freeze([
178
+ "pre_headers",
179
+ "mid_stream",
180
+ "terminal_sse",
181
+ ] as const);
182
+
183
+ export type RequestTransportPhase = typeof REQUEST_TRANSPORT_PHASES[number];
184
+
185
+ /**
186
+ * What an attempt actually delivered, as five bounded counts (#3983).
187
+ *
188
+ * #3983 wanted these signals and emitted one debug line per event to get them. That is a second
189
+ * durable record: `emitDebugLine` writes the in-process ring AND stderr, and stderr is redirected
190
+ * to the service log under both launchd and systemd, so an installed service ends up with a
191
+ * per-event history beside the ledger, carrying its own retention, sequencing and identity. It
192
+ * also fingerprinted each payload under a process-global random key, which makes every repeated
193
+ * prompt fragment, tool name and error message correlatable for the process lifetime.
194
+ *
195
+ * Counts answer the same questions -- a missing terminal, adapter-to-client loss, empty output,
196
+ * partial output size -- and cannot carry content at all. They ride the attempt, so they inherit
197
+ * the ledger's normalization, masking and retention rather than acquiring their own.
198
+ *
199
+ * Counted where the event is DELIVERED, not where it is read. An adapter event the client never
200
+ * received is exactly the discrepancy worth seeing, and counting both ends at the reader would
201
+ * make the two numbers equal by construction.
202
+ */
203
+ export interface AttemptDeliverySummary {
204
+ /** Events this attempt's adapter produced. */
205
+ adapterEvents: number;
206
+ /** Frames that reached the client transport, after a successful enqueue. */
207
+ relayedEvents: number;
208
+ /** UTF-8 bytes of output-bearing delta actually relayed. Never the content itself. */
209
+ semanticBytes: number;
210
+ /** Externally visible effects relayed: a tool call or a search call starting. */
211
+ sideEffectEvents: number;
212
+ /** Terminal frames relayed. Zero on a delivered stream is the missing-terminal signal. */
213
+ terminalEvents: number;
214
+ }
215
+
216
+ /**
217
+ * What one logical request spent upstream, decomposed by how much of it is explained.
218
+ *
219
+ * The counting half of the durable spend record, without the routing detail that sits beside it.
220
+ * Every surface that reports a send total reads these three numbers and none of them recomputes a
221
+ * total of its own -- a recomputed total is how the exporter and the dashboard ended up reporting
222
+ * different send counts for the same request.
223
+ */
224
+ export interface RequestSpendTotals {
225
+ /** Physical upstream sends summed across every attempt, combo children included. */
226
+ sends: number;
227
+ /** Sends whose attempt reached a terminal status, so the spend has a known outcome. */
228
+ settled: number;
229
+ /**
230
+ * Sends charged with no terminal outcome behind them: an attempt abandoned mid-flight, or a
231
+ * budget charge no attempt row ever accounted for. Never folded into `settled` -- an unexplained
232
+ * send is the exact quantity this record exists to make visible.
233
+ */
234
+ unresolved: number;
235
+ /** Model sends the request execution budget charged. Absent when no budget was attached. */
236
+ reserved?: number;
237
+ }
@@ -0,0 +1,236 @@
1
+ import { cacheTokensFromUsage, usageAttributions } from "./summary";
2
+ import type { PersistedUsageEntry } from "./log";
3
+ import { usageDisplayTotalTokens } from "./totals";
4
+
5
+ export type TimelineMetric = "total" | "input" | "output" | "cached";
6
+ export type TimelineAggregation = "sum" | "average" | "max";
7
+ export type TimelineGrouping = "model" | "modelAccount";
8
+ export const TIMELINE_HOURS = [6, 24, 72, 168] as const;
9
+
10
+ export interface TimelineQuery {
11
+ hours: typeof TIMELINE_HOURS[number];
12
+ bucketMinutes: number;
13
+ metric: TimelineMetric;
14
+ aggregation: TimelineAggregation;
15
+ grouping: TimelineGrouping;
16
+ models: string[] | null;
17
+ hiddenProviders: string[];
18
+ now: number;
19
+ }
20
+
21
+ export interface TimelineSeries {
22
+ id: string;
23
+ provider: string;
24
+ model: string;
25
+ accountLogLabel?: string;
26
+ total: number;
27
+ points: number[];
28
+ }
29
+
30
+ export interface UsageTimeline {
31
+ appliedFilters: { models: string[] | null; hiddenProviders: string[] };
32
+ start: number;
33
+ end: number;
34
+ bucketSeconds: number;
35
+ buckets: number;
36
+ metric: TimelineMetric;
37
+ aggregation: TimelineAggregation;
38
+ grouping: TimelineGrouping;
39
+ series: TimelineSeries[];
40
+ availableModels: string[];
41
+ missingMeasurements: number;
42
+ truncated: boolean;
43
+ }
44
+
45
+ const METRICS: readonly TimelineMetric[] = ["total", "input", "output", "cached"];
46
+ const AGGREGATIONS: readonly TimelineAggregation[] = ["sum", "average", "max"];
47
+ const GROUPINGS: readonly TimelineGrouping[] = ["model", "modelAccount"];
48
+
49
+ function enumValue<T extends string>(value: string | null, values: readonly T[], fallback: T): T | { error: string } {
50
+ if (value === null || value === "") return fallback;
51
+ return values.includes(value as T) ? value as T : { error: `invalid value for parameter: ${value}` };
52
+ }
53
+
54
+ export function isTimelineModelId(value: unknown): value is string {
55
+ return typeof value === "string" && /^[^/\s]+\/\S+$/.test(value);
56
+ }
57
+
58
+ function parseModels(raw: string | null): string[] | null | { error: string } {
59
+ if (raw === null || raw.trim() === "") return null;
60
+ const models = raw.split(",").map(model => model.trim());
61
+ if (models.length > 100) return { error: "models must contain at most 100 identifiers" };
62
+ if (models.some(model => !isTimelineModelId(model))) {
63
+ return { error: "models must contain provider/model identifiers" };
64
+ }
65
+ return [...new Set(models)];
66
+ }
67
+
68
+ export function parseTimelineQuery(params: URLSearchParams, now: number): TimelineQuery | { error: string } {
69
+ const rawHours = params.get("hours") ?? "24";
70
+ const hoursNumber = Number(rawHours);
71
+ if (!TIMELINE_HOURS.includes(hoursNumber as typeof TIMELINE_HOURS[number])) {
72
+ return { error: "hours must be one of 6, 24, 72, 168" };
73
+ }
74
+ const bucketMinutes = Number(params.get("bucketMinutes") ?? "60");
75
+ if (!Number.isInteger(bucketMinutes) || bucketMinutes < 1 || bucketMinutes > 1440) {
76
+ return { error: "bucketMinutes must be an integer from 1 through 1440" };
77
+ }
78
+ const buckets = Math.ceil(hoursNumber * 60 / bucketMinutes);
79
+ if (buckets > 2000) return { error: "timeline bucket count must not exceed 2000" };
80
+ const metric = enumValue(params.get("metric"), METRICS, "total");
81
+ if (typeof metric !== "string") return metric;
82
+ const aggregation = enumValue(params.get("aggregation"), AGGREGATIONS, "sum");
83
+ if (typeof aggregation !== "string") return aggregation;
84
+ const grouping = enumValue(params.get("grouping"), GROUPINGS, "model");
85
+ if (typeof grouping !== "string") return grouping;
86
+ const models = parseModels(params.get("models"));
87
+ if (typeof models === "object" && models !== null && "error" in models) return models;
88
+ const hiddenProviders = params.getAll("hiddenProvider");
89
+ if (hiddenProviders.length > 100 || hiddenProviders.some(value => !value || /\s/.test(value))) {
90
+ return { error: "hiddenProvider must contain at most 100 nonblank provider names" };
91
+ }
92
+ if (!Number.isFinite(now)) return { error: "now must be finite" };
93
+ return {
94
+ hours: hoursNumber as TimelineQuery["hours"],
95
+ bucketMinutes,
96
+ metric,
97
+ aggregation,
98
+ grouping,
99
+ models: models as string[] | null,
100
+ hiddenProviders: [...new Set(hiddenProviders)].sort(),
101
+ now,
102
+ };
103
+ }
104
+
105
+ interface SeriesState {
106
+ provider: string;
107
+ model: string;
108
+ accountLogLabel?: string;
109
+ points: number[];
110
+ requests: Map<number, Map<string, number>>;
111
+ }
112
+
113
+ function metricValue(metric: TimelineMetric, attribution: ReturnType<typeof usageAttributions>[number]): number | undefined {
114
+ if (metric === "total") return usageDisplayTotalTokens(attribution.usage, attribution.totalTokens);
115
+ if (metric === "input") return attribution.usage?.inputTokens;
116
+ if (metric === "output") return attribution.usage?.outputTokens;
117
+ return cacheTokensFromUsage(attribution.usage).read;
118
+ }
119
+
120
+ export function createTimelineAccumulator(query: TimelineQuery): { add(entry: PersistedUsageEntry): void; finish(): UsageTimeline } {
121
+ const bucketSeconds = query.bucketMinutes * 60;
122
+ const buckets = Math.ceil(query.hours * 60 / query.bucketMinutes);
123
+ const end = (Math.floor(query.now / 1000 / bucketSeconds) + 1) * bucketSeconds;
124
+ const start = end - buckets * bucketSeconds;
125
+ const startMs = start * 1000;
126
+ const endMs = end * 1000;
127
+ const series = new Map<string, SeriesState>();
128
+ const availableModels = new Set<string>();
129
+ const hiddenProviders = new Set(query.hiddenProviders);
130
+ let missingMeasurements = 0;
131
+
132
+ function add(entry: PersistedUsageEntry): void {
133
+ if (entry.timestamp < startMs || entry.timestamp >= endMs) return;
134
+ const bucket = Math.floor((entry.timestamp - startMs) / (bucketSeconds * 1000));
135
+ if (bucket < 0 || bucket >= buckets) return;
136
+ for (const attribution of usageAttributions(entry)) {
137
+ if (hiddenProviders.has(attribution.provider)) continue;
138
+ const modelId = `${attribution.provider}/${attribution.model}`;
139
+ availableModels.add(modelId);
140
+ if (query.models && !query.models.includes(modelId)) continue;
141
+ const id = query.grouping === "model"
142
+ ? modelId
143
+ : `${modelId} · ${attribution.accountLogLabel ?? "unknown"}`;
144
+ let state = series.get(id);
145
+ if (!state) {
146
+ state = {
147
+ provider: attribution.provider,
148
+ model: attribution.model,
149
+ ...(query.grouping === "modelAccount" ? { accountLogLabel: attribution.accountLogLabel ?? "unknown" } : {}),
150
+ points: Array<number>(buckets).fill(0),
151
+ requests: new Map(),
152
+ };
153
+ series.set(id, state);
154
+ }
155
+ const value = metricValue(query.metric, attribution);
156
+ if (value === undefined) {
157
+ missingMeasurements += 1;
158
+ continue;
159
+ }
160
+ if (query.aggregation === "sum") {
161
+ state.points[bucket] = (state.points[bucket] ?? 0) + value;
162
+ } else {
163
+ let requests = state.requests.get(bucket);
164
+ if (!requests) {
165
+ requests = new Map();
166
+ state.requests.set(bucket, requests);
167
+ }
168
+ requests.set(attribution.requestId, (requests.get(attribution.requestId) ?? 0) + value);
169
+ }
170
+ }
171
+ }
172
+
173
+ function finish(): UsageTimeline {
174
+ const rows = [...series].map(([id, state]): { row: TimelineSeries; state: SeriesState } => {
175
+ if (query.aggregation !== "sum") {
176
+ for (const [bucket, requests] of state.requests) {
177
+ const values = [...requests.values()];
178
+ state.points[bucket] = query.aggregation === "max"
179
+ ? Math.max(...values)
180
+ : values.reduce((sum, value) => sum + value, 0) / values.length;
181
+ }
182
+ }
183
+ const total = state.points.reduce((sum, value) => sum + value, 0);
184
+ return {
185
+ row: {
186
+ id,
187
+ provider: state.provider,
188
+ model: state.model,
189
+ ...(state.accountLogLabel !== undefined ? { accountLogLabel: state.accountLogLabel } : {}),
190
+ total,
191
+ points: state.points,
192
+ },
193
+ state,
194
+ };
195
+ }).sort((left, right) => right.row.total - left.row.total || left.row.id.localeCompare(right.row.id));
196
+ const kept = (rows.length > 24 ? rows.slice(0, 23) : rows).map(({ row }) => row);
197
+ if (rows.length > 24) {
198
+ const otherPoints = Array<number>(buckets).fill(0);
199
+ const folded = rows.slice(23);
200
+ if (query.aggregation === "sum") {
201
+ for (const { row } of folded) {
202
+ for (let index = 0; index < buckets; index += 1) otherPoints[index] = (otherPoints[index] ?? 0) + (row.points[index] ?? 0);
203
+ }
204
+ } else {
205
+ for (let index = 0; index < buckets; index += 1) {
206
+ const values = folded.flatMap(({ state }) => [...(state.requests.get(index)?.values() ?? [])]);
207
+ if (values.length > 0) {
208
+ otherPoints[index] = query.aggregation === "max"
209
+ ? Math.max(...values)
210
+ : values.reduce((sum, value) => sum + value, 0) / values.length;
211
+ }
212
+ }
213
+ }
214
+ kept.push({ id: "other", provider: "", model: "other", total: otherPoints.reduce((sum, value) => sum + value, 0), points: otherPoints });
215
+ }
216
+ return {
217
+ appliedFilters: {
218
+ models: query.models === null ? null : [...new Set(query.models)].sort(),
219
+ hiddenProviders: [...hiddenProviders].sort(),
220
+ },
221
+ start,
222
+ end,
223
+ bucketSeconds,
224
+ buckets,
225
+ metric: query.metric,
226
+ aggregation: query.aggregation,
227
+ grouping: query.grouping,
228
+ series: kept,
229
+ availableModels: [...availableModels].sort(),
230
+ missingMeasurements,
231
+ truncated: false,
232
+ };
233
+ }
234
+
235
+ return { add, finish };
236
+ }
@@ -35,6 +35,54 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-dest
35
35
  */
36
36
  export type VisionSidecarBackend = "openai" | "anthropic" | "routed";
37
37
 
38
+ /**
39
+ * Input modalities an explicit custom row declares for one routed model.
40
+ *
41
+ * A custom row is the operator's own definition of that model, so its declaration outranks the
42
+ * provider-level hints the predicates below read. Without this the two halves of one config
43
+ * disagreed in production: the catalog overlay in `src/codex/catalog/routed-gather.ts` copies
44
+ * `customModels[].inputModalities` onto the row and the dashboard showed "text, image", while
45
+ * the request path consulted only `providers[].noVisionModels` / `modelInputModalities` and
46
+ * stripped the image before dispatch. A model the operator had declared image-capable therefore
47
+ * received an omission marker instead of its attachment.
48
+ *
49
+ * Precedence, highest first: `modelCapabilities` (the documented per-model capability
50
+ * declaration), then this custom-row declaration, then `noVisionModels`, then
51
+ * `modelInputModalities`, then registry/vendor metadata. `modelCapabilities` stays on top
52
+ * because it is the dedicated capability axis and the CLI `--text-only` flag writes it; when the
53
+ * two explicit forms contradict each other the more specific axis wins.
54
+ *
55
+ * Matching is exact on the routed identity — provider name and native model id, the pair the
56
+ * custom-model API keys rows by. A row that declares no modalities returns `undefined` rather
57
+ * than `["text"]`, so it stays silent instead of turning into a text-only claim.
58
+ */
59
+ export function customRowInputModalities(
60
+ config: Pick<OcxConfig, "customModels">,
61
+ providerName: string,
62
+ modelId: string,
63
+ ): string[] | undefined {
64
+ for (const row of config.customModels ?? []) {
65
+ if (row.provider !== providerName || row.modelId !== modelId) continue;
66
+ if (Array.isArray(row.inputModalities) && row.inputModalities.length > 0) {
67
+ return [...row.inputModalities];
68
+ }
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * The custom row's verdict for one model, in the shape the predicates need:
75
+ * `true`/`false` when the row declares modalities, `undefined` when it declares none.
76
+ */
77
+ function customRowAcceptsImageInput(
78
+ config: Pick<OcxConfig, "customModels">,
79
+ providerName: string,
80
+ modelId: string,
81
+ ): boolean | undefined {
82
+ const declared = customRowInputModalities(config, providerName, modelId);
83
+ return declared === undefined ? undefined : declared.includes("image");
84
+ }
85
+
38
86
  /** The two sides every deployment has; also the empty-auth fallback set. */
39
87
  export type UniversalVisionBackend = "openai" | "anthropic";
40
88
 
@@ -60,6 +108,14 @@ export interface VisionCandidateModel {
60
108
  native?: boolean;
61
109
  }
62
110
 
111
+ /**
112
+ * The config slice the capability predicates need. `customModels` is optional so provider-only
113
+ * callers and unit tests keep compiling; a caller that omits it is simply silent about custom
114
+ * rows rather than wrong about them.
115
+ */
116
+ export type VisionCapabilityConfig =
117
+ Pick<OcxConfig, "providers"> & { customModels?: OcxConfig["customModels"] };
118
+
63
119
  export interface VisionModelOption {
64
120
  value: string;
65
121
  label: string;
@@ -142,20 +198,31 @@ function enrichedProviderForVision(
142
198
  }
143
199
 
144
200
  function isVisionSidecarConsumerWithCache(
145
- config: Pick<OcxConfig, "providers">,
201
+ config: VisionCapabilityConfig,
146
202
  providerName: string,
147
203
  modelId: string,
148
204
  cache: EnrichedProviderCache,
149
205
  ): boolean {
150
206
  const provider = enrichedProviderForVision(config, providerName, cache);
151
- return provider !== undefined && isModelVisionSidecarConsumer(provider, modelId);
207
+ if (provider === undefined) return false;
208
+ // Documented precedence, highest first: the dedicated per-model capability axis, then the
209
+ // operator's own custom row, then the provider-level hints. Reading the custom row ahead of
210
+ // `modelCapabilities` would let it override the more specific axis.
211
+ const capabilityDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
212
+ ? provider.modelCapabilities?.[modelId]?.inputModalities : undefined;
213
+ if (capabilityDeclared !== undefined) {
214
+ return capabilityDeclared.includes("text") && !capabilityDeclared.includes("image");
215
+ }
216
+ const customDeclared = customRowInputModalities(config, providerName, modelId);
217
+ if (customDeclared !== undefined) return customDeclared.includes("text") && !customDeclared.includes("image");
218
+ return isModelVisionSidecarConsumer(provider, modelId);
152
219
  }
153
220
 
154
221
  /**
155
222
  * Is this model listed as one the sidecar describes FOR? Such a model cannot be
156
223
  * the describer, and its advertised modalities are untrustworthy.
157
224
  */
158
- export function isVisionSidecarConsumer(config: Pick<OcxConfig, "providers">, providerName: string, modelId: string): boolean {
225
+ export function isVisionSidecarConsumer(config: VisionCapabilityConfig, providerName: string, modelId: string): boolean {
159
226
  return isVisionSidecarConsumerWithCache(config, providerName, modelId, new Map());
160
227
  }
161
228
 
@@ -165,23 +232,30 @@ export function isVisionSidecarConsumer(config: Pick<OcxConfig, "providers">, pr
165
232
  * which callers treat as eligible.
166
233
  */
167
234
  export function modelAcceptsImageInput(
168
- config: Pick<OcxConfig, "providers">,
235
+ config: VisionCapabilityConfig,
169
236
  candidate: VisionCandidateModel,
170
237
  ): boolean | undefined {
171
238
  return modelAcceptsImageInputWithCache(config, candidate, new Map());
172
239
  }
173
240
 
174
241
  function modelAcceptsImageInputWithCache(
175
- config: Pick<OcxConfig, "providers">,
242
+ config: VisionCapabilityConfig,
176
243
  candidate: VisionCandidateModel,
177
244
  cache: EnrichedProviderCache,
178
245
  ): boolean | undefined {
179
246
  if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) {
180
247
  const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache);
181
- if (nativeProvider && isModelVisionSidecarConsumer(nativeProvider, candidate.id)) return false;
182
248
  const declared = Object.hasOwn(nativeProvider?.modelCapabilities ?? {}, candidate.id)
183
249
  ? nativeProvider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
184
250
  if (declared !== undefined) return declared.includes("image");
251
+ // The catalog already lets an explicit custom row replace the native modality list for the
252
+ // same slug; consult it here too or the row would advertise what the request path ignores.
253
+ const fromCustomRow = customRowAcceptsImageInput(config, candidate.provider, candidate.id);
254
+ if (fromCustomRow !== undefined) return fromCustomRow;
255
+ // The sidecar hints come after both explicit declarations, matching the documented order.
256
+ // Ahead of them a `noVisionModels` membership would short-circuit to text-only before the
257
+ // operator's own row was read.
258
+ if (nativeProvider && isModelVisionSidecarConsumer(nativeProvider, candidate.id)) return false;
185
259
  return advertisesImageInput(nativeInputModalities(candidate.id)) ?? true;
186
260
  }
187
261
  if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false;
@@ -189,6 +263,11 @@ function modelAcceptsImageInputWithCache(
189
263
  const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id)
190
264
  ? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
191
265
  if (declared !== undefined) return declared.includes("image");
266
+ // Ahead of the provider's own hints: this row is the operator's definition of this exact model,
267
+ // and the catalog overlay reads the same field. Behind modelCapabilities, which is the dedicated
268
+ // capability axis the CLI `--text-only` writes.
269
+ const fromCustomRow = customRowAcceptsImageInput(config, candidate.provider, candidate.id);
270
+ if (fromCustomRow !== undefined) return fromCustomRow;
192
271
  const configuredModalities = provider ? modelRecordValue(provider.modelInputModalities, candidate.id) : undefined;
193
272
  const fromConfiguredModalities = advertisesImageInput(configuredModalities);
194
273
  if (fromConfiguredModalities !== undefined) return fromConfiguredModalities;
@@ -206,14 +285,14 @@ function modelAcceptsImageInputWithCache(
206
285
 
207
286
  /** Eligible = not a sidecar consumer, and not positively known to be text-only. */
208
287
  export function isVisionEligibleModel(
209
- config: Pick<OcxConfig, "providers">,
288
+ config: VisionCapabilityConfig,
210
289
  candidate: VisionCandidateModel,
211
290
  ): boolean {
212
291
  return isVisionEligibleModelWithCache(config, candidate, new Map());
213
292
  }
214
293
 
215
294
  function isVisionEligibleModelWithCache(
216
- config: Pick<OcxConfig, "providers">,
295
+ config: VisionCapabilityConfig,
217
296
  candidate: VisionCandidateModel,
218
297
  cache: EnrichedProviderCache,
219
298
  ): boolean {
@@ -274,7 +353,7 @@ function baselineCandidate(
274
353
  * backend, and only `value` reaches the client, so first-wins costs nothing.
275
354
  */
276
355
  export function visionEligibleModelOptions(
277
- config: Pick<OcxConfig, "providers">,
356
+ config: VisionCapabilityConfig,
278
357
  candidates: readonly VisionCandidateModel[],
279
358
  enabledBackends: readonly VisionSidecarBackend[],
280
359
  anthropicProviderName?: string,