@bitkyc08/opencodex 2.59.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 (194) hide show
  1. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  2. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -1
  5. package/src/adapters/anthropic-image-codec.ts +16 -2
  6. package/src/adapters/anthropic-image-normalize.ts +49 -2
  7. package/src/adapters/anthropic.ts +4 -1
  8. package/src/adapters/base.ts +23 -0
  9. package/src/adapters/coding-agent/turn.ts +22 -2
  10. package/src/adapters/command-code.ts +50 -3
  11. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  12. package/src/adapters/cursor/discovery.ts +11 -8
  13. package/src/adapters/cursor/live-transport.ts +26 -9
  14. package/src/adapters/cursor/request-builder.ts +7 -1
  15. package/src/adapters/cursor/transport.ts +19 -0
  16. package/src/adapters/cursor.ts +3 -1
  17. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  18. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  19. package/src/adapters/devin.ts +50 -13
  20. package/src/adapters/google-antigravity-replay.ts +1 -1
  21. package/src/adapters/google-antigravity-wire.ts +29 -5
  22. package/src/adapters/google-http.ts +49 -10
  23. package/src/adapters/google-tool-schema.ts +595 -31
  24. package/src/adapters/google-wire-compiler.ts +93 -10
  25. package/src/adapters/google-wire-shape.ts +461 -0
  26. package/src/adapters/google.ts +37 -6
  27. package/src/adapters/openai-chat-images.ts +3 -1
  28. package/src/adapters/openai-chat.ts +5 -1
  29. package/src/adapters/openai-responses/image-gen.ts +8 -6
  30. package/src/adapters/openai-responses/passthrough.ts +15 -3
  31. package/src/adapters/openai-responses/reasoning.ts +7 -0
  32. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  33. package/src/bridge/sse.ts +3 -151
  34. package/src/cli/account-extended.ts +4 -4
  35. package/src/cli/dispatch.ts +3 -3
  36. package/src/cli/doctor.ts +28 -9
  37. package/src/cli/hub.ts +3 -2
  38. package/src/cli/index.ts +7 -2
  39. package/src/cli/opencode.ts +2 -2
  40. package/src/cli/provider.ts +13 -1
  41. package/src/client/machine-api.ts +2 -2
  42. package/src/client/machine-listener.ts +2 -2
  43. package/src/client/runtime.ts +26 -2
  44. package/src/codex/account-store.ts +65 -0
  45. package/src/codex/auth-api/account-list.ts +19 -11
  46. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  47. package/src/codex/catalog/gather-capture.ts +21 -2
  48. package/src/codex/catalog/model-hints.ts +29 -28
  49. package/src/codex/catalog/parsing.ts +7 -0
  50. package/src/codex/catalog/provider-models.ts +19 -2
  51. package/src/codex/catalog/retained-sync.ts +22 -26
  52. package/src/codex/catalog/routed-gather.ts +19 -0
  53. package/src/codex/context-compat.ts +5 -2
  54. package/src/codex/desired-state.ts +4 -1
  55. package/src/codex/history-job.ts +6 -6
  56. package/src/codex/history-provider.ts +20 -166
  57. package/src/codex/history-rollout-read.ts +174 -0
  58. package/src/codex/internal/catalog-writer.ts +33 -1
  59. package/src/codex/model-cache.ts +47 -0
  60. package/src/codex/model-entitlements.ts +29 -10
  61. package/src/codex/observed-model-denials.ts +101 -8
  62. package/src/codex/prompt-text-probe.ts +9 -6
  63. package/src/codex/routing.ts +7 -1
  64. package/src/codex/shim.ts +1 -1
  65. package/src/codex/subagent-model-fallback.ts +22 -4
  66. package/src/combos/failover.ts +3 -0
  67. package/src/config/admitted-identity.ts +222 -0
  68. package/src/config/diagnostics.ts +22 -1
  69. package/src/config/feature-flags.ts +5 -0
  70. package/src/config/load-degrade.ts +18 -0
  71. package/src/config/proxy-env.ts +8 -2
  72. package/src/config/schema/compaction-triggers.ts +11 -0
  73. package/src/config/schema/config-schema.ts +4 -0
  74. package/src/config/schema/leaf-validators.ts +12 -0
  75. package/src/config.ts +2 -2
  76. package/src/generated/compatibility-version.json +249 -169
  77. package/src/grok/reset-coupons.ts +38 -19
  78. package/src/images/loop.ts +6 -1
  79. package/src/integrations/aside-profile-context.ts +37 -3
  80. package/src/integrations/aside-profile-journal.ts +68 -3
  81. package/src/integrations/aside-profiles.ts +128 -3
  82. package/src/integrations/mutation-plan.ts +815 -0
  83. package/src/integrations/writer.ts +85 -99
  84. package/src/lab/live/transport.ts +4 -0
  85. package/src/lab/live/types.ts +5 -0
  86. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  87. package/src/lib/admin-secrets.ts +9 -1
  88. package/src/lib/debug-log-buffer.ts +6 -1
  89. package/src/lib/debug.ts +23 -0
  90. package/src/lib/errors.ts +79 -0
  91. package/src/lib/http-response-semantics.ts +57 -0
  92. package/src/lib/lab-live-pinned-sender.ts +26 -12
  93. package/src/lib/pinned-http.ts +142 -2
  94. package/src/lib/plain-data.ts +103 -0
  95. package/src/lib/process-control.ts +13 -5
  96. package/src/lib/provider-outbound.ts +50 -2
  97. package/src/lib/socks5-fetch.ts +136 -26
  98. package/src/lib/spend-ledger-owner.ts +364 -0
  99. package/src/lib/spend-reservation-ledger.ts +218 -27
  100. package/src/lib/windows-system-proxy.ts +16 -11
  101. package/src/oauth/callback-server.ts +4 -3
  102. package/src/oauth/generic-account-failover.ts +1 -0
  103. package/src/oauth/health.ts +12 -1
  104. package/src/oauth/index.ts +3 -107
  105. package/src/oauth/login-flow-state.ts +127 -0
  106. package/src/providers/derive.ts +34 -17
  107. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  108. package/src/providers/key-failover.ts +66 -18
  109. package/src/providers/model-rename-migration.ts +55 -1
  110. package/src/providers/model-rename-startup.ts +7 -5
  111. package/src/providers/openai-virtual-models.ts +42 -2
  112. package/src/providers/quota/antigravity.ts +22 -2
  113. package/src/providers/quota/vendor-probes-key.ts +1 -1
  114. package/src/providers/registry/entries-core.ts +39 -17
  115. package/src/providers/registry/entries-extended.ts +35 -1
  116. package/src/providers/registry/model-ids.ts +168 -0
  117. package/src/providers/registry/model-seeds.ts +9 -0
  118. package/src/providers/registry/types.ts +2 -0
  119. package/src/providers/resolved-model-policy-merge.ts +167 -0
  120. package/src/providers/resolved-model-policy.ts +406 -0
  121. package/src/providers/stale-vision-classification-migration.ts +137 -0
  122. package/src/responses/apply-patch-envelope.ts +0 -12
  123. package/src/responses/freeform-wrapper-scan.ts +279 -0
  124. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  125. package/src/responses/progressive-freeform-input.ts +130 -0
  126. package/src/responses/reasoning-envelope.ts +30 -0
  127. package/src/responses/state.ts +5 -12
  128. package/src/responses/tool-name-aliases.ts +15 -1
  129. package/src/router.ts +91 -115
  130. package/src/routing/compatibility/behavior.ts +9 -0
  131. package/src/routing/compatibility/subject.ts +16 -1
  132. package/src/server/adapter-resolve.ts +9 -0
  133. package/src/server/auth-cors.ts +3 -0
  134. package/src/server/chat-completions.ts +5 -2
  135. package/src/server/claude-messages.ts +6 -3
  136. package/src/server/effort-row.ts +11 -3
  137. package/src/server/grok-responses-control-frame.ts +160 -1
  138. package/src/server/index/serve-options.ts +86 -29
  139. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  140. package/src/server/index/websocket-handler.ts +6 -1
  141. package/src/server/index.ts +16 -13
  142. package/src/server/management/aside-profile-routes.ts +266 -7
  143. package/src/server/management/config-routes.ts +18 -2
  144. package/src/server/management/context.ts +3 -0
  145. package/src/server/management/integration-routes.ts +287 -5
  146. package/src/server/management/metrics-routes.ts +20 -0
  147. package/src/server/management/model-rows.ts +224 -12
  148. package/src/server/management/route-registry.ts +14 -0
  149. package/src/server/management/shared.ts +10 -3
  150. package/src/server/management/system-restart.ts +7 -2
  151. package/src/server/management/system-routes.ts +2 -0
  152. package/src/server/management/usage-aggregate-cache.ts +4 -0
  153. package/src/server/management-api.ts +2 -0
  154. package/src/server/management-auth.ts +15 -1
  155. package/src/server/readiness.ts +29 -10
  156. package/src/server/relay-eager.ts +24 -2
  157. package/src/server/relay.ts +119 -10
  158. package/src/server/request-log.ts +55 -2
  159. package/src/server/request-metrics.ts +236 -0
  160. package/src/server/responses/adapter-continuation.ts +3 -3
  161. package/src/server/responses/adapter-dispatch.ts +11 -6
  162. package/src/server/responses/compact.ts +32 -10
  163. package/src/server/responses/compaction-routing.ts +111 -0
  164. package/src/server/responses/core-codex-account.ts +8 -3
  165. package/src/server/responses/core-combo.ts +7 -7
  166. package/src/server/responses/core-normalize.ts +6 -12
  167. package/src/server/responses/core-opaque-recovery.ts +1 -0
  168. package/src/server/responses/core-options.ts +4 -0
  169. package/src/server/responses/encrypted-payload.ts +20 -2
  170. package/src/server/responses/passthrough-delivery.ts +31 -14
  171. package/src/server/responses/passthrough-dispatch.ts +36 -9
  172. package/src/server/responses/policy-fallback.ts +5 -13
  173. package/src/server/responses/request-prepare.ts +67 -17
  174. package/src/server/responses/request-send-budget.ts +5 -1
  175. package/src/server/responses/request-sidecar-auth.ts +1 -1
  176. package/src/server/responses/request-transport.ts +2 -2
  177. package/src/server/responses/run-turn-execution.ts +25 -3
  178. package/src/server/responses/sidecar-execution.ts +17 -2
  179. package/src/server/responses/ws-upstream.ts +14 -27
  180. package/src/server/responses-custom-tool-repair.ts +27 -54
  181. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  182. package/src/server/sse-payload-rewrite.ts +1 -1
  183. package/src/tray/windows-tray.ps1 +155 -3
  184. package/src/types/config.ts +11 -3
  185. package/src/types/provider.ts +18 -0
  186. package/src/types/request.ts +2 -0
  187. package/src/types/tools.ts +14 -0
  188. package/src/types.ts +1 -0
  189. package/src/vision/eligibility.ts +88 -9
  190. package/src/vision/plan.ts +34 -10
  191. package/src/web-search/executor.ts +41 -2
  192. package/src/web-search/loop.ts +6 -1
  193. package/gui/dist/assets/index-C5IebErG.js +0 -136
  194. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -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(
@@ -3,7 +3,14 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
3
  import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
4
4
  import { redactSecretString } from "../lib/redact";
5
5
  import { sidecarEnter } from "../lib/sidecar-tracker";
6
- import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry";
6
+ import {
7
+ applyUpstreamRecoveryInit,
8
+ fetchWithResetRetry,
9
+ releaseResponseBodyBestEffort,
10
+ retryBackoffDelayMs,
11
+ sleepWithAbort,
12
+ RETRY_AFTER_CEILING_MS,
13
+ } from "../lib/upstream-retry";
7
14
  import { withUpstreamHttpVersion } from "../lib/upstream-http-version";
8
15
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
9
16
  import type { CodexUpstreamOutcome } from "../codex/routing";
@@ -35,6 +42,22 @@ export const IMAGE_INSTRUCTION =
35
42
 
36
43
  /** A search result, or an `error` string when the search couldn't run (surfaced as a tool result). */
37
44
  export type SidecarOutcome = WebSearchResult & { error?: string };
45
+
46
+ /**
47
+ * Bounded same-search 429 replays for the sidecar POST.
48
+ *
49
+ * The forward backend throttles burst sidecar traffic, and without a replay the 429 becomes a
50
+ * failed tool result that poisons the query for the whole turn (see failedQueries in loop.ts).
51
+ * 1 initial send + 2 replays; Retry-After is honored as a lower bound and capped by
52
+ * RETRY_AFTER_CEILING_MS (an instruction past the ceiling ends with the 429 instead of
53
+ * parking the search). Each wait releases the unread 429 body first so sockets do not
54
+ * accumulate under a rate-limit storm. Abort or timeout ends the wait through the existing
55
+ * catch, exactly like an abort during the SSE parse.
56
+ */
57
+ const SIDECAR_429_MAX_ATTEMPTS = 3;
58
+ const SIDECAR_429_BASE_DELAY_MS = 1_000;
59
+ const SIDECAR_429_MAX_DELAY_MS = 10_000;
60
+
38
61
  export type SidecarOutcomeRecorder = (outcome: CodexUpstreamOutcome) => void;
39
62
 
40
63
  /**
@@ -79,7 +102,7 @@ export async function runWebSearch(
79
102
  const sidecarExit = sidecarEnter("web-search");
80
103
  const t0 = Date.now();
81
104
  try {
82
- const res = await fetchWithResetRetry(
105
+ const sendOnce = () => fetchWithResetRetry(
83
106
  // Recovery nests INSIDE the version helper: applyUpstreamRecoveryInit then always receives a
84
107
  // defined init, and withUpstreamHttpVersion spreads the result, so `protocol` and the
85
108
  // recovery fields (`connection: close` + Bun's transport-level `keepalive: false`) survive
@@ -96,6 +119,22 @@ export async function runWebSearch(
96
119
  }, recovery), forwardProvider)),
97
120
  { replaySafe: true, abortSignal: linkedSignal.signal, label: "web-search-sidecar" },
98
121
  );
122
+ let res = await sendOnce();
123
+ for (let attempt = 0; res.status === 429 && attempt + 1 < SIDECAR_429_MAX_ATTEMPTS; attempt++) {
124
+ const delay = retryBackoffDelayMs(attempt, {
125
+ baseDelayMs: SIDECAR_429_BASE_DELAY_MS,
126
+ maxDelayMs: SIDECAR_429_MAX_DELAY_MS,
127
+ headers: res.headers,
128
+ retryAfterIsLowerBound: true,
129
+ });
130
+ // A deadline, not a clamp: an instruction past the ceiling ends the search with the
131
+ // 429 instead of parking it at a provider that already said it would refuse.
132
+ if (delay > RETRY_AFTER_CEILING_MS) break;
133
+ console.warn(`[web-search] sidecar HTTP 429 — retrying (${attempt + 2}/${SIDECAR_429_MAX_ATTEMPTS}) after ${delay}ms`);
134
+ await releaseResponseBodyBestEffort(res.body, linkedSignal.signal);
135
+ await sleepWithAbort(delay, linkedSignal.signal);
136
+ res = await sendOnce();
137
+ }
99
138
  // Attach the body guard before ANY branch reads it. The success path guarded itself below,
100
139
  // but the failure branch's `res.text()` runs first, so a cancel landing between fetch
101
140
  // resolution and reader attach orphaned the internal rejection (found investigating #1419).
@@ -333,10 +333,15 @@ export interface WebSearchLoopDeps {
333
333
  * 429 states the window's reset epoch even when it omits Retry-After -- and a rotation that
334
334
  * cannot see it cools the drained account for the short default instead of until the window
335
335
  * actually reopens. Optional so existing callers keep compiling.
336
+ *
337
+ * `retryParsed` is the exact iteration-local request the retry will be built from. The loop
338
+ * sends a shallow copy of the outer parsed request, so a rotation that rebinds only the outer
339
+ * object never reaches the wire. Optional so existing callers keep compiling.
336
340
  */
337
341
  on429?: (
338
342
  retryAfterHeader: string | null,
339
343
  responseHeaders?: Headers,
344
+ retryParsed?: OcxParsedRequest,
340
345
  ) => ProviderAdapter | null | Promise<ProviderAdapter | null>;
341
346
  /** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */
342
347
  retryOn429Policy?: Required<RateLimitRetryPolicy> | null;
@@ -558,7 +563,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
558
563
  // 429 key-failover parity with the normal routed path: rotate pool keys until one responds
559
564
  // or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
560
565
  while (prepared.response.status === 429 && deps.on429) {
561
- const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers);
566
+ const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers, iterParsed);
562
567
  if (!rotated) break;
563
568
  // Never let a broken body's cancel promise outlive the cumulative header deadline. Observe
564
569
  // it, but proceed immediately to the rotated fetch under the SAME deadline signal.