diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 752c13b395..75f7ea9cf1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -201,7 +201,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | -| `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not (Ollama Cloud GLM/DeepSeek) answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. Only the `ollama` backend has an executor; the other ids in the union are accepted and stay inert. The `ollama` backend reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. Streaming turns only; a turn that mixes `web_search` with another client tool call fails closed rather than dropping the client's call. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | +| `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` providers only. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index bfd3a5dc7e..3fc8917c5d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -158,9 +158,11 @@ import { import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { - createOllamaBridgeExecutor, + createPassthroughWebSearchBridgeExecutor, createPassthroughWebSearchBridgeStream, planPassthroughWebSearchBridge, + resolvePassthroughWebSearchBridgeAuth, + shouldResolveOpenAiPassthroughWebSearchBridge, } from "../../web-search/passthrough-bridge"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; @@ -4769,7 +4771,8 @@ async function handleResponsesInner( const needsOpenAiVision = !visionDescribeTerminal && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed); const needsOpenAiSearch = !routedCompaction && !adapter.runTurn - && shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough); + && (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough) + || shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough)); if (needsOpenAiVision || needsOpenAiSearch) { try { const candidates = listOpenAiForwardSidecarCandidates(config); @@ -6242,9 +6245,15 @@ async function handleResponsesInner( // conversation upstream, and hands back ordinary Responses SSE — so every rewrite below, // including the guard itself, still inspects the client-facing stream. Default OFF: without // the opt-in this is one planner call and the relay is byte-identical to before. + const webSearchBridgeAuth = resolvePassthroughWebSearchBridgeAuth( + route.provider.webSearchBridge?.backend, + config, + openAiSidecar, + ); const webSearchBridgePlan = planPassthroughWebSearchBridge(parsed, route.provider, { isPassthrough: true, stream: parsed.stream === true, + auth: webSearchBridgeAuth, }); // Capture the binding that actually served the first leg, after its permitted reselection. const webSearchBridgeBinding = requestBindings.get(request); @@ -6278,7 +6287,13 @@ async function handleResponsesInner( }), false, ), - execute: createOllamaBridgeExecutor(webSearchBridgePlan, route.provider.apiKey ?? ""), + execute: createPassthroughWebSearchBridgeExecutor(webSearchBridgePlan, { + providerApiKey: route.provider.apiKey ?? "", + auth: webSearchBridgeAuth, + hostedTool: parsed._webSearch, + describeImages: isModelTextOnly(route.provider, route.modelId), + sidecar: config.webSearchSidecar, + }), // Appending a search result can push the continuation past the ceiling the first leg // was admitted under, so the same limit is re-applied before every later send. checkOutboundBody: (continuationBody: string) => { diff --git a/src/types/provider.ts b/src/types/provider.ts index eb2858fce7..49c9132b0a 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -87,11 +87,11 @@ export interface RateLimitRetryPolicy { } /** - * Backend ids admitted by `providers..webSearchBridge.backend`. Only `"ollama"` has a - * shipped executor; every other id is explicit-only and inert, the same contract the top-level - * `webSearchSidecar` uses for backends whose executor has not landed. Naming one of them keeps - * the bridge disarmed rather than silently falling back to a different search provider — in - * particular it never auto-selects a paid Luna or Exa search. + * Backend ids admitted by `providers..webSearchBridge.backend`. Each id is explicit-only: + * an omitted backend keeps the bridge disarmed rather than silently falling back to a paid + * Luna or Exa search. `ollama` spends this provider's API key on the search endpoint. + * `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and + * that executor's own credential; a missing credential leaves the bridge disarmed. */ export const PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS = [ "ollama", @@ -118,6 +118,7 @@ export type ProviderWebSearchBridgeBackend = typeof PROVIDER_WEB_SEARCH_BRIDGE_B * * Never armed for `authMode: "forward"` (ChatGPT) or for a provider that executes hosted search * upstream; see `planPassthroughWebSearchBridge` in `src/web-search/passthrough-bridge.ts`. + * A mixed `web_search` + client tool call still fails closed. Assistant text is not a search call. */ export interface ProviderWebSearchBridgeConfig { /** Master switch. Absent or false keeps today's relay-and-fail behavior exactly. */ diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 8b73b95645..e700e48a15 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -7,11 +7,17 @@ import { isCodexReserveRequestEligible } from "../codex/loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import { resolveSidecarAuth } from "../sidecar/auth"; -import { getAccountSet } from "../oauth/store"; import { validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; import type { OcxWebSearchSidecarConfig } from "../types"; import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout"; import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; +import { + findAnthropicSidecarProvider, + findGeminiSidecarProvider, + findXaiSidecarProvider, + xaiSearchOptionsFromConfig, + type AnthropicSidecarProvider, +} from "./sidecar-providers"; export { runWithWebSearch } from "./loop"; export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME }; @@ -19,6 +25,13 @@ export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-exe export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor"; export { runExaWebSearch, mapExaSearchResponse } from "./exa-executor"; +export { + findAnthropicSidecarProvider, + findGeminiSidecarProvider, + findXaiSidecarProvider, + xaiSearchOptionsFromConfig, + type AnthropicSidecarProvider, +}; const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset). @@ -85,72 +98,6 @@ export function webSearchStallTimeoutSec( return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC); } -/** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */ -export interface AnthropicSidecarProvider { - providerName: string; - provider: OcxProviderConfig; -} - -/** - * First enabled anthropic-adapter OAuth provider whose ACTIVE account holds a usable credential — the - * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by - * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a - * terminally-invalid account); token refresh happens later at executor time. - * Delegates to the shared sidecar auth module (#2188) so web-search and vision - * cannot drift on what "Anthropic auth present" means. - */ -export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined { - const auth = resolveSidecarAuth(config); - if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined; - return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; -} - -/** - * First enabled provider whose stored Grok OAuth account is active and not marked for - * reauth — the only credential the xai web-search executor may spend. Same account-set - * predicate the shared sidecar auth module applies to Anthropic. - */ -export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { - // The stored Grok credential lives under the provider named "xai" (registry id); - // OAuth account sets are keyed by provider name, so the name IS the credential key. - const provider = config.providers["xai"]; - if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; - const set = getAccountSet("xai"); - const active = set?.accounts.find(account => account.id === set.activeAccountId); - if (active && active.needsReauth !== true) return { providerName: "xai", provider }; - return undefined; -} - -/** - * First usable Antigravity credential holder: the "google-antigravity" provider - * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose - * active stored account is healthy AND carries a discovered CCA projectId — the - * executor cannot form the envelope without it. - */ -export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { - const provider = config.providers["google-antigravity"]; - if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; - const set = getAccountSet("google-antigravity"); - const active = set?.accounts.find(account => account.id === set.activeAccountId); - if (!active || active.needsReauth === true) return undefined; - const projectId = (active.credential as { projectId?: string } | undefined)?.projectId; - if (!projectId) return undefined; - return { providerName: "google-antigravity", provider }; -} - -/** Lift the persisted xSearch config block into executor options (absent block = web_search only). */ -export function xaiSearchOptionsFromConfig(cfg: Pick): XaiSearchOptions { - const x = cfg.xSearch; - if (!x || x.enabled !== true) return {}; - return { - xSearch: true, - ...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}), - ...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}), - ...(x.fromDate ? { fromDate: x.fromDate } : {}), - ...(x.toDate ? { toDate: x.toDate } : {}), - }; -} - /** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */ export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa"; diff --git a/src/web-search/passthrough-bridge.ts b/src/web-search/passthrough-bridge.ts index 6212e25c82..37fff58767 100644 --- a/src/web-search/passthrough-bridge.ts +++ b/src/web-search/passthrough-bridge.ts @@ -22,11 +22,17 @@ * explicit error. Answering both would need the raw mixed-tool continuation contract the * 2.47 track deferred (devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md), * and silently half-doing it would drop the client's own tool call. + * - Assistant text is never treated as a search instruction. The bridge intercepts structured + * function_call / custom_tool_call items named web_search, not XML-like prose. + * - Non-Ollama backends reuse the sidecar executors and those executors' own credentials. + * The passthrough provider's API key is sent only to an ollama search endpoint the operator + * authorized. A backend whose credential is missing stays disarmed rather than falling + * through to a different paid search. * - Continuation legs use a direct send rather than the core recovery ladder: the first leg * still goes through it, and a KEY-auth destination has no OAuth refresh path to replay. - * The caller's outbound body ceiling is re-applied to every continuation body. - * - The client stream is renumbered (sequence_number and output_index) because events are both - * dropped and injected; a plain relay cannot preserve upstream numbering through that. +* The caller's outbound body ceiling is re-applied to every continuation body. +* - The client stream is renumbered (sequence_number and output_index) because events are both +* dropped and injected; a plain relay cannot preserve upstream numbering through that. * * The stream this module produces is ordinary Responses SSE and is handed back to the core relay, * so the undeclared-tool guard, the provider payload rewrites, terminal-outcome recording, and the @@ -35,11 +41,28 @@ */ import { nextSseBlock, sseDataPayload } from "../server/sse-payload-rewrite"; import { toolChoiceToolPredicate } from "../types"; -import type { OcxParsedRequest, OcxProviderConfig, ProviderWebSearchBridgeBackend } from "../types"; -import type { SidecarOutcome } from "./executor"; +import type { + OcxConfig, + OcxParsedRequest, + OcxProviderConfig, + OcxWebSearchSidecarConfig, + ProviderWebSearchBridgeBackend, +} from "../types"; +import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; +import { runWebSearch, type SidecarOutcome, type SidecarSettings } from "./executor"; import { buildWebSearchTool, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; import { safeWebSearchSources } from "./sources"; import { runOllamaWebSearch } from "./ollama-executor"; +import { runAnthropicWebSearch } from "./anthropic-executor"; +import { runXaiWebSearch, validateXaiSearchOptions } from "./xai-executor"; +import { runGeminiWebSearch } from "./gemini-executor"; +import { runExaWebSearch } from "./exa-executor"; +import { + findAnthropicSidecarProvider, + findGeminiSidecarProvider, + findXaiSidecarProvider, + xaiSearchOptionsFromConfig, +} from "./sidecar-providers"; /** Canonical Ollama Cloud origin. The only origin the "ollama" backend derives on its own. */ export const OLLAMA_CLOUD_ORIGIN = "https://ollama.com"; @@ -67,10 +90,10 @@ const CLIENT_EXECUTED_ITEM_TYPES = new Set([ ]); export interface PassthroughWebSearchBridgePlan { - /** Resolved executor id. Only "ollama" has a shipped executor today. */ + /** Resolved executor id. Absent credential for that backend leaves the bridge disarmed. */ backend: ProviderWebSearchBridgeBackend; - /** Absolute search-API URL the executor posts to. */ - endpoint: string; + /** Absolute search-API URL for the ollama backend. Other backends ignore this. */ + endpoint?: string; /** Searches actually executed per turn before further calls are refused. */ maxSearches: number; /** Per-search deadline in milliseconds. */ @@ -112,6 +135,66 @@ export function resolveOllamaWebSearchEndpoint( : undefined; } +/** Credentials that may run a non-Ollama passthrough-bridge search. The key never rides the plan. */ +export interface PassthroughWebSearchBridgeAuth { + openAiSidecar?: ResolvedOpenAiForwardSidecar; + anthropic?: { providerName: string; provider: OcxProviderConfig }; + xai?: { providerName: string; provider: OcxProviderConfig }; + gemini?: { providerName: string; provider: OcxProviderConfig }; + exaApiKey?: string; +} + +/** + * Resolve the credential handle for one explicit bridge backend. Only that backend is inspected, + * so naming `exa` cannot spend a ChatGPT or Grok login, and naming `openai` cannot spend Exa. + */ +export function resolvePassthroughWebSearchBridgeAuth( + backend: ProviderWebSearchBridgeBackend | undefined, + config: OcxConfig, + openAiSidecar?: ResolvedOpenAiForwardSidecar, +): PassthroughWebSearchBridgeAuth { + switch (backend) { + case "openai": + return openAiSidecar ? { openAiSidecar } : {}; + case "anthropic": { + const anthropic = findAnthropicSidecarProvider(config); + return anthropic ? { anthropic } : {}; + } + case "xai": { + const xai = findXaiSidecarProvider(config); + if (!xai) return {}; + if (validateXaiSearchOptions(xaiSearchOptionsFromConfig(config.webSearchSidecar ?? {}))) { + return {}; + } + return { xai }; + } + case "gemini": { + const gemini = findGeminiSidecarProvider(config); + return gemini ? { gemini } : {}; + } + case "exa": { + const exaApiKey = config.webSearchSidecar?.exaApiKey; + return typeof exaApiKey === "string" && exaApiKey.length > 0 ? { exaApiKey } : {}; + } + default: + return {}; + } +} + +/** True when this passthrough turn may need the ChatGPT sidecar for an openai-backed bridge. */ +export function shouldResolveOpenAiPassthroughWebSearchBridge( + provider: OcxProviderConfig, + parsed: OcxParsedRequest, + isPassthrough: boolean, +): boolean { + if (!isPassthrough || parsed.stream !== true || !parsed._webSearch) return false; + if (provider.authMode !== "key") return false; + if (provider.webSearchBridge?.enabled !== true || provider.webSearchBridge.backend !== "openai") { + return false; + } + return toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool()); +} + /** * Decide whether this passthrough turn may run the web-search bridge. * @@ -126,7 +209,11 @@ export function resolveOllamaWebSearchEndpoint( export function planPassthroughWebSearchBridge( parsed: OcxParsedRequest, provider: OcxProviderConfig, - options: { isPassthrough: boolean; stream: boolean }, + options: { + isPassthrough: boolean; + stream: boolean; + auth?: PassthroughWebSearchBridgeAuth; + }, ): PassthroughWebSearchBridgePlan | undefined { if (!options.isPassthrough || !options.stream) return undefined; if (!parsed._webSearch) return undefined; @@ -137,10 +224,9 @@ export function planPassthroughWebSearchBridge( if (!bridge || bridge.enabled !== true) return undefined; // A tool_choice that excludes web search excludes the bridge too; the model may not search. if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined; - // Explicit-only, and inert for every backend whose executor has not shipped. - if (bridge.backend !== "ollama") return undefined; - const endpoint = resolveOllamaWebSearchEndpoint(provider); - if (!endpoint) return undefined; + // Explicit-only: an omitted backend never defaults to a paid sidecar search. + const backend = bridge.backend; + if (!backend) return undefined; const maxSearches = Number.isInteger(bridge.maxSearches) && bridge.maxSearches! >= 1 && bridge.maxSearches! <= 10 @@ -151,7 +237,18 @@ export function planPassthroughWebSearchBridge( && bridge.timeoutMs! <= 600_000 ? bridge.timeoutMs! : DEFAULT_BRIDGE_TIMEOUT_MS; - return { backend: "ollama", endpoint, maxSearches, timeoutMs }; + if (backend === "ollama") { + const endpoint = resolveOllamaWebSearchEndpoint(provider); + if (!endpoint) return undefined; + return { backend, endpoint, maxSearches, timeoutMs }; + } + const auth = options.auth; + if (backend === "openai" && auth?.openAiSidecar) return { backend, maxSearches, timeoutMs }; + if (backend === "anthropic" && auth?.anthropic) return { backend, maxSearches, timeoutMs }; + if (backend === "xai" && auth?.xai) return { backend, maxSearches, timeoutMs }; + if (backend === "gemini" && auth?.gemini) return { backend, maxSearches, timeoutMs }; + if (backend === "exa" && auth?.exaApiKey) return { backend, maxSearches, timeoutMs }; + return undefined; } /** One intercepted search call, carried from the upstream stream into the next request body. */ @@ -571,27 +668,154 @@ export function createOllamaBridgeExecutor( plan: PassthroughWebSearchBridgePlan, apiKey: string, ): PassthroughWebSearchBridgeExecutor { - return async (queries, signal) => { - const texts: string[] = []; - const sources: SidecarOutcome["sources"] = []; - const errors: string[] = []; - for (const query of queries) { - if (signal?.aborted) break; - const outcome = await runOllamaWebSearch(query, apiKey, plan.endpoint, plan.timeoutMs, signal); - if (outcome.error) { - errors.push(outcome.error); - continue; + return createPassthroughWebSearchBridgeExecutor(plan, { providerApiKey: apiKey }); +} + +/** Per-search credentials and sidecar settings. Secrets stay off the plan object. */ +export interface PassthroughWebSearchBridgeExecutorContext { + providerApiKey?: string; + auth?: PassthroughWebSearchBridgeAuth; + hostedTool?: Record; + describeImages?: boolean; + sidecar?: Pick; +} + +const DEFAULT_OPENAI_BRIDGE_MODEL = "gpt-5.6-luna"; +const DEFAULT_ANTHROPIC_BRIDGE_MODEL = "claude-sonnet-5"; +const DEFAULT_XAI_BRIDGE_MODEL = "grok-4.6"; +const DEFAULT_GEMINI_BRIDGE_MODEL = "gemini-3.8-flash"; +const DEFAULT_BRIDGE_REASONING = "low"; + +function sidecarSettingsForBridge( + backend: ProviderWebSearchBridgeBackend, + plan: PassthroughWebSearchBridgePlan, + context: PassthroughWebSearchBridgeExecutorContext, +): SidecarSettings { + const sidecar = context.sidecar ?? {}; + const model = backend === "anthropic" ? sidecar.model ?? DEFAULT_ANTHROPIC_BRIDGE_MODEL + : backend === "xai" ? sidecar.model ?? DEFAULT_XAI_BRIDGE_MODEL + : backend === "gemini" ? sidecar.model ?? DEFAULT_GEMINI_BRIDGE_MODEL + : sidecar.model ?? DEFAULT_OPENAI_BRIDGE_MODEL; + return { + model, + reasoning: sidecar.reasoning ?? DEFAULT_BRIDGE_REASONING, + timeoutMs: plan.timeoutMs, + describeImages: context.describeImages === true, + }; +} + +async function executeBridgeQueries( + queries: string[], + runOne: (query: string, signal?: AbortSignal) => Promise, + signal?: AbortSignal, +): Promise { + const texts: string[] = []; + const sources: SidecarOutcome["sources"] = []; + const errors: string[] = []; + for (const query of queries) { + if (signal?.aborted) break; + const outcome = await runOne(query, signal); + if (outcome.error) { + errors.push(outcome.error); + continue; + } + texts.push(queries.length > 1 ? "Results for \"" + query + "\":\n" + outcome.text : outcome.text); + for (const source of outcome.sources) { + if (!sources.some(existing => existing.url === source.url)) sources.push(source); + } + } + if (texts.length === 0) { + return { text: "", sources: [], error: errors[0] ?? "web search produced no results" }; + } + return { text: texts.join("\n\n"), sources }; +} + +/** + * Bind the executor for a planned backend. Ollama spends this provider's API key on the planned + * endpoint; every other backend spends the sidecar credential that armed the plan. + */ +export function createPassthroughWebSearchBridgeExecutor( + plan: PassthroughWebSearchBridgePlan, + context: PassthroughWebSearchBridgeExecutorContext, +): PassthroughWebSearchBridgeExecutor { + const settings = sidecarSettingsForBridge(plan.backend, plan, context); + return (queries, signal) => executeBridgeQueries(queries, async (query, querySignal) => { + switch (plan.backend) { + case "ollama": + if (!plan.endpoint) { + return { text: "", sources: [], error: "ollama web-search backend selected without an endpoint" }; + } + return runOllamaWebSearch( + query, + context.providerApiKey ?? "", + plan.endpoint, + plan.timeoutMs, + querySignal, + ); + case "openai": { + const sidecar = context.auth?.openAiSidecar; + if (!sidecar) { + return { text: "", sources: [], error: "openai web-search bridge selected without a ChatGPT sidecar" }; + } + return runWebSearch( + query, + context.hostedTool ?? { type: "web_search" }, + sidecar.provider, + sidecar.headers, + settings, + querySignal, + sidecar.recordOutcome, + ); } - texts.push(queries.length > 1 ? "Results for \"" + query + "\":\n" + outcome.text : outcome.text); - for (const source of outcome.sources) { - if (!sources.some(existing => existing.url === source.url)) sources.push(source); + case "anthropic": { + const anthropic = context.auth?.anthropic; + if (!anthropic) { + return { text: "", sources: [], error: "anthropic web-search bridge selected without stored Anthropic OAuth" }; + } + return runAnthropicWebSearch( + query, + anthropic.providerName, + anthropic.provider, + settings, + querySignal, + ); + } + case "xai": { + const xai = context.auth?.xai; + if (!xai) { + return { text: "", sources: [], error: "xai web-search bridge selected without stored Grok OAuth" }; + } + return runXaiWebSearch( + query, + xai.providerName, + xai.provider, + settings, + xaiSearchOptionsFromConfig(context.sidecar ?? {}), + querySignal, + ); + } + case "gemini": { + const gemini = context.auth?.gemini; + if (!gemini) { + return { text: "", sources: [], error: "gemini web-search bridge selected without stored Antigravity OAuth" }; + } + return runGeminiWebSearch( + query, + gemini.providerName, + gemini.provider, + settings, + querySignal, + ); + } + case "exa": { + const exaApiKey = context.auth?.exaApiKey; + if (!exaApiKey) { + return { text: "", sources: [], error: "exa web-search bridge selected without an exaApiKey" }; + } + return runExaWebSearch(query, exaApiKey, settings, querySignal); } } - if (texts.length === 0) { - return { text: "", sources: [], error: errors[0] ?? "web search produced no results" }; - } - return { text: texts.join("\n\n"), sources }; - }; + }, signal); } /** diff --git a/src/web-search/sidecar-providers.ts b/src/web-search/sidecar-providers.ts new file mode 100644 index 0000000000..9a718f6163 --- /dev/null +++ b/src/web-search/sidecar-providers.ts @@ -0,0 +1,76 @@ +/** + * Sidecar credential locators shared by the web-search loop and the key-auth + * passthrough bridge. Kept out of `index.ts` so the bridge can resolve a backend + * without importing the barrel (a cycle: core loads both, and the barrel is still + * evaluating when the bridge asks for these names). + */ +import type { OcxConfig, OcxProviderConfig, OcxWebSearchSidecarConfig } from "../types"; +import { resolveSidecarAuth } from "../sidecar/auth"; +import { getAccountSet } from "../oauth/store"; +import type { XaiSearchOptions } from "./xai-executor"; + +/** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */ +export interface AnthropicSidecarProvider { + providerName: string; + provider: OcxProviderConfig; +} + +/** + * First enabled anthropic-adapter OAuth provider whose ACTIVE account holds a usable credential — the + * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by + * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a + * terminally-invalid account); token refresh happens later at executor time. + * Delegates to the shared sidecar auth module (#2188) so web-search and vision + * cannot drift on what "Anthropic auth present" means. + */ +export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined { + const auth = resolveSidecarAuth(config); + if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined; + return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; +} + +/** + * First enabled provider whose stored Grok OAuth account is active and not marked for + * reauth — the only credential the xai web-search executor may spend. Same account-set + * predicate the shared sidecar auth module applies to Anthropic. + */ +export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { + // The stored Grok credential lives under the provider named "xai" (registry id); + // OAuth account sets are keyed by provider name, so the name IS the credential key. + const provider = config.providers["xai"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; + const set = getAccountSet("xai"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (active && active.needsReauth !== true) return { providerName: "xai", provider }; + return undefined; +} + +/** + * First usable Antigravity credential holder: the "google-antigravity" provider + * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose + * active stored account is healthy AND carries a discovered CCA projectId — the + * executor cannot form the envelope without it. + */ +export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { + const provider = config.providers["google-antigravity"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; + const set = getAccountSet("google-antigravity"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (!active || active.needsReauth === true) return undefined; + const projectId = (active.credential as { projectId?: string } | undefined)?.projectId; + if (!projectId) return undefined; + return { providerName: "google-antigravity", provider }; +} + +/** Lift the persisted xSearch config block into executor options (absent block = web_search only). */ +export function xaiSearchOptionsFromConfig(cfg: Pick): XaiSearchOptions { + const x = cfg.xSearch; + if (!x || x.enabled !== true) return {}; + return { + xSearch: true, + ...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}), + ...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}), + ...(x.fromDate ? { fromDate: x.fromDate } : {}), + ...(x.toDate ? { toDate: x.toDate } : {}), + }; +} diff --git a/structure/runtime.md b/structure/runtime.md index 4aff69172a..a09d007d7b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -239,6 +239,13 @@ dispatch keeps its normal reselection policy. `tests/web-search/web-search-passt covers drift during search, while pacing, and before first-leg headers return, plus successful first-dispatch reselection and result preservation. +`providers..webSearchBridge.backend` is explicit-only. `ollama` spends that provider's API key +on the planned search endpoint. `openai`, `anthropic`, `xai`, `gemini`, and `exa` reuse the matching +sidecar executor and that executor's own credential; a missing credential leaves the bridge +disarmed rather than falling through to another paid search. A leg that mixes an intercepted +`web_search` call with another client-executed tool still fails closed. Assistant text is not +treated as a search instruction. + ## Remote Hub hardening ownership `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. diff --git a/tests/web-search/web-search-passthrough-bridge.test.ts b/tests/web-search/web-search-passthrough-bridge.test.ts index 655c16831b..3bfe28ce2d 100644 --- a/tests/web-search/web-search-passthrough-bridge.test.ts +++ b/tests/web-search/web-search-passthrough-bridge.test.ts @@ -14,6 +14,8 @@ import { createPassthroughWebSearchBridgeStream, planPassthroughWebSearchBridge, resolveOllamaWebSearchEndpoint, + resolvePassthroughWebSearchBridgeAuth, + shouldResolveOpenAiPassthroughWebSearchBridge, WEB_SEARCH_BRIDGE_ERROR_CODE, WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE, type PassthroughWebSearchBridgePlan, @@ -146,7 +148,7 @@ describe("planPassthroughWebSearchBridge arming", () => { )).toBeUndefined(); }); - test("backends without a shipped executor stay inert rather than falling back", () => { + test("backends without resolved credentials stay inert rather than falling back", () => { for (const backend of ["openai", "anthropic", "xai", "gemini", "exa"] as const) { expect(planPassthroughWebSearchBridge( parsedFixture(), @@ -182,6 +184,82 @@ describe("planPassthroughWebSearchBridge arming", () => { expect(plan?.maxSearches).toBe(3); expect(plan?.timeoutMs).toBe(60_000); }); + + test("an openai backend arms only when the ChatGPT sidecar is present", () => { + const provider = providerFixture({ enabled: true, backend: "openai" }, { baseUrl: "https://gateway.example/v1" }); + expect(planPassthroughWebSearchBridge(parsedFixture(), provider, { + isPassthrough: true, + stream: true, + })).toBeUndefined(); + const openAiSidecar = { + providerName: "openai" as const, + provider: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + accountMode: "direct" as const, + authContext: { kind: "main" as const, accountId: null }, + headers: new Headers({ authorization: "Bearer chatgpt" }), + }; + const planned = planPassthroughWebSearchBridge(parsedFixture(), provider, { + isPassthrough: true, + stream: true, + auth: { openAiSidecar }, + }); + expect(planned).toEqual({ backend: "openai", maxSearches: 3, timeoutMs: 60_000 }); + expect(shouldResolveOpenAiPassthroughWebSearchBridge(provider, parsedFixture(), true)).toBe(true); + expect(shouldResolveOpenAiPassthroughWebSearchBridge(providerFixture(armed), parsedFixture(), true)).toBe(false); + }); + + test("sidecar backends arm only with their own credential handle", () => { + const gateway = { baseUrl: "https://gateway.example/v1" }; + const anthropic = { providerName: "claude", provider: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" } }; + const xai = { providerName: "xai", provider: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "oauth" } }; + const gemini = { providerName: "google-antigravity", provider: { adapter: "google-antigravity", baseUrl: "https://cloudcode-pa.googleapis.com", authMode: "oauth" } }; + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "anthropic" }, gateway), + { isPassthrough: true, stream: true, auth: { anthropic } }, + )?.backend).toBe("anthropic"); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "xai" }, gateway), + { isPassthrough: true, stream: true, auth: { xai } }, + )?.backend).toBe("xai"); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "gemini" }, gateway), + { isPassthrough: true, stream: true, auth: { gemini } }, + )?.backend).toBe("gemini"); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "exa" }, gateway), + { isPassthrough: true, stream: true, auth: { exaApiKey: "exa-canary" } }, + )?.backend).toBe("exa"); + // A named backend does not borrow a different credential. + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "exa" }, gateway), + { isPassthrough: true, stream: true, auth: { anthropic, xai, gemini } }, + )).toBeUndefined(); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "openai" }, gateway), + { isPassthrough: true, stream: true, auth: { exaApiKey: "exa-canary" } }, + )).toBeUndefined(); + }); + + test("resolvePassthroughWebSearchBridgeAuth inspects only the named backend", () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: {}, + webSearchSidecar: { exaApiKey: "exa-canary" }, + } as unknown as OcxConfig; + expect(resolvePassthroughWebSearchBridgeAuth("exa", cfg)).toEqual({ exaApiKey: "exa-canary" }); + expect(resolvePassthroughWebSearchBridgeAuth("openai", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("anthropic", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("xai", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("gemini", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("ollama", cfg)).toEqual({}); + }); }); const plan: PassthroughWebSearchBridgePlan = { @@ -390,6 +468,137 @@ describe("the bridged client stream", () => { expect((cell!.item as Record).status).toBe("failed"); }); + test("already-hosted web_search_call items pass through without a proxy search", async () => { + let sends = 0; + let executes = 0; + const hosted = { + type: "web_search_call", + id: "ws_hosted", + status: "completed", + action: { type: "search", query: "latest status" }, + }; + const hostedLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...hosted, status: "in_progress" } }), + frame("response.output_item.done", { output_index: 0, item: hosted }), + frame("response.output_item.added", { output_index: 1, item: { ...answer, content: [] } }), + frame("response.output_item.done", { output_index: 1, item: answer }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [hosted, answer] }, + }), + ); + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(hostedLeg), + requestBody: initialBody, + send: async () => { + sends += 1; + return new Response(null, { status: 500 }); + }, + execute: async () => { + executes += 1; + return { text: "unused", sources: [] }; + }, + }); + const body = await new Response(stream).text(); + expect(sends).toBe(0); + expect(executes).toBe(0); + expect(body).toContain("\"type\":\"web_search_call\""); + expect(body).toContain("The current release is 2.50.0."); + expect(body).not.toContain("response.failed"); + }); + + test("probe B mixed hosted cells plus exec plus web_search still fail closed", async () => { + let sends = 0; + let executes = 0; + const hosted = { + type: "web_search_call", + id: "ws_hosted", + status: "completed", + action: { type: "search", query: "already searched" }, + }; + const execCall = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"cmd\":\"python fetch.py\"}", + }; + const probeB = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...hosted, status: "in_progress" } }), + frame("response.output_item.done", { output_index: 0, item: hosted }), + frame("response.output_item.added", { output_index: 1, item: { ...execCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 1, item: execCall }), + frame("response.output_item.added", { output_index: 2, item: { ...searchCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 2, item: searchCall }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [hosted, execCall, searchCall] }, + }), + ); + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(probeB), + requestBody: initialBody, + send: async () => { + sends += 1; + return new Response(null, { status: 500 }); + }, + execute: async () => { + executes += 1; + return { text: "unused", sources: [] }; + }, + }); + const body = await new Response(stream).text(); + expect(sends).toBe(0); + expect(executes).toBe(0); + expect(body).not.toContain("\"name\":\"exec\""); + const failed = clientEvents(body).find(event => event.type === "response.failed"); + expect((failed!.response as { error: Record }).error.code) + .toBe(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + }); + + test("DeepSeek-style XML assistant text is not dispatched as a search", async () => { + let sends = 0; + let executes = 0; + const xmlAnswer = { + type: "message", + id: "msg_xml", + role: "assistant", + content: [{ + type: "output_text", + text: "I'll search for that information now.\n\n\nDeepSeek V4.1-Flash API price\n\n\nI don't have a web_search tool available.", + }], + }; + const xmlLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...xmlAnswer, content: [] } }), + frame("response.output_item.done", { output_index: 0, item: xmlAnswer }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [xmlAnswer] }, + }), + ); + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(xmlLeg), + requestBody: initialBody, + send: async () => { + sends += 1; + return new Response(null, { status: 500 }); + }, + execute: async () => { + executes += 1; + return { text: "unused", sources: [] }; + }, + }); + const body = await new Response(stream).text(); + expect(sends).toBe(0); + expect(executes).toBe(0); + expect(body).toContain(""); + expect(body).toContain("DeepSeek V4.1-Flash API price"); + expect(body).not.toContain("response.failed"); + expect(clientEvents(body).some(event => + event.type === "response.output_item.added" + && (event.item as Record).type === "web_search_call")).toBe(false); + }); + test("a search that is not the last item keeps its streamed position", async () => { // The model searches first and keeps talking; the hosted cell must open where the call stood. const leg = sseBody( @@ -583,21 +792,37 @@ describe("the reported turn, end to end through handleResponses", () => { outbound: string[]; destinations: Array<{ url: string; authorization: string | null }>; searches: number; + searchUrls: string[]; + searchHeaders: Array<{ url: string; authorization: string | null; xApiKey: string | null }>; }> { const savedFetch = globalThis.fetch; const outbound: string[] = []; const destinations: Array<{ url: string; authorization: string | null }> = []; + const searchUrls: string[] = []; + const searchHeaders: Array<{ url: string; authorization: string | null; xApiKey: string | null }> = []; let searches = 0; let leg = 0; globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; - if (url.includes("/api/web_search")) { + if (url.includes("/api/web_search") || url.includes("api.exa.ai/search")) { searches += 1; + searchUrls.push(url); + const headers = new Headers(init?.headers); + searchHeaders.push({ + url, + authorization: headers.get("authorization"), + xApiKey: headers.get("x-api-key"), + }); hooks.onSearch?.(); return new Response(JSON.stringify({ - results: [{ title: "Releases", url: "https://example.test/rel", content: "opencodex 2.50.0" }], + results: [{ + title: "Releases", + url: "https://example.test/rel", + content: "opencodex 2.50.0", + text: "opencodex 2.50.0", + }], }), { headers: { "content-type": "application/json" } }); } outbound.push(String(init?.body ?? "")); @@ -610,10 +835,10 @@ describe("the reported turn, end to end through handleResponses", () => { try { const response = await handleResponses(new Request("http://localhost/v1/responses", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: "Bearer caller-inbound" }, body: clientRequest, }), ocxConfig, { model: "", provider: "" }); - return { body: await response.text(), outbound, destinations, searches }; + return { body: await response.text(), outbound, destinations, searches, searchUrls, searchHeaders }; } finally { globalThis.fetch = savedFetch; } @@ -816,4 +1041,91 @@ describe("the reported turn, end to end through handleResponses", () => { expect(result.body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); expect(result.body).toContain("frobnicate"); }); + + test("an exa-backed gateway executes hosted-only search without the ollama origin", async () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "fixture-key", + webSearchBridge: { enabled: true, backend: "exa" }, + }, + }, + webSearchSidecar: { exaApiKey: "exa-canary" }, + } as unknown as OcxConfig; + const result = await post(cfg, [searchLeg(), answerLeg()]); + expect(result.searchUrls).toEqual(["https://api.exa.ai/search"]); + expect(result.searchHeaders).toEqual([ + { url: "https://api.exa.ai/search", authorization: null, xApiKey: "exa-canary" }, + ]); + expect(result.body).not.toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(result.body).toContain("\"type\":\"web_search_call\""); + expect(result.body).not.toContain("\"name\":\"web_search\""); + expect(result.body).toContain("The current release is 2.50.0."); + expect(result.destinations.map(destination => destination.url)).toEqual([ + "https://gateway.example/v1/responses", + "https://gateway.example/v1/responses", + ]); + expect(result.destinations.every(destination => destination.authorization === "Bearer fixture-key")).toBe(true); + }); + + test("an exa-backed mixed exec/search turn still fails closed", async () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "fixture-key", + webSearchBridge: { enabled: true, backend: "exa" }, + }, + }, + webSearchSidecar: { exaApiKey: "exa-canary" }, + } as unknown as OcxConfig; + const execCall = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{}", + }; + const mixedLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...searchCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 0, item: searchCall }), + frame("response.output_item.added", { output_index: 1, item: { ...execCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 1, item: execCall }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [searchCall, execCall] }, + }), + ); + const result = await post(cfg, [mixedLeg]); + expect(result.searches).toBe(0); + expect(result.body).toContain(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + expect(result.body).not.toContain("\"name\":\"exec\""); + }); + + test("exa without a key stays disarmed on a non-ollama gateway", async () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "fixture-key", + webSearchBridge: { enabled: true, backend: "exa" }, + }, + }, + } as unknown as OcxConfig; + const result = await post(cfg, [searchLeg()]); + expect(result.searches).toBe(0); + expect(result.body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); });