From 1a02997fffcb546c6a7bff1ff086c81f356428c5 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 04:31:30 +0800 Subject: [PATCH 01/43] feat(codex): serve /alpha/search via internal web-search providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex CLI's web-search tool POSTs a SearchRequest to `/alpha/search` and feeds the returned `output` string back to the model. Serve it from Floway's own configured web-search provider instead of proxying chatgpt.com. codex's `commands` is an object keyed by command kind (`{search_query:[{q}], open:[{ref_id}], find:[{ref_id,pattern}], …}`) — byte-for-byte the shape the Responses web_search shim already parses. So extract the protocol-neutral execution engine (command parsing, provider resolution, search/open/find execution, page cache, IR building, text rendering, domain filtering) out of the Responses shim into a shared tools/web-search/operations.ts, and build both surfaces on it: the shim wraps each op into a web_search_call wire item; the Codex endpoint concatenates the rendered text of every op into one output string. Unimplemented command kinds (image_query, click, screenshot, finance, weather, sports, time, response_length) surface as deterministic per-op error text. Disabled/missing search config surfaces the same in-band 'provider not configured' text the shim uses. Request validated with a zod schema mirroring codex-rs SearchRequest; usage accounted per API key. --- .../interceptors/server-tools/web-search.ts | 866 +----------------- .../server-tools/web-search_test.ts | 44 +- .../src/data-plane/codex/alpha-search.ts | 121 +++ .../gateway/src/data-plane/codex/routes.ts | 10 + .../data-plane/tools/web-search/operations.ts | 852 +++++++++++++++++ 5 files changed, 1051 insertions(+), 842 deletions(-) create mode 100644 packages/gateway/src/data-plane/codex/alpha-search.ts create mode 100644 packages/gateway/src/data-plane/tools/web-search/operations.ts diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts index 36dc3cc3c..7db4a341f 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search.ts @@ -1,16 +1,38 @@ import { shortId } from '../../../../../shared/short-id.ts'; -import { normalizeDomainEntry, normalizeDomainList } from '../../../../tools/web-search/domain-normalize.ts'; -import { fetchPageAndRecordUsage } from '../../../../tools/web-search/fetch-page.ts'; +import { normalizeDomainEntry } from '../../../../tools/web-search/domain-normalize.ts'; +import { + actionSearchQueries, + CONTEXT_SIZE_TO_MAX_RESULTS, + DEFAULT_SEARCH_CONTEXT_SIZE, + executeOperationToIr, + isSearchContextSize, + maxResultsForContextSize, + parseWebSearchOperations, + renderOperationOutputText, + runBackendSearchMulti, + schemaErrorIr, + startBatchFetch, + type ParsedWebSearchOperations, + type WebSearchCallIR, + type WebSearchExecutionSession, + type WebSearchFilters, + type WebSearchOperation, +} from '../../../../tools/web-search/operations.ts'; import { resolveConfiguredWebSearchProvider } from '../../../../tools/web-search/provider.ts'; import { loadSearchConfig } from '../../../../tools/web-search/search-config.ts'; -import { searchWebAndRecordUsage } from '../../../../tools/web-search/search.ts'; -import type { ConfiguredWebSearchProvider, WebSearchProvider, WebSearchProviderName } from '../../../../tools/web-search/types.ts'; +import type { ConfiguredWebSearchProvider } from '../../../../tools/web-search/types.ts'; import { truncatePreservingCodePoints } from '../../../shared/text.ts'; import { type ServerToolLoopState, type ServerToolOutputItem, type ServerToolRegistration } from '../server-tool-shim.ts'; -import type { ResponsesFunctionTool, ResponsesFunctionToolCallItem, ResponsesHostedTool, ResponsesInputItem, ResponsesOutputWebSearchCall, ResponsesTool, ResponsesWebSearchAction, ResponsesWebSearchResult } from '@floway-dev/protocols/responses'; +import type { ResponsesFunctionTool, ResponsesFunctionToolCallItem, ResponsesHostedTool, ResponsesInputItem, ResponsesOutputWebSearchCall, ResponsesTool, ResponsesWebSearchAction } from '@floway-dev/protocols/responses'; import { WEB_SEARCH_HOSTED_TYPE_NAMES } from '@floway-dev/protocols/responses'; import { providerModelOf } from '@floway-dev/provider'; +// Re-export the shared engine's pure helpers under this module's public +// surface so the shim's own tests keep a single entry point for the +// Responses web-search feature. +export { findMatches, formatMatches, isUrlAllowed, parseWebSearchOperations } from '../../../../tools/web-search/operations.ts'; +export type { WebSearchOperation } from '../../../../tools/web-search/operations.ts'; + // Runtime set derived from the canonical tuple declared next to // `ResponsesHostedToolType` so the type union and runtime check can't drift. // https://github.com/openai/openai-python/blob/e75766769547601a25ed83b666c4d0fd046881f0/src/openai/types/responses/web_search_tool.py @@ -21,36 +43,10 @@ export const WEB_SEARCH_HOSTED_TYPES: ReadonlySet = new Set(WEB_ // uses the underscored form of the model's training-time `web.run`. export const SHIM_TOOL_NAME = 'web_search'; -interface ShimToolFilters { - allowedDomains?: string[]; - blockedDomains?: string[]; - userLocation?: { city?: string; region?: string; country?: string; timezone?: string }; - maxResults?: number; -} - -// Approximates the ~40 results native hosted web_search returns -// regardless of search_context_size; backends bill per call, so larger -// result sets only multiply upstream context-window cost. `medium` is -// the native default (matches openai-python `WebSearchTool.search_context_size` -// docstring: "Defaults to 'medium'") — when the client omits the field -// or sends an explicit `'medium'`, we still pass the corresponding -// maxResults so providers don't fall back to their own (smaller) -// default count. -const CONTEXT_SIZE_TO_MAX_RESULTS: Record<'low' | 'medium' | 'high', number> = { - low: 10, - medium: 20, - high: 40, -}; - -const DEFAULT_SEARCH_CONTEXT_SIZE: keyof typeof CONTEXT_SIZE_TO_MAX_RESULTS = 'medium'; - -const isValidSearchContextSize = (v: unknown): v is keyof typeof CONTEXT_SIZE_TO_MAX_RESULTS => - typeof v === 'string' && v in CONTEXT_SIZE_TO_MAX_RESULTS; - // The hosted tool's `user_location` must surface to the model, not just // to the backend provider — without this hint the model asks "Which // city should I check?" even when the client supplied one. -const formatUserLocation = (loc: NonNullable): string => { +const formatUserLocation = (loc: NonNullable): string => { const parts: string[] = []; if (loc.city) parts.push(loc.city); if (loc.region && loc.region !== loc.city) parts.push(loc.region); @@ -167,17 +163,12 @@ export const canonicalizeWebSearchTool = (raw: ResponsesTool): ResponsesHostedTo return canonical; }; -const extractFilters = (tool: ResponsesHostedTool): ShimToolFilters => { - const out: ShimToolFilters = {}; +const extractFilters = (tool: ResponsesHostedTool): WebSearchFilters => { + const out: WebSearchFilters = {}; if (tool.filters?.allowed_domains) out.allowedDomains = tool.filters.allowed_domains; if (tool.filters?.blocked_domains) out.blockedDomains = tool.filters.blocked_domains; if (tool.user_location) out.userLocation = tool.user_location; - // Default to native's documented default (`medium`) when omitted. - // Without this, a provider-side default (e.g. Tavily's smaller - // baseline count) would silently shrink the result set on requests - // that didn't think about search_context_size at all. - const size = tool.search_context_size ?? DEFAULT_SEARCH_CONTEXT_SIZE; - out.maxResults = CONTEXT_SIZE_TO_MAX_RESULTS[size as keyof typeof CONTEXT_SIZE_TO_MAX_RESULTS]; + out.maxResults = maxResultsForContextSize(tool.search_context_size); return out; }; @@ -189,7 +180,7 @@ interface PrepareToolsError { } type PrepareToolsResult = - | { ok: true; filters: ShimToolFilters } + | { ok: true; filters: WebSearchFilters } | { ok: false; error: PrepareToolsError }; // Per-list cap matches the OpenAI documented "up to 100 allowed_domains @@ -223,7 +214,7 @@ const validateDomainListEntry = ( // regardless. const validateHostedEntry = (tool: ResponsesHostedTool): PrepareToolsError | null => { const sizeField = (tool as { search_context_size?: unknown }).search_context_size; - if (sizeField !== undefined && sizeField !== null && !isValidSearchContextSize(sizeField)) { + if (sizeField !== undefined && sizeField !== null && !isSearchContextSize(sizeField)) { return { message: `web_search tool search_context_size must be one of ${Object.keys(CONTEXT_SIZE_TO_MAX_RESULTS).map(k => `'${k}'`).join(' | ')}; got ${JSON.stringify(sizeField)}.`, param: 'tools[].search_context_size', @@ -273,7 +264,7 @@ const validateHostedEntry = (tool: ResponsesHostedTool): PrepareToolsError | nul export const prepareToolsForShim = ( tools: ResponsesTool[], ): PrepareToolsResult => { - let firstHostedFilters: ShimToolFilters = {}; + let firstHostedFilters: WebSearchFilters = {}; let captured = false; for (const tool of tools) { if (isHostedWebSearchTool(tool)) { @@ -288,187 +279,12 @@ export const prepareToolsForShim = ( return { ok: true, filters: firstHostedFilters }; }; -// ── Shim call parsing ── -// Types describe one logical operation parsed out of a shim call -// function_call. 13 documented sub-properties total in the gpt-5.x -// `web.run` shape; the shim implements 3 (search/open/find) and surfaces -// the other 10 as `unsupported` ops. `parseShimOperations` below -// produces a flat list in source order. - -export type ShimOperationErrorKind = 'invalid-ref' | 'missing-arg'; - -export type ShimLogicalOperation = - | { - kind: 'search'; - /** Original index inside the shim's `search_query` array. */ - arrayIndex: number; - query: string; - /** When set, dispatch returns this verbatim instead of hitting the backend. */ - error?: string; - errorKind?: ShimOperationErrorKind; - } - | { - kind: 'open'; - arrayIndex: number; - error?: string; - errorKind?: ShimOperationErrorKind; - url: string; - } - | { - kind: 'find'; - arrayIndex: number; - error?: string; - errorKind?: ShimOperationErrorKind; - url: string; - pattern: string; - } - | { - kind: 'unsupported'; - /** The shim call sub-property name the model populated (e.g. `click`). */ - subProperty: string; - /** Original index inside that sub-property's array. */ - arrayIndex: number; - } - | { - kind: 'wrong-type'; - subProperty: 'search_query' | 'open' | 'find'; - actualType: string; - }; - -export type ParsedShimCall = { kind: 'ops'; ops: ShimLogicalOperation[] } | { kind: 'malformed' }; - -// Stricter than `/^https?:\/\//i`: that regex accepts `https://` (empty -// host). Reject malformed refs at parse time so dispatch always sees a -// well-formed URL. -const isUrl = (s: string): boolean => { - let parsed: URL; - try { - parsed = new URL(s); - } catch { - return false; - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; - if (parsed.hostname === '') return false; - return true; -}; - -const refIdError = (refId: string): string => - `Error: ref_id must be a fully-qualified URL in the gateway shim (got '${refId}'). The gateway shim does not preserve prior-call ids across turns.`; - -const missingArgError = (field: string): string => - `Error: missing required argument "${field}".`; - -const SUPPORTED_KEYS: ReadonlySet = new Set(['search_query', 'open', 'find']); - // Cap on the wire-item dump inlined into the malformed-input branch's // `function_call_output` placeholder. A pathological prior wsc echo // (deeply nested, multi-kilobyte) shouldn't get to blow the upstream // context window through the diagnostic that explains it. const MAX_MALFORMED_WIRE_DUMP_CHARS = 1024; -const stringField = (entry: unknown, key: string): string => { - if (entry === null || typeof entry !== 'object') return ''; - const value = (entry as Record)[key]; - return typeof value === 'string' ? value : ''; -}; - -const describeJsonType = (v: unknown): string => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v; - -export const parseShimOperations = (args: Record | null): ParsedShimCall => { - if (args === null) return { kind: 'malformed' }; - const ops: ShimLogicalOperation[] = []; - - const searchQuery = args.search_query; - if (searchQuery !== undefined) { - if (!Array.isArray(searchQuery)) { - ops.push({ kind: 'wrong-type', subProperty: 'search_query', actualType: describeJsonType(searchQuery) }); - } else { - for (let i = 0; i < searchQuery.length; i++) { - const q = stringField(searchQuery[i], 'q'); - if (q === '') { - ops.push({ kind: 'search', arrayIndex: i, query: '', error: missingArgError('q'), errorKind: 'missing-arg' }); - continue; - } - ops.push({ kind: 'search', arrayIndex: i, query: q }); - } - } - } - - const open = args.open; - if (open !== undefined) { - if (!Array.isArray(open)) { - ops.push({ kind: 'wrong-type', subProperty: 'open', actualType: describeJsonType(open) }); - } else { - for (let i = 0; i < open.length; i++) { - const refId = stringField(open[i], 'ref_id'); - if (refId === '') { - ops.push({ kind: 'open', arrayIndex: i, url: '', error: missingArgError('ref_id'), errorKind: 'missing-arg' }); - continue; - } - if (!isUrl(refId)) { - ops.push({ kind: 'open', arrayIndex: i, url: refId, error: refIdError(refId), errorKind: 'invalid-ref' }); - continue; - } - ops.push({ kind: 'open', arrayIndex: i, url: refId }); - } - } - } - - const find = args.find; - if (find !== undefined) { - if (!Array.isArray(find)) { - ops.push({ kind: 'wrong-type', subProperty: 'find', actualType: describeJsonType(find) }); - } else { - for (let i = 0; i < find.length; i++) { - const refId = stringField(find[i], 'ref_id'); - const pattern = stringField(find[i], 'pattern'); - if (refId === '') { - ops.push({ kind: 'find', arrayIndex: i, url: '', pattern, error: missingArgError('ref_id'), errorKind: 'missing-arg' }); - continue; - } - if (!isUrl(refId)) { - ops.push({ kind: 'find', arrayIndex: i, url: refId, pattern, error: refIdError(refId), errorKind: 'invalid-ref' }); - continue; - } - if (pattern === '') { - ops.push({ kind: 'find', arrayIndex: i, url: refId, pattern: '', error: missingArgError('pattern'), errorKind: 'missing-arg' }); - continue; - } - ops.push({ kind: 'find', arrayIndex: i, url: refId, pattern }); - } - } - } - - // Top-level keys outside the shim call surface as unsupported ops. - for (const key of Object.keys(args)) { - if (SUPPORTED_KEYS.has(key)) continue; - const value = args[key]; - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - ops.push({ kind: 'unsupported', subProperty: key, arrayIndex: i }); - } - } else { - ops.push({ kind: 'unsupported', subProperty: key, arrayIndex: 0 }); - } - } - - return { - kind: 'ops', - ops, - }; -}; - -const ITERATION_CAP = 30; - -// One web_search backend op's result data: the action shape downstream -// references and the result list the renderer formats. Thin DTO — the -// wsc id and `status: 'completed'` live on the dispatcher's slot, not -// in here. -interface WebSearchCallIR { - action: ResponsesWebSearchAction; - results: ResponsesWebSearchResult[]; -} - /** * Persistent `payload.private` shape for one `web_search_call`. One shim call * function_call corresponds to exactly one wsc and one op — multi-op @@ -520,101 +336,6 @@ export const synthesizeWebSearchCallId = (): string => shortId('ws_gw'); // so a replay call_id never reads as a wsc id in logs. const synthesizeReplayCallId = (): string => shortId('cc_replay'); -const searchIr = ( - query: string, - results: ResponsesWebSearchResult[], - sources?: { type: 'url'; url: string }[], -): WebSearchCallIR => searchIrFromQueries([query], results, sources); - -// Builds a single wsc for one or more search queries. Multi-query actions -// are protocol-native (`{type:'search', queries:[...]}`); the singular -// `query` field is emitted alongside for SDKs that only know the legacy -// single-string shape — see `actionSearchQueries`. -const searchIrFromQueries = ( - queries: string[], - results: ResponsesWebSearchResult[], - sources?: { type: 'url'; url: string }[], -): WebSearchCallIR => ({ - action: { - type: 'search', - query: queries.join(' | '), - queries, - // Native gates `sources` on `include: - // ["web_search_call.action.sources"]`; only include when the - // client opted in. The producer (dispatch.ts) decides whether to - // pass the list based on the include token. - ...(sources !== undefined ? { sources } : {}), - }, - results, -}); - -const openPageIr = ( - url: string | undefined, - results: ResponsesWebSearchResult[], -): WebSearchCallIR => ({ - // Omit `url` when undefined to match native's soft-failure shape; - // never emit `url: ''`. - action: url !== undefined && url.length > 0 - ? { type: 'open_page', url } - : { type: 'open_page' }, - results, -}); - -const findInPageIr = ( - url: string, - pattern: string, - results: ResponsesWebSearchResult[], -): WebSearchCallIR => ({ - action: { type: 'find_in_page', url, pattern }, - results, -}); - -// No native action.type fits shim-only error classes (unknown -// sub-property, malformed args); encode them via action.type:'search' -// with the diagnostic in queries[0] so wire-typed SDKs still parse the -// item. -const schemaErrorIr = ( - queryLabel: string, - title: string, - snippet: string, -): WebSearchCallIR => ({ - // Emit both `query` and `queries`; see `actionSearchQueries`. - action: { type: 'search', query: queryLabel, queries: [queryLabel] }, - results: [{ - type: 'text_result', - url: '', - title, - snippet, - }], -}); - -// Error-text phrasings closely follow OpenAI's gpt-oss reference -// simple_browser tool so gpt-oss-family models (trained on those exact -// phrasings) recognize the structure; non-OpenAI models read them as -// plain natural-language tool output. -// -// References (pinned to commit 285b05d for stable line numbers): -// - gpt-oss simple_browser_tool.py `find` no-match phrase, line 246: -// https://github.com/openai/gpt-oss/blob/285b05d96dea9ce7da52ecbbe86791f18239c510/gpt_oss/tools/simple_browser/simple_browser_tool.py#L246 -// - gpt-oss simple_browser_tool.py `BackendError` fetching phrase, lines 444-445: -// https://github.com/openai/gpt-oss/blob/285b05d96dea9ce7da52ecbbe86791f18239c510/gpt_oss/tools/simple_browser/simple_browser_tool.py#L444-L445 -// - litellm `Search failed: ` idiom: -// https://github.com/BerriAI/litellm/blob/main/litellm/integrations/websearch_interception/transformation.py -const searchFailedText = (providerMessage: string): string => - `Search failed: ${providerMessage}`; - -const openFailedText = (url: string, providerMessage: string): string => - `Error fetching URL \`${url}\`: ${providerMessage}`; - -// openai-python `ActionSearch.query` is a single string; some clients -// send only `queries[]`. Accept both: the shim emits both fields on -// every search action so typed SDKs reading either one keep working. -const actionSearchQueries = (action: Extract): string[] => { - if (action.queries !== undefined) return action.queries; - if (action.query !== undefined) return [action.query]; - return []; -}; - // Re-serializes a wire `action` back into the shim's JSON arguments // shape (`{search_query:[{q}]}` / `{open:[{ref_id}]}` / // `{find:[{ref_id,pattern}]}`). Used only on the replay-fallback path to @@ -637,35 +358,6 @@ const actionToShimCallArgsJson = (action: ResponsesWebSearchAction): string => { } }; -// Numeric `[N]` references in the snippet body let the model cite -// specific search hits in its final answer. Empty results emit -// `(no results)` rather than a bare header so the model recognizes the -// call ran successfully but returned nothing. -const formatSearchResultsText = (query: string, results: readonly ResponsesWebSearchResult[]): string => { - const header = `Search results for "${query}":`; - if (results.length === 0) return `${header}\n\n(no results)`; - const sections = results.map((r, i) => `[${i + 1}] ${r.title}\n${r.url}\n${r.snippet}`); - return `${header}\n\n${sections.join('\n\n')}`; -}; - -const renderOpOutputText = (action: ResponsesWebSearchAction, results: ResponsesWebSearchResult[]): string => { - switch (action.type) { - case 'search': { - const queryLabel = actionSearchQueries(action).join(' | '); - return formatSearchResultsText(queryLabel, results); - } - case 'open_page': { - if (results.length === 0) { - const url = action.url ?? '(no url)'; - return `Open ${url}: (no body returned)`; - } - return results[0].snippet; - } - case 'find_in_page': - return results.length > 0 ? results[0].snippet : ''; - } -}; - // Replay preprocessor: turns echoed `web_search_call` items back into the // (function_call, function_call_output) pair the upstream model originally // saw on turn 1. @@ -676,8 +368,8 @@ const renderOpOutputText = (action: ResponsesWebSearchAction, results: Responses // `payload.private`): emit the upstream's literal `functionCallItem` // (jsonrepair-canonical args, original call_id) plus a // `function_call_output` whose body is rendered from the persisted -// `output.action / output.results` via `renderOpOutputText`. This is -// the bit-exact round-trip. +// `output.action / output.results` via `renderOperationOutputText`. This +// is the bit-exact round-trip. // // 2. No payload (`store: false`, expired, foreign id, cross-account, or // schema-version mismatch): degrade to a synthesized pair whose @@ -712,7 +404,7 @@ export const transformInputItemsForWebSearch = ( { type: 'function_call_output', call_id: candidatePayload.functionCallItem.call_id, - output: renderOpOutputText(candidatePayload.ir.action, candidatePayload.ir.results), + output: renderOperationOutputText(candidatePayload.ir.action, candidatePayload.ir.results), }, ); continue; @@ -763,486 +455,21 @@ export const transformInputItemsForWebSearch = ( return out; }; -interface PageCacheEntry { - content: string; - truncated: boolean; - fullContentBytes: number; - title?: string; -} - -interface ShimState { - filters: ShimToolFilters; - // Per-request cache shared across `open` and `find` so a find op can - // reuse a body the model already opened without a second fetch. - pageCache: Map; - // Memoized lazy resolver. The first backend dispatch pays the - // load+resolve cost; later dispatches reuse the cached result. - // Replay-only paths (echoed `web_search_call` input with no hosted - // tool emission) never call this, so an unconfigured search provider - // does not 500 the request. - getProvider: () => Promise; - apiKeyId: string; +// The shim's execution session plus the one wire-shaping flag that lives +// only on the Responses side. +interface ShimState extends WebSearchExecutionSession { // Set when the client passed `include: ["web_search_call.results"]` on // the request. Native Responses gates the `results` field on this // include token; the shim follows suit on the wire item — but the IR // (and therefore `payload.private`) always carries the real results // so a subsequent turn echoing the item id can be hydrated regardless. includeSearchResults: boolean; - // Set when the client passed - // `include: ["web_search_call.action.sources"]` on the request, - // mirroring native Responses' opt-in shape for the search-action - // sources list. Native gates the field on this include token; the - // shim follows suit so the wire shape matches. - includeSearchActionSources: boolean; - // Aborted when the downstream client disconnects. Threaded through - // every backend provider call so a cancelled request stops - // generating upstream load instead of running to completion. - downstreamAbortSignal?: AbortSignal; -} - -type FetchAndCacheResult = - | { ok: true; cached: PageCacheEntry } - | { ok: false; output: string }; - -// Suffix-match per Tavily and Microsoft Grounding search-side filter -// semantics: `example.com` matches `example.com`, `www.example.com`, -// and `sub.example.com`, but NOT `evil-example.com`. -const matchesAnyDomain = (hostname: string, domains: readonly string[]): boolean => { - for (const d of domains) { - if (hostname === d) return true; - if (hostname.endsWith(`.${d}`)) return true; - } - return false; -}; - -export const isUrlAllowed = (url: string, filter: ShimToolFilters): boolean => { - let hostname: string; - try { - hostname = new URL(url).hostname; - } catch { - return false; - } - const blocked = normalizeDomainList(filter.blockedDomains); - if (blocked.length > 0 && matchesAnyDomain(hostname, blocked)) { - return false; - } - const allowed = normalizeDomainList(filter.allowedDomains); - if (allowed.length > 0 && !matchesAnyDomain(hostname, allowed)) { - return false; - } - return true; -}; - -// Literal case-insensitive substring matcher with context windows; -// mirrors gpt-oss `find` rendering minus the cursor-numbered output. -// https://github.com/openai/gpt-oss/blob/285b05d96dea9ce7da52ecbbe86791f18239c510/gpt_oss/tools/simple_browser/simple_browser_tool.py - -interface FindMatch { - before: string; - matched: string; - after: string; } -export const findMatches = ( - text: string, - pattern: string, - opts: { maxMatches: number; contextChars: number }, -): FindMatch[] => { - if (pattern.length === 0) return []; - const lowerText = text.toLowerCase(); - const lowerPat = pattern.toLowerCase(); - const matches: FindMatch[] = []; - let from = 0; - while (matches.length < opts.maxMatches) { - const idx = lowerText.indexOf(lowerPat, from); - if (idx < 0) break; - const beforeStart = Math.max(0, idx - opts.contextChars); - const afterEnd = Math.min(text.length, idx + lowerPat.length + opts.contextChars); - matches.push({ - before: text.slice(beforeStart, idx), - matched: text.slice(idx, idx + lowerPat.length), - after: text.slice(idx + lowerPat.length, afterEnd), - }); - from = idx + lowerPat.length; - } - return matches; -}; - -export const formatMatches = (pattern: string, url: string, matches: readonly FindMatch[]): string => { - if (matches.length === 0) return `No matching \`${pattern}\` found on ${url}.`; - const noun = matches.length === 1 ? 'match' : 'matches'; - const lines: string[] = [`${matches.length} ${noun} for pattern: \`${pattern}\``, '']; - for (let i = 0; i < matches.length; i++) { - const m = matches[i]; - lines.push(`Match ${i + 1}:`); - lines.push(`"...${m.before}[${m.matched}]${m.after}..."`); - lines.push(''); - } - return lines.join('\n').trimEnd(); -}; - -const truncateString = (s: string, maxChars: number): string => - s.length <= maxChars ? s : `${truncatePreservingCodePoints(s, maxChars)}…`; - -const errorSnippet = (title: string, snippet: string): ResponsesWebSearchResult => ({ - type: 'text_result', - url: '', - title, - snippet, -}); - -// Resolve the configured backend or return an `unavailable` reason. -// Disabled / missing-credential is per-op visible: each backend -// dispatch synthesizes a snippet IR so the model sees the error -// in-band instead of the whole request 5xx'ing. -const resolveActiveProvider = async ( - state: ShimState, -): Promise<{ provider: WebSearchProvider; providerName: WebSearchProviderName } | { unavailable: string }> => { - const configured = await state.getProvider(); - if (configured.type === 'enabled') { - return { provider: configured.impl, providerName: configured.provider }; - } - if (configured.type === 'disabled') { - return { unavailable: 'Web search provider is not configured on this gateway.' }; - } - return { unavailable: `Web search provider ${configured.provider} is missing its credential on this gateway.` }; -}; - -const runBackendSearch = async ( - op: Extract, - state: ShimState, -): Promise => { - if (op.error !== undefined) { - const title = op.errorKind === 'missing-arg' ? 'Missing argument' : 'Invalid ref_id'; - return searchIr(op.query, [errorSnippet(title, op.error)]); - } - const active = await resolveActiveProvider(state); - if ('unavailable' in active) { - return searchIr(op.query, [errorSnippet('Search error', searchFailedText(active.unavailable))]); - } - const { results, sources } = await runOneSearchQuery(op.query, state, active); - return searchIr(op.query, results, sources); -}; - -// Collapses N `search_query` entries into one wsc: same-action protocol -// shape (`{type:'search', queries:[...]}`) with the merged result set -// (concatenated in entry order). Per-query failures interleave as error -// snippets so the model sees which queries succeeded. -const runBackendSearchMulti = async ( - ops: Array>, - state: ShimState, -): Promise => { - const queries = ops.map(op => op.query); - const active = await resolveActiveProvider(state); - if ('unavailable' in active) { - return searchIrFromQueries(queries, [errorSnippet('Search error', searchFailedText(active.unavailable))]); - } - const perQuery = await Promise.all(ops.map(op => runOneSearchQuery(op.query, state, active))); - const mergedResults = perQuery.flatMap(r => r.results); - const mergedSources = state.includeSearchActionSources - ? perQuery.flatMap(r => r.sources ?? []) - : undefined; - return searchIrFromQueries(queries, mergedResults, mergedSources); -}; - -interface SearchQueryOutcome { - results: ResponsesWebSearchResult[]; - sources?: { type: 'url'; url: string }[]; -} - -const runOneSearchQuery = async ( - query: string, - state: ShimState, - active: { provider: WebSearchProvider; providerName: WebSearchProviderName }, -): Promise => { - try { - const searchRequest = { - query, - maxResults: state.filters.maxResults, - allowedDomains: state.filters.allowedDomains, - blockedDomains: state.filters.blockedDomains, - userLocation: state.filters.userLocation, - ...(state.downstreamAbortSignal !== undefined ? { signal: state.downstreamAbortSignal } : {}), - }; - const result = await searchWebAndRecordUsage({ - provider: active.provider, - providerName: active.providerName, - keyId: state.apiKeyId, - request: searchRequest, - }); - - if (result.type === 'error') { - const msg = result.message ?? result.errorCode; - return { results: [errorSnippet('Search error', searchFailedText(msg))] }; - } - - // Per-snippet char cap on web_search_call.results[].snippet. Providers - // like Tavily can return multi-KB snippets per hit; without this cap a - // single noisy query can blow the upstream context window. Independent - // of the provider-enforced 10 KiB cap on open_page bodies. - const results: ResponsesWebSearchResult[] = result.results.map(r => ({ - type: 'text_result' as const, - url: r.source, - title: r.title, - snippet: truncateString(r.content.map(c => c.text).join('\n'), 2_048), - })); - // Native gates `action.sources` on `include: - // ["web_search_call.action.sources"]`; build the list only when - // the client opted in. The shape mirrors openai-python - // `ActionSearch.sources[]` (`{type:'url', url}`). - const sources = state.includeSearchActionSources - ? result.results.map(r => ({ type: 'url' as const, url: r.source })) - : undefined; - return sources !== undefined ? { results, sources } : { results }; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return { results: [errorSnippet('Search error', searchFailedText(msg))] }; - } -}; - -const runBatchFetch = async ( - needFetch: string[], - state: ShimState, -): Promise> => { - const perUrl = new Map(); - const active = await resolveActiveProvider(state); - if ('unavailable' in active) { - for (const url of needFetch) { - perUrl.set(url, { ok: false, output: openFailedText(url, active.unavailable) }); - } - return perUrl; - } - try { - const fetchRequest = { - urls: needFetch, - ...(state.downstreamAbortSignal !== undefined ? { signal: state.downstreamAbortSignal } : {}), - }; - const result = await fetchPageAndRecordUsage({ - provider: active.provider, - providerName: active.providerName, - keyId: state.apiKeyId, - request: fetchRequest, - }); - - if (result.type === 'error') { - const msg = result.message ?? result.errorCode; - for (const url of needFetch) { - perUrl.set(url, { ok: false, output: openFailedText(url, msg) }); - } - return perUrl; - } - - const failureByUrl = new Map(result.failures.map(f => [f.url, f])); - const pageByUrl = new Map(result.pages.map(p => [p.url, p])); - for (const url of needFetch) { - const failure = failureByUrl.get(url); - if (failure) { - perUrl.set(url, { ok: false, output: openFailedText(url, failure.message ?? failure.errorCode) }); - continue; - } - const page = pageByUrl.get(url); - if (!page) { - // URL silently dropped by the provider — surface as explicit - // error so the model doesn't see a phantom empty page. - perUrl.set(url, { ok: false, output: openFailedText(url, 'No page returned') }); - continue; - } - const entry: PageCacheEntry = { - content: page.content, - truncated: page.truncated, - fullContentBytes: page.fullContentBytes, - title: page.title, - }; - state.pageCache.set(url, entry); - perUrl.set(url, { ok: true, cached: entry }); - } - return perUrl; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - for (const url of needFetch) { - perUrl.set(url, { ok: false, output: openFailedText(url, msg) }); - } - return perUrl; - } -}; - -// Intra-call batching: collect every URL the shim's -// open[]/find[] sub-arrays reference, dedup, hit cache, and issue -// one batched provider.fetchPage for the remainder. Cross-call -// joining is deliberately NOT done — same-turn serial execution -// means later shim calls can simply read the populated cache. -const fetchAndCacheManyPages = async ( - urls: string[], - state: ShimState, -): Promise> => { - const results = new Map(); - const needFetch: string[] = []; - const seen = new Set(); - - for (const url of urls) { - if (seen.has(url)) continue; - seen.add(url); - const cached = state.pageCache.get(url); - if (cached) { - results.set(url, { ok: true, cached }); - continue; - } - needFetch.push(url); - } - - if (needFetch.length > 0) { - const perUrl = await runBatchFetch(needFetch, state); - for (const url of needFetch) { - results.set(url, perUrl.get(url)!); - } - } - return results; -}; - -const openPageSuccessIr = (url: string, cached: PageCacheEntry): WebSearchCallIR => { - // Provider truncates to its 10 KiB per-page cap. Truncated bodies get - // a sentinel so the model can choose to `find` for specific content. - const body = cached.content - + (cached.truncated - ? `\n\n[Content truncated; full page is ${cached.fullContentBytes} bytes. Use web_search's \`find\` sub-property with a pattern to locate specific content.]` - : ''); - return openPageIr(url, [{ - type: 'text_result', - url, - title: cached.title ?? '', - snippet: body, - }]); -}; - -const runBackendOpenPage = async ( - op: Extract, - batchPromise: Promise>, -): Promise => { - const url = op.url; - - // Invalid-ref-id (`op.error !== undefined`) carries a - // `{type:'search', queries:[ref_id]}` via `searchIr` because a urlless - // open_page action would be meaningless. - if (op.error !== undefined) { - const title = op.errorKind === 'missing-arg' ? 'Missing argument' : 'Invalid ref_id'; - return searchIr(op.url, [errorSnippet(title, op.error)]); - } - - // Batch fetch pre-populates entries for every URL the parser produced - // (blocked URLs get an explicit failure entry), so the lookup is total. - const fetched = (await batchPromise).get(url)!; - if (!fetched.ok) { - return openPageIr(url, [errorSnippet('Open page error', fetched.output)]); - } - return openPageSuccessIr(url, fetched.cached); -}; - -const runBackendFind = async ( - op: Extract, - batchPromise: Promise>, -): Promise => { - const url = op.url; - const pattern = op.pattern; - - if (op.error !== undefined) { - const title = op.errorKind === 'missing-arg' ? 'Missing argument' : 'Invalid ref_id'; - return findInPageIr(url, pattern, [errorSnippet(title, op.error)]); - } - - // Pre-fetch failures keep the `find_in_page` action carrying the - // original url + pattern; switching to `open_page` would silently - // change `action.type` mid-result. - const fetched = (await batchPromise).get(url)!; - if (!fetched.ok) { - return findInPageIr(url, pattern, [errorSnippet('Find error', fetched.output)]); - } - - // Mirror gpt-oss `find` defaults. - const matches = findMatches(fetched.cached.content, pattern, { - maxMatches: 10, - contextChars: 200, - }); - // Native find_in_page returns one result whose snippet either lists - // the matches or says "No matching ...". - const title = matches.length === 0 ? 'No match' : 'Matches'; - return findInPageIr(url, pattern, [{ - type: 'text_result', - url, - title, - snippet: formatMatches(pattern, url, matches), - }]); -}; - -const executeOperation = ( - op: ShimLogicalOperation, - state: ShimState, - batchPromise: Promise>, -): Promise => { - switch (op.kind) { - case 'search': - return runBackendSearch(op, state); - case 'open': - return runBackendOpenPage(op, batchPromise); - case 'find': - return runBackendFind(op, batchPromise); - case 'unsupported': - return Promise.resolve(schemaErrorIr( - `unsupported action: ${op.subProperty}[${op.arrayIndex}]`, - 'Unsupported action', - `Error: the \`${op.subProperty}\` sub-property is not supported by this gateway. Only \`search_query\`, \`open\`, and \`find\` are available.`, - )); - case 'wrong-type': - return Promise.resolve(schemaErrorIr( - `wrong-type sub-property: ${op.subProperty}`, - 'Malformed sub-property', - `Error: the \`${op.subProperty}\` sub-property must be an array of objects; got ${op.actualType}.`, - )); - } -}; - -// Collect the open/find URL set for THIS shim call and kick off one -// provider.fetchPage covering all of them. `fetchAndCacheManyPages` -// installs per-URL inflight slots synchronously so later shim calls in -// the same turn dedup against this batch. -// -// Blocked URLs (failing `isUrlAllowed`) are filtered OUT of the batch -// fetch but populated into the result map with an explicit -// `{ ok: false, output: 'Error fetching URL : Blocked by tool -// filters' }` entry (the `Blocked by tool filters` string runs -// through `openFailedText` for consistency with real fetch failures). -// That way the per-op handlers (`runBackendOpenPage` / -// `runBackendFind`) can trust the gate's verdict by reading the map -// directly instead of re-running `isUrlAllowed` themselves. -const startBatchFetchForShimCall = async ( - parsed: ParsedShimCall, - state: ShimState, -): Promise> => { - if (parsed.kind !== 'ops') return new Map(); - const batchUrls: string[] = []; - const blockedUrls: string[] = []; - const seen = new Set(); - for (const op of parsed.ops) { - if (op.kind !== 'open' && op.kind !== 'find') continue; - if (op.error !== undefined) continue; - const url = op.url; - if (url === '') continue; - if (seen.has(url)) continue; - seen.add(url); - if (!isUrlAllowed(url, state.filters)) { - blockedUrls.push(url); - continue; - } - batchUrls.push(url); - } - const fetched = await fetchAndCacheManyPages(batchUrls, state); - for (const url of blockedUrls) { - fetched.set(url, { ok: false, output: openFailedText(url, 'Blocked by tool filters') }); - } - return fetched; -}; +const ITERATION_CAP = 30; const planShimSlots = ( - parsed: ParsedShimCall, + parsed: ParsedWebSearchOperations, toolName: string, state: ShimState, loopState: ServerToolLoopState, @@ -1275,7 +502,7 @@ const planShimSlots = ( // parse cleanly; one malformed entry forces the model to fix all of // them rather than silently dropping a search. if (parsed.ops.length > 1 && parsed.ops.every(op => op.kind === 'search' && op.error === undefined)) { - const searchOps = parsed.ops as Array>; + const searchOps = parsed.ops as Array>; return { id: synthesizeWebSearchCallId(), promise: runBackendSearchMulti(searchOps, state), @@ -1299,10 +526,9 @@ const planShimSlots = ( }; } - const batchPromise = startBatchFetchForShimCall(parsed, state); return { id: synthesizeWebSearchCallId(), - promise: executeOperation(parsed.ops[0], state, batchPromise), + promise: startBatchFetch(parsed, state).then(batch => executeOperationToIr(parsed.ops[0], state, batch)), }; }; @@ -1338,7 +564,7 @@ export const webSearchServerTool: ServerToolRegistration = (invocation, gatewayC apiKeyId: gatewayCtx.apiKeyId, includeSearchResults: includeArray.includes('web_search_call.results'), includeSearchActionSources: includeArray.includes('web_search_call.action.sources'), - ...(gatewayCtx.abortSignal !== undefined ? { downstreamAbortSignal: gatewayCtx.abortSignal } : {}), + ...(gatewayCtx.abortSignal !== undefined ? { signal: gatewayCtx.abortSignal } : {}), }; return { @@ -1352,7 +578,7 @@ export const webSearchServerTool: ServerToolRegistration = (invocation, gatewayC canonicalize: canonicalizeWebSearchTool, buildFunctionTool: buildShimFunctionTool, dispatcher: ({ intercepted, loopState }) => { - const slot = planShimSlots(parseShimOperations(intercepted.arguments), intercepted.name, state, loopState); + const slot = planShimSlots(parseWebSearchOperations(intercepted.arguments), intercepted.name, state, loopState); const functionCallItem: ResponsesFunctionToolCallItem = { type: 'function_call', call_id: intercepted.callId, diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts index 45ed233b8..8cf9fa9fa 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/web-search_test.ts @@ -6,39 +6,39 @@ import { formatMatches, isHostedWebSearchTool, isUrlAllowed, - parseShimOperations, + parseWebSearchOperations, prepareToolsForShim, SHIM_TOOL_NAME, synthesizeWebSearchCallId, transformInputItemsForWebSearch, WEB_SEARCH_HOSTED_TYPES, - type ShimLogicalOperation, + type WebSearchOperation, type WebSearchCallPrivatePayload, } from './web-search.ts'; import { truncatePreservingCodePoints } from '../../../shared/text.ts'; import type { ResponsesTool, ResponsesWebSearchAction, ResponsesWebSearchResult } from '@floway-dev/protocols/responses'; import { assert, assertEquals, assertFalse } from '@floway-dev/test-utils'; -// ── Shim call argument parsing (parseShimOperations) ── +// ── Shim call argument parsing (parseWebSearchOperations) ── -const opsOf = (args: Record | null): ShimLogicalOperation[] => { - const parsed = parseShimOperations(args); +const opsOf = (args: Record | null): WebSearchOperation[] => { + const parsed = parseWebSearchOperations(args); assert(parsed.kind === 'ops'); return parsed.ops; }; -test('parseShimOperations returns ops:[] for empty object', () => { - assertEquals(parseShimOperations({}), { kind: 'ops', ops: [] }); +test('parseWebSearchOperations returns ops:[] for empty object', () => { + assertEquals(parseWebSearchOperations({}), { kind: 'ops', ops: [] }); }); -test('parseShimOperations parses one search_query entry', () => { +test('parseWebSearchOperations parses one search_query entry', () => { assertEquals( opsOf({ search_query: [{ q: 'hello' }] }), [{ kind: 'search', arrayIndex: 0, query: 'hello' }], ); }); -test('parseShimOperations parses multiple search_query entries with stable arrayIndex', () => { +test('parseWebSearchOperations parses multiple search_query entries with stable arrayIndex', () => { assertEquals( opsOf({ search_query: [{ q: 'a' }, { q: 'b' }, { q: 'c' }] }), [ @@ -49,21 +49,21 @@ test('parseShimOperations parses multiple search_query entries with stable array ); }); -test('parseShimOperations parses open entry with URL ref_id', () => { +test('parseWebSearchOperations parses open entry with URL ref_id', () => { assertEquals( opsOf({ open: [{ ref_id: 'https://example.com' }] }), [{ kind: 'open', arrayIndex: 0, url: 'https://example.com' }], ); }); -test('parseShimOperations parses find entry with URL ref_id and pattern', () => { +test('parseWebSearchOperations parses find entry with URL ref_id and pattern', () => { assertEquals( opsOf({ find: [{ ref_id: 'https://example.com', pattern: 'needle' }] }), [{ kind: 'find', arrayIndex: 0, url: 'https://example.com', pattern: 'needle' }], ); }); -test('parseShimOperations: non-URL open ref_id produces an error sentinel', () => { +test('parseWebSearchOperations: non-URL open ref_id produces an error sentinel', () => { const ops = opsOf({ open: [{ ref_id: 'opaque-prior-id' }] }); assertEquals(ops.length, 1); const op = ops[0]; @@ -75,7 +75,7 @@ test('parseShimOperations: non-URL open ref_id produces an error sentinel', () = assertEquals(err!.includes('opaque-prior-id'), true); }); -test('parseShimOperations: non-URL find ref_id produces an error sentinel', () => { +test('parseWebSearchOperations: non-URL find ref_id produces an error sentinel', () => { const ops = opsOf({ find: [{ ref_id: 'cursor-123', pattern: 'p' }] }); assertEquals(ops.length, 1); const op = ops[0]; @@ -87,7 +87,7 @@ test('parseShimOperations: non-URL find ref_id produces an error sentinel', () = assertEquals(err!.includes('cursor-123'), true); }); -test('parseShimOperations: multi-action batched call returns all ops in order search→open→find', () => { +test('parseWebSearchOperations: multi-action batched call returns all ops in order search→open→find', () => { const ops = opsOf({ search_query: [{ q: 'a' }], open: [{ ref_id: 'https://x' }], @@ -96,7 +96,7 @@ test('parseShimOperations: multi-action batched call returns all ops in order se assertEquals(ops.map(o => o.kind), ['search', 'open', 'find']); }); -test('parseShimOperations: unsupported sub-properties surface one unsupported op per entry', () => { +test('parseWebSearchOperations: unsupported sub-properties surface one unsupported op per entry', () => { const ops = opsOf({ click: [{ ref_id: 'https://x', id: 1 }], screenshot: [{ ref_id: 'https://x', pageno: 1 }, { ref_id: 'https://y', pageno: 2 }], @@ -113,7 +113,7 @@ test('parseShimOperations: unsupported sub-properties surface one unsupported op assertEquals(ops[5], { kind: 'unsupported', subProperty: 'response_length', arrayIndex: 0 }); }); -test('parseShimOperations: missing q on search_query entry surfaces a missing-argument error sentinel', () => { +test('parseWebSearchOperations: missing q on search_query entry surfaces a missing-argument error sentinel', () => { const ops = opsOf({ search_query: [{}] }); assertEquals(ops.length, 1); const op = ops[0]; @@ -123,7 +123,7 @@ test('parseShimOperations: missing q on search_query entry surfaces a missing-ar assert((op as { error: string }).error.includes('"q"')); }); -test('parseShimOperations: missing ref_id on open entry surfaces a missing-argument error sentinel', () => { +test('parseWebSearchOperations: missing ref_id on open entry surfaces a missing-argument error sentinel', () => { const ops = opsOf({ open: [{}] }); assertEquals(ops.length, 1); const op = ops[0]; @@ -132,7 +132,7 @@ test('parseShimOperations: missing ref_id on open entry surfaces a missing-argum assert((op as { error: string }).error.includes('"ref_id"')); }); -test('parseShimOperations: missing pattern on find entry surfaces a missing-argument error sentinel', () => { +test('parseWebSearchOperations: missing pattern on find entry surfaces a missing-argument error sentinel', () => { const ops = opsOf({ find: [{ ref_id: 'https://x' }] }); assertEquals(ops.length, 1); const op = ops[0]; @@ -141,13 +141,13 @@ test('parseShimOperations: missing pattern on find entry surfaces a missing-argu assert((op as { error: string }).error.includes('"pattern"')); }); -test('parseShimOperations: array values for non-array shape are skipped', () => { +test('parseWebSearchOperations: array values for non-array shape are skipped', () => { assertEquals(opsOf({ search_query: 'oops' }), [ { kind: 'wrong-type', subProperty: 'search_query', actualType: 'string' }, ]); }); -test('parseShimOperations: supported key with non-array value surfaces a wrong-type op (search_query)', () => { +test('parseWebSearchOperations: supported key with non-array value surfaces a wrong-type op (search_query)', () => { // A model that populates `search_query: {"q":"x"}` (or any // non-array) used to be silently dropped because the array guard // skipped it. Surface as a model-visible `wrong-type` op so the @@ -158,14 +158,14 @@ test('parseShimOperations: supported key with non-array value surfaces a wrong-t ]); }); -test('parseShimOperations: wrong-typed supported key does not block other supported keys from executing', () => { +test('parseWebSearchOperations: wrong-typed supported key does not block other supported keys from executing', () => { const ops = opsOf({ search_query: { q: 'x' }, open: [{ ref_id: 'https://y' }] }); assertEquals(ops.length, 2); assertEquals(ops[0], { kind: 'wrong-type', subProperty: 'search_query', actualType: 'object' }); assertEquals(ops[1], { kind: 'open', arrayIndex: 0, url: 'https://y' }); }); -test('parseShimOperations: wrong-typed open / find surface as wrong-type ops', () => { +test('parseWebSearchOperations: wrong-typed open / find surface as wrong-type ops', () => { assertEquals(opsOf({ open: 'https://x' }), [ { kind: 'wrong-type', subProperty: 'open', actualType: 'string' }, ]); diff --git a/packages/gateway/src/data-plane/codex/alpha-search.ts b/packages/gateway/src/data-plane/codex/alpha-search.ts new file mode 100644 index 000000000..d39570782 --- /dev/null +++ b/packages/gateway/src/data-plane/codex/alpha-search.ts @@ -0,0 +1,121 @@ +// Codex `/alpha/search` — the endpoint the Codex CLI's web-search tool POSTs +// to when the model emits a `web.run`-style tool call. Instead of proxying +// chatgpt.com, we execute the requested operations through Floway's own +// configured web-search provider and return the single `output` string the +// CLI feeds back to the model. +// +// Request shape mirrors codex-rs `SearchRequest` +// (codex-rs/codex-api/src/search.rs @ 385c0a9). `commands` is an OBJECT +// keyed by command kind — `{ search_query:[{q}], open:[{ref_id}], +// find:[{ref_id,pattern}], … }` — which is byte-for-byte the same shape the +// Responses web_search shim parses, so both share `parseWebSearchOperations` +// and the whole execution engine. Command kinds we don't implement +// (`image_query`, `click`, `screenshot`, `finance`, `weather`, `sports`, +// `time`, `response_length`) surface as deterministic per-op error text +// rather than silently vanishing. `settings.filters` / +// `settings.user_location` / `settings.search_context_size` shape the +// search; the remaining request fields (`id`, `model`, `reasoning`, `input`, +// `max_output_tokens`) are inputs a real backend model would consume and +// carry no meaning for deterministic command execution, so we accept and +// ignore them. +// +// Response is codex-rs `SearchResponse` (`{ encrypted_output, output }`); +// the CLI reads only `output` (search.rs `SearchOutput::new(response.output)`). +// +// Auth is the shared `authMiddleware` that guards the rest of the namespace; +// this handler reads the resolved API key for per-key search-usage +// accounting. + +import { z } from 'zod'; + +import { executeOperationToText, maxResultsForContextSize, parseWebSearchOperations, startBatchFetch, type WebSearchExecutionSession, type WebSearchFilters } from '../tools/web-search/operations.ts'; +import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; +import { loadSearchConfig } from '../tools/web-search/search-config.ts'; +import type { ConfiguredWebSearchProvider } from '../tools/web-search/types.ts'; +import { apiKeyFromContext } from '../../middleware/auth.ts'; +import type { CtxWithJson } from '../../middleware/zod-validator.ts'; + +const domainListSchema = z.array(z.string()); + +// `filters` / `user_location` / `search_context_size` are the only settings +// that steer command execution; `looseObject` keeps the request tolerant of +// the settings a real backend would read but we don't (`image_settings`, +// `allowed_callers`, `external_web_access`). +const searchSettingsSchema = z.looseObject({ + filters: z.looseObject({ + allowed_domains: domainListSchema.optional(), + blocked_domains: domainListSchema.optional(), + }).optional(), + user_location: z.looseObject({ + city: z.string().optional(), + region: z.string().optional(), + country: z.string().optional(), + timezone: z.string().optional(), + }).optional(), + search_context_size: z.enum(['low', 'medium', 'high']).optional(), +}); + +// `commands` is validated only as "an object" — the per-kind arrays are +// parsed and diagnosed by `parseWebSearchOperations`, which already emits +// deterministic text for missing args, non-URL refs, wrong-typed keys, and +// unsupported command kinds. `looseObject` preserves the unimplemented keys +// so they reach that parser as unsupported ops. +export const codexSearchRequestSchema = z.looseObject({ + commands: z.looseObject({}).optional(), + settings: searchSettingsSchema.optional(), +}); + +type CodexSearchRequest = z.infer; + +const filtersFromSettings = (settings: CodexSearchRequest['settings']): WebSearchFilters => { + const filters: WebSearchFilters = { + maxResults: maxResultsForContextSize(settings?.search_context_size), + }; + if (settings?.filters?.allowed_domains) filters.allowedDomains = settings.filters.allowed_domains; + if (settings?.filters?.blocked_domains) filters.blockedDomains = settings.filters.blocked_domains; + const loc = settings?.user_location; + if (loc && (loc.city !== undefined || loc.region !== undefined || loc.country !== undefined || loc.timezone !== undefined)) { + filters.userLocation = { + ...(loc.city !== undefined ? { city: loc.city } : {}), + ...(loc.region !== undefined ? { region: loc.region } : {}), + ...(loc.country !== undefined ? { country: loc.country } : {}), + ...(loc.timezone !== undefined ? { timezone: loc.timezone } : {}), + }; + } + return filters; +}; + +export const codexAlphaSearch = async (c: CtxWithJson): Promise => { + const body = c.req.valid('json'); + + let configuredProvider: Promise | undefined; + const session: WebSearchExecutionSession = { + getProvider: () => { + configuredProvider ??= loadSearchConfig().then(cfg => resolveConfiguredWebSearchProvider(cfg)); + return configuredProvider; + }, + filters: filtersFromSettings(body.settings), + apiKeyId: apiKeyFromContext(c).id, + pageCache: new Map(), + // Codex renders `output` as plain text; the search-action sources list + // is a Responses wire concern with no place here. + includeSearchActionSources: false, + signal: c.req.raw.signal, + }; + + const parsed = parseWebSearchOperations(body.commands ?? {}); + if (parsed.kind !== 'ops' || parsed.ops.length === 0) { + return c.json({ + encrypted_output: null, + output: 'No web search commands were provided. Populate at least one of `search_query`, `open`, or `find`.', + }); + } + + // One batched provider.fetchPage covers every open/find URL; each op then + // renders its own text block. Concatenated in command order so the model + // reads the results in the order it asked for them. + const batch = await startBatchFetch(parsed, session); + const blocks = await Promise.all(parsed.ops.map(op => executeOperationToText(op, session, batch))); + + return c.json({ encrypted_output: null, output: blocks.join('\n\n') }); +}; diff --git a/packages/gateway/src/data-plane/codex/routes.ts b/packages/gateway/src/data-plane/codex/routes.ts index dc2190f13..b788f571a 100644 --- a/packages/gateway/src/data-plane/codex/routes.ts +++ b/packages/gateway/src/data-plane/codex/routes.ts @@ -37,6 +37,12 @@ // generic `/v1/responses` WS handler so codex's session-internal item // store works against this namespace too. // +// `/alpha/search` is codex's web-search endpoint (codex-rs +// codex-api/src/search.rs): the CLI POSTs a `SearchRequest` when the model +// emits a `web.run` tool call and feeds the returned `output` string back to +// the model. We execute the requested commands through Floway's configured +// web-search provider instead of proxying chatgpt.com — see ./alpha-search.ts. +// // Auth: this whole namespace is reached through the same `authMiddleware` // that protects every other API route. The operator forges // `~/.codex/auth.json` with `tokens.access_token` set to their Floway API @@ -47,6 +53,7 @@ import type { Hono } from 'hono'; +import { codexAlphaSearch, codexSearchRequestSchema } from './alpha-search.ts'; import { codexAppsMcp } from './apps-mcp.ts'; import { codexAnalyticsEventsEvents, @@ -58,6 +65,7 @@ import { } from './chatgpt-backend.ts'; import { codexModels } from './models.ts'; import type { AuthVars } from '../../middleware/auth.ts'; +import { zValidator } from '../../middleware/zod-validator.ts'; import { responsesHttp } from '../chat/responses/http.ts'; import { responsesWebSocket } from '../chat/responses/websocket.ts'; @@ -71,6 +79,8 @@ export const mountCodexRoutes = (app: Hono<{ Variables: AuthVars }>) => { app.get(`${CODEX_BASE_PATH}/models`, codexModels); app.post(`${CODEX_BASE_PATH}/codex/analytics-events/events`, codexAnalyticsEventsEvents); + app.post(`${CODEX_BASE_PATH}/alpha/search`, zValidator('json', codexSearchRequestSchema), codexAlphaSearch); + app.post(`${CODEX_BASE_PATH}/api/codex/apps`, codexAppsMcp); app.get(`${CODEX_BASE_PATH}/wham/agent-identities/jwks`, codexWhamAgentIdentitiesJwks); diff --git a/packages/gateway/src/data-plane/tools/web-search/operations.ts b/packages/gateway/src/data-plane/tools/web-search/operations.ts new file mode 100644 index 000000000..047d56f05 --- /dev/null +++ b/packages/gateway/src/data-plane/tools/web-search/operations.ts @@ -0,0 +1,852 @@ +// Protocol-neutral web-search operation engine. +// +// Two surfaces sit on top of this module and share its execution and +// text-rendering logic verbatim: +// +// - The Responses `web_search` server-tool shim +// (`chat/responses/interceptors/server-tools/web-search.ts`), which +// wraps each executed operation into a `web_search_call` wire item and +// drives the internal multi-turn loop. +// - The Codex `/alpha/search` endpoint (`codex/alpha-search.ts`), which +// concatenates the rendered text of every operation into the single +// `output` string the Codex CLI feeds back to the model. +// +// Both accept the identical `{ search_query:[{q}], open:[{ref_id}], +// find:[{ref_id,pattern}], … }` command shape — the Responses shim gets it +// from the model's function-call arguments, Codex from the request body's +// `commands` object — so a single parser (`parseWebSearchOperations`) +// serves both. The engine speaks the Responses `web_search_call` IR +// (`action` + `results`) as its internal representation because those types +// already model "a search/open/find action and its text results"; the Codex +// path never exposes the IR, it only renders it to text. + +import { normalizeDomainList } from './domain-normalize.ts'; +import { fetchPageAndRecordUsage } from './fetch-page.ts'; +import { searchWebAndRecordUsage } from './search.ts'; +import type { ConfiguredWebSearchProvider, WebSearchProvider, WebSearchProviderName } from './types.ts'; +import { truncatePreservingCodePoints } from '../../chat/shared/text.ts'; +import type { ResponsesWebSearchAction, ResponsesWebSearchResult } from '@floway-dev/protocols/responses'; + +// Search-context-size → result-count mapping. Approximates the ~40 results +// native hosted web_search returns regardless of search_context_size; +// backends bill per call, so larger result sets only multiply upstream +// context-window cost. `medium` is the native default (matches openai-python +// `WebSearchTool.search_context_size` docstring: "Defaults to 'medium'"). +// https://github.com/openai/openai-python/blob/main/src/openai/types/responses/web_search_tool.py +export const CONTEXT_SIZE_TO_MAX_RESULTS: Record<'low' | 'medium' | 'high', number> = { + low: 10, + medium: 20, + high: 40, +}; + +export const DEFAULT_SEARCH_CONTEXT_SIZE: keyof typeof CONTEXT_SIZE_TO_MAX_RESULTS = 'medium'; + +export const isSearchContextSize = (v: unknown): v is keyof typeof CONTEXT_SIZE_TO_MAX_RESULTS => + typeof v === 'string' && v in CONTEXT_SIZE_TO_MAX_RESULTS; + +// Default to native's documented default (`medium`) when omitted. Without +// this, a provider-side default (e.g. Tavily's smaller baseline count) would +// silently shrink the result set on requests that didn't set the field. +export const maxResultsForContextSize = (size: unknown): number => + CONTEXT_SIZE_TO_MAX_RESULTS[isSearchContextSize(size) ? size : DEFAULT_SEARCH_CONTEXT_SIZE]; + +// Per-snippet char cap on a search result's rendered text. Providers like +// Tavily can return multi-KB snippets per hit; without this cap a single +// noisy query can blow the upstream context window. Independent of the +// provider-enforced 10 KiB cap on open_page bodies. +const MAX_SEARCH_SNIPPET_CHARS = 2_048; + +export interface WebSearchFilters { + allowedDomains?: string[]; + blockedDomains?: string[]; + userLocation?: { city?: string; region?: string; country?: string; timezone?: string }; + maxResults?: number; +} + +// ── Command parsing ── +// One logical operation parsed out of a `{ search_query, open, find, … }` +// command object. The three implemented kinds (`search`, `open`, `find`) +// carry the backend inputs; every other populated key surfaces as an +// `unsupported` op, and a sub-property whose value isn't an array surfaces +// as `wrong-type`. `parseWebSearchOperations` produces a flat list in source +// order (search → open → find → the rest). + +export type WebSearchOperationErrorKind = 'invalid-ref' | 'missing-arg'; + +export type WebSearchOperation = + | { + kind: 'search'; + /** Original index inside the `search_query` array. */ + arrayIndex: number; + query: string; + /** When set, dispatch returns this verbatim instead of hitting the backend. */ + error?: string; + errorKind?: WebSearchOperationErrorKind; + } + | { + kind: 'open'; + arrayIndex: number; + error?: string; + errorKind?: WebSearchOperationErrorKind; + url: string; + } + | { + kind: 'find'; + arrayIndex: number; + error?: string; + errorKind?: WebSearchOperationErrorKind; + url: string; + pattern: string; + } + | { + kind: 'unsupported'; + /** The command key the caller populated (e.g. `click`). */ + subProperty: string; + /** Original index inside that key's array. */ + arrayIndex: number; + } + | { + kind: 'wrong-type'; + subProperty: 'search_query' | 'open' | 'find'; + actualType: string; + }; + +export type ParsedWebSearchOperations = { kind: 'ops'; ops: WebSearchOperation[] } | { kind: 'malformed' }; + +// Stricter than `/^https?:\/\//i`: that regex accepts `https://` (empty +// host). Reject malformed refs at parse time so dispatch always sees a +// well-formed URL. +const isUrl = (s: string): boolean => { + let parsed: URL; + try { + parsed = new URL(s); + } catch { + return false; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + if (parsed.hostname === '') return false; + return true; +}; + +const refIdError = (refId: string): string => + `Error: ref_id must be a fully-qualified URL in the gateway shim (got '${refId}'). The gateway shim does not preserve prior-call ids across turns.`; + +const missingArgError = (field: string): string => + `Error: missing required argument "${field}".`; + +const SUPPORTED_KEYS: ReadonlySet = new Set(['search_query', 'open', 'find']); + +const stringField = (entry: unknown, key: string): string => { + if (entry === null || typeof entry !== 'object') return ''; + const value = (entry as Record)[key]; + return typeof value === 'string' ? value : ''; +}; + +const describeJsonType = (v: unknown): string => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v; + +export const parseWebSearchOperations = (args: Record | null): ParsedWebSearchOperations => { + if (args === null) return { kind: 'malformed' }; + const ops: WebSearchOperation[] = []; + + const searchQuery = args.search_query; + if (searchQuery !== undefined) { + if (!Array.isArray(searchQuery)) { + ops.push({ kind: 'wrong-type', subProperty: 'search_query', actualType: describeJsonType(searchQuery) }); + } else { + for (let i = 0; i < searchQuery.length; i++) { + const q = stringField(searchQuery[i], 'q'); + if (q === '') { + ops.push({ kind: 'search', arrayIndex: i, query: '', error: missingArgError('q'), errorKind: 'missing-arg' }); + continue; + } + ops.push({ kind: 'search', arrayIndex: i, query: q }); + } + } + } + + const open = args.open; + if (open !== undefined) { + if (!Array.isArray(open)) { + ops.push({ kind: 'wrong-type', subProperty: 'open', actualType: describeJsonType(open) }); + } else { + for (let i = 0; i < open.length; i++) { + const refId = stringField(open[i], 'ref_id'); + if (refId === '') { + ops.push({ kind: 'open', arrayIndex: i, url: '', error: missingArgError('ref_id'), errorKind: 'missing-arg' }); + continue; + } + if (!isUrl(refId)) { + ops.push({ kind: 'open', arrayIndex: i, url: refId, error: refIdError(refId), errorKind: 'invalid-ref' }); + continue; + } + ops.push({ kind: 'open', arrayIndex: i, url: refId }); + } + } + } + + const find = args.find; + if (find !== undefined) { + if (!Array.isArray(find)) { + ops.push({ kind: 'wrong-type', subProperty: 'find', actualType: describeJsonType(find) }); + } else { + for (let i = 0; i < find.length; i++) { + const refId = stringField(find[i], 'ref_id'); + const pattern = stringField(find[i], 'pattern'); + if (refId === '') { + ops.push({ kind: 'find', arrayIndex: i, url: '', pattern, error: missingArgError('ref_id'), errorKind: 'missing-arg' }); + continue; + } + if (!isUrl(refId)) { + ops.push({ kind: 'find', arrayIndex: i, url: refId, pattern, error: refIdError(refId), errorKind: 'invalid-ref' }); + continue; + } + if (pattern === '') { + ops.push({ kind: 'find', arrayIndex: i, url: refId, pattern: '', error: missingArgError('pattern'), errorKind: 'missing-arg' }); + continue; + } + ops.push({ kind: 'find', arrayIndex: i, url: refId, pattern }); + } + } + } + + // Keys outside the implemented set surface as unsupported ops. + for (const key of Object.keys(args)) { + if (SUPPORTED_KEYS.has(key)) continue; + const value = args[key]; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + ops.push({ kind: 'unsupported', subProperty: key, arrayIndex: i }); + } + } else { + ops.push({ kind: 'unsupported', subProperty: key, arrayIndex: 0 }); + } + } + + return { kind: 'ops', ops }; +}; + +// ── Execution session ── + +interface PageCacheEntry { + content: string; + truncated: boolean; + fullContentBytes: number; + title?: string; +} + +// The provider binding + filters + accounting context every operation runs +// against. `pageCache` is shared across `open` and `find` so a find op can +// reuse a body a prior open already fetched without a second round-trip. +export interface WebSearchExecutionSession { + // Memoized lazy resolver. The first backend dispatch pays the + // load+resolve cost; later dispatches reuse the cached result. Replay-only + // paths (Responses shim echoing prior items with no live op) never call + // this, so an unconfigured search provider does not 500 the request. + getProvider: () => Promise; + filters: WebSearchFilters; + apiKeyId: string; + pageCache: Map; + // Whether to populate `action.sources` on search IRs. Native Responses + // gates the field on `include: ["web_search_call.action.sources"]`; the + // Codex path leaves it off (its output is plain text). + includeSearchActionSources: boolean; + // Aborted when the downstream client disconnects. Threaded into every + // backend provider call so a cancelled request stops generating upstream + // load instead of running to completion. + signal?: AbortSignal; +} + +// ── IR construction ── + +// One backend op's result data: the action shape downstream references and +// the result list the renderer formats. Thin DTO — any wire id and status +// live on the caller's slot, not in here. +export interface WebSearchCallIR { + action: ResponsesWebSearchAction; + results: ResponsesWebSearchResult[]; +} + +const searchIr = ( + query: string, + results: ResponsesWebSearchResult[], + sources?: { type: 'url'; url: string }[], +): WebSearchCallIR => searchIrFromQueries([query], results, sources); + +// Builds a single wsc for one or more search queries. Multi-query actions +// are protocol-native (`{type:'search', queries:[...]}`); the singular +// `query` field is emitted alongside for SDKs that only know the legacy +// single-string shape — see `actionSearchQueries`. +export const searchIrFromQueries = ( + queries: string[], + results: ResponsesWebSearchResult[], + sources?: { type: 'url'; url: string }[], +): WebSearchCallIR => ({ + action: { + type: 'search', + query: queries.join(' | '), + queries, + ...(sources !== undefined ? { sources } : {}), + }, + results, +}); + +const openPageIr = ( + url: string | undefined, + results: ResponsesWebSearchResult[], +): WebSearchCallIR => ({ + // Omit `url` when undefined to match native's soft-failure shape; + // never emit `url: ''`. + action: url !== undefined && url.length > 0 + ? { type: 'open_page', url } + : { type: 'open_page' }, + results, +}); + +const findInPageIr = ( + url: string, + pattern: string, + results: ResponsesWebSearchResult[], +): WebSearchCallIR => ({ + action: { type: 'find_in_page', url, pattern }, + results, +}); + +// No native action.type fits the shim-only error classes (unknown +// sub-property, malformed args); encode them via action.type:'search' with +// the diagnostic in queries[0] so wire-typed SDKs still parse the item. +export const schemaErrorIr = ( + queryLabel: string, + title: string, + snippet: string, +): WebSearchCallIR => ({ + action: { type: 'search', query: queryLabel, queries: [queryLabel] }, + results: [{ type: 'text_result', url: '', title, snippet }], +}); + +// ── Error / not-supported text ── +// Phrasings closely follow OpenAI's gpt-oss reference simple_browser tool so +// gpt-oss-family models (trained on those exact phrasings) recognize the +// structure; non-OpenAI models read them as plain natural-language output. +// +// References (pinned to commit 285b05d for stable line numbers): +// - gpt-oss simple_browser_tool.py `find` no-match phrase, line 246: +// https://github.com/openai/gpt-oss/blob/285b05d96dea9ce7da52ecbbe86791f18239c510/gpt_oss/tools/simple_browser/simple_browser_tool.py#L246 +// - gpt-oss simple_browser_tool.py `BackendError` fetching phrase, lines 444-445: +// https://github.com/openai/gpt-oss/blob/285b05d96dea9ce7da52ecbbe86791f18239c510/gpt_oss/tools/simple_browser/simple_browser_tool.py#L444-L445 +// - litellm `Search failed: ` idiom: +// https://github.com/BerriAI/litellm/blob/main/litellm/integrations/websearch_interception/transformation.py +const searchFailedText = (providerMessage: string): string => + `Search failed: ${providerMessage}`; + +const openFailedText = (url: string, providerMessage: string): string => + `Error fetching URL \`${url}\`: ${providerMessage}`; + +export const unsupportedOperationText = (subProperty: string): string => + `Error: the \`${subProperty}\` sub-property is not supported by this gateway. Only \`search_query\`, \`open\`, and \`find\` are available.`; + +export const wrongTypeOperationText = (subProperty: string, actualType: string): string => + `Error: the \`${subProperty}\` sub-property must be an array of objects; got ${actualType}.`; + +const errorSnippet = (title: string, snippet: string): ResponsesWebSearchResult => ({ + type: 'text_result', + url: '', + title, + snippet, +}); + +// ── Text rendering ── + +// openai-python `ActionSearch.query` is a single string; some clients send +// only `queries[]`. Accept both: the engine emits both fields on every +// search action so typed SDKs reading either one keep working. +export const actionSearchQueries = (action: Extract): string[] => { + if (action.queries !== undefined) return action.queries; + if (action.query !== undefined) return [action.query]; + return []; +}; + +// Numeric `[N]` references in the snippet body let the model cite specific +// search hits in its final answer. Empty results emit `(no results)` rather +// than a bare header so the model recognizes the call ran successfully but +// returned nothing. +const formatSearchResultsText = (query: string, results: readonly ResponsesWebSearchResult[]): string => { + const header = `Search results for "${query}":`; + if (results.length === 0) return `${header}\n\n(no results)`; + const sections = results.map((r, i) => `[${i + 1}] ${r.title}\n${r.url}\n${r.snippet}`); + return `${header}\n\n${sections.join('\n\n')}`; +}; + +export const renderOperationOutputText = (action: ResponsesWebSearchAction, results: ResponsesWebSearchResult[]): string => { + switch (action.type) { + case 'search': { + const queryLabel = actionSearchQueries(action).join(' | '); + return formatSearchResultsText(queryLabel, results); + } + case 'open_page': { + if (results.length === 0) { + const url = action.url ?? '(no url)'; + return `Open ${url}: (no body returned)`; + } + return results[0].snippet; + } + case 'find_in_page': + return results.length > 0 ? results[0].snippet : ''; + } +}; + +// ── Domain filtering ── + +// Suffix-match per Tavily and Microsoft Grounding search-side filter +// semantics: `example.com` matches `example.com`, `www.example.com`, and +// `sub.example.com`, but NOT `evil-example.com`. +const matchesAnyDomain = (hostname: string, domains: readonly string[]): boolean => { + for (const d of domains) { + if (hostname === d) return true; + if (hostname.endsWith(`.${d}`)) return true; + } + return false; +}; + +export const isUrlAllowed = (url: string, filter: WebSearchFilters): boolean => { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + return false; + } + const blocked = normalizeDomainList(filter.blockedDomains); + if (blocked.length > 0 && matchesAnyDomain(hostname, blocked)) { + return false; + } + const allowed = normalizeDomainList(filter.allowedDomains); + if (allowed.length > 0 && !matchesAnyDomain(hostname, allowed)) { + return false; + } + return true; +}; + +// ── find (literal case-insensitive substring matcher) ── +// Mirrors gpt-oss `find` rendering minus the cursor-numbered output. +// https://github.com/openai/gpt-oss/blob/285b05d96dea9ce7da52ecbbe86791f18239c510/gpt_oss/tools/simple_browser/simple_browser_tool.py + +interface FindMatch { + before: string; + matched: string; + after: string; +} + +export const findMatches = ( + text: string, + pattern: string, + opts: { maxMatches: number; contextChars: number }, +): FindMatch[] => { + if (pattern.length === 0) return []; + const lowerText = text.toLowerCase(); + const lowerPat = pattern.toLowerCase(); + const matches: FindMatch[] = []; + let from = 0; + while (matches.length < opts.maxMatches) { + const idx = lowerText.indexOf(lowerPat, from); + if (idx < 0) break; + const beforeStart = Math.max(0, idx - opts.contextChars); + const afterEnd = Math.min(text.length, idx + lowerPat.length + opts.contextChars); + matches.push({ + before: text.slice(beforeStart, idx), + matched: text.slice(idx, idx + lowerPat.length), + after: text.slice(idx + lowerPat.length, afterEnd), + }); + from = idx + lowerPat.length; + } + return matches; +}; + +export const formatMatches = (pattern: string, url: string, matches: readonly FindMatch[]): string => { + if (matches.length === 0) return `No matching \`${pattern}\` found on ${url}.`; + const noun = matches.length === 1 ? 'match' : 'matches'; + const lines: string[] = [`${matches.length} ${noun} for pattern: \`${pattern}\``, '']; + for (let i = 0; i < matches.length; i++) { + const m = matches[i]; + lines.push(`Match ${i + 1}:`); + lines.push(`"...${m.before}[${m.matched}]${m.after}..."`); + lines.push(''); + } + return lines.join('\n').trimEnd(); +}; + +const truncateString = (s: string, maxChars: number): string => + s.length <= maxChars ? s : `${truncatePreservingCodePoints(s, maxChars)}…`; + +// ── Provider resolution ── + +// Resolve the configured backend or return an `unavailable` reason. +// Disabled / missing-credential is per-op visible: each backend dispatch +// synthesizes a snippet IR so the model sees the error in-band instead of +// the whole request 5xx'ing. +const resolveActiveProvider = async ( + session: WebSearchExecutionSession, +): Promise<{ provider: WebSearchProvider; providerName: WebSearchProviderName } | { unavailable: string }> => { + const configured = await session.getProvider(); + if (configured.type === 'enabled') { + return { provider: configured.impl, providerName: configured.provider }; + } + if (configured.type === 'disabled') { + return { unavailable: 'Web search provider is not configured on this gateway.' }; + } + return { unavailable: `Web search provider ${configured.provider} is missing its credential on this gateway.` }; +}; + +// ── search ── + +interface SearchQueryOutcome { + results: ResponsesWebSearchResult[]; + sources?: { type: 'url'; url: string }[]; +} + +const runOneSearchQuery = async ( + query: string, + session: WebSearchExecutionSession, + active: { provider: WebSearchProvider; providerName: WebSearchProviderName }, +): Promise => { + try { + const searchRequest = { + query, + maxResults: session.filters.maxResults, + allowedDomains: session.filters.allowedDomains, + blockedDomains: session.filters.blockedDomains, + userLocation: session.filters.userLocation, + ...(session.signal !== undefined ? { signal: session.signal } : {}), + }; + const result = await searchWebAndRecordUsage({ + provider: active.provider, + providerName: active.providerName, + keyId: session.apiKeyId, + request: searchRequest, + }); + + if (result.type === 'error') { + const msg = result.message ?? result.errorCode; + return { results: [errorSnippet('Search error', searchFailedText(msg))] }; + } + + const results: ResponsesWebSearchResult[] = result.results.map(r => ({ + type: 'text_result' as const, + url: r.source, + title: r.title, + snippet: truncateString(r.content.map(c => c.text).join('\n'), MAX_SEARCH_SNIPPET_CHARS), + })); + // Native gates `action.sources` on `include: + // ["web_search_call.action.sources"]`; build the list only when the + // caller opted in. The shape mirrors openai-python `ActionSearch.sources[]` + // (`{type:'url', url}`). + const sources = session.includeSearchActionSources + ? result.results.map(r => ({ type: 'url' as const, url: r.source })) + : undefined; + return sources !== undefined ? { results, sources } : { results }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { results: [errorSnippet('Search error', searchFailedText(msg))] }; + } +}; + +const runBackendSearch = async ( + op: Extract, + session: WebSearchExecutionSession, +): Promise => { + if (op.error !== undefined) { + const title = op.errorKind === 'missing-arg' ? 'Missing argument' : 'Invalid ref_id'; + return searchIr(op.query, [errorSnippet(title, op.error)]); + } + const active = await resolveActiveProvider(session); + if ('unavailable' in active) { + return searchIr(op.query, [errorSnippet('Search error', searchFailedText(active.unavailable))]); + } + const { results, sources } = await runOneSearchQuery(op.query, session, active); + return searchIr(op.query, results, sources); +}; + +// Collapses N `search_query` entries into one wsc: same-action protocol +// shape (`{type:'search', queries:[...]}`) with the merged result set +// (concatenated in entry order). Per-query failures interleave as error +// snippets so the model sees which queries succeeded. +export const runBackendSearchMulti = async ( + ops: Array>, + session: WebSearchExecutionSession, +): Promise => { + const queries = ops.map(op => op.query); + const active = await resolveActiveProvider(session); + if ('unavailable' in active) { + return searchIrFromQueries(queries, [errorSnippet('Search error', searchFailedText(active.unavailable))]); + } + const perQuery = await Promise.all(ops.map(op => runOneSearchQuery(op.query, session, active))); + const mergedResults = perQuery.flatMap(r => r.results); + const mergedSources = session.includeSearchActionSources + ? perQuery.flatMap(r => r.sources ?? []) + : undefined; + return searchIrFromQueries(queries, mergedResults, mergedSources); +}; + +// ── open / find (page fetch + cache) ── + +type FetchAndCacheResult = + | { ok: true; cached: PageCacheEntry } + | { ok: false; output: string }; + +export type WebSearchPageFetchMap = Map; + +const runBatchFetch = async ( + needFetch: string[], + session: WebSearchExecutionSession, +): Promise => { + const perUrl: WebSearchPageFetchMap = new Map(); + const active = await resolveActiveProvider(session); + if ('unavailable' in active) { + for (const url of needFetch) { + perUrl.set(url, { ok: false, output: openFailedText(url, active.unavailable) }); + } + return perUrl; + } + try { + const fetchRequest = { + urls: needFetch, + ...(session.signal !== undefined ? { signal: session.signal } : {}), + }; + const result = await fetchPageAndRecordUsage({ + provider: active.provider, + providerName: active.providerName, + keyId: session.apiKeyId, + request: fetchRequest, + }); + + if (result.type === 'error') { + const msg = result.message ?? result.errorCode; + for (const url of needFetch) { + perUrl.set(url, { ok: false, output: openFailedText(url, msg) }); + } + return perUrl; + } + + const failureByUrl = new Map(result.failures.map(f => [f.url, f])); + const pageByUrl = new Map(result.pages.map(p => [p.url, p])); + for (const url of needFetch) { + const failure = failureByUrl.get(url); + if (failure) { + perUrl.set(url, { ok: false, output: openFailedText(url, failure.message ?? failure.errorCode) }); + continue; + } + const page = pageByUrl.get(url); + if (!page) { + // URL silently dropped by the provider — surface as explicit error + // so the model doesn't see a phantom empty page. + perUrl.set(url, { ok: false, output: openFailedText(url, 'No page returned') }); + continue; + } + const entry: PageCacheEntry = { + content: page.content, + truncated: page.truncated, + fullContentBytes: page.fullContentBytes, + title: page.title, + }; + session.pageCache.set(url, entry); + perUrl.set(url, { ok: true, cached: entry }); + } + return perUrl; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + for (const url of needFetch) { + perUrl.set(url, { ok: false, output: openFailedText(url, msg) }); + } + return perUrl; + } +}; + +// Intra-call batching: collect every URL the open[]/find[] sub-arrays +// reference, dedup, hit cache, and issue one batched provider.fetchPage for +// the remainder. Cross-call joining is deliberately NOT done — same-turn +// serial execution means later calls can simply read the populated cache. +const fetchAndCacheManyPages = async ( + urls: string[], + session: WebSearchExecutionSession, +): Promise => { + const results: WebSearchPageFetchMap = new Map(); + const needFetch: string[] = []; + const seen = new Set(); + + for (const url of urls) { + if (seen.has(url)) continue; + seen.add(url); + const cached = session.pageCache.get(url); + if (cached) { + results.set(url, { ok: true, cached }); + continue; + } + needFetch.push(url); + } + + if (needFetch.length > 0) { + const perUrl = await runBatchFetch(needFetch, session); + for (const url of needFetch) { + results.set(url, perUrl.get(url)!); + } + } + return results; +}; + +// Collect the open/find URL set for a parsed command and kick off one +// provider.fetchPage covering all of them. +// +// Blocked URLs (failing `isUrlAllowed`) are filtered OUT of the batch fetch +// but populated into the result map with an explicit `{ ok: false, output: +// 'Error fetching URL : Blocked by tool filters' }` entry. That way the +// per-op handlers can trust the gate's verdict by reading the map directly +// instead of re-running `isUrlAllowed` themselves. +export const startBatchFetch = async ( + parsed: ParsedWebSearchOperations, + session: WebSearchExecutionSession, +): Promise => { + if (parsed.kind !== 'ops') return new Map(); + const batchUrls: string[] = []; + const blockedUrls: string[] = []; + const seen = new Set(); + for (const op of parsed.ops) { + if (op.kind !== 'open' && op.kind !== 'find') continue; + if (op.error !== undefined) continue; + const url = op.url; + if (url === '') continue; + if (seen.has(url)) continue; + seen.add(url); + if (!isUrlAllowed(url, session.filters)) { + blockedUrls.push(url); + continue; + } + batchUrls.push(url); + } + const fetched = await fetchAndCacheManyPages(batchUrls, session); + for (const url of blockedUrls) { + fetched.set(url, { ok: false, output: openFailedText(url, 'Blocked by tool filters') }); + } + return fetched; +}; + +const openPageSuccessIr = (url: string, cached: PageCacheEntry): WebSearchCallIR => { + // Provider truncates to its 10 KiB per-page cap. Truncated bodies get a + // sentinel so the model can choose to `find` for specific content. + const body = cached.content + + (cached.truncated + ? `\n\n[Content truncated; full page is ${cached.fullContentBytes} bytes. Use web_search's \`find\` sub-property with a pattern to locate specific content.]` + : ''); + return openPageIr(url, [{ + type: 'text_result', + url, + title: cached.title ?? '', + snippet: body, + }]); +}; + +const runBackendOpenPage = async ( + op: Extract, + batch: WebSearchPageFetchMap, +): Promise => { + const url = op.url; + + // Invalid-ref-id (`op.error !== undefined`) carries a `{type:'search', + // queries:[ref_id]}` via `searchIr` because a urlless open_page action + // would be meaningless. + if (op.error !== undefined) { + const title = op.errorKind === 'missing-arg' ? 'Missing argument' : 'Invalid ref_id'; + return searchIr(op.url, [errorSnippet(title, op.error)]); + } + + // Batch fetch pre-populates entries for every URL the parser produced + // (blocked URLs get an explicit failure entry), so the lookup is total. + const fetched = batch.get(url)!; + if (!fetched.ok) { + return openPageIr(url, [errorSnippet('Open page error', fetched.output)]); + } + return openPageSuccessIr(url, fetched.cached); +}; + +const runBackendFind = async ( + op: Extract, + batch: WebSearchPageFetchMap, +): Promise => { + const url = op.url; + const pattern = op.pattern; + + if (op.error !== undefined) { + const title = op.errorKind === 'missing-arg' ? 'Missing argument' : 'Invalid ref_id'; + return findInPageIr(url, pattern, [errorSnippet(title, op.error)]); + } + + // Pre-fetch failures keep the `find_in_page` action carrying the original + // url + pattern; switching to `open_page` would silently change + // `action.type` mid-result. + const fetched = batch.get(url)!; + if (!fetched.ok) { + return findInPageIr(url, pattern, [errorSnippet('Find error', fetched.output)]); + } + + // Mirror gpt-oss `find` defaults. + const matches = findMatches(fetched.cached.content, pattern, { + maxMatches: 10, + contextChars: 200, + }); + const title = matches.length === 0 ? 'No match' : 'Matches'; + return findInPageIr(url, pattern, [{ + type: 'text_result', + url, + title, + snippet: formatMatches(pattern, url, matches), + }]); +}; + +// Execute one operation into its `web_search_call` IR. `search`/`open`/ +// `find` hit the backend (open/find read the pre-issued batch map); +// `unsupported`/`wrong-type` encode a diagnostic search IR so a Responses +// wire item still parses. +export const executeOperationToIr = ( + op: WebSearchOperation, + session: WebSearchExecutionSession, + batch: WebSearchPageFetchMap, +): Promise => { + switch (op.kind) { + case 'search': + return runBackendSearch(op, session); + case 'open': + return runBackendOpenPage(op, batch); + case 'find': + return runBackendFind(op, batch); + case 'unsupported': + return Promise.resolve(schemaErrorIr( + `unsupported action: ${op.subProperty}[${op.arrayIndex}]`, + 'Unsupported action', + unsupportedOperationText(op.subProperty), + )); + case 'wrong-type': + return Promise.resolve(schemaErrorIr( + `wrong-type sub-property: ${op.subProperty}`, + 'Malformed sub-property', + wrongTypeOperationText(op.subProperty, op.actualType), + )); + } +}; + +// Execute one operation and render its model-visible text directly. The +// Codex `/alpha/search` endpoint consumes only text, so `unsupported` / +// `wrong-type` render as their bare diagnostic rather than the IR-wrapped +// "Search results for …" form the Responses wire item needs. +export const executeOperationToText = async ( + op: WebSearchOperation, + session: WebSearchExecutionSession, + batch: WebSearchPageFetchMap, +): Promise => { + switch (op.kind) { + case 'unsupported': + return unsupportedOperationText(op.subProperty); + case 'wrong-type': + return wrongTypeOperationText(op.subProperty, op.actualType); + default: { + const ir = await executeOperationToIr(op, session, batch); + return renderOperationOutputText(ir.action, ir.results); + } + } +}; From 572c5fd40ef1a16a7e87709d416c82e5d1e3a31d Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 04:33:38 +0800 Subject: [PATCH 02/43] test(codex): cover /alpha/search route, execution, and config states Route tests through the codex namespace: auth, zod schema rejection (non-object commands, bad search_context_size), search/open/find command execution and rendered output, per-key usage accounting, unsupported command text, domain-filter blocking, empty commands, and disabled / missing-credential provider states surfaced as in-band output. --- .../src/data-plane/codex/alpha-search_test.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 packages/gateway/src/data-plane/codex/alpha-search_test.ts diff --git a/packages/gateway/src/data-plane/codex/alpha-search_test.ts b/packages/gateway/src/data-plane/codex/alpha-search_test.ts new file mode 100644 index 000000000..27438dbe9 --- /dev/null +++ b/packages/gateway/src/data-plane/codex/alpha-search_test.ts @@ -0,0 +1,286 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type AuthVars, authMiddleware } from '../../middleware/auth.ts'; +import { setupAppTest } from '../../test-helpers.ts'; +import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; +import type { SearchConfig, WebSearchFetchPageRequest, WebSearchFetchPageResult, WebSearchProvider, WebSearchProviderRequest, WebSearchProviderResult } from '../tools/web-search/types.ts'; +import { mountCodexRoutes } from './routes.ts'; + +// Real provider construction (`createTavilyWebSearchProvider` etc.) hits the +// network; replace the resolver so tests drive a stub backend instead. A +// SearchConfig row is still seeded so `loadSearchConfig` returns a real +// value; the mock ignores it and returns the configured state each test +// wants. +vi.mock('../tools/web-search/provider.ts'); +const mockResolveConfigured = vi.mocked(resolveConfiguredWebSearchProvider); + +const TAVILY_CONFIG: SearchConfig = { + provider: 'tavily', + tavily: { apiKey: 'test-key' }, + microsoftGrounding: { apiKey: '' }, + jina: { apiKey: '' }, +}; + +interface ProviderOverrides { + search?: (req: WebSearchProviderRequest) => Promise | WebSearchProviderResult; + fetchPage?: (req: WebSearchFetchPageRequest) => Promise | WebSearchFetchPageResult; +} + +interface BackendCall { + kind: 'search' | 'fetchPage'; + request: WebSearchProviderRequest | WebSearchFetchPageRequest; +} + +const makeStubProvider = (overrides: ProviderOverrides = {}): { provider: WebSearchProvider; calls: BackendCall[] } => { + const calls: BackendCall[] = []; + const provider: WebSearchProvider = { + async search(request) { + calls.push({ kind: 'search', request }); + if (overrides.search) return await overrides.search(request); + return { + type: 'ok', + results: [{ source: 'https://example.com/a', title: 'Example A', content: [{ type: 'text', text: 'snippet A' }] }], + }; + }, + async fetchPage(request) { + calls.push({ kind: 'fetchPage', request }); + if (overrides.fetchPage) return await overrides.fetchPage(request); + return { + type: 'ok', + pages: request.urls.map(url => ({ url, title: 'Page', content: `body of ${url}`, truncated: false, fullContentBytes: 12 })), + failures: [], + }; + }, + }; + return { provider, calls }; +}; + +const buildCodexApp = () => { + const app = new Hono<{ Variables: AuthVars }>(); + app.use('*', authMiddleware); + mountCodexRoutes(app); + return app; +}; + +const SEARCH_PATH = '/azure-api.codex/alpha/search'; + +const postSearch = (app: ReturnType, apiKey: string, body: unknown) => + app.request(SEARCH_PATH, { + method: 'POST', + body: JSON.stringify(body), + headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, + }); + +interface SearchResponseBody { + encrypted_output: string | null; + output: string; +} + +beforeEach(() => { + mockResolveConfigured.mockReset(); +}); + +describe('codex /alpha/search', () => { + describe('auth', () => { + it('rejects requests with no auth header (401)', async () => { + await setupAppTest(); + const app = buildCodexApp(); + const response = await app.request(SEARCH_PATH, { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); + expect(response.status).toBe(401); + }); + + it('rejects an unknown bearer (401)', async () => { + await setupAppTest(); + const app = buildCodexApp(); + const response = await postSearch(app, 'not-an-api-key', {}); + expect(response.status).toBe(401); + }); + }); + + describe('schema validation', () => { + it('rejects a non-object `commands` with 400', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const app = buildCodexApp(); + const response = await postSearch(app, apiKey.key, { commands: [] }); + expect(response.status).toBe(400); + }); + + it('rejects an unknown search_context_size with 400', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const app = buildCodexApp(); + const response = await postSearch(app, apiKey.key, { settings: { search_context_size: 'huge' } }); + expect(response.status).toBe(400); + }); + + it('accepts and ignores the model/id/reasoning/input/max_output_tokens fields codex always sends', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + const response = await postSearch(app, apiKey.key, { + id: 'session-1', + model: 'gpt-5.5', + reasoning: { effort: 'high' }, + input: 'find me the docs', + max_output_tokens: 2048, + commands: { search_query: [{ q: 'react hooks' }] }, + }); + expect(response.status).toBe(200); + }); + }); + + describe('command execution', () => { + it('runs a search_query and returns rendered results as `output`', async () => { + const { apiKey, repo } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { commands: { search_query: [{ q: 'react hooks' }] } }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.encrypted_output).toBeNull(); + expect(body.output).toContain('Search results for "react hooks"'); + expect(body.output).toContain('[1] Example A'); + expect(body.output).toContain('https://example.com/a'); + expect(body.output).toContain('snippet A'); + + // Query filters flow through from settings.search_context_size. + expect(stub.calls).toHaveLength(1); + expect(stub.calls[0].kind).toBe('search'); + expect((stub.calls[0].request as WebSearchProviderRequest).query).toBe('react hooks'); + + // Usage accounted against the caller's key. + const usage = await repo.searchUsage.listAll(); + expect(usage).toHaveLength(1); + expect(usage[0]).toMatchObject({ provider: 'tavily', keyId: apiKey.id, action: 'search', requests: 1 }); + }); + + it('opens a page and returns its body text; accounts one fetch_page usage row', async () => { + const { apiKey, repo } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { commands: { open: [{ ref_id: 'https://example.com/doc' }] } }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.output).toContain('body of https://example.com/doc'); + + const usage = await repo.searchUsage.listAll(); + expect(usage).toHaveLength(1); + expect(usage[0]).toMatchObject({ provider: 'tavily', keyId: apiKey.id, action: 'fetch_page', requests: 1 }); + }); + + it('finds a pattern inside an opened page and renders the matches', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider({ + fetchPage: req => ({ + type: 'ok', + pages: req.urls.map(url => ({ url, title: 'Page', content: 'alpha beta gamma beta delta', truncated: false, fullContentBytes: 27 })), + failures: [], + }), + }); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { commands: { find: [{ ref_id: 'https://example.com/doc', pattern: 'beta' }] } }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.output).toContain('2 matches for pattern: `beta`'); + }); + + it('concatenates multiple commands in order with a blank-line separator', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { + commands: { + search_query: [{ q: 'first' }, { q: 'second' }], + open: [{ ref_id: 'https://example.com/p' }], + }, + }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + const blocks = body.output.split('\n\n'); + expect(body.output).toContain('Search results for "first"'); + expect(body.output).toContain('Search results for "second"'); + expect(body.output).toContain('body of https://example.com/p'); + // Search rendering itself contains blank lines, so assert on markers + // rather than exact block count. + expect(blocks.length).toBeGreaterThan(1); + }); + + it('renders unimplemented command kinds as deterministic text without hitting the provider', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { + commands: { screenshot: [{ ref_id: 'https://example.com', pageno: 0 }], response_length: 'short' }, + }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.output).toContain('the `screenshot` sub-property is not supported'); + expect(body.output).toContain('the `response_length` sub-property is not supported'); + expect(stub.calls).toHaveLength(0); + }); + + it('returns a helpful message when no commands are provided', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const app = buildCodexApp(); + const response = await postSearch(app, apiKey.key, { commands: {} }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.output).toContain('No web search commands were provided'); + }); + + it('blocks an open URL outside the allowed_domains filter', async () => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { + settings: { filters: { allowed_domains: ['example.org'] } }, + commands: { open: [{ ref_id: 'https://example.com/blocked' }] }, + }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.output).toContain('Blocked by tool filters'); + // Blocked URLs never reach the provider. + expect(stub.calls).toHaveLength(0); + }); + }); + + describe('provider not configured', () => { + it('surfaces disabled search as in-band output text (contract-shaped 200)', async () => { + const { apiKey, repo } = await setupAppTest(); + mockResolveConfigured.mockReturnValue({ type: 'disabled' }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { commands: { search_query: [{ q: 'anything' }] } }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.encrypted_output).toBeNull(); + expect(body.output).toContain('Web search provider is not configured on this gateway.'); + // Nothing was billed because no backend ran. + expect(await repo.searchUsage.listAll()).toHaveLength(0); + }); + + it('surfaces a missing provider credential as in-band output text', async () => { + const { apiKey } = await setupAppTest(); + mockResolveConfigured.mockReturnValue({ type: 'missing-credential', provider: 'tavily' }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { commands: { search_query: [{ q: 'anything' }] } }); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.output).toContain('Web search provider tavily is missing its credential on this gateway.'); + }); + }); +}); From 771f9f8a1b7e80ce6644aac2fc677ba27c314557 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 04:34:50 +0800 Subject: [PATCH 03/43] style: apply eslint import-order fixes --- packages/gateway/src/data-plane/codex/alpha-search.ts | 4 ++-- packages/gateway/src/data-plane/codex/alpha-search_test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/gateway/src/data-plane/codex/alpha-search.ts b/packages/gateway/src/data-plane/codex/alpha-search.ts index d39570782..9bc940c2f 100644 --- a/packages/gateway/src/data-plane/codex/alpha-search.ts +++ b/packages/gateway/src/data-plane/codex/alpha-search.ts @@ -28,12 +28,12 @@ import { z } from 'zod'; +import { apiKeyFromContext } from '../../middleware/auth.ts'; +import type { CtxWithJson } from '../../middleware/zod-validator.ts'; import { executeOperationToText, maxResultsForContextSize, parseWebSearchOperations, startBatchFetch, type WebSearchExecutionSession, type WebSearchFilters } from '../tools/web-search/operations.ts'; import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; import { loadSearchConfig } from '../tools/web-search/search-config.ts'; import type { ConfiguredWebSearchProvider } from '../tools/web-search/types.ts'; -import { apiKeyFromContext } from '../../middleware/auth.ts'; -import type { CtxWithJson } from '../../middleware/zod-validator.ts'; const domainListSchema = z.array(z.string()); diff --git a/packages/gateway/src/data-plane/codex/alpha-search_test.ts b/packages/gateway/src/data-plane/codex/alpha-search_test.ts index 27438dbe9..263cc01e7 100644 --- a/packages/gateway/src/data-plane/codex/alpha-search_test.ts +++ b/packages/gateway/src/data-plane/codex/alpha-search_test.ts @@ -1,11 +1,11 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mountCodexRoutes } from './routes.ts'; import { type AuthVars, authMiddleware } from '../../middleware/auth.ts'; import { setupAppTest } from '../../test-helpers.ts'; import { resolveConfiguredWebSearchProvider } from '../tools/web-search/provider.ts'; import type { SearchConfig, WebSearchFetchPageRequest, WebSearchFetchPageResult, WebSearchProvider, WebSearchProviderRequest, WebSearchProviderResult } from '../tools/web-search/types.ts'; -import { mountCodexRoutes } from './routes.ts'; // Real provider construction (`createTavilyWebSearchProvider` etc.) hits the // network; replace the resolver so tests drive a stub backend instead. A From 9328683d00a6a081fb73b184e31bb3f521ab7909 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 04:35:13 +0800 Subject: [PATCH 04/43] docs(readme): document Codex /alpha/search web-search support --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 7218d31d1..8c61acbd6 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,16 @@ Search**), and emitted back as Responses `web_search_call` items, with the shim driving the internal multi-turn loop and replaying prior `web_search_call` items across turns. +The Codex CLI's web-search endpoint (`/alpha/search` under the +`/azure-api.codex/*` namespace) runs on the same provider. Codex POSTs the +`search_query` / `open` / `find` commands the model requested; Floway +executes them against the configured provider and returns the combined text +the CLI feeds back to the model, instead of proxying chatgpt.com. Command +kinds Floway doesn't implement, and a disabled or unconfigured provider, +come back as in-band tool-output text so the model can react rather than the +call hard-failing. + + ## Stateful Responses `/v1/responses` stores replayable Responses input and output items for API-key From db841729aeba1684f3f961c97d1641c8585cbb4b Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:05:02 +0800 Subject: [PATCH 05/43] fix(codex): expose alpha search on client-derived paths Codex derives its remote web-search URL from different base URLs across auth and provider modes. Mount the same authenticated, schema-validated handler at /alpha/search and /v1/alpha/search in addition to the generated config's /azure-api.codex/alpha/search path. --- packages/gateway/src/data-plane/codex/routes.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/gateway/src/data-plane/codex/routes.ts b/packages/gateway/src/data-plane/codex/routes.ts index b788f571a..36512f609 100644 --- a/packages/gateway/src/data-plane/codex/routes.ts +++ b/packages/gateway/src/data-plane/codex/routes.ts @@ -42,6 +42,9 @@ // emits a `web.run` tool call and feeds the returned `output` string back to // the model. We execute the requested commands through Floway's configured // web-search provider instead of proxying chatgpt.com — see ./alpha-search.ts. +// Codex derives the path differently across auth/provider modes, so the same +// handler is also mounted at root `/alpha/search` and `/v1/alpha/search`; +// the generated Floway config continues to use the namespaced route. // // Auth: this whole namespace is reached through the same `authMiddleware` // that protects every other API route. The operator forges @@ -70,6 +73,11 @@ import { responsesHttp } from '../chat/responses/http.ts'; import { responsesWebSocket } from '../chat/responses/websocket.ts'; const CODEX_BASE_PATH = '/azure-api.codex'; +const CODEX_SEARCH_PATHS = [ + `${CODEX_BASE_PATH}/alpha/search`, + '/alpha/search', + '/v1/alpha/search', +] as const; export const mountCodexRoutes = (app: Hono<{ Variables: AuthVars }>) => { app.post(`${CODEX_BASE_PATH}/responses`, responsesHttp.generate); @@ -79,7 +87,9 @@ export const mountCodexRoutes = (app: Hono<{ Variables: AuthVars }>) => { app.get(`${CODEX_BASE_PATH}/models`, codexModels); app.post(`${CODEX_BASE_PATH}/codex/analytics-events/events`, codexAnalyticsEventsEvents); - app.post(`${CODEX_BASE_PATH}/alpha/search`, zValidator('json', codexSearchRequestSchema), codexAlphaSearch); + for (const path of CODEX_SEARCH_PATHS) { + app.post(path, zValidator('json', codexSearchRequestSchema), codexAlphaSearch); + } app.post(`${CODEX_BASE_PATH}/api/codex/apps`, codexAppsMcp); From 02da86b7a8a7dab8d82b67533c69e271e44ac2d8 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:05:35 +0800 Subject: [PATCH 06/43] test(codex): cover every alpha search route alias Parameterize routing, missing/invalid auth, and schema rejection across the namespaced, root, and /v1 alpha-search paths so aliases cannot drift from the primary handler. --- .../src/data-plane/codex/alpha-search_test.ts | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/gateway/src/data-plane/codex/alpha-search_test.ts b/packages/gateway/src/data-plane/codex/alpha-search_test.ts index 263cc01e7..99d5ffe7a 100644 --- a/packages/gateway/src/data-plane/codex/alpha-search_test.ts +++ b/packages/gateway/src/data-plane/codex/alpha-search_test.ts @@ -63,10 +63,20 @@ const buildCodexApp = () => { return app; }; -const SEARCH_PATH = '/azure-api.codex/alpha/search'; - -const postSearch = (app: ReturnType, apiKey: string, body: unknown) => - app.request(SEARCH_PATH, { +const SEARCH_PATHS = [ + '/azure-api.codex/alpha/search', + '/alpha/search', + '/v1/alpha/search', +] as const; +const SEARCH_PATH = SEARCH_PATHS[0]; + +const postSearch = ( + app: ReturnType, + apiKey: string, + body: unknown, + path: (typeof SEARCH_PATHS)[number] = SEARCH_PATH, +) => + app.request(path, { method: 'POST', body: JSON.stringify(body), headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, @@ -82,34 +92,46 @@ beforeEach(() => { }); describe('codex /alpha/search', () => { - describe('auth', () => { - it('rejects requests with no auth header (401)', async () => { + describe('routing and auth', () => { + it.each(SEARCH_PATHS)('serves the same handler at %s', async path => { + const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); + const stub = makeStubProvider(); + mockResolveConfigured.mockReturnValue({ type: 'enabled', provider: 'tavily', impl: stub.provider }); + const app = buildCodexApp(); + + const response = await postSearch(app, apiKey.key, { commands: { search_query: [{ q: 'route probe' }] } }, path); + expect(response.status).toBe(200); + const body = await response.json() as SearchResponseBody; + expect(body.output).toContain('Search results for "route probe"'); + }); + + it.each(SEARCH_PATHS)('rejects missing auth at %s', async path => { await setupAppTest(); const app = buildCodexApp(); - const response = await app.request(SEARCH_PATH, { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); + const response = await app.request(path, { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); expect(response.status).toBe(401); }); - it('rejects an unknown bearer (401)', async () => { + it.each(SEARCH_PATHS)('rejects an unknown bearer at %s', async path => { await setupAppTest(); const app = buildCodexApp(); - const response = await postSearch(app, 'not-an-api-key', {}); + const response = await postSearch(app, 'not-an-api-key', {}, path); expect(response.status).toBe(401); }); }); describe('schema validation', () => { - it('rejects a non-object `commands` with 400', async () => { + it.each(SEARCH_PATHS)('rejects non-object `commands` at %s', async path => { const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); const app = buildCodexApp(); - const response = await postSearch(app, apiKey.key, { commands: [] }); + const response = await postSearch(app, apiKey.key, { commands: [] }, path); expect(response.status).toBe(400); }); - it('rejects an unknown search_context_size with 400', async () => { + it.each(SEARCH_PATHS)('rejects unknown search_context_size at %s', async path => { const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG }); const app = buildCodexApp(); - const response = await postSearch(app, apiKey.key, { settings: { search_context_size: 'huge' } }); + const response = await postSearch(app, apiKey.key, { settings: { search_context_size: 'huge' } }, path); expect(response.status).toBe(400); }); From 3b1b9facb18a02646a31607bc4f4673489b74360 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:06:19 +0800 Subject: [PATCH 07/43] fix(routing): forward root Codex search requests Route /alpha/search to the gateway in Vite development, Cloudflare Static Assets, and the Docker nginx topology. /v1/alpha/search is already covered by each topology's existing /v1 prefix rule. --- apps/web/vite.config.ts | 1 + docker/nginx.conf | 2 +- wrangler.example.jsonc | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 91759f109..7a814178f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -27,6 +27,7 @@ const wranglerProxiedPaths = [ '/v1', '/v1beta', '/azure-api.codex', + '/alpha/search', '/completions', '/chat/completions', '/responses', diff --git a/docker/nginx.conf b/docker/nginx.conf index 8b576d3d9..eb68212f3 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -43,7 +43,7 @@ server { # Bare paths — the gateway accepts every LLM endpoint with and without # the `/v1` prefix so SDKs configured with `baseURL = https://gw/` work. - location ~ ^/(completions|chat/completions|responses(/compact)?|messages(/count_tokens)?|embeddings|models|images/(generations|edits))$ { + location ~ ^/(alpha/search|completions|chat/completions|responses(/compact)?|messages(/count_tokens)?|embeddings|models|images/(generations|edits))$ { proxy_pass http://server:8788; } diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index edd7f6086..0d651a996 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -59,6 +59,7 @@ "/auth/*", "/v1/*", "/v1beta/*", + "/alpha/search", "/completions", "/chat/*", "/responses", From 923380fb32f238c5caba6ef7fb0507f79dab3187 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:06:49 +0800 Subject: [PATCH 08/43] docs(readme): list every Codex search route --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8c61acbd6..10765fb91 100644 --- a/README.md +++ b/README.md @@ -150,16 +150,17 @@ Search**), and emitted back as Responses `web_search_call` items, with the shim driving the internal multi-turn loop and replaying prior `web_search_call` items across turns. -The Codex CLI's web-search endpoint (`/alpha/search` under the -`/azure-api.codex/*` namespace) runs on the same provider. Codex POSTs the -`search_query` / `open` / `find` commands the model requested; Floway -executes them against the configured provider and returns the combined text -the CLI feeds back to the model, instead of proxying chatgpt.com. Command +The Codex CLI's web-search endpoint runs on the same provider. Floway serves +all paths Codex derives across its authentication and model-provider modes: +`/azure-api.codex/alpha/search`, `/alpha/search`, and `/v1/alpha/search`. +The generated dashboard config continues to recommend the namespaced path. +Codex POSTs the `search_query` / `open` / `find` commands the model requested; +Floway executes them against the configured provider and returns the combined +text the CLI feeds back to the model, instead of proxying chatgpt.com. Command kinds Floway doesn't implement, and a disabled or unconfigured provider, come back as in-band tool-output text so the model can react rather than the call hard-failing. - ## Stateful Responses `/v1/responses` stores replayable Responses input and output items for API-key From 7c10ee6a134f213dbf7c4bd41a7bd488f1705b1e Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:16:49 +0800 Subject: [PATCH 09/43] fix(headers): contain Codex web-tool compatibility marker Codex recognizes a custom provider as eligible for its remote web.run tool when the provider config carries a non-empty x-openai-actor-authorization header. Treat that dashboard-generated marker as gateway-owned identity and strip it before provider dispatch so it reaches Floway's routing decision but never leaks to an LLM upstream. --- packages/gateway/src/data-plane/shared/inbound-headers.ts | 7 +++++-- .../gateway/src/data-plane/shared/inbound-headers_test.ts | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/gateway/src/data-plane/shared/inbound-headers.ts b/packages/gateway/src/data-plane/shared/inbound-headers.ts index 7be7f3b3c..d066a0dbf 100644 --- a/packages/gateway/src/data-plane/shared/inbound-headers.ts +++ b/packages/gateway/src/data-plane/shared/inbound-headers.ts @@ -7,8 +7,10 @@ import type { Context } from 'hono'; // content-type on the wire (Azure's `api-key`, Anthropic's `x-api-key`, // `Authorization: Bearer`, each provider's body-derived `content-type`, // the runtime's freshly-derived multipart boundary for FormData -// passthrough). Letting any of these survive would clobber a pinned -// value or leak a private one. +// passthrough). `x-openai-actor-authorization` is also gateway-owned: the +// dashboard's Codex config uses a non-secret marker in that slot to enable +// Codex's remote web tool for a custom provider. Letting any of these +// survive would clobber a pinned value or leak a private one. // // - HTTP/1.1 framing + hop-by-hop (RFC 9110 §7.6.1). `content-length`, // `content-encoding`, and `transfer-encoding` describe the inbound body @@ -65,6 +67,7 @@ const SCRUBBED_INBOUND_HEADER_NAMES = [ 'x-api-key', 'x-client-ip', 'x-floway-session', + 'x-openai-actor-authorization', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', diff --git a/packages/gateway/src/data-plane/shared/inbound-headers_test.ts b/packages/gateway/src/data-plane/shared/inbound-headers_test.ts index 262ed6fef..c75a5af50 100644 --- a/packages/gateway/src/data-plane/shared/inbound-headers_test.ts +++ b/packages/gateway/src/data-plane/shared/inbound-headers_test.ts @@ -22,6 +22,7 @@ describe('inboundHeadersForUpstream', () => { 'x-api-key': 'gateway-api-key', 'x-floway-session': 'sess-1', 'x-goog-api-key': 'goog-key', + 'x-openai-actor-authorization': 'Codex compatibility marker', 'proxy-authorization': 'Basic abcdef', 'cookie': 'session=abc', 'host': 'gateway.example.com', @@ -40,6 +41,7 @@ describe('inboundHeadersForUpstream', () => { assertEquals(headers.has('x-api-key'), false); assertEquals(headers.has('x-floway-session'), false); assertEquals(headers.has('x-goog-api-key'), false); + assertEquals(headers.has('x-openai-actor-authorization'), false); assertEquals(headers.has('proxy-authorization'), false); assertEquals(headers.has('cookie'), false); assertEquals(headers.has('host'), false); From 1fb2900370003483cada52bcb2f4d84632c54ba3 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:17:30 +0800 Subject: [PATCH 10/43] fix(web): enable Codex web.run in generated config Codex gates the remote web-search extension on either an OpenAI provider identity or a non-empty x-openai-actor-authorization provider header. Keep the provider key/name as floway/Floway so the wider is_openai behavior stays disabled, and add a non-secret marker header that enables only the actor-auth compatibility gates. The gateway scrubs the marker before upstream dispatch. Cover the generated TOML so the provider identity, namespaced base URL, and compatibility marker cannot drift. --- apps/web/src/components/keys/CliSnippet.vue | 5 +++ .../src/components/keys/CliSnippet_test.ts | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 apps/web/src/components/keys/CliSnippet_test.ts diff --git a/apps/web/src/components/keys/CliSnippet.vue b/apps/web/src/components/keys/CliSnippet.vue index 245b1133f..abb71ef51 100644 --- a/apps/web/src/components/keys/CliSnippet.vue +++ b/apps/web/src/components/keys/CliSnippet.vue @@ -125,6 +125,11 @@ const codexSnippet = computed(() => [ `base_url = "${codexBaseUrl.value}"`, 'wire_api = "responses"', 'supports_websockets = true', + // Codex only contributes its remote web.run tool to OpenAI providers or + // custom providers carrying this header. A static marker is sufficient; + // Floway strips it before any provider call, so no credential is encoded in + // the generated config and no OpenAI-only provider behavior is enabled. + 'http_headers = { "x-openai-actor-authorization" = "floway-web-search" }', '', '[features]', 'apps = false', diff --git a/apps/web/src/components/keys/CliSnippet_test.ts b/apps/web/src/components/keys/CliSnippet_test.ts new file mode 100644 index 000000000..5de9c3a81 --- /dev/null +++ b/apps/web/src/components/keys/CliSnippet_test.ts @@ -0,0 +1,36 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { defineComponent } from 'vue'; + +import { buildRealModel } from '../../api/test-fixtures.ts'; + +vi.mock('@floway-dev/ui', () => ({ + Code: defineComponent({ + props: { + code: { type: String, required: true }, + language: { type: String, required: false }, + }, + template: '
{{ code }}
', + }), +})); + +const { default: CliSnippet } = await import('./CliSnippet.vue'); + +describe('CliSnippet Codex config', () => { + it('keeps the Floway provider identity while enabling Codex remote web.run', () => { + const wrapper = mount(CliSnippet, { + props: { + apiKey: 'sk-test', + models: [buildRealModel({ id: 'gpt-5.5', endpoints: { responses: {} } })], + }, + }); + + const toml = wrapper.find('pre[data-language="toml"]').text(); + expect(toml).toContain('model_provider = "floway"'); + expect(toml).toContain('[model_providers.floway]'); + expect(toml).toContain('name = "Floway"'); + expect(toml).toContain('base_url = "http://localhost:3000/azure-api.codex"'); + expect(toml).toContain('http_headers = { "x-openai-actor-authorization" = "floway-web-search" }'); + expect(toml).not.toContain('name = "OpenAI"'); + }); +}); From 803324f9e511839e87f041d6b2cb1256dbd5cdcb Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:17:50 +0800 Subject: [PATCH 11/43] docs(readme): explain Codex web.run compatibility marker --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 10765fb91..6cee3f376 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,10 @@ the shim driving the internal multi-turn loop and replaying prior The Codex CLI's web-search endpoint runs on the same provider. Floway serves all paths Codex derives across its authentication and model-provider modes: `/azure-api.codex/alpha/search`, `/alpha/search`, and `/v1/alpha/search`. -The generated dashboard config continues to recommend the namespaced path. +The generated dashboard config continues to recommend the namespaced path +and includes a non-secret provider-header marker that enables Codex's remote +`web.run` extension for the custom `Floway` provider; Floway consumes that +marker at the gateway boundary and never forwards it to an LLM upstream. Codex POSTs the `search_query` / `open` / `find` commands the model requested; Floway executes them against the configured provider and returns the combined text the CLI feeds back to the model, instead of proxying chatgpt.com. Command From f609fdd56d8177b0c6e09a673e44a94c161ecaec Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:23:03 +0800 Subject: [PATCH 12/43] fix(web): disable Codex image generation in generated config The actor-authorization marker that enables remote web.run also makes Codex consider its remote image-generation extension. Floway does not serve the provider-relative images/generations path, so explicitly disable that feature while retaining the marker for web search. Cover and document the generated feature block. --- README.md | 5 ++++- apps/web/src/components/keys/CliSnippet.vue | 4 ++++ apps/web/src/components/keys/CliSnippet_test.ts | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6cee3f376..de8991ffe 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,10 @@ all paths Codex derives across its authentication and model-provider modes: The generated dashboard config continues to recommend the namespaced path and includes a non-secret provider-header marker that enables Codex's remote `web.run` extension for the custom `Floway` provider; Floway consumes that -marker at the gateway boundary and never forwards it to an LLM upstream. +marker at the gateway boundary and never forwards it to an LLM upstream. The +same marker also makes Codex consider its remote image-generation extension, +so the generated config explicitly sets `image_generation = false`; Floway +does not serve Codex's provider-relative image path. Codex POSTs the `search_query` / `open` / `find` commands the model requested; Floway executes them against the configured provider and returns the combined text the CLI feeds back to the model, instead of proxying chatgpt.com. Command diff --git a/apps/web/src/components/keys/CliSnippet.vue b/apps/web/src/components/keys/CliSnippet.vue index abb71ef51..e5050bb39 100644 --- a/apps/web/src/components/keys/CliSnippet.vue +++ b/apps/web/src/components/keys/CliSnippet.vue @@ -133,6 +133,10 @@ const codexSnippet = computed(() => [ '', '[features]', 'apps = false', + // The actor-auth marker also makes Codex consider its remote image tool. + // Floway does not serve the provider-relative `images/generations` path, + // so keep that extension out of the generated configuration. + 'image_generation = false', ].join('\n')); // Unquoted heredoc so `$(date -u +...)` runs in the user's shell to stamp diff --git a/apps/web/src/components/keys/CliSnippet_test.ts b/apps/web/src/components/keys/CliSnippet_test.ts index 5de9c3a81..a5aec7d48 100644 --- a/apps/web/src/components/keys/CliSnippet_test.ts +++ b/apps/web/src/components/keys/CliSnippet_test.ts @@ -31,6 +31,7 @@ describe('CliSnippet Codex config', () => { expect(toml).toContain('name = "Floway"'); expect(toml).toContain('base_url = "http://localhost:3000/azure-api.codex"'); expect(toml).toContain('http_headers = { "x-openai-actor-authorization" = "floway-web-search" }'); + expect(toml).toContain('[features]\napps = false\nimage_generation = false'); expect(toml).not.toContain('name = "OpenAI"'); }); }); From 346e759781fbb8664cb455d3b68974367399d49b Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 05:58:11 +0800 Subject: [PATCH 13/43] chore(codex): clean up shared search wording and ordering Keep the inbound-header scrub list alphabetic, describe alpha-search output in the parser's canonical operation order rather than JSON key order, and make the shared truncated-page hint neutral across Responses and Codex callers. --- .../chat/responses/interceptors/server-tool-shim_test.ts | 2 ++ packages/gateway/src/data-plane/codex/alpha-search.ts | 5 +++-- packages/gateway/src/data-plane/shared/inbound-headers.ts | 2 +- .../gateway/src/data-plane/tools/web-search/operations.ts | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts index cb1353631..d46560b9c 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tool-shim_test.ts @@ -1613,6 +1613,8 @@ test('truncated page contents append the truncation sentinel', async () => { assert(lastOutput.type === 'function_call_output'); const text = (lastOutput as { output: string }).output; assert(text.includes('[Content truncated; full page is 99999 bytes.')); + assert(text.includes('Use the `find` sub-property with a pattern')); + assertFalse(text.includes("web_search's")); }); // ── Multi-turn SSE merge invariants ────────────────────────────────── diff --git a/packages/gateway/src/data-plane/codex/alpha-search.ts b/packages/gateway/src/data-plane/codex/alpha-search.ts index 9bc940c2f..b247464a6 100644 --- a/packages/gateway/src/data-plane/codex/alpha-search.ts +++ b/packages/gateway/src/data-plane/codex/alpha-search.ts @@ -112,8 +112,9 @@ export const codexAlphaSearch = async (c: CtxWithJson executeOperationToText(op, session, batch))); diff --git a/packages/gateway/src/data-plane/shared/inbound-headers.ts b/packages/gateway/src/data-plane/shared/inbound-headers.ts index d066a0dbf..541977efa 100644 --- a/packages/gateway/src/data-plane/shared/inbound-headers.ts +++ b/packages/gateway/src/data-plane/shared/inbound-headers.ts @@ -67,11 +67,11 @@ const SCRUBBED_INBOUND_HEADER_NAMES = [ 'x-api-key', 'x-client-ip', 'x-floway-session', - 'x-openai-actor-authorization', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-goog-api-key', + 'x-openai-actor-authorization', 'x-real-ip', ]; diff --git a/packages/gateway/src/data-plane/tools/web-search/operations.ts b/packages/gateway/src/data-plane/tools/web-search/operations.ts index 047d56f05..35917bad8 100644 --- a/packages/gateway/src/data-plane/tools/web-search/operations.ts +++ b/packages/gateway/src/data-plane/tools/web-search/operations.ts @@ -732,7 +732,7 @@ const openPageSuccessIr = (url: string, cached: PageCacheEntry): WebSearchCallIR // sentinel so the model can choose to `find` for specific content. const body = cached.content + (cached.truncated - ? `\n\n[Content truncated; full page is ${cached.fullContentBytes} bytes. Use web_search's \`find\` sub-property with a pattern to locate specific content.]` + ? `\n\n[Content truncated; full page is ${cached.fullContentBytes} bytes. Use the \`find\` sub-property with a pattern to locate specific content.]` : ''); return openPageIr(url, [{ type: 'text_result', From 08271809309219a254a63f8065bf3b28aaa8559a Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 12 Jul 2026 14:03:53 +0800 Subject: [PATCH 14/43] fix(codex): remove fabricated provider compatibility settings Keep the generated Codex provider config faithful to Floway: do not inject x-openai-actor-authorization, do not special-case that vendor header in the gateway scrubber, and do not disable image generation. Remove the associated gating claims and retain only the actual alpha-search route behavior in the documentation. --- README.md | 8 +------- apps/web/src/components/keys/CliSnippet.vue | 9 --------- apps/web/src/components/keys/CliSnippet_test.ts | 7 ++++--- .../gateway/src/data-plane/shared/inbound-headers.ts | 7 ++----- .../src/data-plane/shared/inbound-headers_test.ts | 2 -- 5 files changed, 7 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index de8991ffe..10765fb91 100644 --- a/README.md +++ b/README.md @@ -153,13 +153,7 @@ the shim driving the internal multi-turn loop and replaying prior The Codex CLI's web-search endpoint runs on the same provider. Floway serves all paths Codex derives across its authentication and model-provider modes: `/azure-api.codex/alpha/search`, `/alpha/search`, and `/v1/alpha/search`. -The generated dashboard config continues to recommend the namespaced path -and includes a non-secret provider-header marker that enables Codex's remote -`web.run` extension for the custom `Floway` provider; Floway consumes that -marker at the gateway boundary and never forwards it to an LLM upstream. The -same marker also makes Codex consider its remote image-generation extension, -so the generated config explicitly sets `image_generation = false`; Floway -does not serve Codex's provider-relative image path. +The generated dashboard config continues to recommend the namespaced path. Codex POSTs the `search_query` / `open` / `find` commands the model requested; Floway executes them against the configured provider and returns the combined text the CLI feeds back to the model, instead of proxying chatgpt.com. Command diff --git a/apps/web/src/components/keys/CliSnippet.vue b/apps/web/src/components/keys/CliSnippet.vue index e5050bb39..245b1133f 100644 --- a/apps/web/src/components/keys/CliSnippet.vue +++ b/apps/web/src/components/keys/CliSnippet.vue @@ -125,18 +125,9 @@ const codexSnippet = computed(() => [ `base_url = "${codexBaseUrl.value}"`, 'wire_api = "responses"', 'supports_websockets = true', - // Codex only contributes its remote web.run tool to OpenAI providers or - // custom providers carrying this header. A static marker is sufficient; - // Floway strips it before any provider call, so no credential is encoded in - // the generated config and no OpenAI-only provider behavior is enabled. - 'http_headers = { "x-openai-actor-authorization" = "floway-web-search" }', '', '[features]', 'apps = false', - // The actor-auth marker also makes Codex consider its remote image tool. - // Floway does not serve the provider-relative `images/generations` path, - // so keep that extension out of the generated configuration. - 'image_generation = false', ].join('\n')); // Unquoted heredoc so `$(date -u +...)` runs in the user's shell to stamp diff --git a/apps/web/src/components/keys/CliSnippet_test.ts b/apps/web/src/components/keys/CliSnippet_test.ts index a5aec7d48..c757e91c9 100644 --- a/apps/web/src/components/keys/CliSnippet_test.ts +++ b/apps/web/src/components/keys/CliSnippet_test.ts @@ -17,7 +17,7 @@ vi.mock('@floway-dev/ui', () => ({ const { default: CliSnippet } = await import('./CliSnippet.vue'); describe('CliSnippet Codex config', () => { - it('keeps the Floway provider identity while enabling Codex remote web.run', () => { + it('keeps the Floway provider identity and namespaced base URL', () => { const wrapper = mount(CliSnippet, { props: { apiKey: 'sk-test', @@ -30,8 +30,9 @@ describe('CliSnippet Codex config', () => { expect(toml).toContain('[model_providers.floway]'); expect(toml).toContain('name = "Floway"'); expect(toml).toContain('base_url = "http://localhost:3000/azure-api.codex"'); - expect(toml).toContain('http_headers = { "x-openai-actor-authorization" = "floway-web-search" }'); - expect(toml).toContain('[features]\napps = false\nimage_generation = false'); + expect(toml).toContain('[features]\napps = false'); + expect(toml).not.toContain('http_headers'); + expect(toml).not.toContain('image_generation'); expect(toml).not.toContain('name = "OpenAI"'); }); }); diff --git a/packages/gateway/src/data-plane/shared/inbound-headers.ts b/packages/gateway/src/data-plane/shared/inbound-headers.ts index 541977efa..7be7f3b3c 100644 --- a/packages/gateway/src/data-plane/shared/inbound-headers.ts +++ b/packages/gateway/src/data-plane/shared/inbound-headers.ts @@ -7,10 +7,8 @@ import type { Context } from 'hono'; // content-type on the wire (Azure's `api-key`, Anthropic's `x-api-key`, // `Authorization: Bearer`, each provider's body-derived `content-type`, // the runtime's freshly-derived multipart boundary for FormData -// passthrough). `x-openai-actor-authorization` is also gateway-owned: the -// dashboard's Codex config uses a non-secret marker in that slot to enable -// Codex's remote web tool for a custom provider. Letting any of these -// survive would clobber a pinned value or leak a private one. +// passthrough). Letting any of these survive would clobber a pinned +// value or leak a private one. // // - HTTP/1.1 framing + hop-by-hop (RFC 9110 §7.6.1). `content-length`, // `content-encoding`, and `transfer-encoding` describe the inbound body @@ -71,7 +69,6 @@ const SCRUBBED_INBOUND_HEADER_NAMES = [ 'x-forwarded-host', 'x-forwarded-proto', 'x-goog-api-key', - 'x-openai-actor-authorization', 'x-real-ip', ]; diff --git a/packages/gateway/src/data-plane/shared/inbound-headers_test.ts b/packages/gateway/src/data-plane/shared/inbound-headers_test.ts index c75a5af50..262ed6fef 100644 --- a/packages/gateway/src/data-plane/shared/inbound-headers_test.ts +++ b/packages/gateway/src/data-plane/shared/inbound-headers_test.ts @@ -22,7 +22,6 @@ describe('inboundHeadersForUpstream', () => { 'x-api-key': 'gateway-api-key', 'x-floway-session': 'sess-1', 'x-goog-api-key': 'goog-key', - 'x-openai-actor-authorization': 'Codex compatibility marker', 'proxy-authorization': 'Basic abcdef', 'cookie': 'session=abc', 'host': 'gateway.example.com', @@ -41,7 +40,6 @@ describe('inboundHeadersForUpstream', () => { assertEquals(headers.has('x-api-key'), false); assertEquals(headers.has('x-floway-session'), false); assertEquals(headers.has('x-goog-api-key'), false); - assertEquals(headers.has('x-openai-actor-authorization'), false); assertEquals(headers.has('proxy-authorization'), false); assertEquals(headers.has('cookie'), false); assertEquals(headers.has('host'), false); From b9d462ed9f048c5d350409a00862451c4edfa4a8 Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 15 Jul 2026 17:59:21 +0800 Subject: [PATCH 15/43] feat(search): add alpha search provider call Add callAlphaSearch to the provider contract. Codex dispatches to the ChatGPT subscription endpoint with its OAuth account, custom dispatches to /v1/alpha/search with static auth, and other provider kinds reject the unsupported call. --- packages/provider-azure/src/provider.ts | 1 + packages/provider-claude-code/src/provider.ts | 1 + packages/provider-codex/src/constants.ts | 1 + packages/provider-codex/src/fetch.ts | 46 ++++++++++++++++++- packages/provider-codex/src/provider.ts | 16 ++++++- packages/provider-copilot/src/provider.ts | 1 + packages/provider-custom/src/fetch.ts | 2 + packages/provider-custom/src/provider.ts | 3 +- packages/provider-ollama/src/provider.ts | 1 + packages/provider/src/provider.ts | 1 + packages/test-utils/src/stubs.ts | 1 + 11 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/provider-azure/src/provider.ts b/packages/provider-azure/src/provider.ts index 209a0b5ba..0ffe18db1 100644 --- a/packages/provider-azure/src/provider.ts +++ b/packages/provider-azure/src/provider.ts @@ -43,6 +43,7 @@ export const createAzureProvider = (record: UpstreamRecord): Provider => { }; const instance: ProviderInstance = { + callAlphaSearch: rejectUnsupported('callAlphaSearch'), getProvidedModels() { return Promise.resolve(azure.config.models.map(model => { const effective = resolveEffectiveFlags([AZURE_DEFAULT_FLAGS, azure.flagOverrides, model.flagOverrides]); diff --git a/packages/provider-claude-code/src/provider.ts b/packages/provider-claude-code/src/provider.ts index ae4f19437..fd80fd1c9 100644 --- a/packages/provider-claude-code/src/provider.ts +++ b/packages/provider-claude-code/src/provider.ts @@ -24,6 +24,7 @@ export const createClaudeCodeProvider = (record: UpstreamRecord): Provider => { const enabledFlags = resolveEffectiveFlags([CLAUDE_CODE_DEFAULT_FLAGS, record.flagOverrides]); const instance: ProviderInstance = { + callAlphaSearch: rejectUnsupported('callAlphaSearch'), // Catalog refresh mints an access token and hits /v1/models on every // dispatcher poll. `ensureClaudeCodeAccessToken` flips the row to // `refresh_failed` and throws `ClaudeCodeOAuthSessionTerminatedError` diff --git a/packages/provider-codex/src/constants.ts b/packages/provider-codex/src/constants.ts index 1613f64f9..69142a153 100644 --- a/packages/provider-codex/src/constants.ts +++ b/packages/provider-codex/src/constants.ts @@ -32,6 +32,7 @@ export const CODEX_OAUTH_USER_AGENT = 'codex-cli/0.91.0'; export const CODEX_BACKEND_BASE = 'https://chatgpt.com/backend-api'; export const CODEX_RESPONSES_PATH = '/codex/responses'; +export const CODEX_ALPHA_SEARCH_PATH = '/codex/alpha/search'; // Native unary compaction endpoint. The Codex CLI defaults to a client-side // `RemoteCompactionV2` path that re-uses `/codex/responses` with an appended // `compaction_trigger` item, but the server still serves this canonical diff --git a/packages/provider-codex/src/fetch.ts b/packages/provider-codex/src/fetch.ts index a6fb75e55..4b79f9aef 100644 --- a/packages/provider-codex/src/fetch.ts +++ b/packages/provider-codex/src/fetch.ts @@ -2,6 +2,7 @@ import { ensureCodexAccessToken, invalidateCodexAccessToken, mintCodexAccessToke import { CodexOAuthSessionTerminatedError } from './auth/oauth.ts'; import { CODEX_BACKEND_BASE, + CODEX_ALPHA_SEARCH_PATH, CODEX_ORIGINATOR, CODEX_RESPONSES_COMPACT_PATH, CODEX_RESPONSES_PATH, @@ -15,7 +16,7 @@ import { import type { CodexAccountCredential } from './state.ts'; import type { CanonicalResponsesCompactPayload, CanonicalResponsesPayload, ResponsesInputItem, ResponsesResult, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; import { parseResponsesStream } from '@floway-dev/protocols/responses'; -import { type ProviderModel, type ProviderStreamResult, streamingProviderCall, uuidV7, type UpstreamCallOptions } from '@floway-dev/provider'; +import { type ProviderCallResult, type ProviderModel, type ProviderStreamResult, streamingProviderCall, uuidV7, type UpstreamCallOptions } from '@floway-dev/provider'; export type ProviderCompactionResult = | { ok: true; result: ResponsesResult; modelKey: string } @@ -52,6 +53,10 @@ export interface CallCodexResponsesCompactOptions extends CodexBackendCallBase { body: Omit; } +export interface CallCodexAlphaSearchOptions extends CodexBackendCallBase { + body: Record; +} + type CodexResponsesBody = CallCodexResponsesOptions['body'] | CallCodexResponsesCompactOptions['body']; export const callCodexResponses = async (opts: CallCodexResponsesOptions): Promise> => { @@ -66,6 +71,12 @@ export const callCodexResponsesCompact = async (opts: CallCodexResponsesCompactO return await performUnaryCompactCall(opts, ready.accessToken, false); }; +export const callCodexAlphaSearch = async (opts: CallCodexAlphaSearchOptions): Promise => { + const ready = await prepareCodexCall(opts); + if (!ready.ok) return { modelKey: opts.model.id, response: ready.response }; + return await performAlphaSearchCall(opts, ready.accessToken, false); +}; + // Pre-fetch gates + initial access-token mint. const prepareCodexCall = async (opts: CodexBackendCallBase): Promise<{ ok: true; accessToken: string } | { ok: false; response: Response }> => { if (opts.account.state !== 'active') { @@ -445,6 +456,39 @@ const performUnaryCompactCall = async ( return { ok: true, modelKey: opts.model.id, result }; }; +const performAlphaSearchCall = async ( + opts: CallCodexAlphaSearchOptions, + accessToken: string, + alreadyRetried: boolean, +): Promise => { + const requestId = stringField(opts.body, 'id') ?? uuidV7(); + const identity: CodexRequestIdentity = { + installationId: opts.account.openaiDeviceId, + sessionId: requestId, + threadId: requestId, + clientRequestId: requestId, + turnId: uuidV7(), + windowId: `${requestId}:0`, + }; + const turnMetadataJson = trimHeader(opts.headers, 'x-codex-turn-metadata') ?? '{}'; + const response = await dispatchCodexHttpCall( + opts, + accessToken, + CODEX_ALPHA_SEARCH_PATH, + 'application/json', + { ...opts.body, model: opts.model.id }, + identity, + turnMetadataJson, + ); + + if (response.status === 401 && !alreadyRetried) { + const fresh = await refreshAccessTokenForRetry(opts); + if (!fresh.ok) return { modelKey: opts.model.id, response: fresh.response }; + return await performAlphaSearchCall(opts, fresh.accessToken, true); + } + return { modelKey: opts.model.id, response }; +}; + const parseUpstreamError = (rawText: string): { code: string | null; message: string } => { try { const obj = JSON.parse(rawText) as { error?: { code?: unknown; message?: unknown }; detail?: unknown }; diff --git a/packages/provider-codex/src/provider.ts b/packages/provider-codex/src/provider.ts index 995453a91..d79095daf 100644 --- a/packages/provider-codex/src/provider.ts +++ b/packages/provider-codex/src/provider.ts @@ -2,7 +2,7 @@ import { ensureCodexAccessToken, mintCodexAccessToken } from './access-token-cac import { CodexOAuthSessionTerminatedError } from './auth/oauth.ts'; import { assertCodexUpstreamRecord, type CodexUpstreamConfig } from './config.ts'; import { CODEX_DEFAULT_FLAGS } from './defaults.ts'; -import { callCodexResponses, callCodexResponsesCompact, type CodexCallEffects } from './fetch.ts'; +import { callCodexAlphaSearch, callCodexResponses, callCodexResponsesCompact, type CodexCallEffects } from './fetch.ts'; import { CODEX_RESPONSES_BOUNDARY } from './interceptors/responses/index.ts'; import type { ResponsesBoundaryCtx } from './interceptors/responses/types.ts'; import { codexRawToProviderModel, fetchCodexCatalog } from './models.ts'; @@ -96,6 +96,20 @@ export const createCodexProvider = (record: UpstreamRecord): Provider => { return raw.map(r => codexRawToProviderModel(r, enabledFlags)); }, + callAlphaSearch: async (model, body, signal, opts) => { + const { account } = await readActiveAccount(); + return await callCodexAlphaSearch({ + upstreamId: record.id, + account, + model, + headers: new Headers(opts.headers), + signal, + effects, + call: opts, + body, + }); + }, + callResponses: async (model, body, action, signal, opts) => { const ctx: ResponsesBoundaryCtx = { payload: { ...body, model: model.id }, diff --git a/packages/provider-copilot/src/provider.ts b/packages/provider-copilot/src/provider.ts index 800eaea88..d6c9c990f 100644 --- a/packages/provider-copilot/src/provider.ts +++ b/packages/provider-copilot/src/provider.ts @@ -286,6 +286,7 @@ export const createCopilotProvider = (record: UpstreamRecord): Provider => { }; const instance: ProviderInstance = { + callAlphaSearch: rejectUnsupported('callAlphaSearch'), getProvidedModels: async fetcher => { const fresh = await getProviderRepo().upstreams.getById(copilot.id); if (!fresh) throw new Error(`Copilot upstream ${copilot.id} disappeared mid-request`); diff --git a/packages/provider-custom/src/fetch.ts b/packages/provider-custom/src/fetch.ts index c198f2e54..856790bad 100644 --- a/packages/provider-custom/src/fetch.ts +++ b/packages/provider-custom/src/fetch.ts @@ -57,6 +57,8 @@ export const customFetchImagesGenerations = (config: CustomUpstreamConfig, init: customFetchInternal(config, resolveOverridable(config, '/images/generations'), init, options); export const customFetchImagesEdits = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => customFetchInternal(config, resolveOverridable(config, '/images/edits'), init, options); +export const customFetchAlphaSearch = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => + customFetchInternal(config, '/v1/alpha/search', init, options); // /models lives on its own fetch toggle (see config.modelsFetch.endpoint), // not in pathOverrides. export const customFetchModels = (config: CustomUpstreamConfig, init: RequestInit, options: UpstreamFetchOptions): Promise => diff --git a/packages/provider-custom/src/provider.ts b/packages/provider-custom/src/provider.ts index 576894229..8f832dece 100644 --- a/packages/provider-custom/src/provider.ts +++ b/packages/provider-custom/src/provider.ts @@ -1,7 +1,7 @@ import { assertCustomUpstreamRecord, type CustomUpstreamConfig } from './config.ts'; import { CUSTOM_DEFAULT_FLAGS } from './defaults.ts'; import { fetchCustomModels, type CustomModelsResponse, type CustomRawModel } from './fetch-models.ts'; -import { customFetchChatCompletions, customFetchCompletions, customFetchEmbeddings, customFetchImagesEdits, customFetchImagesGenerations, customFetchMessages, customFetchMessagesCountTokens, customFetchResponses, customFetchResponsesCompact } from './fetch.ts'; +import { customFetchAlphaSearch, customFetchChatCompletions, customFetchCompletions, customFetchEmbeddings, customFetchImagesEdits, customFetchImagesGenerations, customFetchMessages, customFetchMessagesCountTokens, customFetchResponses, customFetchResponsesCompact } from './fetch.ts'; import { inferEndpointsFromModelId } from './infer-endpoints.ts'; import { parseChatCompletionsStream } from '@floway-dev/protocols/chat-completions'; import { type ModelEndpoints, kindForEndpoints } from '@floway-dev/protocols/common'; @@ -148,6 +148,7 @@ export const createCustomProvider = (record: UpstreamRecord): Provider => { const filtered: CustomModelsResponse = { data: response.data.filter(raw => !overriddenIds.has(raw.id)) }; return [...effectiveManualModels, ...finalizeCustomModels(filtered, configuredEndpoints, upstreamFlags)]; }, + callAlphaSearch: (model, body, signal, opts) => call(customFetchAlphaSearch, model, body, signal, opts.headers, opts), callCompletions: (model, body, signal, opts) => call(customFetchCompletions, model, body, signal, opts.headers, opts), callChatCompletions: (model, body, signal, opts) => callStreaming(customFetchChatCompletions, model, body, signal, opts.headers, parseChatCompletionsStream, opts), callResponses: async (model, body, action, signal, opts) => { diff --git a/packages/provider-ollama/src/provider.ts b/packages/provider-ollama/src/provider.ts index 2cd37a8b0..21b14d1b2 100644 --- a/packages/provider-ollama/src/provider.ts +++ b/packages/provider-ollama/src/provider.ts @@ -132,6 +132,7 @@ export const createOllamaProvider = (record: UpstreamRecord): Provider => { Promise.reject(new Error(`Ollama provider does not implement ${capability}`)); const instance: ProviderInstance = { + callAlphaSearch: rejectUnsupported('callAlphaSearch'), getProvidedModels: async fetcher => { const catalog = await fetchOllamaCatalog(config, fetcher); const auto = finalizeOllamaModels( diff --git a/packages/provider/src/provider.ts b/packages/provider/src/provider.ts index c1761524d..9d1ac628c 100644 --- a/packages/provider/src/provider.ts +++ b/packages/provider/src/provider.ts @@ -110,6 +110,7 @@ export interface ProviderInstance { // latency budget, so it takes the per-upstream fetcher directly instead of // the broader `UpstreamCallOptions` bag the data-plane `call*` methods use. getProvidedModels(fetcher: Fetcher): Promise; + callAlphaSearch(model: ProviderModel, body: Record, signal: AbortSignal | undefined, opts: UpstreamCallOptions): Promise; // /v1/completions text completions. Passthrough. Providers whose // upstream doesn't expose /v1/completions set `endpoints.completions` // to absent in getProvidedModels, so this method is unreachable for diff --git a/packages/test-utils/src/stubs.ts b/packages/test-utils/src/stubs.ts index 6120905b7..b60867b13 100644 --- a/packages/test-utils/src/stubs.ts +++ b/packages/test-utils/src/stubs.ts @@ -69,6 +69,7 @@ export const mockPerfTelemetryContext = (overrides: Partial = {}): ProviderInstance => ({ getProvidedModels: overrides.getProvidedModels ?? (() => Promise.resolve([])), + callAlphaSearch: overrides.callAlphaSearch ?? (() => Promise.reject(new Error('stubProvider.callAlphaSearch was called'))), callCompletions: overrides.callCompletions ?? (() => Promise.reject(new Error('stubProvider.callCompletions was called'))), callChatCompletions: overrides.callChatCompletions ?? (() => Promise.reject(new Error('stubProvider.callChatCompletions was called'))), callResponses: overrides.callResponses ?? (() => Promise.reject(new Error('stubProvider.callResponses was called'))), From 1626f8c41c226d19136485bff2d6452815c07204 Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 15 Jul 2026 18:03:55 +0800 Subject: [PATCH 16/43] feat(search): configure OpenAI search passthrough Persist a default-off OpenAI search passthrough selection with upstream and model. Add the Settings toggle and scoped Codex/Custom upstream and chat-model pickers while preserving the existing general search provider as the default. --- apps/web/src/api/types.ts | 1 + .../settings/SearchConfigSection.vue | 90 ++++++++++++++++++- apps/web/src/pages/dashboard/settings.vue | 3 + .../0056_search_alpha_passthrough.sql | 3 + packages/gateway/src/control-plane/schemas.ts | 13 +++ .../tools/web-search/search-config.ts | 20 +++++ .../src/data-plane/tools/web-search/types.ts | 5 ++ packages/gateway/src/repo/sql.ts | 21 +++-- 8 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 packages/gateway/migrations/0056_search_alpha_passthrough.sql diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 9cee44943..9c848c5e4 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -355,6 +355,7 @@ export interface SearchConfig { tavily: { apiKey: string }; microsoftGrounding: { apiKey: string }; jina: { apiKey: string }; + passthroughOpenAiSearch: { enabled: boolean; upstreamId: string; model: string }; } export interface CopilotQuotaSnapshot { diff --git a/apps/web/src/components/settings/SearchConfigSection.vue b/apps/web/src/components/settings/SearchConfigSection.vue index 4eee33597..3acafe076 100644 --- a/apps/web/src/components/settings/SearchConfigSection.vue +++ b/apps/web/src/components/settings/SearchConfigSection.vue @@ -2,10 +2,10 @@ import { computed, ref } from 'vue'; import { callApi, useApi } from '../../api/client.ts'; -import type { SearchConfig } from '../../api/types.ts'; +import type { ControlPlaneModel, SearchConfig, UpstreamRecord } from '../../api/types.ts'; import { useAuthStore } from '../../stores/auth.ts'; import SecretInput from '../shared/SecretInput.vue'; -import { Button, Input, Select } from '@floway-dev/ui'; +import { Button, Input, Select, Switch } from '@floway-dev/ui'; interface SearchTestResult { ok: boolean; @@ -62,6 +62,8 @@ const PROVIDER_OPTIONS: ProviderOption[] = [ const props = defineProps<{ initialConfig: SearchConfig; initialError?: string | null; + upstreams: UpstreamRecord[]; + models: ControlPlaneModel[]; }>(); const auth = useAuthStore(); @@ -73,6 +75,19 @@ const saving = ref(false); const testing = ref(false); const testResult = ref(null); +const eligibleUpstreams = computed(() => props.upstreams.filter(upstream => + upstream.enabled + && (upstream.kind === 'codex' || upstream.kind === 'custom') + && props.models.some(model => model.kind === 'chat' && model.upstreams.some(candidate => candidate.id === upstream.id)))); +const alphaUpstreamOptions = computed(() => eligibleUpstreams.value.map(upstream => ({ + value: upstream.id, + label: upstream.name, + description: upstream.kind === 'codex' ? 'ChatGPT Codex subscription' : 'Custom OpenAI-compatible upstream', +}))); +const alphaModelOptions = computed(() => props.models + .filter(model => model.kind === 'chat' && model.upstreams.some(upstream => upstream.id === draft.value.passthroughOpenAiSearch.upstreamId)) + .map(model => ({ value: model.id, label: model.display_name || model.id }))); + const activeOption = computed(() => PROVIDER_OPTIONS.find(option => option.value === draft.value.provider) ?? PROVIDER_OPTIONS[0]); const setProvider = (provider: SearchConfig['provider']) => { @@ -90,6 +105,36 @@ const setSearchCredentialValue = (v: string) => { } }; +const setAlphaUpstream = (upstreamId: string) => { + const model = props.models.find(candidate => + candidate.kind === 'chat' && candidate.upstreams.some(upstream => upstream.id === upstreamId))?.id ?? ''; + draft.value = { + ...draft.value, + passthroughOpenAiSearch: { enabled: true, upstreamId, model }, + }; +}; + +const setAlphaModel = (model: string) => { + draft.value = { + ...draft.value, + passthroughOpenAiSearch: { ...draft.value.passthroughOpenAiSearch, model }, + }; +}; + +const setPassthroughOpenAiSearch = (enabled: boolean) => { + if (!enabled) { + draft.value = { + ...draft.value, + passthroughOpenAiSearch: { ...draft.value.passthroughOpenAiSearch, enabled: false }, + }; + return; + } + const selected = eligibleUpstreams.value.find(upstream => upstream.id === draft.value.passthroughOpenAiSearch.upstreamId) + ?? eligibleUpstreams.value[0]; + if (selected === undefined) return; + setAlphaUpstream(selected.id); +}; + const save = async () => { saving.value = true; const { error: err } = await callApi(() => api.api['search-config'].$put({ json: draft.value })); @@ -133,7 +178,7 @@ const test = async () => {

Web Search

-

Configure the search provider used by Anthropic Messages web search.

+

Configure the search provider used by gateway-managed Messages and Responses web search.

{{ error }}

@@ -170,6 +215,45 @@ const test = async () => {
+
+
+
+ +

Use a selected Codex or Custom upstream instead of the general search provider for OpenAI search calls.

+
+ +
+ +
+
+ + +
+
+ +