@bitkyc08/opencodex 2.58.0 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -356,6 +356,22 @@ export interface OcxProviderConfig {
356
356
  * `ocxr1` envelopes are still stripped because no upstream can decrypt them.
357
357
  */
358
358
  preserveResponsesReasoningContent?: boolean;
359
+ /**
360
+ * Treat this provider's `modelReasoningEfforts` as authoritative at the wire, not only in the
361
+ * catalog. Adapters that ship their own per-model effort table (currently `command-code`)
362
+ * otherwise let that table win for models it knows, so a widened row is advertised in the
363
+ * picker and then stripped on the way out. Opt-in because presets are SEEDED with the shipped
364
+ * table: without a declared flag there is no way to tell an operator's row from a copy an
365
+ * older release persisted. A rung the upstream then refuses is returned as that error rather
366
+ * than silently retried without the effort, since the operator asked for it.
367
+ */
368
+ modelReasoningEffortsAuthoritative?: boolean;
369
+ /**
370
+ * Drop replayed Responses `reasoning` items from input history before forwarding.
371
+ * Some OpenAI-compatible Responses upstreams accept tool-call replay but reject
372
+ * reasoning output items when they are sent back on a continuation.
373
+ */
374
+ dropResponsesReasoningItems?: boolean;
359
375
  /**
360
376
  * Explicit opt-in for a relay that genuinely fronts OpenAI and can decode native
361
377
  * compaction blobs. Absent or false degrades foreign blobs to an opaque note.
@@ -618,6 +634,8 @@ export interface OcxProviderConfig {
618
634
  reasoningEfforts?: string[];
619
635
  /** Model-specific Codex-visible reasoning tiers. An empty array means “do not expose effort”. */
620
636
  modelReasoningEfforts?: Record<string, string[]>;
637
+ /** Catalog-only: do not synthesize a missing max rung for matching routed models. */
638
+ modelSuppressSyntheticMax?: Record<string, boolean>;
621
639
  /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */
622
640
  modelDefaultReasoningEfforts?: Record<string, string>;
623
641
  /** Operator-owned effort override; none omits effort and uses the provider default. */
@@ -677,6 +695,23 @@ export interface OcxProviderConfig {
677
695
  * apply_patch passthrough compatibility for OpenAI and unclassified gateways.
678
696
  */
679
697
  supportsResponsesCustomTools?: boolean;
698
+ /**
699
+ * Hosted tool declarations this Responses destination rejects, so they are stripped from
700
+ * the request instead of being forwarded and 400'd.
701
+ *
702
+ * This is how an OpenAI-compatible gateway with a narrower capability set than OpenAI
703
+ * describes itself. Before it existed, a destination that accepted plain Responses and
704
+ * `function` tools but rejected hosted `web_search` could only be handled by adding a
705
+ * hard-coded baseUrl rule to `src/responses/hosted-tool-policy.ts`, so every such gateway
706
+ * needed a proxy release; a text-only prompt like "Reply exactly with OK" failed before
707
+ * the model answered because the hosted declaration travelled with it (#5002).
708
+ *
709
+ * Values come from `DECLARABLE_HOSTED_TOOL_TYPES`. Spelling variants of one capability
710
+ * are aliased, so `["web_search"]` also denies `web_search_preview`. Pair this with
711
+ * `supportsResponsesCustomTools: false` for a gateway that also rejects native custom
712
+ * tools; the two capabilities are independent and denied independently.
713
+ */
714
+ unsupportedHostedTools?: string[];
680
715
  /**
681
716
  * Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical
682
717
  * fields or closing events (#893). Disabled by default and applied only to client-facing
@@ -882,6 +917,8 @@ export interface OcxProviderConfig {
882
917
  * "cloud-code-assist" = Google Antigravity (Cloud Code Assist) OAuth + CCA envelope.
883
918
  */
884
919
  googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
920
+ /** Google tool-schema compatibility policy. Omitted preserves compatible report-only behavior. */
921
+ googleToolSchemaPolicy?: "compatible" | "reject-lossy";
885
922
  /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */
886
923
  project?: string;
887
924
  /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */
@@ -68,6 +68,12 @@ export interface OcxParsedRequest {
68
68
  _cursorConversationId?: string;
69
69
  /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */
70
70
  _clientThreadId?: string;
71
+ /**
72
+ * This request's OWN Codex thread id (`thread-id`), as opposed to `_clientThreadId`, which
73
+ * carries `x-codex-parent-thread-id` and is therefore shared by every parallel child of one
74
+ * parent. Only a surface that must distinguish siblings should read it.
75
+ */
76
+ _codexOwnThreadId?: string;
71
77
  /** True when promptCacheKey identifies a shared cache cohort rather than one conversation. */
72
78
  _promptCacheKeyIsSharedCohort?: boolean;
73
79
  /** Cursor-only thread owner; may be an opaque process-local Desktop session/thread identity. */
@@ -120,6 +126,8 @@ export interface OcxParsedRequest {
120
126
  * (see src/responses/compaction.ts).
121
127
  */
122
128
  _compactionRequest?: boolean;
129
+ /** Manual compaction moved to another provider: summarize portably even on a canonical ChatGPT target. */
130
+ _portableCompaction?: boolean;
123
131
  /**
124
132
  * True when the current request newly introduced a stored compaction summary/marker. Historical
125
133
  * markers restored by previous_response_id expansion were already acknowledged and do not reset
@@ -312,7 +320,7 @@ export interface OcxProviderContinuationState {
312
320
  }
313
321
 
314
322
  export type AdapterEvent =
315
- | { type: "heartbeat" }
323
+ | { type: "heartbeat"; replayUnsafe?: true }
316
324
  | { type: "text_delta"; text: string; phase?: OcxMessagePhase }
317
325
  | { type: "thinking_delta"; thinking: string }
318
326
  // Anthropic extended-thinking round-trip: signature_delta for the current thinking block, and
@@ -67,6 +67,20 @@ const CODE_MODE_HELPER_TOOL_NAMES = [
67
67
  */
68
68
  export const CODE_MODE_EXEC_TOOL_NAME = "exec";
69
69
 
70
+ /**
71
+ * The nested-helper spellings, as a membership view of the same list.
72
+ *
73
+ * A code-mode catalog never DECLARES any of them — they exist only as `tools.<helper>(...)` inside
74
+ * `exec` — so a recorded call under one of these names can only have come from a provider echoing
75
+ * the helper, which is what makes the set usable as a bounded recovery vocabulary for stored
76
+ * history (#5095). Kept beside the tuple it is built from so the two can never drift; this is a
77
+ * different question from `NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES` below, which also covers `exec`
78
+ * itself because declaring THAT name is what turns normalization on.
79
+ */
80
+ export const CODE_MODE_HELPER_WIRE_NAMES: ReadonlySet<string> = new Set<string>(
81
+ CODE_MODE_HELPER_TOOL_NAMES,
82
+ );
83
+
70
84
  /**
71
85
  * Spellings that may never be MANUFACTURED as a bare alias for a namespaced tool.
72
86
  *
package/src/types/wire.ts CHANGED
@@ -46,7 +46,15 @@ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet<string> = new Set([
46
46
  * Anthropic for these models.
47
47
  */
48
48
  const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {
49
- "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]),
49
+ "opencode-go": new Set([
50
+ "minimax-m2.5",
51
+ "minimax-m2.7",
52
+ "minimax-m3",
53
+ // OpenCode's catalog identifies Union Alpha as @ai-sdk/anthropic while the
54
+ // provider defaults to OpenAI-compatible; direct Chat returns 500 and direct
55
+ // Messages reaches the session check (#4847).
56
+ "union-alpha",
57
+ ]),
50
58
  };
51
59
 
52
60
  function anthropicWireModelsForProvider(providerName: string): ReadonlySet<string> | undefined {
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  export type { OcxTool, OcxToolChoice } from "./types/tools";
5
5
  export {
6
6
  CODE_MODE_EXEC_TOOL_NAME,
7
+ CODE_MODE_HELPER_WIRE_NAMES,
7
8
  dottedToolName,
8
9
  namespacedToolName,
9
10
  normalizeDeclaredToolName,
@@ -124,6 +124,16 @@ const META_MUSE_SPARK_13_CONTRIBUTOR: Cost4 = { input: 0.1, output: 0.2, cacheRe
124
124
  const META_SPARK_SOURCE = `Meta Model API published price ${META_MODEL_PRICING}`;
125
125
  const META_SPARK_CONTRIBUTOR_SOURCE = `Meta Model API published Contributor-tier price ${META_MODEL_PRICING}; data-sharing discount tier`;
126
126
  const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-details-usd; V4 Flash alias transition scheduled 2026-07-24 — re-verify after";
127
+ /*
128
+ * DeepSeek V4.1-Flash list prices (USD / 1M tokens), verified 2026-09-17 against
129
+ * https://api-docs.deepseek.com/quick_start/pricing. The page prices a peak window
130
+ * (09:30-24:00 Beijing) and an off-peak window; the tuple below is the peak-window
131
+ * list rate and the off-peak discount (0.15 / 0.60, cache-hit 0.003) is deliberately
132
+ * not baked in — the same rule as the Devin time-boxed promos. cacheWrite=0 follows
133
+ * the existing deepseek-chat / deepseek-reasoner rows: DeepSeek publishes no
134
+ * cache-write charge.
135
+ */
136
+ const DEEPSEEK_V41_FLASH: Cost4 = { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 };
127
137
  // Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the
128
138
  // cache-miss input price (Kimi auto-caches with no separate write billing). 2026-07-20 re-verified.
129
139
  const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (official table; cacheWrite derived = input, Kimi auto-cache has no write billing)";
@@ -142,6 +152,14 @@ const BIGMODEL_NOTE = "z.ai international list price shown as estimate; domestic
142
152
  // anywhere. Cache stays 0 rather than inheriting the reseller's 0.15 — a reseller number
143
153
  // under a vendor-price label would be a wrong value wearing a verified badge.
144
154
  const QWEN38_MAX_PRICING = "https://qwen.ai/blog?id=qwen3.8 (Qwen release announcement; no Model Studio billing row yet; cache rates unpublished -> 0)";
155
+ // Qwen-published qwen3.8-flash rate ($0.16 in / $0.47 out per 1M tokens) via the
156
+ // Qwen3.8 release announcement, corroborated by API-vendor price tables. No cache
157
+ // rate is published anywhere, so cache stays 0 rather than borrowing a reseller's
158
+ // number — the same hold QWEN38_MAX takes. The announcement marks API availability
159
+ // as coming soon, but the id is already served (and logged) on OpenCode Go, so the
160
+ // estimate applies to real usage rows now.
161
+ const QWEN38_FLASH: Cost4 = { input: 0.16, output: 0.47, cacheRead: 0, cacheWrite: 0 };
162
+ const QWEN38_FLASH_PRICING = "https://qwen.ai/blog?id=qwen3.8-2026 (Qwen release announcement; API marked coming soon at announcement; cache rates unpublished -> 0; input/output corroborated by https://docs.b.ai/guides/models/qwen/qwen3.8-flash)";
145
163
 
146
164
  /*
147
165
  * Cognition/Devin list prices (USD / 1M tokens), verified 2026-09-13 against the
@@ -289,6 +307,16 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
289
307
  // for what that source does and does not cover.
290
308
  { provider: "alibaba-token-plan", modelId: "qwen3.8-max", cost4: QWEN38_MAX, source: QWEN38_MAX_PRICING, verifiedAt: "2026-08-04", status: "verified" },
291
309
  { provider: "alibaba-token-plan-intl", modelId: "qwen3.8-max", cost4: QWEN38_MAX, source: QWEN38_MAX_PRICING, verifiedAt: "2026-08-04", status: "verified" },
310
+ // OpenCode Go — five served ids with no jawcode bundle row and no vendor-level
311
+ // fallback (the fallback only searches jawcode metadata, never overlays), so the
312
+ // Usage estimated-cost column and the per-model breakdown rendered an em dash for
313
+ // every request through them. Each row reuses the vendor's own published list
314
+ // price as an estimate: Go itself is subscription-billed, hence verified-derived.
315
+ { provider: "opencode-go", modelId: "qwen3.8-max", cost4: QWEN38_MAX, source: `vendor list price applied to the OpenCode Go surface; ${QWEN38_MAX_PRICING}`, verifiedAt: "2026-09-17", status: "verified-derived" },
316
+ { provider: "opencode-go", modelId: "qwen3.8-flash", cost4: QWEN38_FLASH, source: `vendor list price applied to the OpenCode Go surface; ${QWEN38_FLASH_PRICING}`, verifiedAt: "2026-09-17", status: "verified-derived" },
317
+ { provider: "opencode-go", modelId: "deepseek-v4.1-flash", cost4: DEEPSEEK_V41_FLASH, source: `peak-window list rate applied to the OpenCode Go surface (off-peak 0.15/0.60 + cache-hit 0.003 not baked in); ${DEEPSEEK_PRICING}`, verifiedAt: "2026-09-17", status: "verified-derived" },
318
+ { provider: "opencode-go", modelId: "glm-5.3-flash", cost4: GLM_53_FLASH, source: `z.ai list price applied to the OpenCode Go surface as an estimate; ${ZAI_PRICING}`, verifiedAt: "2026-09-17", status: "verified-derived" },
319
+ { provider: "opencode-go", modelId: "muse-spark-1.3-contributor", cost4: META_MUSE_SPARK_13_CONTRIBUTOR, source: `Meta Model API Contributor-tier price applied to the OpenCode Go surface as an estimate; ${META_SPARK_CONTRIBUTOR_SOURCE}`, verifiedAt: "2026-09-17", status: "verified-derived" },
292
320
  // Cursor Auto router — Cursor's published fixed token price (verified).
293
321
  { provider: "cursor", modelId: "auto", cost4: { input: 1.25, output: 6, cacheRead: 0.25, cacheWrite: 1.25 }, source: "https://docs.cursor.com/account/pricing + https://cursor.com/blog/aug-2025-pricing", verifiedAt: "2026-07-20", status: "verified" },
294
322
  // Z.AI GLM family — the zai bundle's rows are all-zero upstream, and the four
package/src/usage/log.ts CHANGED
@@ -77,6 +77,26 @@ export type AttemptRecoveryKind =
77
77
  | "empty-completion"
78
78
  | "reasoning-effort-downgrade";
79
79
 
80
+ /**
81
+ * Why a recovery this request was otherwise willing to make did not happen.
82
+ *
83
+ * Recorded separately from `recoveryKinds` and from `sendCount`, because the question it
84
+ * answers is different from either. A log showing one physical send and no recovery kind used
85
+ * to be ambiguous: it could mean nothing was eligible, or that something was eligible and the
86
+ * send budget withheld it. Those need opposite follow-ups, and the second one was invisible
87
+ * (#5044).
88
+ *
89
+ * `sendCount` deliberately does not move for these. A refused attempt is not a physical send,
90
+ * and inflating the count to signal the refusal would corrupt the one number that means
91
+ * "requests this proxy actually made".
92
+ *
93
+ * Bounded vocabulary on purpose: it is a wire value a maintainer reads, never a credential, an
94
+ * account id, an upstream body, prompt content, or exception text.
95
+ */
96
+ export type AttemptRecoveryWithheld =
97
+ | "retry-send-budget"
98
+ | "rotation-send-budget";
99
+
80
100
  /** Request-time upstream credential class, never a credential or account identifier. */
81
101
  export type UsageCredentialSource = "grok-oauth" | "xai-api-key";
82
102
 
@@ -139,6 +159,11 @@ export interface PersistedUsageAttempt {
139
159
  firstOutputMs?: number;
140
160
  sendCount: number;
141
161
  recoveryKinds: AttemptRecoveryKind[];
162
+ /**
163
+ * Recoveries this attempt was eligible for and did not make. Absent on ordinary attempts so
164
+ * old rows keep their exact shape.
165
+ */
166
+ recoveryWithheld?: AttemptRecoveryWithheld[];
142
167
  usageStatus: UsageStatus;
143
168
  /**
144
169
  * True when the proxy answered this turn locally and issued no upstream request. It travels on
@@ -487,6 +512,10 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
487
512
  "empty-completion",
488
513
  "reasoning-effort-downgrade",
489
514
  ]);
515
+ const ATTEMPT_RECOVERY_WITHHELD = new Set<AttemptRecoveryWithheld>([
516
+ "retry-send-budget",
517
+ "rotation-send-budget",
518
+ ]);
490
519
  const USAGE_STATUSES = new Set<UsageStatus>([
491
520
  "reported",
492
521
  "unreported",
@@ -620,6 +649,14 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
620
649
  && ATTEMPT_RECOVERY_KINDS.has(value as AttemptRecoveryKind),
621
650
  ))]
622
651
  : [];
652
+ // Same shape as `recoveryKinds`: unknown values are dropped rather than failing the row, so a
653
+ // log written by a newer build stays readable by an older one.
654
+ const recoveryWithheld = Array.isArray(attempt.recoveryWithheld)
655
+ ? [...new Set(attempt.recoveryWithheld.filter(
656
+ (value): value is AttemptRecoveryWithheld => typeof value === "string"
657
+ && ATTEMPT_RECOVERY_WITHHELD.has(value as AttemptRecoveryWithheld),
658
+ ))]
659
+ : [];
623
660
  return {
624
661
  ordinal: attempt.ordinal as number,
625
662
  provider: attempt.provider,
@@ -639,6 +676,7 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
639
676
  : {}),
640
677
  sendCount: attempt.sendCount as number,
641
678
  recoveryKinds,
679
+ ...(recoveryWithheld.length ? { recoveryWithheld } : {}),
642
680
  usageStatus: attempt.usageStatus as UsageStatus,
643
681
  ...(isCodexUsageAccountLogLabel(attempt.accountLogLabel)
644
682
  ? { accountLogLabel: attempt.accountLogLabel }
@@ -852,18 +890,61 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
852
890
  };
853
891
  }
854
892
 
855
- function ensureUsageLogDir(): void {
893
+ // Bound hot-path filesystem hardening to once per second while ensuring an external mode
894
+ // widening cannot suppress write-triggered repair for the lifetime of the process.
895
+ const USAGE_LOG_PERMISSION_RECHECK_MS = 1_000;
896
+
897
+ type UsageLogPermissionCheck = {
898
+ path: string;
899
+ checkedAt: number;
900
+ };
901
+
902
+ let ensuredUsageLogDir: UsageLogPermissionCheck | null = null;
903
+ let ensuredUsageLogFile: UsageLogPermissionCheck | null = null;
904
+
905
+ function usageLogPermissionCheckIsCurrent(
906
+ check: UsageLogPermissionCheck | null,
907
+ path: string,
908
+ now: number,
909
+ ): boolean {
910
+ return check?.path === path
911
+ && now >= check.checkedAt
912
+ && now - check.checkedAt < USAGE_LOG_PERMISSION_RECHECK_MS;
913
+ }
914
+
915
+ function ensureUsageLogDir(now: number): void {
856
916
  const dir = getConfigDir();
917
+ if (usageLogPermissionCheckIsCurrent(ensuredUsageLogDir, dir, now)) return;
857
918
  recordOwnedConfigPath(dir, usageLogPath());
858
919
  mkdirSync(dir, { recursive: true, mode: 0o700 });
859
920
  try { chmodSync(dir, 0o700); } catch { /* best-effort on platforms that ignore chmod */ }
921
+ ensuredUsageLogDir = { path: dir, checkedAt: now };
860
922
  }
861
923
 
862
924
  export function appendUsageEntry(entry: PersistedUsageEntry): void {
863
- ensureUsageLogDir();
925
+ const line = `${JSON.stringify(normalizeUsageEntry(entry))}\n`;
864
926
  const path = usageLogPath();
865
- appendFileSync(path, `${JSON.stringify(normalizeUsageEntry(entry))}\n`, { encoding: "utf-8", mode: 0o600 });
866
- try { chmodSync(path, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
927
+ const now = Date.now();
928
+ const doAppend = (): void => {
929
+ ensureUsageLogDir(now);
930
+ const filePermissionsCurrent = usageLogPermissionCheckIsCurrent(ensuredUsageLogFile, path, now);
931
+ appendFileSync(path, line, { encoding: "utf-8", mode: 0o600 });
932
+ if (!filePermissionsCurrent) {
933
+ try { chmodSync(path, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
934
+ ensuredUsageLogFile = { path, checkedAt: now };
935
+ }
936
+ };
937
+ try {
938
+ doAppend();
939
+ } catch (error: any) {
940
+ if (error?.code === "ENOENT") {
941
+ ensuredUsageLogDir = null;
942
+ ensuredUsageLogFile = null;
943
+ doAppend();
944
+ return;
945
+ }
946
+ throw error;
947
+ }
867
948
  }
868
949
 
869
950
  export type UsageLogRevision = {
@@ -1057,6 +1138,8 @@ export function resetUsageReadCacheForTests(): void {
1057
1138
  managementUsageReadInflight?.abort.abort();
1058
1139
  managementUsageReadInflight = null;
1059
1140
  retainedUsageSnapshot = null;
1141
+ ensuredUsageLogDir = null;
1142
+ ensuredUsageLogFile = null;
1060
1143
  }
1061
1144
 
1062
1145
  function readExactly(fd: number, length: number, position: number): Buffer | null {
@@ -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,
@@ -5,7 +5,11 @@ import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
5
5
  import type { CodexAuthPolicyConfig } from "../codex/auth-context";
6
6
  import { isCodexReserveRequestEligible } from "../codex/loopback-target";
7
7
  import type { DataPlaneAdmission } from "../server/auth-cors";
8
- import { isModelVisionSidecarConsumer as isModelTextOnly, modelAcceptsImageInput } from "./eligibility";
8
+ import {
9
+ customRowInputModalities,
10
+ isModelVisionSidecarConsumer as isModelTextOnly,
11
+ modelAcceptsImageInput,
12
+ } from "./eligibility";
9
13
  import { normalizeVisionReasoningForModel } from "./reasoning";
10
14
  import { resolveSidecarAuth } from "../sidecar/auth";
11
15
  import { DEFAULT_VISION_TIMEOUT_MS, MAX_VISION_TIMEOUT_MS, MIN_VISION_TIMEOUT_MS } from "./timeout-bounds";
@@ -95,23 +99,40 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
95
99
  /**
96
100
  * Direct-image admission for a routed target. Returns true when capability evidence proves the
97
101
  * target cannot accept image input, so the caller must describe or strip the image first.
98
- * Explicit text-only config, an explicit per-model modality list without `image`, and
99
- * proven-negative registry/vendor metadata each require the vision preprocessor. A genuinely
100
- * unknown custom model is NOT guessed blind: it keeps the established pass-through behaviour.
101
- * The provider-only fallback keeps legacy unit callers stable; production dispatch always
102
- * supplies providerName so the complete capability chain is consulted.
102
+ * Evidence is consulted highest-first: `modelCapabilities` (the dedicated per-model capability
103
+ * axis), an explicit custom row for the same routed identity, `noVisionModels`, an explicit
104
+ * per-model modality list without `image`, and finally proven-negative registry/vendor metadata.
105
+ * Any of them can require the vision preprocessor. A genuinely unknown custom model is NOT
106
+ * guessed blind: it keeps the established pass-through behaviour. The provider-only fallback
107
+ * keeps legacy unit callers stable; production dispatch always supplies providerName so the
108
+ * complete capability chain is consulted.
103
109
  */
104
110
  export function requiresVisionPreprocessing(
105
- config: Pick<OcxConfig, "providers">,
111
+ config: Pick<OcxConfig, "providers"> & { customModels?: OcxConfig["customModels"] },
106
112
  provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities" | "modelCapabilities">,
107
113
  modelId: string,
108
114
  providerName?: string,
109
115
  ): boolean {
110
- if (isModelTextOnly(provider, modelId)) return true;
111
116
  const runtimeDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
112
117
  ? provider.modelCapabilities?.[modelId]?.inputModalities
113
118
  : undefined;
119
+ // `modelCapabilities` is the dedicated per-model capability axis, so it outranks everything
120
+ // below — including an explicit custom row that disagrees. The CLI `--text-only` flag writes it.
114
121
  if (runtimeDeclared !== undefined) return !runtimeDeclared.includes("image");
122
+ // An explicit custom row outranks the provider-level hints below, mirroring the catalog overlay
123
+ // in `src/codex/catalog/routed-gather.ts` that already copies this same declaration onto the
124
+ // advertised row. Without it the dashboard advertised "text, image" from the operator's own row
125
+ // while this predicate stripped the image before dispatch.
126
+ const customDeclared = providerName === undefined
127
+ ? undefined
128
+ : customRowInputModalities(config, providerName, modelId);
129
+ // Same rule as the `modelCapabilities` branch above: the declaration answers "can this model
130
+ // take an image", not "is it a text model". A row that lists only `audio` or `video` excludes
131
+ // image input just as `["text"]` does, and `modelAcceptsImageInput` already answers "no image"
132
+ // for it; the narrower `includes("text")` test here made the two predicates disagree and sent
133
+ // the attachment to a model that cannot read it.
134
+ if (customDeclared !== undefined) return !customDeclared.includes("image");
135
+ if (isModelTextOnly(provider, modelId)) return true;
115
136
  const runtimeModalities = modelRecordValue(provider.modelInputModalities, modelId);
116
137
  if (Array.isArray(runtimeModalities) && runtimeModalities.length > 0) {
117
138
  return !runtimeModalities.includes("image");
@@ -129,9 +150,12 @@ function usableRoutedVisionModel(config: OcxConfig): string | undefined {
129
150
  if (!routedModel || sep <= 0) return undefined;
130
151
  const targetProvider = routedModel.slice(0, sep);
131
152
  const targetId = routedModel.slice(sep + 1);
132
- const targetProviderConfig = config.providers?.[targetProvider];
153
+ // `modelAcceptsImageInput` is the one predicate here: it resolves the whole capability chain,
154
+ // custom row included. The provider-only `isModelTextOnly` that used to be ANDed in could only
155
+ // subtract — it never saw a custom row, so a describer the operator had declared image-capable
156
+ // was refused for a provider hint that same row overrides.
133
157
  return modelAcceptsImageInput(config, { provider: targetProvider, id: targetId }) !== false
134
- && !(targetProviderConfig && isModelTextOnly(targetProviderConfig, targetId)) ? routedModel : undefined;
158
+ ? routedModel : undefined;
135
159
  }
136
160
 
137
161
  export function shouldResolveOpenAiVisionSidecar(