@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914

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 (86) hide show
  1. package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic-image-codec.ts +57 -0
  5. package/src/adapters/anthropic-image-normalize.ts +28 -1
  6. package/src/adapters/anthropic.ts +68 -6
  7. package/src/adapters/base.ts +8 -0
  8. package/src/adapters/coding-agent/protocol.ts +41 -16
  9. package/src/adapters/cursor/cursor-errors.ts +1 -1
  10. package/src/adapters/cursor/live-transport.ts +5 -1
  11. package/src/adapters/cursor/native-exec-fs.ts +10 -10
  12. package/src/adapters/cursor/native-exec-network.ts +2 -2
  13. package/src/adapters/cursor/native-exec-shell.ts +13 -12
  14. package/src/adapters/cursor/native-exec.ts +51 -10
  15. package/src/adapters/cursor/policy-error.ts +75 -0
  16. package/src/adapters/cursor/protobuf-request.ts +105 -1
  17. package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
  18. package/src/adapters/devin/live-models.ts +33 -2
  19. package/src/adapters/google-wire-compiler.ts +8 -0
  20. package/src/adapters/google.ts +46 -0
  21. package/src/adapters/input-media-guard.ts +45 -0
  22. package/src/adapters/kiro/adapter.ts +8 -0
  23. package/src/adapters/kiro/payload.ts +28 -6
  24. package/src/adapters/kiro-events.ts +25 -6
  25. package/src/adapters/kiro-images.ts +30 -0
  26. package/src/adapters/kiro-retry.ts +8 -0
  27. package/src/adapters/openai-chat.ts +33 -4
  28. package/src/adapters/openai-responses.ts +26 -0
  29. package/src/adapters/registry.ts +4 -0
  30. package/src/bridge.ts +163 -116
  31. package/src/chat/image-parts.ts +151 -0
  32. package/src/chat/inbound.ts +70 -33
  33. package/src/cli/connect.ts +30 -9
  34. package/src/cli/dispatch.ts +7 -3
  35. package/src/cli/index.ts +3 -0
  36. package/src/cli/runtime-api.ts +25 -0
  37. package/src/cli/status.ts +21 -19
  38. package/src/cli/system-restart-client.ts +25 -0
  39. package/src/clients/config-export.ts +14 -4
  40. package/src/codex/app-server-processes.ts +25 -0
  41. package/src/codex/auth-context.ts +8 -0
  42. package/src/codex/autostart-health.ts +36 -2
  43. package/src/codex/catalog/provider-fetch.ts +41 -0
  44. package/src/codex/catalog-auto-refresh.ts +182 -0
  45. package/src/codex/catalog-refresh-status.ts +93 -0
  46. package/src/codex/history-provider.ts +55 -0
  47. package/src/codex/model-entitlements.ts +78 -0
  48. package/src/codex/native-profile-processes.ts +114 -15
  49. package/src/codex/prompt-text-probe.ts +274 -41
  50. package/src/codex/routing-adoption.ts +189 -0
  51. package/src/codex/routing.ts +520 -48
  52. package/src/codex/runtime.ts +249 -7
  53. package/src/combos/failover.ts +45 -0
  54. package/src/config.ts +124 -4
  55. package/src/generated/compatibility-version.json +110 -74
  56. package/src/generated/model-metadata.ts +1 -0
  57. package/src/lib/request-execution-budget.ts +202 -0
  58. package/src/lib/upstream-retry.ts +95 -8
  59. package/src/lib/workflow-budget.ts +172 -0
  60. package/src/oauth/devin.ts +57 -12
  61. package/src/providers/quota.ts +37 -6
  62. package/src/providers/registry.ts +53 -6
  63. package/src/responses/input-media.ts +65 -0
  64. package/src/responses/parser-content.ts +42 -0
  65. package/src/responses/schema.ts +12 -2
  66. package/src/server/audio-live.ts +1 -2
  67. package/src/server/audio-transcriptions.ts +1 -2
  68. package/src/server/auth-cors.ts +1 -1
  69. package/src/server/background-lifecycle.ts +18 -0
  70. package/src/server/chat-completions.ts +23 -8
  71. package/src/server/chat-native.ts +17 -17
  72. package/src/server/index.ts +24 -0
  73. package/src/server/management/request-history-routes.ts +5 -0
  74. package/src/server/request-log.ts +8 -2
  75. package/src/server/responses/compact.ts +51 -3
  76. package/src/server/responses/core.ts +238 -28
  77. package/src/server/search.ts +7 -9
  78. package/src/types/config.ts +51 -6
  79. package/src/usage/log.ts +37 -0
  80. package/src/vision/eligibility.ts +37 -4
  81. package/src/vision/index.ts +1 -0
  82. package/src/vision/plan.ts +45 -10
  83. package/src/web-search/alpha-search.ts +324 -0
  84. package/src/web-search/index.ts +13 -22
  85. package/src/web-search/passthrough-bridge.ts +195 -22
  86. package/src/web-search/sidecar-providers.ts +22 -0
package/src/usage/log.ts CHANGED
@@ -2,6 +2,7 @@ import { createHash, type Hash } from "node:crypto";
2
2
  import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
+ import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing";
5
6
  import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
6
7
  import { recordOwnedConfigPath } from "../lib/config-ownership";
7
8
  import { sanitizeLogMetadataString } from "../lib/redact";
@@ -187,6 +188,13 @@ export interface PersistedUsageEntry {
187
188
  transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
188
189
  /** Whether the terminal came from upstream or a proxy-generated tail. */
189
190
  terminalSource?: "upstream" | "synthetic";
191
+ /**
192
+ * What happened to this request's Codex pool binding, and why (#4546). A move discards the
193
+ * prompt-cache prefix warmed on the previous account, so it is recorded as an event rather
194
+ * than left to be inferred from account labels across rows. Additive; older rows omit it.
195
+ */
196
+ affinity?: CodexAffinityMove;
197
+ affinityReason?: CodexAffinityReason;
190
198
  /**
191
199
  * Bounded route-decision trace (RI-01): why this provider/model/account was
192
200
  * selected. Additive field; old rows without it parse unchanged. Never
@@ -248,6 +256,28 @@ export function isKnownTerminalSource(value: unknown): value is NonNullable<Pers
248
256
  return typeof value === "string" && KNOWN_TERMINAL_SOURCES.has(value as NonNullable<PersistedUsageEntry["terminalSource"]>);
249
257
  }
250
258
 
259
+ /**
260
+ * The persisted entry is built by an explicit whitelist, so a field the writer sets but this
261
+ * normalizer does not name is dropped without a word. #4592 added the affinity record at the
262
+ * call site and it never reached disk for exactly that reason.
263
+ */
264
+ const KNOWN_AFFINITY_MOVES = new Set<NonNullable<PersistedUsageEntry["affinity"]>>([
265
+ "reused", "held", "detour", "rebound", "new_bind", "cleared",
266
+ ]);
267
+ const KNOWN_AFFINITY_REASONS = new Set<NonNullable<PersistedUsageEntry["affinityReason"]>>([
268
+ "healthy", "quota_headroom", "quota_refusal", "transient", "transient_hold_expired",
269
+ "unusable", "paused", "plan_excluded", "cooldown", "quota_avoided", "generation",
270
+ "expired", "model_lane",
271
+ ]);
272
+
273
+ export function isKnownAffinityMove(value: unknown): value is NonNullable<PersistedUsageEntry["affinity"]> {
274
+ return typeof value === "string" && KNOWN_AFFINITY_MOVES.has(value as NonNullable<PersistedUsageEntry["affinity"]>);
275
+ }
276
+
277
+ export function isKnownAffinityReason(value: unknown): value is NonNullable<PersistedUsageEntry["affinityReason"]> {
278
+ return typeof value === "string" && KNOWN_AFFINITY_REASONS.has(value as NonNullable<PersistedUsageEntry["affinityReason"]>);
279
+ }
280
+
251
281
  export function usageLogPath(configDir?: string): string {
252
282
  return join(configDir ?? getConfigDir(), "usage.jsonl");
253
283
  }
@@ -590,6 +620,11 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
590
620
  const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
591
621
  const transportPhase = isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : undefined;
592
622
  const terminalSource = isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : undefined;
623
+ const affinity = isKnownAffinityMove(entry.affinity) ? entry.affinity : undefined;
624
+ // A reason without a move describes nothing, so it is only kept alongside one.
625
+ const affinityReason = affinity !== undefined && isKnownAffinityReason(entry.affinityReason)
626
+ ? entry.affinityReason
627
+ : undefined;
593
628
  const routeDecision = entry.routeDecision
594
629
  ? normalizeRouteDecisionTrace(entry.routeDecision)
595
630
  : undefined;
@@ -660,6 +695,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
660
695
  ...(Array.isArray(entry.attempts) ? { attempts } : {}),
661
696
  ...(transportPhase ? { transportPhase } : {}),
662
697
  ...(terminalSource ? { terminalSource } : {}),
698
+ ...(affinity ? { affinity } : {}),
699
+ ...(affinityReason ? { affinityReason } : {}),
663
700
  ...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
664
701
  ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
665
702
  ...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
@@ -26,6 +26,7 @@ import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../gen
26
26
  import { nativeInputModalities } from "../codex/catalog/metadata";
27
27
  import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
28
28
  import { enrichProviderFromRegistry } from "../providers/derive";
29
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination";
29
30
 
30
31
  /**
31
32
  * The wire protocols `planVisionSidecar` can dispatch to (#2188 roadmap 170
@@ -113,7 +114,28 @@ function enrichedProviderForVision(
113
114
  if (cached) return cached;
114
115
  const configured = config.providers?.[providerName];
115
116
  if (!configured) return undefined;
116
- const enriched = structuredClone(configured);
117
+ // Runtime providers may carry non-cloneable hooks (for example a provider-scoped fetch).
118
+ // Enrichment mutates top-level fields but does not mutate nested provider values in place, so
119
+ // a shallow copy plus private copies of the vision-capability containers is sufficient and
120
+ // avoids both config mutation and structuredClone(DataCloneError) on runtime functions.
121
+ const enriched: OcxProviderConfig = {
122
+ ...configured,
123
+ ...(configured.noVisionModels ? { noVisionModels: [...configured.noVisionModels] } : {}),
124
+ ...(configured.modelInputModalities ? {
125
+ modelInputModalities: Object.fromEntries(
126
+ Object.entries(configured.modelInputModalities).map(([id, modalities]) => [id, [...modalities]]),
127
+ ),
128
+ } : {}),
129
+ ...(configured.modelCapabilities ? {
130
+ modelCapabilities: Object.fromEntries(Object.entries(configured.modelCapabilities).map(([id, capability]) => [
131
+ id,
132
+ {
133
+ ...capability,
134
+ ...(capability.inputModalities ? { inputModalities: [...capability.inputModalities] } : {}),
135
+ },
136
+ ])),
137
+ } : {}),
138
+ };
117
139
  enrichProviderFromRegistry(providerName, enriched);
118
140
  cache.set(providerName, enriched);
119
141
  return enriched;
@@ -156,9 +178,10 @@ function modelAcceptsImageInputWithCache(
156
178
  ): boolean | undefined {
157
179
  if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) {
158
180
  const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache);
159
- if (nativeProvider && isModelVisionSidecarConsumer({
160
- noVisionModels: nativeProvider.noVisionModels, modelInputModalities: nativeProvider.modelInputModalities,
161
- }, candidate.id)) return false;
181
+ if (nativeProvider && isModelVisionSidecarConsumer(nativeProvider, candidate.id)) return false;
182
+ const declared = Object.hasOwn(nativeProvider?.modelCapabilities ?? {}, candidate.id)
183
+ ? nativeProvider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
184
+ if (declared !== undefined) return declared.includes("image");
162
185
  return advertisesImageInput(nativeInputModalities(candidate.id)) ?? true;
163
186
  }
164
187
  if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false;
@@ -166,6 +189,16 @@ function modelAcceptsImageInputWithCache(
166
189
  const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id)
167
190
  ? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
168
191
  if (declared !== undefined) return declared.includes("image");
192
+ const configuredModalities = provider ? modelRecordValue(provider.modelInputModalities, candidate.id) : undefined;
193
+ const fromConfiguredModalities = advertisesImageInput(configuredModalities);
194
+ if (fromConfiguredModalities !== undefined) return fromConfiguredModalities;
195
+ const canonicalCodex = candidate.provider === "openai"
196
+ && provider !== undefined
197
+ && isCanonicalOpenAiForwardProvider(provider);
198
+ if (canonicalCodex) {
199
+ const fromCodexBackend = metadataImageInput("openai-codex", candidate.id);
200
+ if (fromCodexBackend !== undefined) return fromCodexBackend;
201
+ }
169
202
  const fromRow = advertisesImageInput(candidate.inputModalities);
170
203
  if (fromRow !== undefined) return fromRow;
171
204
  return metadataImageInput(candidate.provider, candidate.id);
@@ -35,6 +35,7 @@ export {
35
35
  resolveEffectiveVisionModel,
36
36
  shouldResolveOpenAiVisionSidecar,
37
37
  planVisionSidecar,
38
+ requiresVisionPreprocessing,
38
39
  } from "./plan";
39
40
  export type { AnthropicVisionProvider, VisionPlan } from "./plan";
40
41
  export { stripImagesInPlace } from "./image-rewrite";
@@ -1,5 +1,5 @@
1
1
  import type { OcxConfig, OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../types";
2
- import type { VisionReasoningEffort } from "../reasoning-effort";
2
+ import { modelRecordValue, type VisionReasoningEffort } from "../reasoning-effort";
3
3
  import type { VisionSettings } from "./describe";
4
4
  import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
5
5
  import type { CodexAuthPolicyConfig } from "../codex/auth-context";
@@ -92,6 +92,34 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
92
92
  carriesImages(m.role) && Array.isArray(m.content) && (m.content as OcxContentPart[]).some(p => p.type === "image"));
93
93
  }
94
94
 
95
+ /**
96
+ * Direct-image admission for a routed target. Returns true when capability evidence proves the
97
+ * 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.
103
+ */
104
+ export function requiresVisionPreprocessing(
105
+ config: Pick<OcxConfig, "providers">,
106
+ provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities" | "modelCapabilities">,
107
+ modelId: string,
108
+ providerName?: string,
109
+ ): boolean {
110
+ if (isModelTextOnly(provider, modelId)) return true;
111
+ const runtimeDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
112
+ ? provider.modelCapabilities?.[modelId]?.inputModalities
113
+ : undefined;
114
+ if (runtimeDeclared !== undefined) return !runtimeDeclared.includes("image");
115
+ const runtimeModalities = modelRecordValue(provider.modelInputModalities, modelId);
116
+ if (Array.isArray(runtimeModalities) && runtimeModalities.length > 0) {
117
+ return !runtimeModalities.includes("image");
118
+ }
119
+ if (!providerName) return false;
120
+ return modelAcceptsImageInput(config, { provider: providerName, id: modelId }) === false;
121
+ }
122
+
95
123
  /** Shared by auth admission and planning so a routed describer never borrows OpenAI auth. */
96
124
  function usableRoutedVisionModel(config: OcxConfig): string | undefined {
97
125
  const cfg = config.visionSidecar;
@@ -109,10 +137,11 @@ function usableRoutedVisionModel(config: OcxConfig): string | undefined {
109
137
  export function shouldResolveOpenAiVisionSidecar(
110
138
  config: OcxConfig,
111
139
  provider: OcxProviderConfig,
112
- modelId: string,
113
- parsed: OcxParsedRequest,
140
+ modelId: string,
141
+ parsed: OcxParsedRequest,
142
+ providerName?: string,
114
143
  ): boolean {
115
- if (!isModelTextOnly(provider, modelId) || !messagesHaveImage(parsed)) return false;
144
+ if (!requiresVisionPreprocessing(config, provider, modelId, providerName) || !messagesHaveImage(parsed)) return false;
116
145
  const cfg = config.visionSidecar ?? {};
117
146
  if (cfg.enabled === false) return false;
118
147
  if (usableRoutedVisionModel(config)) return false;
@@ -132,10 +161,12 @@ export interface VisionPlan {
132
161
  }
133
162
 
134
163
  /**
135
- * Decide whether the vision sidecar should pre-describe images for this request, returning the plan
136
- * if so. Active when: the routed model is in `provider.noVisionModels`, the request actually carries
137
- * an image, the sidecar isn't disabled, and the selected backend has usable auth. Returns undefined
138
- * otherwise (the caller strips images before sending to a text-only model).
164
+ * Decide whether the vision sidecar should pre-describe images for this request. Raw image
165
+ * delivery is capability-driven: targets proven text-only are preprocessed, targets proven
166
+ * image-capable bypass this planner, and genuinely unknown custom targets retain legacy behavior.
167
+ * The request must
168
+ * carry an image, the sidecar must be enabled, and the selected backend must be dispatchable.
169
+ * Returns undefined otherwise; the caller strips images before any unverified upstream send.
139
170
  */
140
171
  export function planVisionSidecar(
141
172
  config: OcxConfig,
@@ -143,9 +174,13 @@ export function planVisionSidecar(
143
174
  modelId: string,
144
175
  parsed: OcxParsedRequest,
145
176
  openAiSidecar?: ResolvedOpenAiForwardSidecar,
146
- options: { admission?: Pick<DataPlaneAdmission, "source">; codexAuthPolicy?: CodexAuthPolicyConfig } = {},
177
+ options: {
178
+ admission?: Pick<DataPlaneAdmission, "source">;
179
+ codexAuthPolicy?: CodexAuthPolicyConfig;
180
+ providerName?: string;
181
+ } = {},
147
182
  ): VisionPlan | undefined {
148
- if (!isModelTextOnly(provider, modelId)) return undefined;
183
+ if (!requiresVisionPreprocessing(config, provider, modelId, options.providerName)) return undefined;
149
184
  if (!messagesHaveImage(parsed)) return undefined;
150
185
  const cfg = config.visionSidecar ?? {};
151
186
  if (cfg.enabled === false) return undefined;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Serve Codex's built-in `/v1/alpha/search` when no ChatGPT forward provider exists.
3
+ *
4
+ * The ChatGPT relay in src/server/search.ts is byte-identical on purpose: the client talks an
5
+ * unpublished alpha envelope, and the only honest answer while a forward provider is configured
6
+ * is to copy bytes. That leaves API-key-only and routed-provider deployments with a 400 even
7
+ * when they already paid for a web-search sidecar. This module is the empty-candidates branch
8
+ * of that handler — it never runs when a forward provider is present, and it never borrows a
9
+ * different paid backend than the one the operator named.
10
+ *
11
+ * Do not import `./index.ts` from here. The barrel is still evaluating when search.ts loads,
12
+ * and pulling it in recreates the cycle sidecar-providers.ts exists to avoid.
13
+ */
14
+ import { formatErrorResponse } from "../bridge";
15
+ import { redactSecretString } from "../lib/redact";
16
+ import { sidecarEnter } from "../lib/sidecar-tracker";
17
+ import type { OcxConfig, OcxProviderConfig, OcxWebSearchSidecarConfig } from "../types";
18
+ import { runAnthropicWebSearch } from "./anthropic-executor";
19
+ import { runExaWebSearch } from "./exa-executor";
20
+ import type { SidecarOutcome, SidecarSettings } from "./executor";
21
+ import { runGeminiWebSearch } from "./gemini-executor";
22
+ import {
23
+ findAnthropicSidecarProvider,
24
+ findGeminiSidecarProvider,
25
+ findXaiSidecarProvider,
26
+ resolveSidecarBackend,
27
+ xaiSearchOptionsFromConfig,
28
+ } from "./sidecar-providers";
29
+ import { safeWebSearchSources } from "./sources";
30
+ import { runXaiWebSearch } from "./xai-executor";
31
+
32
+ /**
33
+ * Same total-search budget the ChatGPT relay uses in src/server/search.ts. The sidecar loop's
34
+ * 60s default is a different contract (a helper turn beside a routed model); alpha/search is
35
+ * the whole request, so it keeps the relay's 200s ceiling unless config.search.timeoutMs says
36
+ * otherwise.
37
+ */
38
+ const SEARCH_UPSTREAM_TIMEOUT_MS = 200_000;
39
+ /** Queries honored from one alpha/search body; the rest are ignored rather than billed. */
40
+ const MAX_QUERIES_PER_CALL = 3;
41
+ const MAX_QUERY_CHARS = 1_000;
42
+ const DEFAULT_REASONING = "low";
43
+
44
+ /**
45
+ * Search model each sidecar backend runs when the operator did not name one for THIS backend.
46
+ * Copied from the passthrough bridge's table on purpose: sending a ChatGPT slug to Anthropic
47
+ * is the failure that table exists to prevent, and alpha/search would reproduce it if it
48
+ * trusted `webSearchSidecar.model` unconditionally.
49
+ */
50
+ const DEFAULT_BACKEND_MODELS = {
51
+ anthropic: "claude-sonnet-5",
52
+ xai: "grok-4.6",
53
+ gemini: "gemini-3.8-flash",
54
+ // Exa ignores model; the placeholder only satisfies SidecarSettings.
55
+ exa: "gpt-5.6-luna",
56
+ } as const;
57
+
58
+ export type AlphaSearchSidecarBackend = keyof typeof DEFAULT_BACKEND_MODELS;
59
+
60
+ type ResolvedAlphaSearchSidecar =
61
+ | { backend: "anthropic"; providerName: string; provider: OcxProviderConfig }
62
+ | { backend: "xai"; providerName: string; provider: OcxProviderConfig }
63
+ | { backend: "gemini"; providerName: string; provider: OcxProviderConfig }
64
+ | { backend: "exa"; apiKey: string };
65
+
66
+ /**
67
+ * Why this path cannot serve the request, kept distinct from "nobody asked for it".
68
+ *
69
+ * The two refusals read identically to the operator but mean opposite things: `unconfigured` is
70
+ * a deployment that never named a backend, while `missing-credential` is one that named a
71
+ * backend the proxy cannot authenticate. Answering both with the ChatGPT-auth sentence is the
72
+ * behaviour the feature request called out — it tells an operator who already chose Exa to go
73
+ * set up ChatGPT OAuth, which is the one thing they were trying to avoid.
74
+ */
75
+ export type AlphaSearchSidecarResolution =
76
+ | { status: "ready"; sidecar: ResolvedAlphaSearchSidecar }
77
+ | { status: "unconfigured" }
78
+ | { status: "missing-credential"; backend: AlphaSearchSidecarBackend };
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return !!value && typeof value === "object" && !Array.isArray(value);
82
+ }
83
+
84
+ /**
85
+ * Only the operator's explicit sidecar backend can serve this path, and only with THAT
86
+ * backend's own credential. `openai` is the ChatGPT forward path, which is absent by the
87
+ * time we are here; auto-selecting a different paid backend from leftover keys is how an
88
+ * anthropic-named config would silently spend Exa.
89
+ */
90
+ export function resolveAlphaSearchSidecar(config: OcxConfig): AlphaSearchSidecarResolution {
91
+ const sidecar = config.webSearchSidecar;
92
+ // The master switch is the operator saying this sidecar may not run. planWebSearch honors it the
93
+ // same way, and ignoring it here would make `enabled: false` mean "off for the routed loop, on
94
+ // for alpha/search" — the one reading under which a disabled backend still spends money.
95
+ if (sidecar?.enabled === false) return { status: "unconfigured" };
96
+ const backend = resolveSidecarBackend(sidecar?.backend);
97
+ if (backend === "openai") return { status: "unconfigured" };
98
+ switch (backend) {
99
+ case "anthropic": {
100
+ const found = findAnthropicSidecarProvider(config);
101
+ return found
102
+ ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } }
103
+ : { status: "missing-credential", backend };
104
+ }
105
+ case "xai": {
106
+ const found = findXaiSidecarProvider(config);
107
+ return found
108
+ ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } }
109
+ : { status: "missing-credential", backend };
110
+ }
111
+ case "gemini": {
112
+ const found = findGeminiSidecarProvider(config);
113
+ return found
114
+ ? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } }
115
+ : { status: "missing-credential", backend };
116
+ }
117
+ case "exa": {
118
+ const apiKey = sidecar?.exaApiKey;
119
+ return typeof apiKey === "string" && apiKey.length > 0
120
+ ? { status: "ready", sidecar: { backend, apiKey } }
121
+ : { status: "missing-credential", backend };
122
+ }
123
+ }
124
+ }
125
+
126
+ function pushQuery(queries: string[], value: unknown): void {
127
+ if (typeof value !== "string") return;
128
+ const trimmed = value.trim();
129
+ if (trimmed.length === 0 || queries.includes(trimmed)) return;
130
+ if (queries.length < MAX_QUERIES_PER_CALL) queries.push(trimmed.slice(0, MAX_QUERY_CHARS));
131
+ }
132
+
133
+ /**
134
+ * Codex's live-search client sends a Responses-shaped envelope whose primary operation is
135
+ * `commands.search_query: [{ q }]`. Top-level `query` / `q` / `search_query` strings are the
136
+ * fallback for tests and any thinner client; they are consulted only when the envelope form
137
+ * produced nothing usable, so a present-but-empty `search_query` array cannot hide a
138
+ * top-level query the operator actually sent.
139
+ */
140
+ export function extractAlphaSearchQueries(body: unknown): string[] {
141
+ const queries: string[] = [];
142
+ if (!isRecord(body)) return queries;
143
+ const searchQuery = isRecord(body.commands) ? body.commands.search_query : undefined;
144
+ if (Array.isArray(searchQuery)) {
145
+ for (const entry of searchQuery) {
146
+ if (isRecord(entry)) pushQuery(queries, entry.q);
147
+ }
148
+ }
149
+ if (queries.length === 0) {
150
+ pushQuery(queries, body.query);
151
+ if (queries.length === 0) pushQuery(queries, body.q);
152
+ if (queries.length === 0) pushQuery(queries, body.search_query);
153
+ }
154
+ return queries;
155
+ }
156
+
157
+ function modelForAlphaSearchBackend(
158
+ backend: AlphaSearchSidecarBackend,
159
+ sidecar: Pick<OcxWebSearchSidecarConfig, "backend" | "model"> | undefined,
160
+ ): string {
161
+ const backendDefault = DEFAULT_BACKEND_MODELS[backend];
162
+ if (resolveSidecarBackend(sidecar?.backend) !== backend) return backendDefault;
163
+ return sidecar?.model ?? backendDefault;
164
+ }
165
+
166
+ function sidecarSettingsForAlphaSearch(
167
+ backend: AlphaSearchSidecarBackend,
168
+ config: OcxConfig,
169
+ ): SidecarSettings {
170
+ const sidecar = config.webSearchSidecar;
171
+ return {
172
+ model: modelForAlphaSearchBackend(backend, sidecar),
173
+ reasoning: sidecar?.reasoning ?? DEFAULT_REASONING,
174
+ timeoutMs: config.search?.timeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS,
175
+ };
176
+ }
177
+
178
+ async function runAlphaSearchQuery(
179
+ query: string,
180
+ resolved: ResolvedAlphaSearchSidecar,
181
+ settings: SidecarSettings,
182
+ config: OcxConfig,
183
+ signal?: AbortSignal,
184
+ ): Promise<SidecarOutcome> {
185
+ switch (resolved.backend) {
186
+ case "anthropic":
187
+ return runAnthropicWebSearch(query, resolved.providerName, resolved.provider, settings, signal);
188
+ case "xai":
189
+ return runXaiWebSearch(
190
+ query,
191
+ resolved.providerName,
192
+ resolved.provider,
193
+ settings,
194
+ xaiSearchOptionsFromConfig(config.webSearchSidecar ?? {}),
195
+ signal,
196
+ );
197
+ case "gemini":
198
+ return runGeminiWebSearch(query, resolved.providerName, resolved.provider, settings, signal);
199
+ case "exa":
200
+ return runExaWebSearch(query, resolved.apiKey, settings, signal);
201
+ }
202
+ }
203
+
204
+ function formatAlphaSearchBody(text: string, sources: SidecarOutcome["sources"]): {
205
+ encrypted_output: null;
206
+ output: string;
207
+ results: Array<{ title: string; url: string }>;
208
+ } {
209
+ // Title falls back to the URL so the client always sees both fields; unsafe URLs are
210
+ // dropped entirely rather than echoed into `results`.
211
+ return {
212
+ encrypted_output: null,
213
+ output: text,
214
+ results: safeWebSearchSources(sources).map(source => ({
215
+ url: source.url,
216
+ title: source.title ?? source.url,
217
+ })),
218
+ };
219
+ }
220
+
221
+ const NO_FORWARD_PROVIDER_MESSAGE =
222
+ "Built-in web search needs a ChatGPT forward provider, but none is configured in opencodex. "
223
+ + "Routed and OpenAI API-key providers cannot serve /v1/alpha/search. "
224
+ + "Configure webSearchSidecar.backend (anthropic, xai, gemini, or exa) with that backend's credential instead.";
225
+
226
+ /**
227
+ * What a named backend is missing, said in the operator's own terms.
228
+ *
229
+ * An operator who already chose a backend does not need to be told to configure ChatGPT auth —
230
+ * that answer is what the request asked this path to stop giving. They need to know which
231
+ * credential the backend they named could not find.
232
+ */
233
+ function missingCredentialMessage(backend: AlphaSearchSidecarBackend): string {
234
+ const detail: Record<AlphaSearchSidecarBackend, string> = {
235
+ anthropic: "no usable stored Anthropic OAuth account was found",
236
+ xai: "no usable stored Grok OAuth account was found",
237
+ gemini: "no usable stored Antigravity OAuth account with a discovered project was found",
238
+ exa: "webSearchSidecar.exaApiKey is not set",
239
+ };
240
+ return "Built-in web search is configured to use the " + backend + " backend, but "
241
+ + detail[backend] + ". Restore that backend's credential, or choose another "
242
+ + "webSearchSidecar.backend. This request was not sent to any other backend.";
243
+ }
244
+
245
+ /**
246
+ * Run the named sidecar backend against an alpha/search body. Callers must already know there
247
+ * is no ChatGPT forward candidate — this function does not re-check that, so a mis-call would
248
+ * spend the sidecar even when the relay could have copied bytes.
249
+ */
250
+ export async function handleAlphaSearchSidecarFallback(
251
+ body: unknown,
252
+ config: OcxConfig,
253
+ signal?: AbortSignal,
254
+ logCtx?: { provider: string },
255
+ ): Promise<Response> {
256
+ const resolution = resolveAlphaSearchSidecar(config);
257
+ if (resolution.status === "missing-credential") {
258
+ // Never the ChatGPT-auth sentence here: the operator already named a backend, so the honest
259
+ // answer names what that backend is missing.
260
+ if (logCtx) logCtx.provider = resolution.backend;
261
+ return formatErrorResponse(400, "invalid_request_error", missingCredentialMessage(resolution.backend));
262
+ }
263
+ if (resolution.status !== "ready") {
264
+ return formatErrorResponse(400, "invalid_request_error", NO_FORWARD_PROVIDER_MESSAGE);
265
+ }
266
+ const resolved = resolution.sidecar;
267
+ if (logCtx) logCtx.provider = resolved.backend;
268
+
269
+ const queries = extractAlphaSearchQueries(body);
270
+ if (queries.length === 0) {
271
+ return formatErrorResponse(
272
+ 400,
273
+ "invalid_request_error",
274
+ "Built-in web search request is missing a usable query (commands.search_query, query, q, or search_query).",
275
+ );
276
+ }
277
+
278
+ const settings = sidecarSettingsForAlphaSearch(resolved.backend, config);
279
+ const sidecarExit = sidecarEnter("search");
280
+ try {
281
+ const texts: string[] = [];
282
+ const sources: SidecarOutcome["sources"] = [];
283
+ const errors: string[] = [];
284
+ for (const query of queries) {
285
+ if (signal?.aborted) break;
286
+ const outcome = await runAlphaSearchQuery(query, resolved, settings, config, signal);
287
+ if (outcome.error) {
288
+ errors.push(outcome.error);
289
+ continue;
290
+ }
291
+ texts.push(queries.length > 1 ? `Results for "${query}":\n${outcome.text}` : outcome.text);
292
+ for (const source of outcome.sources) {
293
+ if (!sources.some(existing => existing.url === source.url)) sources.push(source);
294
+ }
295
+ }
296
+ if (signal?.aborted) {
297
+ return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
298
+ }
299
+ if (texts.length === 0) {
300
+ const detail = redactSecretString(errors[0] ?? "web search produced no results");
301
+ return formatErrorResponse(
302
+ 502,
303
+ "upstream_error",
304
+ resolved.backend + " web search failed: " + detail,
305
+ );
306
+ }
307
+ return new Response(JSON.stringify(formatAlphaSearchBody(texts.join("\n\n"), sources)), {
308
+ status: 200,
309
+ headers: { "content-type": "application/json" },
310
+ });
311
+ } catch (err) {
312
+ if (signal?.aborted) {
313
+ return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
314
+ }
315
+ const detail = redactSecretString(err instanceof Error ? err.message : String(err));
316
+ return formatErrorResponse(
317
+ 502,
318
+ "upstream_error",
319
+ resolved.backend + " web search failed: " + detail,
320
+ );
321
+ } finally {
322
+ sidecarExit();
323
+ }
324
+ }
@@ -1,6 +1,6 @@
1
1
  import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
2
2
  import { modelInList, toolChoiceToolPredicate } from "../types";
3
- import { isModelTextOnly } from "../vision";
3
+ import { requiresVisionPreprocessing } from "../vision";
4
4
  import type { SidecarSettings } from "./executor";
5
5
  import type { CodexAuthPolicyConfig } from "../codex/auth-context";
6
6
  import { isCodexReserveRequestEligible } from "../codex/loopback-target";
@@ -15,8 +15,10 @@ import {
15
15
  findAnthropicSidecarProvider,
16
16
  findGeminiSidecarProvider,
17
17
  findXaiSidecarProvider,
18
+ resolveSidecarBackend,
18
19
  xaiSearchOptionsFromConfig,
19
20
  type AnthropicSidecarProvider,
21
+ type WebSearchBackendId,
20
22
  } from "./sidecar-providers";
21
23
 
22
24
  export { runWithWebSearch } from "./loop";
@@ -29,8 +31,10 @@ export {
29
31
  findAnthropicSidecarProvider,
30
32
  findGeminiSidecarProvider,
31
33
  findXaiSidecarProvider,
34
+ resolveSidecarBackend,
32
35
  xaiSearchOptionsFromConfig,
33
36
  type AnthropicSidecarProvider,
37
+ type WebSearchBackendId,
34
38
  };
35
39
 
36
40
  const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
@@ -98,24 +102,6 @@ export function webSearchStallTimeoutSec(
98
102
  return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC);
99
103
  }
100
104
 
101
- /** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */
102
- export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa";
103
-
104
- /**
105
- * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The
106
- * anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting
107
- * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna)
108
- * to the Anthropic API.
109
- * The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their
110
- * planWebSearch arms stay fail-closed until each executor layer lands.
111
- */
112
- export function resolveSidecarBackend(
113
- explicit: WebSearchBackendId | undefined,
114
- ): WebSearchBackendId {
115
- if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit;
116
- return "openai";
117
- }
118
-
119
105
  export interface SidecarPlan {
120
106
  /** Which executor runs the search. Anthropic does not require a forward provider. */
121
107
  backend: WebSearchBackendId;
@@ -166,7 +152,11 @@ export function planWebSearch(
166
152
  provider: OcxProviderConfig,
167
153
  modelId: string,
168
154
  openAiSidecar?: ResolvedOpenAiForwardSidecar,
169
- options: { admission?: Pick<DataPlaneAdmission, "source">; codexAuthPolicy?: CodexAuthPolicyConfig } = {},
155
+ options: {
156
+ admission?: Pick<DataPlaneAdmission, "source">;
157
+ codexAuthPolicy?: CodexAuthPolicyConfig;
158
+ providerName?: string;
159
+ } = {},
170
160
  ): SidecarPlan | undefined {
171
161
  if (!parsed._webSearch || isPassthrough) return undefined;
172
162
  if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined;
@@ -190,8 +180,9 @@ export function planWebSearch(
190
180
  routedModelStallTimeoutMs,
191
181
  timeoutMs,
192
182
  );
193
- // The routed model being text-only means the search model must verbalize image results (either backend).
194
- const describeImages = isModelTextOnly(provider, modelId);
183
+ // A target proven unable to accept image input receives verbalized image results instead of
184
+ // search-result images. A genuinely unknown custom target keeps the established pass-through.
185
+ const describeImages = requiresVisionPreprocessing(config, provider, modelId, options.providerName);
195
186
  const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING;
196
187
  const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true;
197
188