@bitkyc08/opencodex 2.54.0 → 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-CkvITofZ.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
@@ -18,10 +18,19 @@
18
18
  *
19
19
  * Deliberate boundaries of this first slice:
20
20
  * - Streaming SSE turns only. A non-streaming turn stays on the existing path.
21
- * - A leg that mixes the search call with any OTHER client tool call fails closed with an
22
- * explicit error. Answering both would need the raw mixed-tool continuation contract the
23
- * 2.47 track deferred (devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md),
24
- * and silently half-doing it would drop the client's own tool call.
21
+ * - A leg that mixes the search call with a client-executed tool call ends the turn ON that
22
+ * leg: the intercepted searches still run proxy-side so the hosted cell completes, the
23
+ * held client calls are released for Codex to run, and the leg's own terminal closes the
24
+ * turn. No continuation is sent upstream, because the client's call is unanswered and the
25
+ * conversation owes the client a turn, not the gateway. When the leg's terminal already
26
+ * ended the turn (response.failed / response.incomplete) the searches are not run at all:
27
+ * the opened cells close unanswered and that terminal is relayed, because billing a search
28
+ * for a dead turn buys nothing. What is still not fixed: the
29
+ * gateway never receives the executed search result -- Codex replays the hosted
30
+ * web_search_call cell (query and sources, no result text) on the next turn and the
31
+ * gateway's own function_call/function_call_output pair is not reconstructed. Making it
32
+ * whole needs the outbound body rewritten before the first leg is dispatched, which lives
33
+ * in src/server/responses/core.ts and is out of this module's scope.
25
34
  * - Assistant text is never treated as a search instruction. The bridge intercepts structured
26
35
  * function_call / custom_tool_call items named web_search, not XML-like prose.
27
36
  * - Non-Ollama backends reuse the sidecar executors and those executors' own credentials.
@@ -61,13 +70,56 @@ import {
61
70
  findAnthropicSidecarProvider,
62
71
  findGeminiSidecarProvider,
63
72
  findXaiSidecarProvider,
73
+ resolveSidecarBackend,
64
74
  xaiSearchOptionsFromConfig,
65
75
  } from "./sidecar-providers";
76
+ import { providerDestinationConfigError } from "../lib/destination-policy";
77
+ import { redactSecretString } from "../lib/redact";
66
78
 
67
79
  /** Canonical Ollama Cloud origin. The only origin the "ollama" backend derives on its own. */
68
80
  export const OLLAMA_CLOUD_ORIGIN = "https://ollama.com";
69
81
  const OLLAMA_WEB_SEARCH_PATH = "/api/web_search";
70
82
 
83
+ /**
84
+ * Providers already warned about a destination-refused bridge endpoint. The planner runs per
85
+ * request, so without this a refused endpoint would warn on every turn. Keyed on provider plus
86
+ * endpoint so that editing the config warns again; the key itself is never logged.
87
+ */
88
+ const warnedRefusedBridgeEndpoints = new Set<string>();
89
+ /** Bound the dedupe set so a pathological config cannot grow it without limit. */
90
+ const MAX_WARNED_REFUSED_ENDPOINTS = 64;
91
+
92
+ /**
93
+ * A refused endpoint disarms the bridge, and the refusal itself has to stay silent at the point of
94
+ * use -- returning undefined is what keeps the key unspent. But silence alone made a real
95
+ * configuration fail invisibly: a provider keyed under a CUSTOM name (say "my-ollama") pointing at
96
+ * a loopback endpoint used to arm, and the destination policy now refuses it because only the
97
+ * registry ids are local by default. The config file never reaches
98
+ * "providerWebSearchBridgeConfigError", so nothing else would tell the operator. One warning per
99
+ * provider and endpoint gives them the remedy without leaking the destination: the URL is
100
+ * deliberately omitted and the provider name is redacted, because a provider key is
101
+ * caller-controlled and can be token-shaped.
102
+ */
103
+ function warnRefusedBridgeEndpointOnce(providerName: string, endpoint: string): void {
104
+ const key = providerName + "\u0000" + endpoint;
105
+ if (warnedRefusedBridgeEndpoints.has(key)) return;
106
+ if (warnedRefusedBridgeEndpoints.size >= MAX_WARNED_REFUSED_ENDPOINTS) {
107
+ warnedRefusedBridgeEndpoints.clear();
108
+ }
109
+ warnedRefusedBridgeEndpoints.add(key);
110
+ console.warn(
111
+ "[web-search] provider " + JSON.stringify(redactSecretString(providerName))
112
+ + " webSearchBridge.endpoint was refused by destination policy, so the bridge stays disarmed."
113
+ + " Set allowPrivateNetwork:true for an intentionally local endpoint, or key the provider under"
114
+ + " its registry id (ollama, vllm, lm-studio, litellm).",
115
+ );
116
+ }
117
+
118
+ /** Test seam: the dedupe is process-wide, so a test that asserts the warning must reset it. */
119
+ export function resetRefusedBridgeEndpointWarningsForTests(): void {
120
+ warnedRefusedBridgeEndpoints.clear();
121
+ }
122
+
71
123
  const DEFAULT_BRIDGE_MAX_SEARCHES = 3;
72
124
  const DEFAULT_BRIDGE_TIMEOUT_MS = 60_000;
73
125
  /** Queries honored from one call's "queries" array; the rest are ignored rather than billed. */
@@ -77,6 +129,11 @@ const MAX_RETAINED_OUTPUT_ITEMS = 500;
77
129
  /** Refuse to buffer an unbounded partial SSE event from a misbehaving upstream. */
78
130
  const MAX_SSE_BUFFER_CHARS = 8 * 1024 * 1024;
79
131
 
132
+ /**
133
+ * Retained for importers that pinned the first slice's contract: a leg mixing the search with
134
+ * a client-executed call used to fail with this code. Such legs now end the turn on the leg
135
+ * instead of failing, so nothing emits it any more.
136
+ */
80
137
  export const WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE = "web_search_bridge_mixed_tools";
81
138
  export const WEB_SEARCH_BRIDGE_ERROR_CODE = "web_search_bridge_failed";
82
139
 
@@ -122,13 +179,33 @@ function originOf(value: string | undefined): string | undefined {
122
179
  * that receives this provider's API key. Without one, the origin must be canonical Ollama Cloud
123
180
  * -- a renamed row pointing at an arbitrary host must not silently receive the key just because
124
181
  * its adapter happens to be openai-responses.
182
+ *
183
+ * Naming a destination is not the same as it being an allowed one. The endpoint therefore gets the
184
+ * same literal destination assessment "baseUrl" already gets (#4519): metadata addresses are
185
+ * refused outright, and loopback/private need the provider's "allowPrivateNetwork" opt-in or a
186
+ * registry entry that is local by definition, so a local Ollama on 127.0.0.1 keeps working. This
187
+ * is the ONLY reader of "webSearchBridge.endpoint" in the tree, which is what lets it act as the
188
+ * authorization boundary for a config file the operator edited by hand -- that path never reaches
189
+ * "providerWebSearchBridgeConfigError", so a value that survives file load simply cannot be spent.
190
+ * The refusal returns undefined rather than an error, because disarming is what keeps the key
191
+ * unspent -- but it is not silent: see warnRefusedBridgeEndpointOnce for why a custom-named local
192
+ * provider has to be told, once, that its endpoint was refused and how to re-authorize it.
125
193
  */
126
194
  export function resolveOllamaWebSearchEndpoint(
195
+ providerName: string,
127
196
  provider: OcxProviderConfig,
128
197
  ): string | undefined {
129
198
  const configured = provider.webSearchBridge?.endpoint;
130
199
  if (configured !== undefined) {
131
- return originOf(configured) === undefined ? undefined : configured;
200
+ if (originOf(configured) === undefined) return undefined;
201
+ if (providerDestinationConfigError(providerName, {
202
+ baseUrl: configured,
203
+ allowPrivateNetwork: provider.allowPrivateNetwork,
204
+ })) {
205
+ warnRefusedBridgeEndpointOnce(providerName, configured);
206
+ return undefined;
207
+ }
208
+ return configured;
132
209
  }
133
210
  return originOf(provider.baseUrl) === OLLAMA_CLOUD_ORIGIN
134
211
  ? OLLAMA_CLOUD_ORIGIN + OLLAMA_WEB_SEARCH_PATH
@@ -210,6 +287,12 @@ export function planPassthroughWebSearchBridge(
210
287
  parsed: OcxParsedRequest,
211
288
  provider: OcxProviderConfig,
212
289
  options: {
290
+ /**
291
+ * Registry key for this provider. Required rather than optional: the destination assessment
292
+ * consults the registry's local-by-default entries, and an absent name would silently pick a
293
+ * different answer than the operator configured.
294
+ */
295
+ providerName: string;
213
296
  isPassthrough: boolean;
214
297
  stream: boolean;
215
298
  auth?: PassthroughWebSearchBridgeAuth;
@@ -238,7 +321,7 @@ export function planPassthroughWebSearchBridge(
238
321
  ? bridge.timeoutMs!
239
322
  : DEFAULT_BRIDGE_TIMEOUT_MS;
240
323
  if (backend === "ollama") {
241
- const endpoint = resolveOllamaWebSearchEndpoint(provider);
324
+ const endpoint = resolveOllamaWebSearchEndpoint(options.providerName, provider);
242
325
  if (!endpoint) return undefined;
243
326
  return { backend, endpoint, maxSearches, timeoutMs };
244
327
  }
@@ -357,10 +440,16 @@ async function* readSseBlocks(
357
440
  }
358
441
  }
359
442
  interface LegDecision {
360
- kind: "end" | "continue" | "fail";
443
+ kind: "end" | "endAfterSearch" | "endWithoutSearch" | "continue" | "fail";
361
444
  searches: InterceptedSearchCall[];
362
445
  message?: string;
363
446
  code?: string;
447
+ /**
448
+ * Whether an endWithoutSearch leg may hand its withheld client-executed calls back.
449
+ * Only `response.incomplete` may: the client can still act on that turn. A
450
+ * `response.failed` terminal must not, for the same reason the fail path drops them.
451
+ */
452
+ releaseHeldCalls?: boolean;
364
453
  }
365
454
 
366
455
  /** One client-executed call event held until the leg's fate is known. */
@@ -584,21 +673,40 @@ class BridgeStreamState {
584
673
  return blocks;
585
674
  }
586
675
 
676
+ /**
677
+ * Discard the withheld client-executed calls without emitting them. Used when the turn is
678
+ * ending in a state the client cannot act on, where releasing the call would start work
679
+ * under a turn that is already over.
680
+ */
681
+ dropHeldCalls(): void {
682
+ this.heldCalls = [];
683
+ }
684
+
587
685
  /** Decide what the leg's terminal means once the whole leg has been read. */
588
686
  decide(remainingLegs: number): LegDecision {
589
687
  if (this.searches.length === 0) return { kind: "end", searches: [] };
590
- if (this.sawClientExecutedCall) {
688
+ const terminalType = this.terminalPayload?.type;
689
+ if (terminalType === "response.failed" || terminalType === "response.incomplete") {
690
+ // The upstream terminal already ended this leg, so running the intercepted searches now
691
+ // would bill a search for a dead turn. The opened cells are closed unanswered instead.
692
+ //
693
+ // The two terminals differ in what happens to a withheld client-executed call, and
694
+ // lumping them together released one under a failed turn. `response.incomplete` leaves a
695
+ // turn the client can still act on, so its held call goes back. `response.failed` does
696
+ // not, and handing Codex a tool call to start executing inside a dead turn is the exact
697
+ // thing the fail path below refuses to do.
591
698
  return {
592
- kind: "fail",
699
+ kind: "endWithoutSearch",
593
700
  searches: this.searches,
594
- code: WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE,
595
- message: "routed provider requested web_search alongside another client tool in one turn; "
596
- + "the web-search bridge cannot answer both without dropping the client's call",
701
+ releaseHeldCalls: terminalType === "response.incomplete",
597
702
  };
598
703
  }
599
- const terminalType = this.terminalPayload?.type;
600
- if (terminalType === "response.failed" || terminalType === "response.incomplete") {
601
- return { kind: "end", searches: [] };
704
+ if (this.sawClientExecutedCall) {
705
+ // The client's own call is unanswered, so this leg cannot continue upstream: the
706
+ // conversation owes the client a turn, not the gateway. The intercepted searches still
707
+ // run so the hosted cell completes rather than dangling, then the held calls go back to
708
+ // the client and the leg's own terminal ends the turn.
709
+ return { kind: "endAfterSearch", searches: this.searches };
602
710
  }
603
711
  if (remainingLegs <= 0) {
604
712
  return {
@@ -677,7 +785,7 @@ export interface PassthroughWebSearchBridgeExecutorContext {
677
785
  auth?: PassthroughWebSearchBridgeAuth;
678
786
  hostedTool?: Record<string, unknown>;
679
787
  describeImages?: boolean;
680
- sidecar?: Pick<OcxWebSearchSidecarConfig, "model" | "reasoning" | "xSearch">;
788
+ sidecar?: Pick<OcxWebSearchSidecarConfig, "backend" | "model" | "reasoning" | "xSearch">;
681
789
  }
682
790
 
683
791
  const DEFAULT_OPENAI_BRIDGE_MODEL = "gpt-5.6-luna";
@@ -686,18 +794,52 @@ const DEFAULT_XAI_BRIDGE_MODEL = "grok-4.6";
686
794
  const DEFAULT_GEMINI_BRIDGE_MODEL = "gemini-3.8-flash";
687
795
  const DEFAULT_BRIDGE_REASONING = "low";
688
796
 
689
- function sidecarSettingsForBridge(
797
+ /**
798
+ * Search model each bridge backend runs when the global sidecar block was configured for a
799
+ * DIFFERENT backend (see modelForBridgeBackend). Exhaustive over the backend union on purpose:
800
+ * a seventh backend must decide its own default here rather than fall through to a ChatGPT model.
801
+ * The `ollama` and `exa` rows are inert — runOllamaWebSearch takes no model argument and
802
+ * runExaWebSearch reads only settings.timeoutMs — and must stay that way.
803
+ */
804
+ const DEFAULT_BRIDGE_MODELS: Record<ProviderWebSearchBridgeBackend, string> = {
805
+ ollama: DEFAULT_OPENAI_BRIDGE_MODEL,
806
+ openai: DEFAULT_OPENAI_BRIDGE_MODEL,
807
+ anthropic: DEFAULT_ANTHROPIC_BRIDGE_MODEL,
808
+ xai: DEFAULT_XAI_BRIDGE_MODEL,
809
+ gemini: DEFAULT_GEMINI_BRIDGE_MODEL,
810
+ exa: DEFAULT_OPENAI_BRIDGE_MODEL,
811
+ };
812
+
813
+ /**
814
+ * `sidecar` is the GLOBAL `config.webSearchSidecar` block, which carries the model chosen for
815
+ * ITS backend. The bridge backend is the per-provider `webSearchBridge.backend` and the two are
816
+ * configured independently, so the operator's model only means anything here when they agree:
817
+ * a global {backend:"openai", model:"gpt-5.6-luna"} otherwise reaches runAnthropicWebSearch and
818
+ * Anthropic rejects the model. On a mismatch the bridge falls back to the backend's own default.
819
+ * The same reasoning already pins the backend first in planWebSearch.
820
+ *
821
+ * Only the model is gated. `reasoning` is a generic effort level, and `xSearch` is xai-only with
822
+ * no per-backend default and no `webSearchBridge.xSearch` equivalent, so gating it would make an
823
+ * openai sidecar plus an xai bridge plus x_search impossible to express at all.
824
+ */
825
+ function modelForBridgeBackend(
826
+ backend: ProviderWebSearchBridgeBackend,
827
+ sidecar: Pick<OcxWebSearchSidecarConfig, "backend" | "model">,
828
+ ): string {
829
+ const backendDefault = DEFAULT_BRIDGE_MODELS[backend];
830
+ if (resolveSidecarBackend(sidecar.backend) !== backend) return backendDefault;
831
+ return sidecar.model ?? backendDefault;
832
+ }
833
+
834
+ /** The settings a bridge executor will run with. Exported for tests; the executor closes over it. */
835
+ export function sidecarSettingsForBridge(
690
836
  backend: ProviderWebSearchBridgeBackend,
691
837
  plan: PassthroughWebSearchBridgePlan,
692
838
  context: PassthroughWebSearchBridgeExecutorContext,
693
839
  ): SidecarSettings {
694
840
  const sidecar = context.sidecar ?? {};
695
- const model = backend === "anthropic" ? sidecar.model ?? DEFAULT_ANTHROPIC_BRIDGE_MODEL
696
- : backend === "xai" ? sidecar.model ?? DEFAULT_XAI_BRIDGE_MODEL
697
- : backend === "gemini" ? sidecar.model ?? DEFAULT_GEMINI_BRIDGE_MODEL
698
- : sidecar.model ?? DEFAULT_OPENAI_BRIDGE_MODEL;
699
841
  return {
700
- model,
842
+ model: modelForBridgeBackend(backend, sidecar),
701
843
  reasoning: sidecar.reasoning ?? DEFAULT_BRIDGE_REASONING,
702
844
  timeoutMs: plan.timeoutMs,
703
845
  describeImages: context.describeImages === true,
@@ -881,6 +1023,26 @@ async function* bridgeStreamBlocks(
881
1023
  return;
882
1024
  }
883
1025
 
1026
+ if (decision.kind === "endWithoutSearch") {
1027
+ // The upstream terminal already ended this leg, so billing a search now would pay for a
1028
+ // dead turn. The opened cells still have to close -- an in_progress web_search_call left
1029
+ // under a finished turn is the same dangling "Searching the web" spinner the failure path
1030
+ // above closes for. This also tightens the pre-existing non-mixed failed-leg path, which
1031
+ // used to drop the searches and leave the cell open.
1032
+ for (const call of decision.searches) {
1033
+ yield* emit(state.searchEndFrames(call, [], {
1034
+ text: "",
1035
+ sources: [],
1036
+ error: "the upstream turn ended before the web search could run",
1037
+ }));
1038
+ }
1039
+ // Only an incomplete terminal hands the withheld call back; a failed one drops it.
1040
+ if (decision.releaseHeldCalls) yield* emit(state.flushHeldCalls());
1041
+ else state.dropHeldCalls();
1042
+ yield* emit(state.terminalFrames());
1043
+ return;
1044
+ }
1045
+
884
1046
  const turns: { call: InterceptedSearchCall; output: string }[] = [];
885
1047
  for (const call of decision.searches) {
886
1048
  const queries = parseQueries(call.argumentsText);
@@ -907,6 +1069,17 @@ async function* bridgeStreamBlocks(
907
1069
  });
908
1070
  }
909
1071
 
1072
+ if (decision.kind === "endAfterSearch") {
1073
+ // A mixed leg ends here rather than continuing upstream: the client's own call is
1074
+ // unanswered, so the conversation owes the CLIENT a turn, not the gateway. The searches
1075
+ // completed their hosted cells above; now the held calls go back for Codex to run and
1076
+ // the leg's terminal closes the turn. No continuation is sent and no function_call_output
1077
+ // is fabricated for a call the bridge cannot execute.
1078
+ yield* emit(state.flushHeldCalls());
1079
+ yield* emit(state.terminalFrames());
1080
+ return;
1081
+ }
1082
+
910
1083
  const nextBody = appendBridgeSearchTurn(requestBody, turns);
911
1084
  if (nextBody === undefined) {
912
1085
  yield* emit(state.failureFrames(
@@ -9,6 +9,28 @@ import { resolveSidecarAuth } from "../sidecar/auth";
9
9
  import { getAccountSet } from "../oauth/store";
10
10
  import type { XaiSearchOptions } from "./xai-executor";
11
11
 
12
+ /** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */
13
+ export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa";
14
+
15
+ /**
16
+ * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The
17
+ * anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting
18
+ * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna)
19
+ * to the Anthropic API.
20
+ * The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their
21
+ * planWebSearch arms stay fail-closed until each executor layer lands.
22
+ *
23
+ * Lives here rather than in `index.ts` for the reason at the top of this file: the passthrough
24
+ * bridge has to answer "which backend was this global sidecar block configured for?" without
25
+ * value-importing the barrel.
26
+ */
27
+ export function resolveSidecarBackend(
28
+ explicit: WebSearchBackendId | undefined,
29
+ ): WebSearchBackendId {
30
+ if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit;
31
+ return "openai";
32
+ }
33
+
12
34
  /** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */
13
35
  export interface AnthropicSidecarProvider {
14
36
  providerName: string;