Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
1a02997
feat(codex): serve /alpha/search via internal web-search providers
Menci Jul 11, 2026
572c5fd
test(codex): cover /alpha/search route, execution, and config states
Menci Jul 11, 2026
771f9f8
style: apply eslint import-order fixes
Menci Jul 11, 2026
9328683
docs(readme): document Codex /alpha/search web-search support
Menci Jul 11, 2026
db84172
fix(codex): expose alpha search on client-derived paths
Menci Jul 11, 2026
02da86b
test(codex): cover every alpha search route alias
Menci Jul 11, 2026
3b1b9fa
fix(routing): forward root Codex search requests
Menci Jul 11, 2026
923380f
docs(readme): list every Codex search route
Menci Jul 11, 2026
7c10ee6
fix(headers): contain Codex web-tool compatibility marker
Menci Jul 11, 2026
1fb2900
fix(web): enable Codex web.run in generated config
Menci Jul 11, 2026
803324f
docs(readme): explain Codex web.run compatibility marker
Menci Jul 11, 2026
f609fdd
fix(web): disable Codex image generation in generated config
Menci Jul 11, 2026
346e759
chore(codex): clean up shared search wording and ordering
Menci Jul 11, 2026
0827180
fix(codex): remove fabricated provider compatibility settings
Menci Jul 12, 2026
3552329
Merge remote-tracking branch 'origin/main' into worktree-agent-a8a2e1…
Menci Jul 15, 2026
b9d462e
feat(search): add alpha search provider call
Menci Jul 15, 2026
1626f8c
feat(search): configure OpenAI search passthrough
Menci Jul 15, 2026
61a1ac9
feat(search): route Responses search to alpha upstream
Menci Jul 15, 2026
757dc87
test(search): align passthrough config fixtures
Menci Jul 15, 2026
306723c
test(search): cover alpha passthrough routing
Menci Jul 15, 2026
157bccb
fix(search): type alpha dispatcher spies
Menci Jul 15, 2026
927fa33
fix(search): tighten passthrough UI and dispatcher
Menci Jul 15, 2026
1fd9379
docs(search): describe passthrough routing
Menci Jul 15, 2026
94fe924
test(search): update data transfer fixtures
Menci Jul 15, 2026
ea32e82
style(search): format updated tests
Menci Jul 15, 2026
be98116
feat(search): bump transfer format for passthrough config
Menci Jul 15, 2026
dcf93a2
fix(search): align alpha provider contracts
Menci Jul 15, 2026
040742f
refactor(search): tighten passthrough configuration
Menci Jul 15, 2026
af2f7b7
refactor(search): simplify alpha dispatch
Menci Jul 15, 2026
65e5c56
fix(web): accept search config transfer v10
Menci Jul 15, 2026
6928ecb
docs: hide provider-relative Codex routes
Menci Jul 15, 2026
f2abc14
fix(search): relay fetched alpha responses
Menci Jul 15, 2026
1b35e86
style(search): remove obsolete test import
Menci Jul 15, 2026
0c10cfe
docs(search): clarify passthrough scope
Menci Jul 15, 2026
c60117b
fix(search): relay decoded alpha responses safely
Menci Jul 16, 2026
96a72d4
fix(search): make alpha result adaptation explicit
Menci Jul 16, 2026
dc685b7
docs(search): cite relayed header rules
Menci Jul 16, 2026
9baafd7
refactor(search): colocate alpha search implementation
Menci Jul 16, 2026
8018391
test(search): follow alpha route relocation
Menci Jul 16, 2026
175f762
style(search): order relocated route imports
Menci Jul 16, 2026
8c8bdb3
fix(search): resolve relocated config type
Menci Jul 16, 2026
136eeaf
style(search): order alpha upstream imports
Menci Jul 16, 2026
a3864ba
refactor(search): separate alpha routes from implementation
Menci Jul 16, 2026
129c113
refactor(search): keep Codex alpha path namespaced
Menci Jul 16, 2026
f01a89b
Merge remote-tracking branch 'origin/main' into worktree-agent-a8a2e1…
Menci Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/components/keys/CliSnippet_test.ts
Original file line number Diff line number Diff line change
@@ -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: '<pre :data-language="language">{{ code }}</pre>',
}),
}));

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"');
});
});
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/ImportSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
93 changes: 90 additions & 3 deletions apps/web/src/components/settings/SearchConfigSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +62,8 @@ const PROVIDER_OPTIONS: ProviderOption[] = [
const props = defineProps<{
initialConfig: SearchConfig;
initialError?: string | null;
upstreams: Array<Pick<UpstreamRecord, 'id' | 'name' | 'kind' | 'enabled'>>;
models: ControlPlaneModel[];
}>();

const auth = useAuthStore();
Expand All @@ -73,6 +75,21 @@ const saving = ref(false);
const testing = ref(false);
const testResult = ref<SearchTestResult | null>(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']) => {
Expand All @@ -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 }));
Expand Down Expand Up @@ -133,7 +181,7 @@ const test = async () => {
<div class="glass-card p-5 sm:p-6 animate-in delay-2">
<div class="mb-4">
<h3 class="text-white font-semibold mb-1">Web Search</h3>
<p class="text-sm text-gray-400">Configure the search provider used by Anthropic Messages web search.</p>
<p class="text-sm text-gray-400">Configure the search provider used by gateway-managed Messages and Responses web search.</p>
</div>

<p v-if="error" class="mb-4 rounded-md border border-accent-rose/40 bg-accent-rose/10 px-3 py-2 text-xs text-accent-rose">{{ error }}</p>
Expand Down Expand Up @@ -170,6 +218,45 @@ const test = async () => {
</div>
</div>

<div class="mt-5 border-t border-white/[0.06] pt-5">
<div class="flex items-start justify-between gap-4">
<div>
<label class="text-sm font-medium text-white">Passthrough OpenAI search (/alpha/search and Responses hosted tool)</label>
<p class="mt-1 text-xs text-gray-500">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.</p>
</div>
<Switch
:model-value="draft.passthroughOpenAiSearch.enabled"
:disabled="eligibleUpstreams.length === 0"
@update:model-value="value => setPassthroughOpenAiSearch(value === true)"
/>
</div>

<div v-if="draft.passthroughOpenAiSearch.enabled" class="mt-4 grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-xs font-medium text-gray-500">Search Upstream</label>
<Select
:model-value="draft.passthroughOpenAiSearch.upstreamId"
:options="alphaUpstreamOptions"
@update:model-value="value => value !== undefined && setAlphaUpstream(value)"
>
<template #description="{ option }">
<p class="text-[11px] text-gray-500">{{ option.description }}</p>
</template>
</Select>
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-gray-500">Search Model</label>
<Select
:model-value="draft.passthroughOpenAiSearch.model"
:options="alphaModelOptions"
@update:model-value="value => value !== undefined && setAlphaModel(value)"
/>
</div>
</div>

<p v-else-if="eligibleUpstreams.length === 0" class="mt-3 text-xs text-gray-500">Add an enabled Codex or Custom upstream with a chat model to use OpenAI search passthrough.</p>
</div>

<div class="mt-5 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button :loading="saving" @click="save">Save Search Config</Button>
<Button variant="secondary" :loading="testing" :disabled="draft.provider === 'disabled'" @click="test">Test Search</Button>
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/components/settings/SearchConfigSection_test.ts
Original file line number Diff line number Diff line change
@@ -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<UpstreamRecord, 'id' | 'name' | 'kind' | 'enabled'> => ({
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');
});
2 changes: 2 additions & 0 deletions apps/web/src/components/upstream-edit/customConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const PATH_KEYS = [
'/responses',
'/messages',
'/embeddings',
'/alpha/search',
'/images/generations',
'/images/edits',
] as const;
Expand All @@ -17,6 +18,7 @@ export const emptyPathOverrides = (): Record<PathKey, string> => ({
'/responses': '',
'/messages': '',
'/embeddings': '',
'/alpha/search': '',
'/images/generations': '',
'/images/edits': '',
});
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/pages/dashboard/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const defaultSearchConfig: SearchConfig = {
tavily: { apiKey: '' },
microsoftGrounding: { apiKey: '' },
jina: { apiKey: '' },
passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
};

export const useSettingsPageData = defineBasicLoader(async () => {
Expand Down Expand Up @@ -98,7 +99,7 @@ const openAliasDialog = (record: ModelAlias | null): void => {
<UpstreamsSettingsCard
v-model:ordered="ordered"
:loading="storeLoading"
:models="modelsStore.models.value"
:models="modelsStore.models.value ?? []"
@add="(kind: UpstreamProviderKind) => router.push(`/dashboard/upstreams/new/${kind}`)"
@edit="(record: UpstreamRecord) => router.push(`/dashboard/upstreams/${record.id}`)"
@changed="reloadAll"
Expand All @@ -116,6 +117,8 @@ const openAliasDialog = (record: ModelAlias | null): void => {
<SearchConfigSection
:initial-config="settingsData.data.value.searchConfig"
:initial-error="settingsData.data.value.searchConfigError"
:upstreams="ordered"
:models="modelsStore.models.value ?? []"
/>
</div>

Expand Down
5 changes: 3 additions & 2 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ 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',
'/auth',
'/v1',
'/v1beta',
'/azure-api.codex',
'/alpha/search',
'/completions',
'/chat/completions',
'/responses',
Expand Down
9 changes: 3 additions & 6 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/gateway/migrations/0056_search_alpha_passthrough.sql
Original file line number Diff line number Diff line change
@@ -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 '';
4 changes: 2 additions & 2 deletions packages/gateway/src/control-plane/data-transfer/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface SerializedProxy {
}

interface ExportPayload {
version: 9;
version: 10;
exportedAt: string;
data: {
users: User[];
Expand All @@ -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<PerformanceMetric>(['ttft_ms', 'tpot_us']);
const UPSTREAM_PROVIDERS = new Set<UpstreamProviderKind>(ALL_PROVIDER_KINDS);
Expand Down
Loading