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 () => {
+
+
+
+ +

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.

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