From a37a184805ab78be88ef7661f7cfe0283b9884ad Mon Sep 17 00:00:00 2001 From: dvanh2 Date: Wed, 16 Sep 2026 10:09:57 +0700 Subject: [PATCH] feat(web-search): add openai-apikey backend with a per-lane reasoning toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the key-auth twin of the ChatGPT forward "openai" backend: "openai-apikey" POSTs the hosted web_search to api.openai.com/v1/responses with an operator-supplied key (webSearchSidecar.openaiApiKey, falling back to the OPENAI_API_KEY env). The key never rides the plan; it is resolved at unpack time and scrubbed from every log and error string. Reuses webSearchSidecar.reasoning as a switch: "off" (or empty) omits the reasoning field from the request entirely, which non-reasoning models such as gpt-4.1-mini require — sending reasoning.effort to them is a 400. Unset keeps the lane default ("low"); any other value is forwarded verbatim. The toggle is resolvable per backend through the shared resolveSidecarReasoning helper and is editable via PUT/GET /api/sidecar-settings. --- src/lib/redact.ts | 4 +- .../management/agent-settings-routes.ts | 4 +- src/server/management/config-routes.ts | 28 +++- src/server/responses/sidecar-execution.ts | 5 +- src/types/config.ts | 21 ++- src/web-search/alpha-search.ts | 10 +- src/web-search/backends.ts | 12 ++ src/web-search/executor.ts | 34 ++++- src/web-search/index.ts | 31 ++++- src/web-search/loop.ts | 11 ++ src/web-search/openai-apikey-executor.ts | 123 ++++++++++++++++++ src/web-search/passthrough-bridge.ts | 4 +- src/web-search/sidecar-providers.ts | 28 +++- src/web-search/xai-executor.ts | 3 +- 14 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 src/web-search/openai-apikey-executor.ts diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 2206a9baa9..f29978b025 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]"; * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than * re-deriving a narrower local list. */ -export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; +export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|openai[-_]?api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; /** * Colon-labelled credential headers echoed back inside an error body @@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook // Every letter position also accepts \u0001, the placeholder the fold emits for // an unresolved HTML named reference: `authorⅈzation` is the label with one // character we cannot name, and that is still the label. -const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; +const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|openai[_-]?api[_-]?key|openaiApiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token"; const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW .replace(/(?; if (requested.backend === null) delete override.backend; else if (requested.backend !== undefined) override.backend = requested.backend as never; diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 03d549a24a..c4aac68d92 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -706,6 +706,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise !!config.webSearchSidecar?.exaApiKey, eligibleModel: () => false, }, + { + backend: "openai-apikey", + // Probe = operator key resolvable (config field or OPENAI_API_KEY env). + isActive: (_auth, config) => resolveOpenAiApiKeyCredential(config) !== undefined, + // The executor POSTs the model string VERBATIM to api.openai.com, so only a + // bare native OpenAI slug is runnable — same stance as the "openai" lane + // (no namespaced ids, which this fixed public endpoint cannot route). + eligibleModel: candidate => candidate.provider === "openai" + && (candidate.native === true || candidate.authSlot === true) + && !candidate.id.includes("/"), + }, ]; /** diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 489fa8f399..c4cedc6607 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -11,7 +11,14 @@ import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; export interface SidecarSettings { model: string; - reasoning: string; + /** + * Reasoning effort to send upstream, or `undefined` to omit the `reasoning` field from the + * request entirely. `resolveSidecarReasoning` produces it from config: an unset value keeps + * the lane default, while `"off"` (or an empty string) yields `undefined` because + * non-reasoning models (e.g. gpt-4.1-mini) reject the field with a 400 — the operator turns + * it off per backend. Executors omit the field whenever this is undefined. + */ + reasoning?: string; timeoutMs: number; /** Effective Desktop authless compatibility does not grant auxiliary model use. */ reserveCompatibility?: boolean; @@ -23,6 +30,27 @@ export interface SidecarSettings { describeImages?: boolean; } +/** + * Resolve the operator's reasoning effort (`webSearchSidecar.reasoning`) to the value + * `SidecarSettings.reasoning` carries. Unset keeps the lane default — a config that never named + * an effort behaves exactly as before. `"off"` (or an empty/whitespace string) means "send no + * reasoning field at all": non-reasoning models (e.g. gpt-4.1-mini) reject `reasoning` with a + * 400, so the operator can turn it off per backend. Anything else passes through verbatim — the + * executor posts it as `reasoning.effort` and the upstream decides. + * + * Lives in the shared executor leaf so the forward plan (index.ts), the alpha/search plan + * (alpha-search.ts, which must not import the barrel) and the passthrough-bridge plan all + * collapse `"off"` the same way instead of diverging. + */ +export function resolveSidecarReasoning( + configured: string | undefined, + defaultEffort: string, +): string | undefined { + if (configured === undefined) return defaultEffort; + const trimmed = configured.trim(); + return trimmed === "" || trimmed.toLowerCase() === "off" ? undefined : configured; +} + // Shared with the anthropic-backed executor (single source; audit F3). The instruction is // backend-agnostic — both the gpt-mini sidecar and a Claude sidecar answer the same way. export const BASE_INSTRUCTION = @@ -67,7 +95,9 @@ export async function runWebSearch( input: [{ type: "message", role: "user", content: [{ type: "input_text", text: query }] }], tools: [hostedTool], tool_choice: "auto", - reasoning: { effort: settings.reasoning }, + // Omitted entirely when the operator turned reasoning off ("off"/"" in config): non-reasoning + // models reject the field with a 400. `reasoning.effort` values are sent verbatim otherwise. + ...(settings.reasoning !== undefined ? { reasoning: { effort: settings.reasoning } } : {}), // NOTE: the ChatGPT (codex) backend rejects `max_output_tokens` ("Unsupported parameter") and // requires `store: false` — keep this body minimal. The shared SSE parser bounds raw response // bytes before format-result applies its smaller display clamp. diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 4a942dc453..ff266a7d60 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -1,7 +1,7 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { modelInList, toolChoiceToolPredicate } from "../types"; import { requiresVisionPreprocessing } from "../vision"; -import type { SidecarSettings } from "./executor"; +import { resolveSidecarReasoning, type SidecarSettings } from "./executor"; import type { CodexAuthPolicyConfig } from "../codex/auth-context"; import { isCodexReserveRequestEligible } from "../codex/loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; @@ -15,6 +15,7 @@ import { findAnthropicSidecarProvider, findGeminiSidecarProvider, findXaiSidecarProvider, + resolveOpenAiApiKeyCredential, resolveSidecarBackend, xaiSearchOptionsFromConfig, type AnthropicSidecarProvider, @@ -27,10 +28,12 @@ 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 { runOpenAiApiKeyWebSearch } from "./openai-apikey-executor"; export { findAnthropicSidecarProvider, findGeminiSidecarProvider, findXaiSidecarProvider, + resolveOpenAiApiKeyCredential, resolveSidecarBackend, xaiSearchOptionsFromConfig, type AnthropicSidecarProvider, @@ -117,6 +120,12 @@ export interface SidecarPlan { xaiSearchOptions?: XaiSearchOptions; /** Presence marker for the exa backend — the API key itself never rides the plan. */ exaConfigured?: true; + /** + * Presence marker for the openai-apikey backend — the API key itself never rides the plan (the + * call site reads it from config at unpack time, exactly as exa does); the executor posts to the + * fixed `api.openai.com` Responses endpoint, so no URL rides the plan either. + */ + openaiApiKeyConfigured?: true; hostedTool: Record; settings: SidecarSettings; maxSearches: number; @@ -183,7 +192,7 @@ export function planWebSearch( // A target proven unable to accept image input receives verbalized image results instead of // search-result images. A genuinely unknown custom target keeps the established pass-through. const describeImages = requiresVisionPreprocessing(config, provider, modelId, options.providerName); - const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING; + const reasoning = resolveSidecarReasoning(cfg.reasoning, DEFAULT_SIDECAR_REASONING); const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true; // Anthropic backend authenticates with the STORED credential — no forward provider or ChatGPT login gate. @@ -259,6 +268,24 @@ export function planWebSearch( }; } + // openai-apikey: explicit-only, keyed by the operator's OpenAI API key (config or env) — the + // key-auth twin of the forward "openai" backend. The KEY never rides the plan (the call site + // resolves it at unpack time, exactly as exa does); the plan carries only a presence marker. + // Fail-closed without a usable key (no default OpenAI key is ever borrowed). + if (backend === "openai-apikey") { + if (!resolveOpenAiApiKeyCredential(config)) return undefined; + return { + backend: "openai-apikey", + openaiApiKeyConfigured: true, + hostedTool: parsed._webSearch, + settings: { model: cfg.model ?? DEFAULT_SIDECAR_MODEL, reasoning, timeoutMs, describeImages }, + maxSearches, + routedModelStallTimeoutMs, + stallTimeoutSec, + streamRoutedModelOutput, + }; + } + // OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search. if (!openAiSidecar) return undefined; return { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 849eece2da..4300f433d9 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -10,6 +10,7 @@ import { runAnthropicWebSearch } from "./anthropic-executor"; import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor"; import { runGeminiWebSearch } from "./gemini-executor"; import { runExaWebSearch } from "./exa-executor"; +import { runOpenAiApiKeyWebSearch } from "./openai-apikey-executor"; import type { WebSearchBackendId } from "./index"; import { clearableDeadline } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; @@ -289,6 +290,8 @@ export interface WebSearchLoopDeps { geminiSidecar?: { providerName: string; provider: OcxProviderConfig }; /** Required for the exa backend: the operator key, read from config at plan unpack (L9). */ exaApiKey?: string; + /** Required for the openai-apikey backend: the operator's OpenAI API key, read from config at plan unpack. */ + openaiApiKey?: string; /** Opt-in x_search options for the xai backend. */ xaiSearchOptions?: XaiSearchOptions; hostedTool: Record; @@ -744,6 +747,14 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise`, then reuses the shared Responses + * SSE parser (`parseSidecarSSE`) and the same `SidecarSettings`/`SidecarOutcome` contract. + * + * Transport mirrors the exa key-based executor (no `withUpstreamHttpVersion` pin — the key-auth + * path has no `upstreamHttpVersion` of its own): `applyUpstreamRecoveryInit` for the recovery + * fields, `redirect: "manual"` so a cross-origin 3xx cannot carry the `Authorization` header to a + * redirect target. Never throws — every error string passes `redactSecretString` AND the key is + * scrubbed from the literal value, because pattern-based redaction cannot be trusted to recognize + * an arbitrary operator key. + */ +import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; +import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; +import { sidecarEnter } from "../lib/sidecar-tracker"; +import { redactSecretString } from "../lib/redact"; +import { parseSidecarSSE } from "./parse"; +import { + BASE_INSTRUCTION, + IMAGE_INSTRUCTION, + type SidecarOutcome, + type SidecarOutcomeRecorder, + type SidecarSettings, +} from "./executor"; + +/** + * The public OpenAI Responses endpoint that hosts the web_search tool for a standard `sk-…` key. + * Hardcoded (like the exa executor's EXA_SEARCH_URL): this backend is a fixed public capability, + * not a configurable provider, so the operator supplies only the key. + */ +const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; + +export async function runOpenAiApiKeyWebSearch( + query: string, + apiKey: string, + hostedTool: Record, + settings: SidecarSettings, + abortSignal?: AbortSignal, + recordOutcome?: SidecarOutcomeRecorder, +): Promise { + if (!apiKey) { + return { text: "", sources: [], error: "openai-apikey sidecar selected without a usable OpenAI API key (webSearchSidecar.openaiApiKey or OPENAI_API_KEY)" }; + } + // The executor KNOWS the secret — pattern-based redaction cannot be trusted to recognize an + // arbitrary operator key, so scrub the literal value explicitly before anything reaches a log + // (mirrors the exa executor's scrub; runWebSearch has no key to scrub because it is keyless). + const scrub = (s: string) => redactSecretString(s.split(apiKey).join("[redacted-openai-key]")); + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; + const body = { + model: settings.model, + instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: query }] }], + tools: [hostedTool], + tool_choice: "auto", + // Omitted when `SidecarSettings.reasoning` is undefined (operator set `reasoning: "off"` or + // left it empty): non-reasoning models such as gpt-4.1-mini reject the field with a 400. + ...(settings.reasoning !== undefined ? { reasoning: { effort: settings.reasoning } } : {}), + // Same minimal body runWebSearch sends the forward backend: the hosted web_search runs + // server-side and the shared SSE parser bounds the streamed response. + store: false, + stream: true, + }; + const url = OPENAI_RESPONSES_URL; + const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); + const sidecarExit = sidecarEnter("web-search"); + const t0 = Date.now(); + try { + const res = await fetchWithResetRetry( + recovery => fetch(url, applyUpstreamRecoveryInit({ + method: "POST", + headers, + body: JSON.stringify(body), + signal: linkedSignal.signal, + // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization` across + // origins, but a manual redirect is the explicit, auditable form (mirrors runWebSearch). + redirect: "manual", + }, recovery)), + { abortSignal: linkedSignal.signal, label: "web-search-sidecar" }, + ); + // Attach the body guard before ANY branch reads it. The success path guards itself below, but + // the failure branch's `res.text()` runs first, so a cancel landing between fetch resolution + // and reader attach would otherwise orphan the internal rejection. + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + if (!res.ok) { + recordOutcome?.(res.status); + const t = await res.text().catch(() => ""); + detachBodyGuard(); + console.warn(`[web-search] openai-apikey sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); + // Scrub BEFORE truncating: slicing first can cut the literal key at the boundary, leaving an + // unscrubbable key prefix in the surviving error text. + return { text: "", sources: [], error: `openai-apikey sidecar HTTP ${res.status}: ${scrub(t.slice(0, 200))}` }; + } + try { + const parsed = await parseSidecarSSE(res); + if (linkedSignal.signal.aborted) throw linkedSignal.signal.reason; + recordOutcome?.(res.status); + return parsed; + } finally { + detachBodyGuard(); + } + } catch (e) { + const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + const callerAborted = abortSignal?.aborted === true + && linkedSignal.signal.aborted + && linkedSignal.signal.reason === abortSignal.reason + && e === linkedSignal.signal.reason; + recordOutcome?.(callerAborted ? "connect_neutral" : kind); + console.warn(`[web-search] openai-apikey sidecar ${kind} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`); + return { text: "", sources: [], error: scrub(e instanceof Error ? e.message : String(e)) }; + } finally { + sidecarExit(); + linkedSignal.cleanup(); + } +} diff --git a/src/web-search/passthrough-bridge.ts b/src/web-search/passthrough-bridge.ts index be03084fb7..1571a59737 100644 --- a/src/web-search/passthrough-bridge.ts +++ b/src/web-search/passthrough-bridge.ts @@ -58,7 +58,7 @@ import type { ProviderWebSearchBridgeBackend, } from "../types"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; -import { runWebSearch, type SidecarOutcome, type SidecarSettings } from "./executor"; +import { resolveSidecarReasoning, 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"; @@ -840,7 +840,7 @@ export function sidecarSettingsForBridge( const sidecar = context.sidecar ?? {}; return { model: modelForBridgeBackend(backend, sidecar), - reasoning: sidecar.reasoning ?? DEFAULT_BRIDGE_REASONING, + reasoning: resolveSidecarReasoning(sidecar.reasoning, DEFAULT_BRIDGE_REASONING), timeoutMs: plan.timeoutMs, describeImages: context.describeImages === true, }; diff --git a/src/web-search/sidecar-providers.ts b/src/web-search/sidecar-providers.ts index c98cd28a15..958c7979dd 100644 --- a/src/web-search/sidecar-providers.ts +++ b/src/web-search/sidecar-providers.ts @@ -10,7 +10,7 @@ import { getAccountSet } from "../oauth/store"; import type { XaiSearchOptions } from "./xai-executor"; /** 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"; +export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa" | "openai-apikey"; /** * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The @@ -18,7 +18,9 @@ export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "ex * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna) * to the Anthropic API. * The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their - * planWebSearch arms stay fail-closed until each executor layer lands. + * planWebSearch arms stay fail-closed until each executor layer lands. openai-apikey is the + * explicit-only, key-auth twin of the ChatGPT forward "openai" backend — resolved to itself like + * the other explicit-only ids (no auto-selection from key availability). * * Lives here rather than in `index.ts` for the reason at the top of this file: the passthrough * bridge has to answer "which backend was this global sidecar block configured for?" without @@ -27,10 +29,30 @@ export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "ex export function resolveSidecarBackend( explicit: WebSearchBackendId | undefined, ): WebSearchBackendId { - if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit; + if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa" + || explicit === "openai-apikey") return explicit; return "openai"; } +/** + * The OpenAI API key the "openai-apikey" backend authenticates with: the operator's explicit + * `webSearchSidecar.openaiApiKey`, falling back to the process `OPENAI_API_KEY`. It NEVER borrows + * a persisted ChatGPT login (this backend is the key-auth twin of the forward "openai" path, and + * borrowing a stored credential here would silently spend a login the operator did not name). + * Empty/whitespace resolves to undefined so the caller fails closed exactly like `exaApiKey`. + */ +export function resolveOpenAiApiKeyCredential( + config: OcxConfig, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const explicit = config.webSearchSidecar?.openaiApiKey; + const fromEnv = env.OPENAI_API_KEY; + const candidate = typeof explicit === "string" && explicit.trim() !== "" + ? explicit + : (typeof fromEnv === "string" && fromEnv.trim() !== "" ? fromEnv : ""); + return candidate === "" ? undefined : candidate; +} + /** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */ export interface AnthropicSidecarProvider { providerName: string; diff --git a/src/web-search/xai-executor.ts b/src/web-search/xai-executor.ts index 3fda1a3777..09d8fbccca 100644 --- a/src/web-search/xai-executor.ts +++ b/src/web-search/xai-executor.ts @@ -94,7 +94,8 @@ export async function runXaiWebSearch( input: [{ role: "user", content: query }], tools: [{ type: "web_search" }, ...(options.xSearch ? [buildXSearchTool(options)] : [])], include: ["web_search_call.action.sources"], - reasoning: { effort: settings.reasoning }, + // Omitted when reasoning is off — `SidecarSettings.reasoning` is undefined then. + ...(settings.reasoning !== undefined ? { reasoning: { effort: settings.reasoning } } : {}), stream: true, }; const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);