diff --git a/README.md b/README.md
index d521f60a9..5d283ee47 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,7 @@ target ships in the same repo for self-hosting on a long-lived process.
| Google Gemini (generate / count tokens) | `POST /v1beta/models/...` |
`POST /v1/images/edits` accepts multipart image uploads and JSON `images`
-references. The dashboard's Codex provider base, `/azure-api.codex`, exposes
-the same generation and edit handlers at their provider-relative paths.
+references.
For each public model, Floway picks the first (provider, model) pair that can
serve the request, translating between source and target protocols when the
@@ -111,8 +110,7 @@ Compose starts two services: `server` runs the Node.js target on
`http://localhost:8788` with SQLite/files persisted in the `floway-data`
volume, and `web` serves the built dashboard on `http://localhost:18088`.
The nginx web container proxies Floway API paths to `server`, including
-WebSocket-capable `/v1/responses` and the Codex-compatible
-`/azure-api.codex/*` routes. Pass `FLOWAY_WEB_PORT` or
+WebSocket-capable `/v1/responses`. Pass `FLOWAY_WEB_PORT` or
`FLOWAY_SERVER_PORT` alongside `ADMIN_KEY` if those host ports are already in
use.
@@ -144,7 +142,7 @@ current deployment before importing.
`/v1/messages` accepts Anthropic-style web search. When the resolved upstream
can run the native server tool, Floway passes it through; otherwise it shims the
-search via **Settings -> Web Search** (`tavily` or `microsoft-grounding`,
+search via **Settings -> Web Search** (`tavily`, `microsoft-grounding`, or `jina`,
default `disabled`).
`/v1/responses` has a shared server-tool shim layer for hosted Responses
@@ -154,6 +152,15 @@ 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.
+Floway also serves the Codex CLI's search contract at `/alpha/search` and
+`/v1/alpha/search`.
+By default these routes and the Responses web-search shim use the same general
+provider configured above. **Settings -> Web Search** can instead enable
+**Passthrough OpenAI search** and select a Codex or Custom upstream plus model;
+then both surfaces use that provider's alpha-search endpoint, while Messages
+search continues using the general provider. Passthrough failures are returned
+without falling back to another search backend.
+
## Stateful Responses
`/v1/responses` stores replayable Responses input and output items for API-key
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/keys/CliSnippet_test.ts b/apps/web/src/components/keys/CliSnippet_test.ts
new file mode 100644
index 000000000..c757e91c9
--- /dev/null
+++ b/apps/web/src/components/keys/CliSnippet_test.ts
@@ -0,0 +1,38 @@
+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 and namespaced base URL', () => {
+ 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('[features]\napps = false');
+ expect(toml).not.toContain('http_headers');
+ expect(toml).not.toContain('image_generation');
+ expect(toml).not.toContain('name = "OpenAI"');
+ });
+});
diff --git a/apps/web/src/components/settings/ImportSection.vue b/apps/web/src/components/settings/ImportSection.vue
index 471c2e3f5..661630a16 100644
--- a/apps/web/src/components/settings/ImportSection.vue
+++ b/apps/web/src/components/settings/ImportSection.vue
@@ -20,7 +20,7 @@ interface ExportPayload {
// The dashboard only round-trips the current export format. Older exports are
// rejected rather than silently coerced.
-const EXPORT_VERSION = 9 as const;
+const EXPORT_VERSION = 10 as const;
const api = useApi();
diff --git a/apps/web/src/components/settings/SearchConfigSection.vue b/apps/web/src/components/settings/SearchConfigSection.vue
index 4eee33597..6b11b39f7 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: Array>;
+ models: ControlPlaneModel[];
}>();
const auth = useAuthStore();
@@ -73,6 +75,21 @@ const saving = ref(false);
const testing = ref(false);
const testResult = ref(null);
+const chatModelsForUpstream = (upstreamId: string) => props.models.filter(model =>
+ model.kind === 'chat' && model.upstreams.some(upstream => upstream.id === upstreamId));
+
+const eligibleUpstreams = computed(() => props.upstreams.filter(upstream =>
+ upstream.enabled
+ && (upstream.kind === 'codex' || upstream.kind === 'custom')
+ && chatModelsForUpstream(upstream.id).length > 0));
+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(() => chatModelsForUpstream(draft.value.passthroughOpenAiSearch.upstreamId)
+ .map(model => ({ value: model.id, label: model.display_name })));
+
const activeOption = computed(() => PROVIDER_OPTIONS.find(option => option.value === draft.value.provider) ?? PROVIDER_OPTIONS[0]);
const setProvider = (provider: SearchConfig['provider']) => {
@@ -90,6 +107,37 @@ const setSearchCredentialValue = (v: string) => {
}
};
+const setAlphaUpstream = (upstreamId: string, preferredModel?: string) => {
+ const models = chatModelsForUpstream(upstreamId);
+ const model = models.find(candidate => candidate.id === preferredModel) ?? models[0];
+ if (model === undefined) throw new Error(`OpenAI search upstream ${upstreamId} has no chat model`);
+ draft.value = {
+ ...draft.value,
+ passthroughOpenAiSearch: { enabled: true, upstreamId, model: model.id },
+ };
+};
+
+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) throw new Error('OpenAI search passthrough requires an eligible upstream');
+ setAlphaUpstream(selected.id, draft.value.passthroughOpenAiSearch.model);
+};
+
const save = async () => {
saving.value = true;
const { error: err } = await callApi(() => api.api['search-config'].$put({ json: draft.value }));
@@ -133,7 +181,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 +218,45 @@ const test = async () => {
+
+
+
+
Passthrough OpenAI search (/alpha/search and Responses hosted tool)
+
Use a selected upstream (with /alpha/search support) instead of the general search provider for OpenAI search calls. Anthropic Messages API is not affected by this option.
+
+
setPassthroughOpenAiSearch(value === true)"
+ />
+
+
+
+
+
Search Upstream
+
value !== undefined && setAlphaUpstream(value)"
+ >
+
+ {{ option.description }}
+
+
+
+
+ Search Model
+ value !== undefined && setAlphaModel(value)"
+ />
+
+
+
+
Add an enabled Codex or Custom upstream with a chat model to use OpenAI search passthrough.
+
+
Save Search Config
Test Search
diff --git a/apps/web/src/components/settings/SearchConfigSection_test.ts b/apps/web/src/components/settings/SearchConfigSection_test.ts
new file mode 100644
index 000000000..e8865d2dd
--- /dev/null
+++ b/apps/web/src/components/settings/SearchConfigSection_test.ts
@@ -0,0 +1,56 @@
+import { mount } from '@vue/test-utils';
+import { expect, test, vi } from 'vitest';
+import { nextTick } from 'vue';
+
+import { buildRealModel } from '../../api/test-fixtures.ts';
+import type { SearchConfig, UpstreamProviderKind, UpstreamRecord } from '../../api/types.ts';
+
+vi.mock('../../stores/auth.ts', () => ({ useAuthStore: () => ({ authToken: 'session' }) }));
+vi.mock('../../api/client.ts', () => ({
+ useApi: () => ({ api: {} }),
+ callApi: vi.fn(),
+}));
+
+const { default: SearchConfigSection } = await import('./SearchConfigSection.vue');
+
+const config: SearchConfig = {
+ provider: 'tavily',
+ tavily: { apiKey: 'key' },
+ microsoftGrounding: { apiKey: '' },
+ jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+};
+
+const upstream = (id: string, name: string, kind: UpstreamProviderKind): Pick
=> ({
+ id,
+ name,
+ kind,
+ enabled: true,
+});
+
+test('OpenAI search passthrough exposes only Codex and Custom upstream models', async () => {
+ const wrapper = mount(SearchConfigSection, {
+ props: {
+ initialConfig: config,
+ upstreams: [
+ upstream('up_codex', 'Codex Search', 'codex'),
+ upstream('up_custom', 'Custom Search', 'custom'),
+ upstream('up_azure', 'Azure Search', 'azure'),
+ ],
+ models: [
+ buildRealModel({ id: 'gpt-codex', upstreams: [{ id: 'up_codex', name: 'Codex Search', kind: 'codex', color: null }] }),
+ buildRealModel({ id: 'gpt-custom', upstreams: [{ id: 'up_custom', name: 'Custom Search', kind: 'custom', color: null }] }),
+ buildRealModel({ id: 'gpt-azure', upstreams: [{ id: 'up_azure', name: 'Azure Search', kind: 'azure', color: null }] }),
+ ],
+ },
+ });
+
+ const toggle = wrapper.find('button[role="switch"]');
+ expect(toggle.exists()).toBe(true);
+ await toggle.trigger('click');
+ await nextTick();
+
+ expect(wrapper.text()).toContain('Search Upstream');
+ expect(wrapper.text()).toContain('Search Model');
+ expect(wrapper.text()).not.toContain('Azure Search');
+});
diff --git a/apps/web/src/components/upstream-edit/customConfig.ts b/apps/web/src/components/upstream-edit/customConfig.ts
index 963799f86..58088ed6f 100644
--- a/apps/web/src/components/upstream-edit/customConfig.ts
+++ b/apps/web/src/components/upstream-edit/customConfig.ts
@@ -6,6 +6,7 @@ export const PATH_KEYS = [
'/responses',
'/messages',
'/embeddings',
+ '/alpha/search',
'/images/generations',
'/images/edits',
] as const;
@@ -17,6 +18,7 @@ export const emptyPathOverrides = (): Record => ({
'/responses': '',
'/messages': '',
'/embeddings': '',
+ '/alpha/search': '',
'/images/generations': '',
'/images/edits': '',
});
diff --git a/apps/web/src/pages/dashboard/settings.vue b/apps/web/src/pages/dashboard/settings.vue
index 67da7af32..fe87877b7 100644
--- a/apps/web/src/pages/dashboard/settings.vue
+++ b/apps/web/src/pages/dashboard/settings.vue
@@ -25,6 +25,7 @@ const defaultSearchConfig: SearchConfig = {
tavily: { apiKey: '' },
microsoftGrounding: { apiKey: '' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
};
export const useSettingsPageData = defineBasicLoader(async () => {
@@ -98,7 +99,7 @@ const openAliasDialog = (record: ModelAlias | null): void => {
router.push(`/dashboard/upstreams/new/${kind}`)"
@edit="(record: UpstreamRecord) => router.push(`/dashboard/upstreams/${record.id}`)"
@changed="reloadAll"
@@ -116,6 +117,8 @@ const openAliasDialog = (record: ModelAlias | null): void => {
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 91759f109..cd35e9546 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -18,8 +18,8 @@ import { defineConfig } from 'vite';
// Cloudflare Workers topology, where the SPA is served from Workers
// Static Assets and the listed paths divert to the Worker).
//
-// Bare LLM paths (without `/v1` prefix) are listed because the gateway
-// accepts both forms.
+// Bare data-plane paths are listed because the gateway accepts both root and
+// `/v1` forms where the upstream protocol defines them.
const wranglerOrigin = 'http://127.0.0.1:8788';
const wranglerProxiedPaths = [
'/api',
@@ -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..198ab803a 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -34,16 +34,13 @@ server {
proxy_buffering off;
proxy_read_timeout 1h;
- # Prefix paths. `/v1/*` covers chat/completions, responses, messages,
- # completions, embeddings, models, images/* under the OpenAI-style `/v1`
- # base URL.
+ # Prefix paths. `/v1/*` covers the OpenAI-style endpoints plus alpha/search.
location ~ ^/(api|auth|v1|v1beta|azure-api\.codex)/ {
proxy_pass http://server:8788;
}
- # 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))$ {
+ # Bare data-plane paths let clients use a root base URL where supported.
+ location ~ ^/(alpha/search|completions|chat/completions|responses(/compact)?|messages(/count_tokens)?|embeddings|models|images/(generations|edits))$ {
proxy_pass http://server:8788;
}
diff --git a/packages/gateway/migrations/0056_search_alpha_passthrough.sql b/packages/gateway/migrations/0056_search_alpha_passthrough.sql
new file mode 100644
index 000000000..ab37da9b8
--- /dev/null
+++ b/packages/gateway/migrations/0056_search_alpha_passthrough.sql
@@ -0,0 +1,3 @@
+ALTER TABLE search_config ADD COLUMN passthrough_openai_search INTEGER NOT NULL DEFAULT 0 CHECK (passthrough_openai_search IN (0, 1));
+ALTER TABLE search_config ADD COLUMN alpha_search_upstream_id TEXT NOT NULL DEFAULT '';
+ALTER TABLE search_config ADD COLUMN alpha_search_model TEXT NOT NULL DEFAULT '';
diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts
index ce6756d5e..cfd76e7b4 100644
--- a/packages/gateway/src/control-plane/data-transfer/routes.ts
+++ b/packages/gateway/src/control-plane/data-transfer/routes.ts
@@ -49,7 +49,7 @@ interface SerializedProxy {
}
interface ExportPayload {
- version: 9;
+ version: 10;
exportedAt: string;
data: {
users: User[];
@@ -64,7 +64,7 @@ interface ExportPayload {
};
}
-const EXPORT_VERSION = 9;
+const EXPORT_VERSION = 10;
const SEARCH_USAGE_HOUR_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}$/;
const PERFORMANCE_METRICS = new Set(['ttft_ms', 'tpot_us']);
const UPSTREAM_PROVIDERS = new Set(ALL_PROVIDER_KINDS);
diff --git a/packages/gateway/src/control-plane/data-transfer/routes_test.ts b/packages/gateway/src/control-plane/data-transfer/routes_test.ts
index 13cf31e1d..2aab55dbb 100644
--- a/packages/gateway/src/control-plane/data-transfer/routes_test.ts
+++ b/packages/gateway/src/control-plane/data-transfer/routes_test.ts
@@ -295,7 +295,7 @@ const doExport = async (app: Hono, includePerformance = false) => {
return (await resp.json()) as Record;
};
-const doImport = async (app: Hono, mode: string, data: unknown, version: unknown = 9) => {
+const doImport = async (app: Hono, mode: string, data: unknown, version: unknown = 10) => {
const resp = await app.request('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -333,13 +333,13 @@ test('import validates generic pricing selectors', async () => {
assertEquals(String(fractional.body.error).includes('positive safe integer'), true);
});
-test('export emits the v9 envelope with users and upstreams', async () => {
+test('export emits the v10 envelope with users and upstreams', async () => {
const { app, repo } = setup();
await repo.users.save(SEED_ADMIN);
const result = await doExport(app);
- assertEquals(result.version, 9);
+ assertEquals(result.version, 10);
assertEquals(typeof result.exportedAt, 'string');
assertEquals(result.data.users, [SEED_ADMIN]);
assertEquals(result.data.apiKeys, []);
@@ -363,7 +363,13 @@ test('export includes full upstream configs and omits performance by default', a
await repo.usage.set(USAGE_1);
await repo.searchUsage.set(SEARCH_USAGE_1);
await repo.performance.set(PERFORMANCE_1);
- await repo.searchConfig.save({ provider: 'tavily', tavily: { apiKey: 'tvly-test' }, microsoftGrounding: { apiKey: 'ms-test' }, jina: { apiKey: '' } });
+ await repo.searchConfig.save({
+ provider: 'tavily',
+ tavily: { apiKey: 'tvly-test' },
+ microsoftGrounding: { apiKey: 'ms-test' },
+ jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+ });
const result = await doExport(app);
@@ -398,7 +404,7 @@ test('import rejects any version other than the current one before deleting data
await repo.apiKeys.save(KEY_A);
await repo.upstreams.save(CUSTOM_UPSTREAM);
- const VERSION_ERROR = 'version must be 9 — older export formats are not supported; re-export from the current deployment';
+ const VERSION_ERROR = 'version must be 10 — older export formats are not supported; re-export from the current deployment';
const priorVersion = await doImport(app, 'replace', { apiKeys: [] }, 3);
const ancientVersion = await doImport(app, 'replace', { apiKeys: [] }, 1);
const missingVersionResponse = await app.request('/import', {
@@ -425,7 +431,13 @@ test('import replace writes upstreams and clears replaced collections', async ()
await repo.usage.set(USAGE_1);
await repo.searchUsage.set(SEARCH_USAGE_1);
await repo.responsesItems.insertMany([STORED_RESPONSES_ITEM]);
- await repo.searchConfig.save({ provider: 'tavily', tavily: { apiKey: 'old' }, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' } });
+ await repo.searchConfig.save({
+ provider: 'tavily',
+ tavily: { apiKey: 'old' },
+ microsoftGrounding: { apiKey: '' },
+ jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+ });
const result = await doImport(app, 'replace', {
users: [SEED_ADMIN],
@@ -434,7 +446,13 @@ test('import replace writes upstreams and clears replaced collections', async ()
usage: [USAGE_2],
searchUsage: [SEARCH_USAGE_2],
performanceIncluded: false,
- searchConfig: { provider: 'microsoft-grounding', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: 'ms-new' }, jina: { apiKey: '' } },
+ searchConfig: {
+ provider: 'microsoft-grounding',
+ tavily: { apiKey: '' },
+ microsoftGrounding: { apiKey: 'ms-new' },
+ jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+ },
});
assertEquals(result.status, 200);
@@ -444,7 +462,13 @@ test('import replace writes upstreams and clears replaced collections', async ()
assertEquals(await repo.usage.listAll(), [USAGE_2]);
assertEquals(await repo.searchUsage.listAll(), [SEARCH_USAGE_2]);
assertEquals(await repo.responsesItems.lookupMany('key-a', [STORED_RESPONSES_ITEM.id]), []);
- assertEquals(await repo.searchConfig.get(), { provider: 'microsoft-grounding', tavily: { apiKey: '' }, microsoftGrounding: { apiKey: 'ms-new' }, jina: { apiKey: '' } });
+ assertEquals(await repo.searchConfig.get(), {
+ provider: 'microsoft-grounding',
+ tavily: { apiKey: '' },
+ microsoftGrounding: { apiKey: 'ms-new' },
+ jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+ });
});
test('import merge upserts by repository key without clearing unrelated rows', async () => {
@@ -673,7 +697,7 @@ test('import rejects negative historical unit prices with a dimension-specific e
assertEquals(result.body.error, 'invalid usage at index 0: rates.input must be a finite non-negative number');
});
-test('v9 import requires exact usage token and rate maps', async () => {
+test('v10 import requires exact usage token and rate maps', async () => {
const { app } = setup();
const missingTokens = await doImport(app, 'replace', latestImportData({
usage: [{ ...USAGE_2, tokens: undefined }],
@@ -867,7 +891,7 @@ test('import rejects legacy enabled_fixes payloads before mutating', async () =>
assertEquals(await repo.upstreams.list(), [CUSTOM_UPSTREAM]);
});
-test('import rejects missing latest-v9 arrays before clearing existing data', async () => {
+test('import rejects missing latest-v10 arrays before clearing existing data', async () => {
const { app, repo } = setup();
await repo.apiKeys.save(KEY_A);
await repo.upstreams.save(CUSTOM_UPSTREAM);
@@ -893,14 +917,14 @@ test('import rejects missing latest-v9 arrays before clearing existing data', as
test('import validates mode and data before mutating', async () => {
const { app } = setup();
- const invalidMode = await doImport(app, 'invalid', {}, 9);
+ const invalidMode = await doImport(app, 'invalid', {}, 10);
const missingData = await app.request('/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ mode: 'replace', version: 9 }),
+ body: JSON.stringify({ mode: 'replace', version: 10 }),
});
- const missingUpstreams = await doImport(app, 'merge', {}, 9);
- const emptyMerge = await doImport(app, 'merge', latestImportData(), 9);
+ const missingUpstreams = await doImport(app, 'merge', {}, 10);
+ const emptyMerge = await doImport(app, 'merge', latestImportData(), 10);
assertEquals(invalidMode.status, 400);
assertEquals(invalidMode.body.error, "mode must be 'merge' or 'replace'");
@@ -1052,7 +1076,7 @@ test('import replace wipes proxy_upstream_backoffs alongside the proxies it cool
assertEquals(await repo.proxyBackoffs.listAll(), []);
});
-test('v9 export/import round-trips users and per-key user_id', async () => {
+test('v10 export/import round-trips users and per-key user_id', async () => {
const { app, repo } = setup();
await repo.users.save(SEED_ADMIN);
await repo.users.save(USER_BOB);
@@ -1060,10 +1084,10 @@ test('v9 export/import round-trips users and per-key user_id', async () => {
await repo.apiKeys.save({ ...KEY_B, userId: USER_BOB.id });
const exportResult = await doExport(app);
- assertEquals(exportResult.version, 9);
+ assertEquals(exportResult.version, 10);
assertEquals(exportResult.data.users.map((u: any) => u.id).sort(), [SEED_ADMIN.id, USER_BOB.id]);
- const result = await doImport(app, 'replace', exportResult.data, 9);
+ const result = await doImport(app, 'replace', exportResult.data, 10);
assertEquals(result.status, 200);
assertEquals(result.body.imported.users, 2);
assertEquals(result.body.imported.apiKeys, 2);
@@ -1074,7 +1098,7 @@ test('v9 export/import round-trips users and per-key user_id', async () => {
assertEquals(restoredKey?.userId, USER_BOB.id);
});
-test('v9 import rejects api_keys whose user_id does not appear in the payload', async () => {
+test('v10 import rejects api_keys whose user_id does not appear in the payload', async () => {
const { app, repo } = setup();
await repo.users.save(SEED_ADMIN);
@@ -1086,13 +1110,13 @@ test('v9 import rejects api_keys whose user_id does not appear in the payload',
searchUsage: [],
performanceIncluded: false,
searchConfig: DEFAULT_SEARCH_CONFIG,
- }, 9);
+ }, 10);
assertEquals(result.status, 400);
assertEquals(result.body.error, 'invalid apiKeys at index 0: user_id 99 does not match any user in the payload');
});
-test('v9 import rejects malformed users (bad username, bad password_hash)', async () => {
+test('v10 import rejects malformed users (bad username, bad password_hash)', async () => {
const { app } = setup();
const badUsername = await doImport(app, 'replace', {
@@ -1103,7 +1127,7 @@ test('v9 import rejects malformed users (bad username, bad password_hash)', asyn
searchUsage: [],
performanceIncluded: false,
searchConfig: DEFAULT_SEARCH_CONFIG,
- }, 9);
+ }, 10);
assertEquals(badUsername.status, 400);
assertEquals(String(badUsername.body.error).startsWith('invalid users at index 0:'), true);
@@ -1115,7 +1139,7 @@ test('v9 import rejects malformed users (bad username, bad password_hash)', asyn
searchUsage: [],
performanceIncluded: false,
searchConfig: DEFAULT_SEARCH_CONFIG,
- }, 9);
+ }, 10);
assertEquals(badHash.status, 400);
assertEquals(String(badHash.body.error).includes('passwordHash'), true);
});
@@ -1137,7 +1161,7 @@ test('import rejects a pre-accounts v3 export instead of coercing its legacy api
}, 3);
assertEquals(result.status, 400);
- assertEquals(String(result.body.error).includes('version must be 9'), true);
+ assertEquals(String(result.body.error).includes('version must be 10'), true);
// Rejected at the version gate, before touching any data.
assertEquals(await repo.apiKeys.list(), [KEY_A]);
assertEquals((await repo.users.list()).map(u => u.id), [SEED_ADMIN.id]);
@@ -1158,7 +1182,7 @@ test('replace-mode import clears sessions before writing users', async () => {
searchUsage: [],
performanceIncluded: false,
searchConfig: DEFAULT_SEARCH_CONFIG,
- }, 9);
+ }, 10);
assertEquals(result.status, 200);
// No public listAll on sessions; create a fresh session and check the
@@ -1167,7 +1191,7 @@ test('replace-mode import clears sessions before writing users', async () => {
assertEquals(await repo.sessions.deleteByUserId(USER_BOB.id), 0);
});
-test('v9 import rejects users[i].upstreamIds === undefined', async () => {
+test('v10 import rejects users[i].upstreamIds === undefined', async () => {
const { app } = setup();
const result = await doImport(app, 'replace', {
users: [SEED_ADMIN, { ...USER_BOB, upstreamIds: undefined }],
@@ -1177,12 +1201,12 @@ test('v9 import rejects users[i].upstreamIds === undefined', async () => {
searchUsage: [],
performanceIncluded: false,
searchConfig: DEFAULT_SEARCH_CONFIG,
- }, 9);
+ }, 10);
assertEquals(result.status, 400);
expect(result.body.error).toMatch(/upstreamIds/);
});
-test('v9 import rejects users[i].deletedAt of non-string non-null type', async () => {
+test('v10 import rejects users[i].deletedAt of non-string non-null type', async () => {
const { app } = setup();
const result = await doImport(app, 'replace', {
users: [SEED_ADMIN, { ...USER_BOB, deletedAt: 42 }],
@@ -1192,12 +1216,12 @@ test('v9 import rejects users[i].deletedAt of non-string non-null type', async (
searchUsage: [],
performanceIncluded: false,
searchConfig: DEFAULT_SEARCH_CONFIG,
- }, 9);
+ }, 10);
assertEquals(result.status, 400);
expect(result.body.error).toMatch(/deletedAt/);
});
-test('v9 replace import refuses payload missing user 1', async () => {
+test('v10 replace import refuses payload missing user 1', async () => {
const { app } = setup();
const result = await doImport(app, 'replace', {
users: [USER_BOB],
@@ -1207,12 +1231,12 @@ test('v9 replace import refuses payload missing user 1', async () => {
searchUsage: [],
performanceIncluded: false,
searchConfig: DEFAULT_SEARCH_CONFIG,
- }, 9);
+ }, 10);
assertEquals(result.status, 400);
expect(result.body.error).toMatch(/user 1/);
});
-test('a full v9 export re-imports verbatim — the export→import round trip is closed', async () => {
+test('a full v10 export re-imports verbatim — the export→import round trip is closed', async () => {
const { app, repo } = setup();
await repo.users.save(SEED_ADMIN);
await repo.users.save(USER_BOB);
@@ -1228,16 +1252,22 @@ test('a full v9 export re-imports verbatim — the export→import round trip is
await repo.searchUsage.set(SEARCH_USAGE_2);
await repo.performance.set(PERFORMANCE_1);
await repo.performance.set(PERFORMANCE_2);
- const config = { provider: 'tavily' as const, tavily: { apiKey: 'tk' }, microsoftGrounding: { apiKey: '' }, jina: { apiKey: '' } };
+ const config = {
+ provider: 'tavily' as const,
+ tavily: { apiKey: 'tk' },
+ microsoftGrounding: { apiKey: '' },
+ jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+ };
await repo.searchConfig.save(config);
const exported = await doExport(app, true);
- assertEquals(exported.version, 9);
+ assertEquals(exported.version, 10);
// Replace-import the export's own `data`, verbatim. If the export emits any
// shape the import parser rejects, this 400s — the round trip is the
// invariant, so this test fails the moment the two sides drift.
- const result = await doImport(app, 'replace', exported.data, 9);
+ const result = await doImport(app, 'replace', exported.data, 10);
assertEquals(result.status, 200);
assertEquals(result.body.imported, { users: 2, apiKeys: 2, upstreams: 4, proxies: 0, usage: 2, searchUsage: 2, performance: 2 });
@@ -1268,10 +1298,10 @@ test('any data bearing a historical version is rejected on the version gate, bef
searchConfig: DEFAULT_SEARCH_CONFIG,
};
- for (const version of [1, 2, 3, 4, 5, 6, 7, 8]) {
+ for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
const result = await doImport(app, 'replace', wellFormed, version);
assertEquals(result.status, 400);
- assertEquals(String(result.body.error).includes('version must be 9'), true);
+ assertEquals(String(result.body.error).includes('version must be 10'), true);
}
// Nothing was touched — the version gate runs before any delete or write.
@@ -1362,7 +1392,7 @@ test('replace-mode import surfaces a purgeAll failure', async () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- mode: 'replace', version: 9, data: latestImportData({
+ mode: 'replace', version: 10, data: latestImportData({
apiKeys: [{ ...KEY_A, dumpRetentionSeconds: 3600 }],
}),
}),
@@ -1380,7 +1410,7 @@ test('merge-mode retention transition surfaces a purgeAll failure', async () =>
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- mode: 'merge', version: 9, data: latestImportData({
+ mode: 'merge', version: 10, data: latestImportData({
apiKeys: [{ ...KEY_A, dumpRetentionSeconds: null }],
}),
}),
diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts
index 6ca69a9b6..20462ff69 100644
--- a/packages/gateway/src/control-plane/schemas.ts
+++ b/packages/gateway/src/control-plane/schemas.ts
@@ -520,6 +520,19 @@ export const searchConfigSchema = z.object({
tavily: z.object({ apiKey: z.string() }),
microsoftGrounding: z.object({ apiKey: z.string() }),
jina: z.object({ apiKey: z.string() }),
+ passthroughOpenAiSearch: z.object({
+ enabled: z.boolean(),
+ upstreamId: z.string(),
+ model: z.string(),
+ }),
+}).superRefine((config, ctx) => {
+ if (!config.passthroughOpenAiSearch.enabled) return;
+ if (config.passthroughOpenAiSearch.upstreamId.trim() === '') {
+ ctx.addIssue({ code: 'custom', path: ['passthroughOpenAiSearch', 'upstreamId'], message: 'Select an upstream' });
+ }
+ if (config.passthroughOpenAiSearch.model.trim() === '') {
+ ctx.addIssue({ code: 'custom', path: ['passthroughOpenAiSearch', 'model'], message: 'Select a model' });
+ }
});
// --- model aliases ---
@@ -644,7 +657,7 @@ export const updateAliasBody = aliasBodyCore.superRefine(aliasBodyRulesRefinemen
// --- data transfer ---
export const importBody = z.object({
- version: z.literal(9, { error: 'version must be 9 — older export formats are not supported; re-export from the current deployment' }),
+ version: z.literal(10, { error: 'version must be 10 — older export formats are not supported; re-export from the current deployment' }),
mode: z.enum(['merge', 'replace'], { error: "mode must be 'merge' or 'replace'" }),
data: z.unknown().optional(),
});
diff --git a/packages/gateway/src/control-plane/search-config/routes.ts b/packages/gateway/src/control-plane/search-config/routes.ts
index 61184e08c..4305c9772 100644
--- a/packages/gateway/src/control-plane/search-config/routes.ts
+++ b/packages/gateway/src/control-plane/search-config/routes.ts
@@ -1,21 +1,18 @@
import type { Context } from 'hono';
import { testSearchConfigConnection } from '../../data-plane/tools/web-search/provider.ts';
-import { loadSearchConfig, normalizeSearchConfig, saveSearchConfig } from '../../data-plane/tools/web-search/search-config.ts';
+import { loadSearchConfig, parseSearchConfigStrict, saveSearchConfig } from '../../data-plane/tools/web-search/search-config.ts';
import { type CtxWithJson } from '../../middleware/zod-validator.ts';
import type { searchConfigSchema } from '../schemas.ts';
export const getSearchConfigRoute = async (c: Context) => c.json(await loadSearchConfig());
export const putSearchConfigRoute = async (c: CtxWithJson) => {
- // saveSearchConfig still runs normalizeSearchConfig for the canonical shape
- // (defaulting nulls, trimming strings); the schema guarantees the discriminator
- // and presence of nested apiKey fields.
const config = await saveSearchConfig(c.req.valid('json'));
return c.json(config);
};
export const testSearchConfigRoute = async (c: CtxWithJson) => {
- const result = await testSearchConfigConnection(normalizeSearchConfig(c.req.valid('json')));
+ const result = await testSearchConfigConnection(parseSearchConfigStrict(c.req.valid('json')));
return c.json(result, result.ok ? 200 : 400);
};
diff --git a/packages/gateway/src/control-plane/search-config/routes_test.ts b/packages/gateway/src/control-plane/search-config/routes_test.ts
index 2970475b3..ca234cb10 100644
--- a/packages/gateway/src/control-plane/search-config/routes_test.ts
+++ b/packages/gateway/src/control-plane/search-config/routes_test.ts
@@ -55,6 +55,7 @@ test('/api/search-config PUT persists config and POST /test returns preview', as
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: 'jina-test' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
};
const putResponse = await requestApp('/api/search-config', {
diff --git a/packages/gateway/src/control-plane/search-usage/routes_test.ts b/packages/gateway/src/control-plane/search-usage/routes_test.ts
index 895d1c60e..482478958 100644
--- a/packages/gateway/src/control-plane/search-usage/routes_test.ts
+++ b/packages/gateway/src/control-plane/search-usage/routes_test.ts
@@ -49,6 +49,7 @@ test('/api/search-usage in self-by-key mode includes per-key metadata for the ac
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
const response = await requestApp('/api/search-usage?start=2026-03-15T00&end=2026-03-16T00&include_key_metadata=1', {
diff --git a/packages/gateway/src/data-plane/alpha-search/routes.ts b/packages/gateway/src/data-plane/alpha-search/routes.ts
new file mode 100644
index 000000000..83c71de76
--- /dev/null
+++ b/packages/gateway/src/data-plane/alpha-search/routes.ts
@@ -0,0 +1,145 @@
+// Codex `/alpha/search` compatibility endpoint. The private request carries
+// model/session context plus a command object; the response is
+// `{ encrypted_output?, output, results? }`.
+// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/search.rs#L8-L29
+// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/search.rs#L297-L305
+// Clients append `alpha/search` to an OpenAI-compatible provider base. The
+// aliases below cover Floway's general root and `/v1` base conventions.
+// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/endpoint/search.rs#L31-L47
+//
+// In the default mode, Floway executes supported commands through the general
+// configured search provider and renders a local `{ encrypted_output: null,
+// output }` response. Passthrough mode instead returns the selected Codex or
+// Custom provider response verbatim, preserving its optional structured data.
+//
+// The shared data-plane auth middleware guards every alias; this handler reads
+// the resolved API key for per-key search-usage accounting.
+
+import type { Hono } from 'hono';
+import { z } from 'zod';
+
+import { type AuthVars, apiKeyFromContext, effectiveUpstreamIdsFromContext } from '../../middleware/auth.ts';
+import { type CtxWithJson, zValidator } from '../../middleware/zod-validator.ts';
+import { backgroundSchedulerFromContext } from '../../runtime/background.ts';
+import { getRuntimeLocation } from '../../runtime/runtime-info.ts';
+import { relayFetchedResponse } from '../tools/web-search/alpha-search/relay-response.ts';
+import { resolveAlphaSearchDispatcher } from '../tools/web-search/alpha-search/upstream.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';
+
+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.
+const alphaSearchRequestSchema = z.looseObject({
+ commands: z.looseObject({}).optional(),
+ settings: searchSettingsSchema.optional(),
+});
+
+type AlphaSearchRequest = z.infer;
+
+const filtersFromSettings = (settings: AlphaSearchRequest['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;
+};
+
+const alphaSearch = async (c: CtxWithJson): Promise => {
+ const body = c.req.valid('json');
+ const searchConfig = await loadSearchConfig();
+ if (searchConfig.passthroughOpenAiSearch.enabled) {
+ const dispatcher = await resolveAlphaSearchDispatcher({
+ config: searchConfig.passthroughOpenAiSearch,
+ upstreamIds: effectiveUpstreamIdsFromContext(c),
+ scheduler: backgroundSchedulerFromContext(c),
+ runtimeLocation: getRuntimeLocation(c.req.raw),
+ });
+ const headers = new Headers();
+ const turnMetadata = c.req.header('x-codex-turn-metadata');
+ if (turnMetadata !== undefined) headers.set('x-codex-turn-metadata', turnMetadata);
+ const response = await dispatcher(body, c.req.raw.signal, headers);
+ return relayFetchedResponse(response);
+ }
+
+ let configuredProvider: Promise | undefined;
+ const session: WebSearchExecutionSession = {
+ getProvider: () => {
+ configuredProvider ??= Promise.resolve(resolveConfiguredWebSearchProvider(searchConfig));
+ 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. The shared parser's canonical order is
+ // search_query → open → find → unsupported keys, preserving array order
+ // within each command kind.
+ 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') });
+};
+
+const ALPHA_SEARCH_PATHS = [
+ '/alpha/search',
+ '/v1/alpha/search',
+] as const;
+
+export const mountAlphaSearchRoute = (app: Hono<{ Variables: AuthVars }>, path: string) => {
+ app.post(path, zValidator('json', alphaSearchRequestSchema), alphaSearch);
+};
+
+export const mountAlphaSearchRoutes = (app: Hono<{ Variables: AuthVars }>) => {
+ for (const path of ALPHA_SEARCH_PATHS) {
+ mountAlphaSearchRoute(app, path);
+ }
+};
diff --git a/packages/gateway/src/data-plane/alpha-search/routes_test.ts b/packages/gateway/src/data-plane/alpha-search/routes_test.ts
new file mode 100644
index 000000000..7fbda60a1
--- /dev/null
+++ b/packages/gateway/src/data-plane/alpha-search/routes_test.ts
@@ -0,0 +1,360 @@
+import { Hono } from 'hono';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { mountAlphaSearchRoutes } from './routes.ts';
+import { type AuthVars, authMiddleware } from '../../middleware/auth.ts';
+import { buildCustomUpstreamRecord, 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 { withMockedFetch } from '@floway-dev/test-utils';
+
+// 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: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+};
+
+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 buildAlphaSearchApp = () => {
+ const app = new Hono<{ Variables: AuthVars }>();
+ app.use('*', authMiddleware);
+ mountAlphaSearchRoutes(app);
+ return app;
+};
+
+const SEARCH_PATHS = [
+ '/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' },
+ });
+
+interface SearchResponseBody {
+ encrypted_output: string | null;
+ output: string;
+}
+
+beforeEach(() => {
+ mockResolveConfigured.mockReset();
+});
+
+describe('/alpha/search data plane', () => {
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+ const response = await app.request(path, { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });
+ expect(response.status).toBe(401);
+ });
+
+ it.each(SEARCH_PATHS)('rejects an unknown bearer at %s', async path => {
+ await setupAppTest();
+ const app = buildAlphaSearchApp();
+ const response = await postSearch(app, 'not-an-api-key', {}, path);
+ expect(response.status).toBe(401);
+ });
+ });
+
+ describe('schema validation', () => {
+ it.each(SEARCH_PATHS)('rejects non-object `commands` at %s', async path => {
+ const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG });
+ const app = buildAlphaSearchApp();
+ const response = await postSearch(app, apiKey.key, { commands: [] }, path);
+ expect(response.status).toBe(400);
+ });
+
+ it.each(SEARCH_PATHS)('rejects unknown search_context_size at %s', async path => {
+ const { apiKey } = await setupAppTest({ searchConfig: TAVILY_CONFIG });
+ const app = buildAlphaSearchApp();
+ const response = await postSearch(app, apiKey.key, { settings: { search_context_size: 'huge' } }, path);
+ 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 = buildAlphaSearchApp();
+ 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('raw-passthrough mode dispatches to the selected custom upstream and preserves its response', async () => {
+ const searchConfig: SearchConfig = {
+ ...TAVILY_CONFIG,
+ passthroughOpenAiSearch: { enabled: true, upstreamId: 'up_alpha', model: 'gpt-search' },
+ };
+ const { apiKey, repo } = await setupAppTest({ searchConfig });
+ await repo.upstreams.deleteAll();
+ await repo.upstreams.save(buildCustomUpstreamRecord({
+ id: 'up_alpha',
+ name: 'Alpha Search',
+ config: {
+ baseUrl: 'https://search.example.com',
+ authStyle: 'bearer',
+ apiKey: 'search-secret',
+ endpoints: { responses: {} },
+ modelsFetch: { enabled: false },
+ models: [{ upstreamModelId: 'gpt-search', endpoints: { responses: {} } }],
+ },
+ }));
+ const upstreamPayload = {
+ encrypted_output: 'opaque',
+ output: 'upstream output',
+ results: [{ type: 'text_result', ref_id: 'turn0search0', url: 'https://example.com', title: 'Example', snippet: 'Snippet' }],
+ };
+ const immutableUpstreamResponse = await fetch(`data:application/json,${encodeURIComponent(JSON.stringify(upstreamPayload))}`);
+ let upstreamBody: Record | undefined;
+ await withMockedFetch(
+ async request => {
+ if (request.url === 'https://search.example.com/v1/alpha/search') {
+ upstreamBody = await request.json() as Record;
+ return immutableUpstreamResponse;
+ }
+ throw new Error(`Unhandled fetch ${request.url}`);
+ },
+ async () => {
+ const response = await postSearch(buildAlphaSearchApp(), apiKey.key, {
+ id: 'session-search',
+ model: 'caller-model',
+ commands: { search_query: [{ q: 'Floway' }] },
+ });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual(upstreamPayload);
+ },
+ );
+ expect(upstreamBody).toMatchObject({
+ id: 'session-search',
+ model: 'gpt-search',
+ commands: { search_query: [{ q: 'Floway' }] },
+ });
+ });
+
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+
+ 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 = buildAlphaSearchApp();
+
+ 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.');
+ });
+ });
+});
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 0f483ab6b..fa73a88bb 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
@@ -14,9 +14,11 @@ import {
} from './server-tool-shim.ts';
import { SHIM_TOOL_NAME, webSearchServerTool } from './server-tools/web-search.ts';
import type { ResponsesInterceptor, ResponsesInvocation } from './types.ts';
-import { initRepo } from '../../../../repo/index.ts';
+import { getRepo, initRepo } from '../../../../repo/index.ts';
import { InMemoryRepo } from '../../../../repo/memory.ts';
import { mockChatGatewayCtx } from '../../../../test-helpers/gateway-ctx.ts';
+import { resolveAlphaSearchDispatcher } from '../../../tools/web-search/alpha-search/upstream.ts';
+import type { AlphaSearchDispatcher } from '../../../tools/web-search/alpha-search/upstream.ts';
import { resolveConfiguredWebSearchProvider } from '../../../tools/web-search/provider.ts';
import type {
ConfiguredWebSearchProvider,
@@ -181,6 +183,7 @@ const mkReasoningDone = (outputIndex: number, reasoningId: string): ProtocolFram
// test stub. Tests that need a specific configured state set
// `mockResolveConfigured.mockReturnValue(...)` per call.
vi.mock('../../../tools/web-search/provider.ts');
+vi.mock('../../../tools/web-search/alpha-search/upstream.ts');
const mkResponseCompleted = (
usage?: ResponsesResult['usage'],
@@ -272,12 +275,14 @@ interface DepsOverrides {
}
const mockResolveConfigured = vi.mocked(resolveConfiguredWebSearchProvider);
+const mockResolveAlpha = vi.mocked(resolveAlphaSearchDispatcher);
// Seed a per-test InMemoryRepo and a default tavily search config so
// `loadSearchConfig()` returns a non-default value. The actual provider
// construction is short-circuited by the module mock above; tests
// override `mockResolveConfigured` to point at a stub backend.
beforeEach(() => {
+ mockResolveAlpha.mockReset();
const repo = new InMemoryRepo();
initRepo(repo);
void repo.searchConfig.save({
@@ -285,6 +290,7 @@ beforeEach(() => {
tavily: { apiKey: 'test-key' },
microsoftGrounding: { apiKey: '' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
} satisfies SearchConfig);
});
@@ -1638,6 +1644,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 ──────────────────────────────────
@@ -3902,6 +3910,36 @@ test('responses target with flag on: function_call_output is plain-text formatte
assert(text.startsWith('Search results for "q1":'));
});
+test('responses target with OpenAI passthrough uses the selected alpha search dispatcher', async () => {
+ makeStubDeps();
+ await getRepo().searchConfig.save({
+ provider: 'tavily',
+ tavily: { apiKey: 'test-key' },
+ microsoftGrounding: { apiKey: '' },
+ jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: true, upstreamId: 'up_codex', model: 'gpt-search' },
+ } satisfies SearchConfig);
+ const call = vi.fn(async () => new Response(JSON.stringify({
+ encrypted_output: null,
+ output: 'alpha output',
+ results: [{ type: 'text_result', url: 'https://example.com', title: 'Example', snippet: 'alpha snippet' }],
+ }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ mockResolveAlpha.mockResolvedValue(call);
+ const inv = makeInvocation({
+ targetApi: 'responses',
+ enabledFlags: new Set(['responses-web-search-shim']),
+ });
+ const script = scriptedRun([
+ searchCallTurn(0, 'call_1', 'alpha query'),
+ messageTurn('done', 0),
+ ]);
+
+ await runShimAndDrain(withResponsesWebSearchShim, inv, makeGatewayCtx(), script.run);
+
+ assertEquals(lastFunctionCallOutput(inv.payload.input as ResponsesInputItem[]), 'alpha output');
+ assertEquals(call.mock.calls[0]?.[0].commands, { search_query: [{ q: 'alpha query' }] });
+});
+
test('chat-completions target: function_call_output is plain-text formatted search results', async () => {
makeStubDeps();
const shim = withResponsesWebSearchShim;
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 602338d0e..b3cdb3021 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,13 +1,31 @@
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 { executeAlphaSearch } from '../../../../tools/web-search/alpha-search/execution.ts';
+import { resolveAlphaSearchDispatcher } from '../../../../tools/web-search/alpha-search/upstream.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,
+ renderWebSearchCallOutput,
+ 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';
@@ -21,36 +39,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 +159,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 +176,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 +210,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',
@@ -271,7 +258,7 @@ const validateHostedEntry = (tool: ResponsesHostedTool): PrepareToolsError | nul
export const prepareToolsForShim = (
tools: ResponsesTool[],
): PrepareToolsResult => {
- let selectedFilters: ShimToolFilters = {};
+ let selectedFilters: WebSearchFilters = {};
for (const tool of tools) {
if (isHostedWebSearchTool(tool)) {
const reject = validateHostedEntry(tool);
@@ -282,187 +269,12 @@ export const prepareToolsForShim = (
return { ok: true, filters: selectedFilters };
};
-// ── 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
@@ -477,11 +289,10 @@ interface WebSearchCallIR {
* call_id, name, status — passes through untouched). Replayed verbatim
* so the upstream model's prior assistant turn looks bit-exact.
*
- * - `ir` is the in-flight `(action, results)` tuple straight from
- * `planShimSlots`. Stored as data so the rendering format can
- * evolve without re-persisting; reused by the renderer at replay time.
- * Composition (rather than inlining `action` / `results` here) keeps
- * any future IR field automatically reaching the persisted shape.
+ * - `ir` stores the action, structured results, and optional upstream
+ * model-facing output straight from `planShimSlots`. Replay uses
+ * `renderWebSearchCallOutput`, which preserves that output when present
+ * and otherwise renders the action and results.
*
* Version-tagged: an unknown `v` falls through the no-payload branch in
* `transformInputItemsForWebSearch` (action re-serialized into the
@@ -514,101 +325,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
@@ -631,35 +347,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.
@@ -669,9 +356,8 @@ const renderOpOutputText = (action: ResponsesWebSearchAction, results: Responses
// 1. Private payload hit (the request resolved the wsc id to a persisted
// `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.
+// `function_call_output` whose body comes from
+// `renderWebSearchCallOutput`. 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
@@ -706,7 +392,7 @@ export const transformInputItemsForWebSearch = (
{
type: 'function_call_output',
call_id: candidatePayload.functionCallItem.call_id,
- output: renderOpOutputText(candidatePayload.ir.action, candidatePayload.ir.results),
+ output: renderWebSearchCallOutput(candidatePayload.ir),
},
);
continue;
@@ -757,486 +443,23 @@ 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 }[];
+ executeAlpha?: (commands: Record, action: ResponsesWebSearchAction) => Promise;
}
-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,
+ commands: Record,
toolName: string,
state: ShimState,
loopState: ServerToolLoopState,
@@ -1263,13 +486,31 @@ const planShimSlots = (
};
}
+ if (state.executeAlpha !== undefined) {
+ const first = parsed.ops[0];
+ let action: ResponsesWebSearchAction;
+ if (first.kind === 'search') {
+ const queries = parsed.ops.filter((op): op is Extract => op.kind === 'search').map(op => op.query);
+ action = queries.length === 1
+ ? { type: 'search', query: queries[0], queries }
+ : { type: 'search', query: queries.join(' | '), queries };
+ } else if (first.kind === 'open') {
+ action = { type: 'open_page', url: first.url };
+ } else if (first.kind === 'find') {
+ action = { type: 'find_in_page', url: first.url, pattern: first.pattern };
+ } else {
+ action = { type: 'search', query: Object.keys(commands).join(', ') };
+ }
+ return { id: synthesizeWebSearchCallId(), promise: state.executeAlpha(commands, action) };
+ }
+
// Multi-`search_query` entries collapse into one wsc with a multi-query
// action (`{type:'search', queries:[...]}`) — protocol-native and the
// only same-kind shape that fits in one wsc. Require every entry to
// 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),
@@ -1293,14 +534,13 @@ 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)),
};
};
-export const webSearchServerTool: ServerToolRegistration = (invocation, gatewayCtx) => {
+export const webSearchServerTool: ServerToolRegistration = async (invocation, gatewayCtx) => {
if (invocation.targetApi === 'responses' && !providerModelOf(invocation.candidate).enabledFlags.has('responses-web-search-shim')) {
return { type: 'inactive' };
}
@@ -1320,20 +560,45 @@ export const webSearchServerTool: ServerToolRegistration = (invocation, gatewayC
}
const { filters } = prepared;
+ const searchConfig = await loadSearchConfig();
const includeArray = Array.isArray(invocation.payload.include) ? invocation.payload.include : [];
let configuredProvider: Promise | undefined;
const state: ShimState = {
filters,
pageCache: new Map(),
getProvider: () => {
- configuredProvider ??= loadSearchConfig().then(cfg => resolveConfiguredWebSearchProvider(cfg));
+ configuredProvider ??= Promise.resolve(resolveConfiguredWebSearchProvider(searchConfig));
return configuredProvider;
},
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 } : {}),
};
+ if (searchConfig.passthroughOpenAiSearch.enabled) {
+ const dispatcher = resolveAlphaSearchDispatcher({
+ config: searchConfig.passthroughOpenAiSearch,
+ upstreamIds: gatewayCtx.upstreamIds,
+ scheduler: gatewayCtx.backgroundScheduler,
+ runtimeLocation: gatewayCtx.runtimeLocation,
+ });
+ const sessionId = crypto.randomUUID();
+ const hosted = tools.filter(isHostedWebSearchTool).at(-1);
+ const settings: Record = {
+ ...(hosted?.search_context_size === undefined ? {} : { search_context_size: hosted.search_context_size }),
+ ...(hosted?.filters === undefined ? {} : { filters: hosted.filters }),
+ ...(hosted?.user_location === undefined ? {} : { user_location: hosted.user_location }),
+ };
+ state.executeAlpha = async (commands, action) => await executeAlphaSearch({
+ dispatcher: await dispatcher,
+ sessionId,
+ commands,
+ settings,
+ input: invocation.payload.input,
+ action,
+ signal: gatewayCtx.abortSignal,
+ });
+ }
return {
type: 'active',
@@ -1346,7 +611,8 @@ export const webSearchServerTool: ServerToolRegistration = (invocation, gatewayC
canonicalize: canonicalizeWebSearchTool,
buildFunctionTool: buildShimFunctionTool,
dispatcher: ({ intercepted, loopState }) => {
- const slot = planShimSlots(parseShimOperations(intercepted.arguments), intercepted.name, state, loopState);
+ const commands = intercepted.arguments ?? {};
+ const slot = planShimSlots(parseWebSearchOperations(intercepted.arguments), commands, 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 a13365ef7..97fc97481 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
@@ -2,43 +2,39 @@ import { test } from 'vitest';
import { resolveServerToolName } from '../server-tool-shim.ts';
import {
- findMatches,
- formatMatches,
isHostedWebSearchTool,
- isUrlAllowed,
- parseShimOperations,
prepareToolsForShim,
SHIM_TOOL_NAME,
synthesizeWebSearchCallId,
transformInputItemsForWebSearch,
WEB_SEARCH_HOSTED_TYPES,
- type ShimLogicalOperation,
type WebSearchCallPrivatePayload,
} from './web-search.ts';
+import { findMatches, formatMatches, isUrlAllowed, parseWebSearchOperations, type WebSearchOperation } from '../../../../tools/web-search/operations.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 +45,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 +71,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 +83,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 +92,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 +109,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 +119,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 +128,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 +137,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 +154,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/routes.ts b/packages/gateway/src/data-plane/codex/routes.ts
index 67fdb9786..9bdaeb077 100644
--- a/packages/gateway/src/data-plane/codex/routes.ts
+++ b/packages/gateway/src/data-plane/codex/routes.ts
@@ -49,6 +49,7 @@ import {
} from './chatgpt-backend.ts';
import { codexModels } from './models.ts';
import type { AuthVars } from '../../middleware/auth.ts';
+import { mountAlphaSearchRoute } from '../alpha-search/routes.ts';
import { responsesHttp } from '../chat/responses/http.ts';
import { responsesWebSocket } from '../chat/responses/websocket.ts';
import { imagesEdits, imagesGenerations } from '../images/serve.ts';
@@ -56,6 +57,10 @@ import { imagesEdits, imagesGenerations } from '../images/serve.ts';
const CODEX_BASE_PATH = '/azure-api.codex';
export const mountCodexRoutes = (app: Hono<{ Variables: AuthVars }>) => {
+ // Codex appends `alpha/search` to this special provider base. Keep the path
+ // owned by this namespace while reusing the general data-plane handler.
+ // https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/endpoint/search.rs#L31-L47
+ mountAlphaSearchRoute(app, `${CODEX_BASE_PATH}/alpha/search`);
app.post(`${CODEX_BASE_PATH}/responses`, responsesHttp.generate);
app.post(`${CODEX_BASE_PATH}/responses/compact`, responsesHttp.compact);
app.get(`${CODEX_BASE_PATH}/responses`, responsesWebSocket);
diff --git a/packages/gateway/src/data-plane/codex/routes_test.ts b/packages/gateway/src/data-plane/codex/routes_test.ts
index 5dd3df82b..942345d2a 100644
--- a/packages/gateway/src/data-plane/codex/routes_test.ts
+++ b/packages/gateway/src/data-plane/codex/routes_test.ts
@@ -171,6 +171,35 @@ interface CodexModelsResponse {
}
describe('codex 1p namespace', () => {
+ describe('standalone search', () => {
+ it('owns the namespaced alpha-search path', async () => {
+ const { apiKey } = await setupAppTest();
+ const app = buildCodexApp();
+
+ const response = await app.request('/azure-api.codex/alpha/search', {
+ method: 'POST',
+ body: JSON.stringify({ commands: {} }),
+ headers: { authorization: `Bearer ${apiKey.key}`, 'content-type': 'application/json' },
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({ output: expect.stringContaining('No web search commands were provided') });
+ });
+
+ it.each(['/alpha/search', '/v1/alpha/search'])('does not mount the general alias %s', async path => {
+ const { apiKey } = await setupAppTest();
+ const app = buildCodexApp();
+
+ const response = await app.request(path, {
+ method: 'POST',
+ body: JSON.stringify({ commands: {} }),
+ headers: { authorization: `Bearer ${apiKey.key}`, 'content-type': 'application/json' },
+ });
+
+ expect(response.status).toBe(404);
+ });
+ });
+
describe('auth', () => {
it('accepts a Floway api key supplied as `Authorization: Bearer `', async () => {
const { apiKey } = await setupAppTest();
diff --git a/packages/gateway/src/data-plane/providers/custom/provider_test.ts b/packages/gateway/src/data-plane/providers/custom/provider_test.ts
index e4f2172fe..94af2d807 100644
--- a/packages/gateway/src/data-plane/providers/custom/provider_test.ts
+++ b/packages/gateway/src/data-plane/providers/custom/provider_test.ts
@@ -243,6 +243,45 @@ test('Custom provider callImagesEdits forwards multipart body with model field a
assertEquals(forwarded.form.get('image') instanceof File, true);
});
+test('Custom provider callAlphaSearch posts JSON to /v1/alpha/search with the upstream model', async () => {
+ await setupAppTest();
+ const record = buildCustomUpstreamRecord({
+ config: { baseUrl: 'https://custom.example.com', authStyle: 'bearer', apiKey: 'sk-custom', endpoints: { responses: {} } },
+ });
+ let forwarded: { url: string; body: Record } | undefined;
+ await withMockedFetch(
+ async request => {
+ const url = new URL(request.url);
+ if (url.pathname === '/v1/models') return jsonResponse({ data: [{ id: 'gpt-search' }] });
+ if (url.pathname === '/v1/alpha/search') {
+ forwarded = { url: request.url, body: await request.json() as Record };
+ return jsonResponse({ encrypted_output: null, output: 'result', results: [] });
+ }
+ throw new Error(`Unhandled fetch ${request.url}`);
+ },
+ async () => {
+ const provider = createCustomProvider(record);
+ const model = (await provider.instance.getProvidedModels(directFetcher))[0];
+ const result = await provider.instance.callAlphaSearch(
+ model,
+ { id: 'search-session', commands: { search_query: [{ q: 'Floway' }] } },
+ undefined,
+ noopUpstreamCallOptions(),
+ );
+ assertEquals(result.response.status, 200);
+ assertEquals(result.modelKey, 'gpt-search');
+ },
+ );
+ assertEquals(forwarded, {
+ url: 'https://custom.example.com/v1/alpha/search',
+ body: {
+ id: 'search-session',
+ commands: { search_query: [{ q: 'Floway' }] },
+ model: 'gpt-search',
+ },
+ });
+});
+
test('Custom provider with modelsFetch disabled serves only manual models and never fetches', async () => {
await setupAppTest();
diff --git a/packages/gateway/src/data-plane/routes.ts b/packages/gateway/src/data-plane/routes.ts
index 11d181e73..e4143f025 100644
--- a/packages/gateway/src/data-plane/routes.ts
+++ b/packages/gateway/src/data-plane/routes.ts
@@ -1,5 +1,6 @@
import type { Hono } from 'hono';
+import { mountAlphaSearchRoutes } from './alpha-search/routes.ts';
import { mountChatRoutes } from './chat/routes.ts';
import { mountCodexRoutes } from './codex/routes.ts';
import { completions } from './completions/serve.ts';
@@ -10,6 +11,7 @@ import { models } from './models/serve.ts';
import type { AuthVars } from '../middleware/auth.ts';
export const mountDataPlane = (app: Hono<{ Variables: AuthVars }>) => {
+ mountAlphaSearchRoutes(app);
mountChatRoutes(app);
mountCodexRoutes(app);
diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/execution.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/execution.ts
new file mode 100644
index 000000000..c2f9ac04b
--- /dev/null
+++ b/packages/gateway/src/data-plane/tools/web-search/alpha-search/execution.ts
@@ -0,0 +1,53 @@
+import type { AlphaSearchDispatcher } from './upstream.ts';
+import type { WebSearchCallIR } from '../operations.ts';
+import type { ResponsesInputItem, ResponsesWebSearchAction } from '@floway-dev/protocols/responses';
+
+export const executeAlphaSearch = async ({
+ dispatcher,
+ sessionId,
+ commands,
+ settings,
+ input,
+ action,
+ signal,
+}: {
+ dispatcher: AlphaSearchDispatcher;
+ sessionId: string;
+ commands: Record;
+ settings: Record;
+ input: ResponsesInputItem[];
+ action: ResponsesWebSearchAction;
+ signal: AbortSignal | undefined;
+}): Promise => {
+ const response = await dispatcher({
+ id: sessionId,
+ input,
+ commands,
+ settings,
+ }, signal, new Headers());
+ const raw = await response.text();
+ if (!response.ok) throw new Error(`OpenAI search upstream returned HTTP ${response.status}: ${raw.slice(0, 512)}`);
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new Error('OpenAI search upstream returned a non-JSON success body');
+ }
+ if (parsed === null || typeof parsed !== 'object' || typeof (parsed as { output?: unknown }).output !== 'string') {
+ throw new Error('OpenAI search upstream response must include an output string');
+ }
+ const body = parsed as { output: string };
+ return {
+ action,
+ // Alpha results are opaque UI metadata (`ref_id`, `domain`, optional
+ // thumbnail) and live responses do not include the hosted-search
+ // `snippet` field. The model-facing output is the only lossless bridge;
+ // this explicitly synthetic entry serves Responses clients that request
+ // `web_search_call.results` without pretending alpha DTOs were preserved.
+ results: body.output === ''
+ ? []
+ : [{ type: 'text_result', url: '', title: 'OpenAI search output', snippet: body.output }],
+ outputText: body.output,
+ };
+};
diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/execution_test.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/execution_test.ts
new file mode 100644
index 000000000..ca63c3dc7
--- /dev/null
+++ b/packages/gateway/src/data-plane/tools/web-search/alpha-search/execution_test.ts
@@ -0,0 +1,43 @@
+import { expect, test, vi } from 'vitest';
+
+import { executeAlphaSearch } from './execution.ts';
+import type { AlphaSearchDispatcher } from './upstream.ts';
+import { assertEquals } from '@floway-dev/test-utils';
+
+test('executeAlphaSearch preserves model-facing output without retyping opaque alpha results', async () => {
+ const call = vi.fn(async () => new Response(JSON.stringify({
+ encrypted_output: 'opaque',
+ output: 'rendered output',
+ results: [{ type: 'text_result', ref_id: 'turn0search0', url: 'https://example.com', title: 'Example', domain: 'example.com', future: true }],
+ }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ const ir = await executeAlphaSearch({
+ dispatcher: call,
+ sessionId: 'session-search',
+ commands: { search_query: [{ q: 'Floway' }] },
+ settings: { external_web_access: true },
+ input: [{ type: 'message', role: 'user', content: 'Search' }],
+ action: { type: 'search', query: 'Floway' },
+ signal: undefined,
+ });
+
+ assertEquals(ir.outputText, 'rendered output');
+ assertEquals(ir.results, [{ type: 'text_result', url: '', title: 'OpenAI search output', snippet: 'rendered output' }]);
+ assertEquals(call.mock.calls[0]?.[0], {
+ id: 'session-search',
+ input: [{ type: 'message', role: 'user', content: 'Search' }],
+ commands: { search_query: [{ q: 'Floway' }] },
+ settings: { external_web_access: true },
+ });
+});
+
+test('executeAlphaSearch exposes upstream failure without fallback', async () => {
+ await expect(executeAlphaSearch({
+ dispatcher: async () => new Response('unavailable', { status: 503 }),
+ sessionId: 'session-search',
+ commands: { search_query: [{ q: 'Floway' }] },
+ settings: {},
+ input: [],
+ action: { type: 'search', query: 'Floway' },
+ signal: undefined,
+ })).rejects.toThrow('HTTP 503');
+});
diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response.ts
new file mode 100644
index 000000000..d990344a7
--- /dev/null
+++ b/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response.ts
@@ -0,0 +1,36 @@
+// Alpha-search upstream Fetch decodes a response's content coding before exposing its body stream,
+// but keeps the upstream Content-Encoding and Content-Length headers. Relaying
+// that stream with the stale representation headers makes the next Fetch
+// consumer decode plain bytes a second time. Rebuild the response around the
+// decoded stream and preserve every header that still describes it.
+
+const BLOCKED_RELAY_HEADERS: ReadonlySet = new Set([
+ // Hop-by-hop headers (RFC 9110 §7.6.1).
+ // https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1
+ 'connection',
+ 'keep-alive',
+ 'proxy-authenticate',
+ 'proxy-authorization',
+ 'te',
+ 'trailer',
+ 'transfer-encoding',
+ 'upgrade',
+ // Fetch owns the decoded body's representation framing.
+ 'content-encoding',
+ 'content-length',
+ // Upstream session cookies must not bind a gateway client.
+ 'set-cookie',
+ 'set-cookie2',
+]);
+
+export const relayFetchedResponse = (response: Response): Response => {
+ const headers = new Headers();
+ for (const [name, value] of response.headers) {
+ if (!BLOCKED_RELAY_HEADERS.has(name.toLowerCase())) headers.set(name, value);
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+};
diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response_test.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response_test.ts
new file mode 100644
index 000000000..a6cbb69dc
--- /dev/null
+++ b/packages/gateway/src/data-plane/tools/web-search/alpha-search/relay-response_test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+
+import { relayFetchedResponse } from './relay-response.ts';
+
+describe('relayFetchedResponse', () => {
+ it('keeps decoded bytes and representation-safe upstream headers', async () => {
+ const body = JSON.stringify({ output: 'decoded' });
+ const upstream = new Response(body, {
+ status: 202,
+ statusText: 'Accepted upstream',
+ headers: {
+ connection: 'close',
+ 'content-encoding': 'gzip',
+ 'content-length': '999',
+ 'content-type': 'application/json',
+ 'set-cookie': 'session=upstream-secret',
+ 'transfer-encoding': 'chunked',
+ 'x-oai-request-id': 'req_search_1',
+ },
+ });
+
+ const relayed = relayFetchedResponse(upstream);
+
+ expect(relayed.status).toBe(202);
+ expect(relayed.statusText).toBe('Accepted upstream');
+ expect(await relayed.text()).toBe(body);
+ expect(relayed.headers.get('content-type')).toBe('application/json');
+ expect(relayed.headers.get('x-oai-request-id')).toBe('req_search_1');
+ expect(relayed.headers.get('connection')).toBeNull();
+ expect(relayed.headers.get('content-encoding')).toBeNull();
+ expect(relayed.headers.get('content-length')).toBeNull();
+ expect(relayed.headers.get('set-cookie')).toBeNull();
+ expect(relayed.headers.get('transfer-encoding')).toBeNull();
+ });
+});
diff --git a/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts b/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts
new file mode 100644
index 000000000..a08d4c934
--- /dev/null
+++ b/packages/gateway/src/data-plane/tools/web-search/alpha-search/upstream.ts
@@ -0,0 +1,54 @@
+import { enumerateModelCandidates } from '../../../providers/registry.ts';
+import type { SearchConfig } from '../types.ts';
+import type { BackgroundScheduler } from '@floway-dev/platform';
+import { identityWrapUpstreamCall, providerModelOf } from '@floway-dev/provider';
+
+export type AlphaSearchDispatcher = (body: Record, signal: AbortSignal | undefined, headers: Headers) => Promise;
+
+export const resolveAlphaSearchDispatcher = async ({
+ config,
+ upstreamIds,
+ scheduler,
+ runtimeLocation,
+}: {
+ config: Pick;
+ upstreamIds: readonly string[] | null;
+ scheduler: BackgroundScheduler;
+ runtimeLocation: string;
+}): Promise => {
+ if (upstreamIds !== null && !upstreamIds.includes(config.upstreamId)) {
+ throw new Error('Selected OpenAI search upstream is outside this API key scope');
+ }
+ const { candidates } = await enumerateModelCandidates({
+ upstreamIds: [config.upstreamId],
+ model: config.model,
+ kind: 'chat',
+ scheduler,
+ runtimeLocation,
+ });
+ const candidate = candidates.find(value => value.provider.upstream === config.upstreamId);
+ if (candidate === undefined) {
+ throw new Error(`Selected OpenAI search model ${config.model} is unavailable`);
+ }
+ if (candidate.provider.kind !== 'codex' && candidate.provider.kind !== 'custom') {
+ throw new Error('Selected upstream does not support OpenAI search passthrough');
+ }
+
+ return async (body, signal, headers) => {
+ const { model: _callerModel, ...request } = body;
+ // TODO: pin SearchRequest.id to one provider account when Codex upstreams
+ // support account pools. The current Codex provider has one active account.
+ const result = await candidate.provider.instance.callAlphaSearch(
+ providerModelOf(candidate),
+ request,
+ signal,
+ {
+ fetcher: candidate.fetcher,
+ waitUntil: scheduler,
+ headers,
+ wrapUpstreamCall: identityWrapUpstreamCall,
+ },
+ );
+ return result.response;
+ };
+};
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..4ff4136e1
--- /dev/null
+++ b/packages/gateway/src/data-plane/tools/web-search/operations.ts
@@ -0,0 +1,845 @@
+// Shared parser, local-provider executor, and Responses web-search IR.
+// Responses always uses the parser and IR; its local mode also executes and
+// renders operations here, while alpha passthrough delegates the commands and
+// retains the upstream model-facing output. The Codex compatibility route uses
+// this engine only in its default local-provider mode.
+
+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';
+import { isAbortError } from '@floway-dev/provider';
+
+// 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/f16fbbd2bd25dc1ff150b5f78dbd15ff6bab6d91/src/openai/types/responses/web_search_tool.py#L65-L70
+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';
+
+const SEARCH_CONTEXT_SIZES = new Set(['low', 'medium', 'high']);
+
+export const isSearchContextSize = (v: unknown): v is keyof typeof CONTEXT_SIZE_TO_MAX_RESULTS =>
+ typeof v === 'string' && SEARCH_CONTEXT_SIZES.has(v as keyof typeof 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: keyof typeof CONTEXT_SIZE_TO_MAX_RESULTS | undefined): number =>
+ CONTEXT_SIZE_TO_MAX_RESULTS[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[];
+ outputText?: string;
+}
+
+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,
+ ...(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/6a797f97b22d74cc5603ddacb16e38bf4a259858/litellm/integrations/websearch_interception/handler.py#L180-L186
+const searchFailedText = (providerMessage: string): string =>
+ `Search failed: ${providerMessage}`;
+
+const openFailedText = (url: string, providerMessage: string): string =>
+ `Error fetching URL \`${url}\`: ${providerMessage}`;
+
+const unsupportedOperationText = (subProperty: string): string =>
+ `Error: the \`${subProperty}\` sub-property is not supported by this gateway. Only \`search_query\`, \`open\`, and \`find\` are available.`;
+
+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')}`;
+};
+
+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 : '';
+ }
+};
+
+export const renderWebSearchCallOutput = (ir: WebSearchCallIR): string =>
+ ir.outputText ?? renderOperationOutputText(ir.action, ir.results);
+
+// ── 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) {
+ if (isAbortError(e)) throw 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) {
+ if (isAbortError(e)) throw 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 the \`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 renderWebSearchCallOutput(ir);
+ }
+ }
+};
diff --git a/packages/gateway/src/data-plane/tools/web-search/provider_test.ts b/packages/gateway/src/data-plane/tools/web-search/provider_test.ts
index ab9018f03..9aff8ad1c 100644
--- a/packages/gateway/src/data-plane/tools/web-search/provider_test.ts
+++ b/packages/gateway/src/data-plane/tools/web-search/provider_test.ts
@@ -17,6 +17,7 @@ test('resolveConfiguredWebSearchProvider returns disabled, missing-credential, o
tavily: { apiKey: '' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
}),
{
type: 'missing-credential',
@@ -29,6 +30,7 @@ test('resolveConfiguredWebSearchProvider returns disabled, missing-credential, o
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(resolved.type, 'enabled');
@@ -55,6 +57,7 @@ test('testSearchConfigConnection returns structured disabled and missing-credent
tavily: { apiKey: '' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
}),
{
ok: false,
@@ -102,6 +105,7 @@ test('testSearchConfigConnection previews at most three normalized results', asy
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(result.ok, true);
@@ -131,6 +135,7 @@ test('testSearchConfigConnection returns no_results when the provider returns no
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
}),
{
ok: false,
@@ -165,6 +170,7 @@ test('testSearchConfigConnection returns preview results for Microsoft Grounding
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(result.ok, true);
@@ -207,6 +213,7 @@ test('testSearchConfigConnection does not record search usage', async () => {
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: '' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(result.ok, true);
diff --git a/packages/gateway/src/data-plane/tools/web-search/search-config.ts b/packages/gateway/src/data-plane/tools/web-search/search-config.ts
index 33f92a678..7b1b21688 100644
--- a/packages/gateway/src/data-plane/tools/web-search/search-config.ts
+++ b/packages/gateway/src/data-plane/tools/web-search/search-config.ts
@@ -8,6 +8,7 @@ export const DEFAULT_SEARCH_CONFIG: SearchConfig = {
tavily: { apiKey: '' },
microsoftGrounding: { apiKey: '' },
jina: { apiKey: '' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
};
export const FIXED_SEARCH_CONFIG_TEST_QUERY = 'React documentation';
@@ -44,30 +45,24 @@ export const parseSearchConfigStrict = (input: unknown): SearchConfig => {
if (typeof input.jina.apiKey !== 'string') {
throw new Error('search config jina.apiKey must be a string');
}
+ if (!isJsonObject(input.passthroughOpenAiSearch)) {
+ throw new Error('search config passthroughOpenAiSearch must be an object');
+ }
+ const passthrough = input.passthroughOpenAiSearch;
+ if (typeof passthrough.enabled !== 'boolean' || typeof passthrough.upstreamId !== 'string' || typeof passthrough.model !== 'string') {
+ throw new Error('search config passthroughOpenAiSearch must contain enabled, upstreamId, and model');
+ }
+ const upstreamId = passthrough.upstreamId.trim();
+ const model = passthrough.model.trim();
+ if (passthrough.enabled && (upstreamId === '' || model === '')) {
+ throw new Error('enabled OpenAI search passthrough requires an upstream and model');
+ }
return {
provider: input.provider,
tavily: { apiKey: input.tavily.apiKey.trim() },
microsoftGrounding: { apiKey: input.microsoftGrounding.apiKey.trim() },
jina: { apiKey: input.jina.apiKey.trim() },
- };
-};
-
-// Lossy normalize: coerces any unknown into a SearchConfig, defaulting
-// missing or malformed fields to the disabled/empty shape. Used by
-// zod-validated input paths where the schema already enforced the
-// outer shape; the normalizer just guarantees the canonical form
-// (trimmed strings, fields always present).
-export const normalizeSearchConfig = (input: unknown): SearchConfig => {
- const record = isJsonObject(input) ? input : {};
- const tavily = isJsonObject(record.tavily) ? record.tavily : {};
- const microsoftGrounding = isJsonObject(record.microsoftGrounding) ? record.microsoftGrounding : {};
- const jina = isJsonObject(record.jina) ? record.jina : {};
-
- return {
- provider: isWebSearchProviderName(record.provider) ? record.provider : 'disabled',
- tavily: { apiKey: typeof tavily.apiKey === 'string' ? tavily.apiKey.trim() : '' },
- microsoftGrounding: { apiKey: typeof microsoftGrounding.apiKey === 'string' ? microsoftGrounding.apiKey.trim() : '' },
- jina: { apiKey: typeof jina.apiKey === 'string' ? jina.apiKey.trim() : '' },
+ passthroughOpenAiSearch: { enabled: passthrough.enabled, upstreamId, model },
};
};
diff --git a/packages/gateway/src/data-plane/tools/web-search/search-config_test.ts b/packages/gateway/src/data-plane/tools/web-search/search-config_test.ts
index 517f7c171..7823aef05 100644
--- a/packages/gateway/src/data-plane/tools/web-search/search-config_test.ts
+++ b/packages/gateway/src/data-plane/tools/web-search/search-config_test.ts
@@ -1,6 +1,7 @@
import { test } from 'vitest';
import { DEFAULT_SEARCH_CONFIG, FIXED_SEARCH_CONFIG_TEST_QUERY, loadSearchConfig, parseSearchConfigDefault, parseSearchConfigStrict, saveSearchConfig } from './search-config.ts';
+import type { SearchConfig } from './types.ts';
import { initRepo } from '../../../repo/index.ts';
import { InMemoryRepo } from '../../../repo/memory.ts';
import { SqlRepo } from '../../../repo/sql.ts';
@@ -12,16 +13,22 @@ interface SearchConfigRow {
tavily_api_key: string;
microsoft_grounding_api_key: string;
jina_api_key: string;
+ passthrough_openai_search: number;
+ alpha_search_upstream_id: string;
+ alpha_search_model: string;
}
-const SELECT_SQL = 'SELECT provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key FROM search_config WHERE id = 1';
-const UPSERT_SQL = `INSERT INTO search_config (id, provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, updated_at)
- VALUES (1, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+const SELECT_SQL = 'SELECT provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model FROM search_config WHERE id = 1';
+const UPSERT_SQL = `INSERT INTO search_config (id, provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model, updated_at)
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
tavily_api_key = excluded.tavily_api_key,
microsoft_grounding_api_key = excluded.microsoft_grounding_api_key,
jina_api_key = excluded.jina_api_key,
+ passthrough_openai_search = excluded.passthrough_openai_search,
+ alpha_search_upstream_id = excluded.alpha_search_upstream_id,
+ alpha_search_model = excluded.alpha_search_model,
updated_at = excluded.updated_at`;
class FakeSqlPreparedStatement {
@@ -53,6 +60,9 @@ class FakeSqlPreparedStatement {
tavily_api_key: String(this.binds[1]),
microsoft_grounding_api_key: String(this.binds[2]),
jina_api_key: String(this.binds[3]),
+ passthrough_openai_search: Number(this.binds[4]),
+ alpha_search_upstream_id: String(this.binds[5]),
+ alpha_search_model: String(this.binds[6]),
};
return Promise.resolve({ results: [], success: true, meta: {} });
}
@@ -82,6 +92,7 @@ test('search config repo defaults to disabled and round-trips provider keys', as
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: 'jina-test' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(await loadSearchConfig(), {
@@ -89,6 +100,7 @@ test('search config repo defaults to disabled and round-trips provider keys', as
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: 'jina-test' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(FIXED_SEARCH_CONFIG_TEST_QUERY, 'React documentation');
});
@@ -102,7 +114,8 @@ test('loadSearchConfig strict-parses a stored row and rejects unknown provider v
tavily: { apiKey: ' tvly-test ' },
microsoftGrounding: { apiKey: ' ms-test ' },
jina: { apiKey: '' },
- });
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
+ } as unknown as SearchConfig);
await assertRejects(() => loadSearchConfig(), Error, 'provider');
});
@@ -116,6 +129,7 @@ test('loadSearchConfig strict-parses a stored row and trims valid api keys', asy
tavily: { apiKey: ' tvly-trim ' },
microsoftGrounding: { apiKey: ' ms-trim ' },
jina: { apiKey: ' jina-trim ' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(await loadSearchConfig(), {
@@ -123,6 +137,7 @@ test('loadSearchConfig strict-parses a stored row and trims valid api keys', asy
tavily: { apiKey: 'tvly-trim' },
microsoftGrounding: { apiKey: 'ms-trim' },
jina: { apiKey: 'jina-trim' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
});
@@ -154,6 +169,13 @@ test('parseSearchConfigStrict throws on missing required fields', () => {
);
});
+test('parseSearchConfigStrict requires upstream and model when passthrough is enabled', () => {
+ assertThrows(() => parseSearchConfigStrict({
+ ...DEFAULT_SEARCH_CONFIG,
+ passthroughOpenAiSearch: { enabled: true, upstreamId: '', model: '' },
+ }), Error, 'requires an upstream and model');
+});
+
test('saveSearchConfig writes the typed columns and round-trips through the same db', async () => {
const db = new FakeSqlDatabase();
initRepo(new SqlRepo(db));
@@ -163,6 +185,7 @@ test('saveSearchConfig writes the typed columns and round-trips through the same
tavily: { apiKey: ' tvly-test ' },
microsoftGrounding: { apiKey: ' ms-test ' },
jina: { apiKey: ' jina-test ' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(saved, {
@@ -170,17 +193,22 @@ test('saveSearchConfig writes the typed columns and round-trips through the same
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: 'jina-test' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
assertEquals(db.searchConfig, {
provider: 'disabled',
tavily_api_key: 'tvly-test',
microsoft_grounding_api_key: 'ms-test',
jina_api_key: 'jina-test',
+ passthrough_openai_search: 0,
+ alpha_search_upstream_id: '',
+ alpha_search_model: '',
});
assertEquals(await loadSearchConfig(), {
provider: 'disabled',
tavily: { apiKey: 'tvly-test' },
microsoftGrounding: { apiKey: 'ms-test' },
jina: { apiKey: 'jina-test' },
+ passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
});
});
diff --git a/packages/gateway/src/data-plane/tools/web-search/types.ts b/packages/gateway/src/data-plane/tools/web-search/types.ts
index b5349bee1..cd88dd78f 100644
--- a/packages/gateway/src/data-plane/tools/web-search/types.ts
+++ b/packages/gateway/src/data-plane/tools/web-search/types.ts
@@ -1,14 +1,7 @@
-import type { WebSearchProviderName } from '../../../shared/web-search-providers.ts';
+import type { SearchConfig, WebSearchProviderName } from '../../../shared/web-search-providers.ts';
import type { MessagesWebSearchErrorCode } from '@floway-dev/protocols/messages';
-export type { WebSearchProviderName } from '../../../shared/web-search-providers.ts';
-
-export interface SearchConfig {
- provider: 'disabled' | WebSearchProviderName;
- tavily: { apiKey: string };
- microsoftGrounding: { apiKey: string };
- jina: { apiKey: string };
-}
+export type { SearchConfig, WebSearchProviderName } from '../../../shared/web-search-providers.ts';
export const DEFAULT_WEB_SEARCH_RESULT_COUNT = 10;
diff --git a/packages/gateway/src/repo/memory.ts b/packages/gateway/src/repo/memory.ts
index 4b6374ec5..ca39ee0e9 100644
--- a/packages/gateway/src/repo/memory.ts
+++ b/packages/gateway/src/repo/memory.ts
@@ -29,6 +29,7 @@ import type {
Repo,
ResponsesItemsRepo,
ResponsesSnapshotsRepo,
+ SearchConfig,
SearchConfigRepo,
SearchUsageRecord,
SearchUsageRepo,
@@ -518,8 +519,8 @@ class MemorySearchConfigRepo implements SearchConfigRepo {
return Promise.resolve(this.config === null ? null : structuredClone(this.config));
}
- save(config: unknown): Promise {
- this.config = config === undefined ? null : structuredClone(config);
+ save(config: SearchConfig): Promise {
+ this.config = structuredClone(config);
return Promise.resolve();
}
}
diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts
index c18c8e9e8..eb2187892 100644
--- a/packages/gateway/src/repo/sql.ts
+++ b/packages/gateway/src/repo/sql.ts
@@ -22,6 +22,7 @@ import type {
Repo,
ResponsesItemsRepo,
ResponsesSnapshotsRepo,
+ SearchConfig,
SearchConfigRepo,
SearchUsageRecord,
SearchUsageRepo,
@@ -1034,36 +1035,39 @@ class SqlSearchConfigRepo implements SearchConfigRepo {
async get(): Promise {
const row = await this.db
- .prepare('SELECT provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key FROM search_config WHERE id = 1')
- .first<{ provider: string; tavily_api_key: string; microsoft_grounding_api_key: string; jina_api_key: string }>();
+ .prepare('SELECT provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model FROM search_config WHERE id = 1')
+ .first<{ provider: string; tavily_api_key: string; microsoft_grounding_api_key: string; jina_api_key: string; passthrough_openai_search: number; alpha_search_upstream_id: string; alpha_search_model: string }>();
if (!row) throw new Error('search_config singleton row missing');
return {
provider: row.provider,
tavily: { apiKey: row.tavily_api_key },
microsoftGrounding: { apiKey: row.microsoft_grounding_api_key },
jina: { apiKey: row.jina_api_key },
+ passthroughOpenAiSearch: {
+ enabled: row.passthrough_openai_search === 1,
+ upstreamId: row.alpha_search_upstream_id,
+ model: row.alpha_search_model,
+ },
};
}
- async save(config: unknown): Promise {
- const { provider, tavily, microsoftGrounding, jina } = config as {
- provider: string;
- tavily: { apiKey: string };
- microsoftGrounding: { apiKey: string };
- jina: { apiKey: string };
- };
+ async save(config: SearchConfig): Promise {
+ const { provider, tavily, microsoftGrounding, jina, passthroughOpenAiSearch } = config;
await this.db
.prepare(
- `INSERT INTO search_config (id, provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, updated_at)
- VALUES (1, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+ `INSERT INTO search_config (id, provider, tavily_api_key, microsoft_grounding_api_key, jina_api_key, passthrough_openai_search, alpha_search_upstream_id, alpha_search_model, updated_at)
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT (id) DO UPDATE SET
provider = excluded.provider,
tavily_api_key = excluded.tavily_api_key,
microsoft_grounding_api_key = excluded.microsoft_grounding_api_key,
jina_api_key = excluded.jina_api_key,
+ passthrough_openai_search = excluded.passthrough_openai_search,
+ alpha_search_upstream_id = excluded.alpha_search_upstream_id,
+ alpha_search_model = excluded.alpha_search_model,
updated_at = excluded.updated_at`,
)
- .bind(provider, tavily.apiKey, microsoftGrounding.apiKey, jina.apiKey)
+ .bind(provider, tavily.apiKey, microsoftGrounding.apiKey, jina.apiKey, passthroughOpenAiSearch.enabled ? 1 : 0, passthroughOpenAiSearch.upstreamId, passthroughOpenAiSearch.model)
.run();
}
}
diff --git a/packages/gateway/src/repo/types.ts b/packages/gateway/src/repo/types.ts
index c96efaae0..f591b9ba7 100644
--- a/packages/gateway/src/repo/types.ts
+++ b/packages/gateway/src/repo/types.ts
@@ -1,4 +1,5 @@
-import type { WebSearchProviderName } from '../shared/web-search-providers.ts';
+import type { SearchConfig, WebSearchProviderName } from '../shared/web-search-providers.ts';
+export type { SearchConfig } from '../shared/web-search-providers.ts';
import type { AliasSelection, AliasTarget, AnnouncedMetadata, BillingDimension, ModelKind, PriceVector, PricingSelector } from '@floway-dev/protocols/common';
import type { PerformanceTelemetryContext, ProviderModel, UpstreamRecord } from '@floway-dev/provider';
@@ -233,7 +234,7 @@ export interface ModelsCacheRepo {
export interface SearchConfigRepo {
get(): Promise;
- save(config: unknown): Promise;
+ save(config: SearchConfig): Promise;
}
export interface UpstreamRepo {
diff --git a/packages/gateway/src/shared/web-search-providers.ts b/packages/gateway/src/shared/web-search-providers.ts
index ba4712215..9b11a91d7 100644
--- a/packages/gateway/src/shared/web-search-providers.ts
+++ b/packages/gateway/src/shared/web-search-providers.ts
@@ -2,6 +2,18 @@ export const WEB_SEARCH_PROVIDER_NAMES = ['tavily', 'microsoft-grounding', 'jina
export type WebSearchProviderName = (typeof WEB_SEARCH_PROVIDER_NAMES)[number];
+export interface SearchConfig {
+ provider: 'disabled' | WebSearchProviderName;
+ tavily: { apiKey: string };
+ microsoftGrounding: { apiKey: string };
+ jina: { apiKey: string };
+ passthroughOpenAiSearch: {
+ enabled: boolean;
+ upstreamId: string;
+ model: string;
+ };
+}
+
const WEB_SEARCH_PROVIDER_NAME_SET = new Set(WEB_SEARCH_PROVIDER_NAMES);
export const isWebSearchProviderName = (value: unknown): value is WebSearchProviderName => typeof value === 'string' && WEB_SEARCH_PROVIDER_NAME_SET.has(value);
diff --git a/packages/provider-azure/src/provider.ts b/packages/provider-azure/src/provider.ts
index 209a0b5ba..cd79ac500 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: () => Promise.reject(new Error('Azure provider does not support 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..055160073 100644
--- a/packages/provider-codex/src/constants.ts
+++ b/packages/provider-codex/src/constants.ts
@@ -32,6 +32,9 @@ 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';
+// Codex appends `alpha/search` to its ChatGPT model-provider base.
+// https://github.com/openai/codex/blob/2e1607ee2fa8099a233df7437adee5f16a741905/codex-rs/codex-api/src/endpoint/search.rs#L31-L47
+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..91e3d3778 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,14 @@ export const callCodexResponsesCompact = async (opts: CallCodexResponsesCompactO
return await performUnaryCompactCall(opts, ready.accessToken, false);
};
+export const callCodexAlphaSearch = async (opts: CallCodexAlphaSearchOptions): Promise => {
+ const requestId = stringField(opts.body, 'id') ?? uuidV7();
+ const normalized = { ...opts, body: { ...opts.body, id: requestId } };
+ const ready = await prepareCodexCall(normalized);
+ if (!ready.ok) return { modelKey: normalized.model.id, response: ready.response };
+ return await performAlphaSearchCall(normalized, 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') {
@@ -308,7 +321,7 @@ const dispatchCodexHttpCall = async (
accept: string,
body: Record,
identity: CodexRequestIdentity,
- turnMetadataJson: string,
+ turnMetadataJson: string | null,
): Promise => {
const headers = new Headers();
headers.set('authorization', `Bearer ${accessToken}`);
@@ -321,7 +334,7 @@ const dispatchCodexHttpCall = async (
headers.set('thread-id', identity.threadId);
headers.set('x-client-request-id', identity.clientRequestId);
headers.set('x-codex-window-id', identity.windowId);
- headers.set('x-codex-turn-metadata', turnMetadataJson);
+ if (turnMetadataJson !== null) headers.set('x-codex-turn-metadata', turnMetadataJson);
const response = await opts.call.wrapUpstreamCall(() => opts.call.fetcher(`${CODEX_BACKEND_BASE}${path}`, {
method: 'POST',
@@ -445,6 +458,40 @@ 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');
+ if (requestId === null) throw new Error('Normalized Codex alpha search request is missing id');
+ 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/fetch_test.ts b/packages/provider-codex/src/fetch_test.ts
index e8952b112..eac7cbbb0 100644
--- a/packages/provider-codex/src/fetch_test.ts
+++ b/packages/provider-codex/src/fetch_test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { CODEX_ORIGINATOR, CODEX_USER_AGENT } from './constants.ts';
-import { callCodexResponses, callCodexResponsesCompact, type CodexCallEffects } from './fetch.ts';
+import { callCodexAlphaSearch, callCodexResponses, callCodexResponsesCompact, type CodexCallEffects } from './fetch.ts';
import type { CodexAccessTokenEntry, CodexAccountCredential, CodexQuotaSnapshotMapEntry, CodexUpstreamState } from './state.ts';
import type { ResponsesResult } from '@floway-dev/protocols/responses';
import { initProviderRepo, type UpstreamRecord } from '@floway-dev/provider';
@@ -861,3 +861,62 @@ describe('callCodexResponsesCompact', () => {
});
});
+
+describe('callCodexAlphaSearch', () => {
+ test('posts the search request to the ChatGPT Codex endpoint with selected model and account auth', async () => {
+ seedFreshAccessToken();
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
+ encrypted_output: null,
+ output: 'Search result',
+ results: [],
+ }), { status: 200, headers: { 'content-type': 'application/json' } }));
+
+ const result = await callCodexAlphaSearch({
+ upstreamId,
+ account: activeAccount,
+ model,
+ body: { id: 'search-session', commands: { search_query: [{ q: 'Floway' }] } },
+ headers: new Headers({ 'x-codex-turn-metadata': '{"turn_id":"turn-search"}' }),
+ effects: makeEffects(),
+ call: noopUpstreamCallOptions(),
+ });
+
+ expect(result.response.status).toBe(200);
+ expect(result.modelKey).toBe('gpt-5.4');
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe('https://chatgpt.com/backend-api/codex/alpha/search');
+ const headers = new Headers(init.headers);
+ expect(headers.get('authorization')).toBe('Bearer at_kv');
+ expect(headers.get('chatgpt-account-id')).toBe('acc');
+ expect(headers.get('x-codex-turn-metadata')).toBe('{"turn_id":"turn-search"}');
+ expect(JSON.parse(init.body as string)).toMatchObject({
+ id: 'search-session',
+ model: 'gpt-5.4',
+ commands: { search_query: [{ q: 'Floway' }] },
+ });
+ });
+
+ test('normalizes a missing request id and omits absent turn metadata', async () => {
+ seedFreshAccessToken();
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ output: 'Search result' }), { status: 200 }));
+
+ await callCodexAlphaSearch({
+ upstreamId,
+ account: activeAccount,
+ model,
+ body: { commands: { search_query: [{ q: 'Floway' }] } },
+ headers: new Headers(),
+ effects: makeEffects(),
+ call: noopUpstreamCallOptions(),
+ });
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
+ const headers = new Headers(init.headers);
+ const body = JSON.parse(init.body as string) as Record;
+ expect(headers.has('x-codex-turn-metadata')).toBe(false);
+ expect(typeof body.id).toBe('string');
+ expect(headers.get('session-id')).toBe(body.id);
+ expect(headers.get('thread-id')).toBe(body.id);
+ expect(headers.get('x-client-request-id')).toBe(body.id);
+ });
+});
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/config.ts b/packages/provider-custom/src/config.ts
index 2eebd1a52..9c87e54f2 100644
--- a/packages/provider-custom/src/config.ts
+++ b/packages/provider-custom/src/config.ts
@@ -40,6 +40,7 @@ type CustomPathOverrideKey =
| '/responses'
| '/messages'
| '/embeddings'
+ | '/alpha/search'
| '/images/generations'
| '/images/edits';
@@ -103,6 +104,7 @@ const PATH_OVERRIDE_KEYS = new Set([
'/responses',
'/messages',
'/embeddings',
+ '/alpha/search',
'/images/generations',
'/images/edits',
]);
diff --git a/packages/provider-custom/src/fetch.ts b/packages/provider-custom/src/fetch.ts
index c198f2e54..04b0cf1b8 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, resolveOverridable(config, '/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/fetch_test.ts b/packages/provider-custom/src/fetch_test.ts
index b28c4d545..f51c719d5 100644
--- a/packages/provider-custom/src/fetch_test.ts
+++ b/packages/provider-custom/src/fetch_test.ts
@@ -2,6 +2,7 @@ import { test } from 'vitest';
import { assertCustomUpstreamRecord } from './config.ts';
import {
+ customFetchAlphaSearch,
customFetchChatCompletions,
customFetchEmbeddings,
customFetchMessages,
@@ -51,6 +52,7 @@ test('typed transports use default /v1/* paths', async () => {
await customFetchResponsesCompact(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
await customFetchMessages(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
await customFetchMessagesCountTokens(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
+ await customFetchAlphaSearch(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
await customFetchEmbeddings(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
await customFetchModels(config, { method: 'GET' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
},
@@ -62,6 +64,7 @@ test('typed transports use default /v1/* paths', async () => {
'https://custom.example.com/v1/responses/compact',
'https://custom.example.com/v1/messages',
'https://custom.example.com/v1/messages/count_tokens',
+ 'https://custom.example.com/v1/alpha/search',
'https://custom.example.com/v1/embeddings',
'https://custom.example.com/v1/models',
]);
@@ -75,6 +78,7 @@ test('admin pathOverrides replace defaults and propagate to derived sub-paths',
pathOverrides: {
'/messages': '/api/v1/messages',
'/responses': '/api/v1/responses',
+ '/alpha/search': '/api/search',
},
},
});
@@ -89,6 +93,7 @@ test('admin pathOverrides replace defaults and propagate to derived sub-paths',
// count_tokens / compact follow their parent override.
await customFetchMessagesCountTokens(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
await customFetchResponsesCompact(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
+ await customFetchAlphaSearch(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
// Endpoints without an override fall back to the OpenAI default.
await customFetchChatCompletions(config, { method: 'POST', body: '{}' }, { fetcher: directFetcher, wrapUpstreamCall: identityWrapUpstreamCall });
},
@@ -98,6 +103,7 @@ test('admin pathOverrides replace defaults and propagate to derived sub-paths',
'https://custom.example.com/api/v1/messages',
'https://custom.example.com/api/v1/messages/count_tokens',
'https://custom.example.com/api/v1/responses/compact',
+ 'https://custom.example.com/api/search',
'https://custom.example.com/v1/chat/completions',
]);
});
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'))),
diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc
index edd7f6086..ca4803856 100644
--- a/wrangler.example.jsonc
+++ b/wrangler.example.jsonc
@@ -51,14 +51,15 @@
// proxy-to-wrangler-dev topology).
//
// All three describe the same concept: every path the gateway owns; the
- // rest is the SPA. Bare LLM paths (without `/v1` prefix) are listed
- // because the gateway accepts both forms.
+ // rest is the SPA. Bare data-plane paths are listed when the gateway also
+ // accepts a root form.
"run_worker_first": [
"/api/*",
"/auth",
"/auth/*",
"/v1/*",
"/v1beta/*",
+ "/alpha/search",
"/completions",
"/chat/*",
"/responses",