Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <origin>/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 <origin>/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 `<web_search>` 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. |
Expand Down
21 changes: 18 additions & 3 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down
11 changes: 6 additions & 5 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ export interface RateLimitRetryPolicy {
}

/**
* Backend ids admitted by `providers.<name>.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.<name>.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",
Expand All @@ -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. */
Expand Down
81 changes: 14 additions & 67 deletions src/web-search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,31 @@ 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 };
export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor";
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).
Expand Down Expand Up @@ -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<OcxWebSearchSidecarConfig, "xSearch">): 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";

Expand Down
Loading
Loading