From 697c7b96a971b5fdd9406877174678d2e32a7638 Mon Sep 17 00:00:00 2001 From: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:43:31 -0300 Subject: [PATCH] fix(audio): let the audio routes use audio-typed provider nodes, and gate remote ones behind a default-off flag (#9101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log --- .env.example | 9 + docs/reference/ENVIRONMENT.md | 1 + open-sse/config/audioRegistry.ts | 38 ++++- src/app/api/v1/_shared/audioProviderNodes.ts | 119 +++++++++++++ src/app/api/v1/audio/speech/route.ts | 38 +---- src/app/api/v1/audio/transcriptions/route.ts | 156 ++++++++++++------ src/app/api/v1/audio/translations/route.ts | 37 ++--- .../constants/featureFlagDefinitions.ts | 12 ++ .../audio-provider-nodes-selection.test.ts | 144 ++++++++++++++++ ...io-transcriptions-combo-resolution.test.ts | 113 +++++++++++++ tests/unit/feature-flags-settings.test.ts | 17 +- 11 files changed, 565 insertions(+), 119 deletions(-) create mode 100644 src/app/api/v1/_shared/audioProviderNodes.ts create mode 100644 tests/unit/audio-provider-nodes-selection.test.ts create mode 100644 tests/unit/audio-transcriptions-combo-resolution.test.ts diff --git a/.env.example b/.env.example index 4cb877425c..229f98e1a8 100644 --- a/.env.example +++ b/.env.example @@ -1893,6 +1893,15 @@ APP_LOG_TO_FILE=true # CHANGELOG_BASE_REF=origin/release/v0.0.0 # ALLOW_CHANGELOG_REMOVALS=1 +# ── Remote audio provider nodes ── +# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/* +# routes use an OpenAI-compatible provider node hosted outside localhost. +# OFF by default: routing audio to a remote host changes egress identity, so it +# must be an explicit operator decision. Loopback/private nodes (localhost, +# 127.0.0.1, 172.16-31.x) are always allowed and unaffected by this flag. +# When enabled, the node authenticates with the API key stored on its connection. +# AUDIO_REMOTE_PROVIDER_NODES=false + # ── 1Proxy egress pool ── # Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute # CrofAI 1Proxy service. Disable, override URL, or tune the import quality. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 1bf2f0f064..56c826b7bc 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -201,6 +201,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) | +| `AUDIO_REMOTE_PROVIDER_NODES` | `false` | `src/app/api/v1/_shared/audioProviderNodes.ts` | Let the `/v1/audio/*` routes (transcriptions, speech, translations) use an OpenAI-compatible provider node hosted outside localhost. Off by default — routing audio to a remote host changes egress identity and must be an explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1, 172.16-31.x) are always allowed and unaffected. (#3963) | ### Hardening Checklist diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 0419622f36..6ee45169fd 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -14,6 +14,14 @@ interface AudioModel { export interface AudioProvider { id: string; + /** + * Provider key to look credentials up under. Dynamic provider nodes are exposed + * to callers under their `prefix` (that is what appears in `provider/model`), + * but their connections are stored under the node **id** — without this the + * credential lookup silently misses. Absent for hardcoded providers, where the + * id already is the credential key. + */ + credentialProviderId?: string; baseUrl: string; authType: string; authHeader: string; @@ -564,27 +572,49 @@ export function getSpeechProvider(providerId: string): AudioProvider | null { } export interface ProviderNodeRow { + /** provider_node row id — the key its connections (and credentials) are stored under. */ + id?: string; prefix: string; name: string; baseUrl: string; apiType?: string; } +/** Hosts reachable only from the operator's machine/Docker network. */ +function isLoopbackNodeHost(baseUrl: string): boolean { + try { + const hostname = new URL(baseUrl).hostname; + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) + ); + } catch { + return false; + } +} + /** * Build a dynamic AudioProvider from a provider_node DB entry. - * Only used for local providers (localhost/127.0.0.1) — remote nodes are - * excluded by the caller to prevent auth bypass and SSRF. + * + * Loopback nodes keep `authType: "none"` — a local Ollama/LM Studio has no key and + * must not be blocked on a missing credential. A remote node is the opposite: it is + * only reachable when the operator opted in, and it must present the credential + * stored on its connection, so it is built as an api-key provider keyed by the node + * id (`credentialProviderId`) rather than by the caller-facing prefix. */ export function buildDynamicAudioProvider(node: ProviderNodeRow, audioPath: string): AudioProvider { if (!node.prefix || !node.baseUrl) { throw new Error(`Invalid provider_node: missing prefix or baseUrl`); } const baseUrl = node.baseUrl.replace(/\/+$/, ""); + const isLocal = isLoopbackNodeHost(node.baseUrl); return { id: node.prefix, + ...(node.id ? { credentialProviderId: node.id } : {}), baseUrl: `${baseUrl}${audioPath}`, - authType: "none", - authHeader: "none", + authType: isLocal ? "none" : "apikey", + authHeader: isLocal ? "none" : "bearer", models: [], }; } diff --git a/src/app/api/v1/_shared/audioProviderNodes.ts b/src/app/api/v1/_shared/audioProviderNodes.ts new file mode 100644 index 0000000000..062b9577b2 --- /dev/null +++ b/src/app/api/v1/_shared/audioProviderNodes.ts @@ -0,0 +1,119 @@ +/** + * Shared provider-node resolution for the audio routes + * (`/v1/audio/transcriptions`, `/v1/audio/speech`, `/v1/audio/translations`). + * + * The three routes each carried an identical copy of this filter, and every copy + * accepted only nodes typed `chat`/`responses` — so a node explicitly typed + * `audio-transcriptions` was rejected by the very route it exists for, and its + * models fell through to the hardcoded registry's bare-id lookup (where an + * unrelated provider owning a model literally named `whisper` silently won). + * + * Two axes are resolved here: + * + * 1. **apiType** — a node qualifies when its type matches the route's own audio + * type, or when it is a general `chat`/`responses` node (a multimodal gateway + * that serves audio on the same base URL). + * + * 2. **host** — loopback/private nodes are always eligible. Remote nodes are + * opt-in via `AUDIO_REMOTE_PROVIDER_NODES`, default OFF: routing audio to an + * arbitrary remote host changes egress identity, so it must be an explicit + * operator decision rather than a silent default (cf. #3963). + */ + +import { getCachedProviderNodes } from "@/lib/db/readCache"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { + buildDynamicAudioProvider, + type AudioProvider, + type ProviderNodeRow, +} from "@omniroute/open-sse/config/audioRegistry.ts"; + +/** Feature flag gating remote (non-loopback) audio provider nodes. Default OFF. */ +export const AUDIO_REMOTE_NODES_FLAG = "AUDIO_REMOTE_PROVIDER_NODES"; + +/** + * Loopback / private-range hosts that never leave the operator's machine or + * Docker network. `::1` stays excluded, matching the previous SSRF hardening. + */ +export function isLocalAudioNodeHost(baseUrl: string): boolean { + try { + const hostname = new URL(baseUrl).hostname; + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + // Strictly 172.16.0.0/12 (Docker/local) + /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) + ); + } catch { + return false; + } +} + +/** + * Pure selection step — no DB, no flag lookup, so the policy is directly testable. + * + * @param nodes provider_node rows + * @param audioPath endpoint suffix, e.g. "/audio/transcriptions" + * @param nodeApiType the audio apiType this route serves, e.g. "audio-transcriptions" + * @param allowRemote whether non-loopback nodes are eligible (feature-flagged) + */ +export function selectAudioProviderNodes( + nodes: ProviderNodeRow[], + { + audioPath, + nodeApiType, + allowRemote, + }: { audioPath: string; nodeApiType: string; allowRemote: boolean } +): AudioProvider[] { + const eligible = nodes.filter((node) => { + // A node qualifies on its own audio type, or as a general chat/responses + // gateway that also serves audio on the same base URL. + if (node.apiType !== nodeApiType && node.apiType !== "chat" && node.apiType !== "responses") { + return false; + } + if (!node.baseUrl) return false; + return isLocalAudioNodeHost(node.baseUrl) || allowRemote; + }); + + const providers: AudioProvider[] = []; + for (const node of eligible) { + const byPrefix = buildDynamicAudioProvider(node, audioPath); + providers.push(byPrefix); + // A node is addressable two ways: by its `prefix` (what a human types) and by + // its row id (what combos and /v1/models store). Registering only the prefix + // made the id form — which the catalog itself advertises, and which combo + // expansion produces — parse as an unknown provider and 400. + if (node.id && node.id !== node.prefix) { + providers.push({ ...byPrefix, id: node.id }); + } + } + return providers; +} + +/** + * Load provider nodes and resolve the ones this audio route may use. + * Never throws — a DB failure degrades to the hardcoded registry only. + */ +export async function resolveDynamicAudioProviders( + audioPath: string, + nodeApiType: string +): Promise { + try { + const nodes = await getCachedProviderNodes(); + if (!Array.isArray(nodes)) return []; + let allowRemote = false; + try { + allowRemote = isFeatureFlagEnabled(AUDIO_REMOTE_NODES_FLAG); + } catch { + // Fail closed: an unreadable flag store keeps remote nodes disabled. + allowRemote = false; + } + return selectAudioProviderNodes(nodes as unknown as ProviderNodeRow[], { + audioPath, + nodeApiType, + allowRemote, + }); + } catch { + return []; + } +} diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 16eff17c77..278d04aa2e 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -4,16 +4,11 @@ import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, } from "@/sse/services/auth"; -import { - parseSpeechModel, - getSpeechProvider, - buildDynamicAudioProvider, - type ProviderNodeRow, -} from "@omniroute/open-sse/config/audioRegistry.ts"; +import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts"; +import { resolveDynamicAudioProviders } from "@/app/api/v1/_shared/audioProviderNodes"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { getCachedProviderNodes } from "@/lib/localDb"; import { v1AudioSpeechSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { @@ -60,29 +55,9 @@ async function postHandler(request, context) { const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; - // Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF) - let dynamicProviders: ReturnType[] = []; - try { - const nodes = await getCachedProviderNodes(); - dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : []) - .filter((n: ProviderNodeRow) => { - if (n.apiType !== "chat" && n.apiType !== "responses") return false; - try { - const hostname = new URL(n.baseUrl).hostname; - // Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) - ); - } catch { - return false; - } - }) - .map((n) => buildDynamicAudioProvider(n, "/audio/speech")); - } catch { - // DB error — fall back to hardcoded providers only - } + // Provider nodes eligible for speech: this route's own audio type plus general + // chat/responses gateways. Remote hosts are opt-in (default OFF). + const dynamicProviders = await resolveDynamicAudioProviders("/audio/speech", "audio-speech"); const { provider, model: resolvedModel } = parseSpeechModel(body.model, dynamicProviders); if (!provider) { @@ -99,7 +74,8 @@ async function postHandler(request, context) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentialsWithQuotaPreflight(provider); + const credentialKey = providerConfig.credentialProviderId || provider; + credentials = await getProviderCredentialsWithQuotaPreflight(credentialKey); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } diff --git a/src/app/api/v1/audio/transcriptions/route.ts b/src/app/api/v1/audio/transcriptions/route.ts index 548f3e8993..788b335aa7 100644 --- a/src/app/api/v1/audio/transcriptions/route.ts +++ b/src/app/api/v1/audio/transcriptions/route.ts @@ -8,19 +8,35 @@ import { import { parseTranscriptionModel, getTranscriptionProvider, - buildDynamicAudioProvider, - type ProviderNodeRow, } from "@omniroute/open-sse/config/audioRegistry.ts"; +import { resolveDynamicAudioProviders } from "@/app/api/v1/_shared/audioProviderNodes"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { getCachedProviderNodes } from "@/lib/localDb"; import { isAllRateLimitedCredentials, rateLimitedProviderResponse, } from "@/app/api/v1/_shared/rateLimit"; import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; +import { getComboByName, getCombos, getDatabaseSettings } from "@/lib/localDb"; +import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; +import { log } from "@omniroute/open-sse/utils/logger.ts"; + +/** + * Copy a multipart body, swapping only the `model` field. Combo fan-out needs one + * body per target, and the uploaded file part is reused as-is (a Blob can be read + * more than once). + */ +function withModel(formData: FormData, modelStr: string): FormData { + const next = new FormData(); + for (const [key, value] of formData.entries()) { + if (key === "model") continue; + next.append(key, value as string | Blob); + } + next.set("model", modelStr); + return next; +} /** * Handle CORS preflight @@ -35,60 +51,26 @@ export async function OPTIONS() { } /** - * POST /v1/audio/transcriptions — transcribe audio files - * OpenAI Whisper API compatible (multipart/form-data) + * Transcribe with one concrete `provider/model` string. Split out of POST so combo + * fan-out can invoke it once per target. */ -export async function POST(request) { - let formData; - try { - formData = await request.formData(); - } catch { - return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart form data"); - } - - const startTime = Date.now(); - - const model = formData.get("model"); - if (!model) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); - } - - // Enforce API key policies (model restrictions + budget limits) - const policy = await enforceApiKeyPolicy(request, model as string); - if (policy.rejection) return policy.rejection; - - // Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF) - let dynamicProviders: ReturnType[] = []; - try { - const nodes = await getCachedProviderNodes(); - dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : []) - .filter((n: ProviderNodeRow) => { - if (n.apiType !== "chat" && n.apiType !== "responses") return false; - try { - const hostname = new URL(n.baseUrl).hostname; - // Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) - ); - } catch { - return false; - } - }) - .map((n) => buildDynamicAudioProvider(n, "/audio/transcriptions")); - } catch { - // DB error — fall back to hardcoded providers only - } - - const { provider, model: resolvedModel } = parseTranscriptionModel( - model as string, - dynamicProviders +async function transcribeWithModel( + formData: FormData, + modelStr: string, + startTime: number +): Promise { + // Provider nodes eligible for transcription: this route's own audio type plus + // general chat/responses gateways. Remote hosts are opt-in (default OFF). + const dynamicProviders = await resolveDynamicAudioProviders( + "/audio/transcriptions", + "audio-transcriptions" ); + + const { provider, model: resolvedModel } = parseTranscriptionModel(modelStr, dynamicProviders); if (!provider) { return errorResponse( HTTP_STATUS.BAD_REQUEST, - `Invalid transcription model: ${model}. Use format: provider/model` + `Invalid transcription model: ${modelStr}. Use format: provider/model` ); } @@ -96,10 +78,15 @@ export async function POST(request) { const providerConfig = getTranscriptionProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null; - // Get credentials — skip for local providers (authType: "none") + // Get credentials — skip for local providers (authType: "none"). + // A dynamic node is addressed by its prefix but stores connections under the node + // id, so credentials must be looked up under `credentialProviderId` when present. let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentialsWithQuotaPreflight(provider); + const credentialKey = providerConfig.credentialProviderId || provider; + // NOTE: the 2nd arg of this helper is `excludeConnectionId`, not "use this + // connection" — a combo target's connectionId must never be passed here. + credentials = await getProviderCredentialsWithQuotaPreflight(credentialKey); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } @@ -128,3 +115,64 @@ export async function POST(request) { } return response; } + +/** + * POST /v1/audio/transcriptions — transcribe audio files + * OpenAI Whisper API compatible (multipart/form-data) + */ +export async function POST(request) { + let formData; + try { + formData = await request.formData(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart form data"); + } + + const startTime = Date.now(); + + const model = formData.get("model"); + if (!model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + const modelStr = String(model); + + // Enforce API key policies (model restrictions + budget limits) + const policy = await enforceApiKeyPolicy(request, modelStr); + if (policy.rejection) return policy.rejection; + + // A bare name (no "/") may be a combo. /v1/models advertises combos, and chat and + // embeddings both resolve them — resolving here too keeps the catalog honest and + // frees callers from hardcoding a provider's internal model id. + if (!modelStr.includes("/")) { + try { + const combo = await getComboByName(modelStr); + if (combo) { + let allCombos: Awaited> = []; + try { + allCombos = await getCombos(); + } catch {} + let settings = {}; + try { + settings = getDatabaseSettings(); + } catch {} + + return handleComboChat({ + body: { model: modelStr } as any, + combo: combo as any, + handleSingleModel: async (_reqBody: any, targetModelStr: string) => + transcribeWithModel(withModel(formData, targetModelStr), targetModelStr, startTime), + isModelAvailable: undefined, + log, + settings, + allCombos: allCombos as any, + relayOptions: undefined, + signal: undefined, + } as any); + } + } catch (err) { + log.error("AUDIO", `Combo resolution failed for ${modelStr}: ${err}`); + } + } + + return transcribeWithModel(formData, modelStr, startTime); +} diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts index 7283dda555..f0c0acfa4e 100644 --- a/src/app/api/v1/audio/translations/route.ts +++ b/src/app/api/v1/audio/translations/route.ts @@ -8,13 +8,11 @@ import { import { parseTranslationModel, getTranslationProvider, - buildDynamicAudioProvider, - type ProviderNodeRow, } from "@omniroute/open-sse/config/audioRegistry.ts"; +import { resolveDynamicAudioProviders } from "@/app/api/v1/_shared/audioProviderNodes"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { getCachedProviderNodes } from "@/lib/localDb"; import { isAllRateLimitedCredentials, rateLimitedProviderResponse, @@ -59,29 +57,13 @@ export async function POST(request) { const policy = await enforceApiKeyPolicy(request, model as string); if (policy.rejection) return policy.rejection; - // Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF) - let dynamicProviders: ReturnType[] = []; - try { - const nodes = await getCachedProviderNodes(); - dynamicProviders = (Array.isArray(nodes) ? (nodes as unknown as ProviderNodeRow[]) : []) - .filter((n: ProviderNodeRow) => { - if (n.apiType !== "chat" && n.apiType !== "responses") return false; - try { - const hostname = new URL(n.baseUrl).hostname; - // Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) - ); - } catch { - return false; - } - }) - .map((n) => buildDynamicAudioProvider(n, "/audio/translations")); - } catch { - // DB error — fall back to hardcoded providers only - } + // Translation is served by the transcription-capable nodes (Whisper-style + // endpoints expose both), plus general chat/responses gateways. Remote hosts are + // opt-in (default OFF). + const dynamicProviders = await resolveDynamicAudioProviders( + "/audio/translations", + "audio-transcriptions" + ); const { provider, model: resolvedModel } = parseTranslationModel( model as string, @@ -101,7 +83,8 @@ export async function POST(request) { // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentialsWithQuotaPreflight(provider); + const credentialKey = providerConfig.credentialProviderId || provider; + credentials = await getProviderCredentialsWithQuotaPreflight(credentialKey); if (!credentials) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index d08fc7e329..d6b4a28c3f 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -117,6 +117,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: true, warningLevel: "info", }, + { + key: "AUDIO_REMOTE_PROVIDER_NODES", + label: "Remote Audio Provider Nodes", + description: + "Allow the /v1/audio/* routes to use OpenAI-compatible provider nodes hosted outside localhost. Off by default — routing audio to a remote host changes egress identity and must be an explicit operator decision. Loopback nodes are always allowed and unaffected.", + descriptionI18nKey: "settings.featureFlags.audioRemoteProviderNodes", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "danger", + }, { key: "ONEPROXY_ENABLED", label: "OneProxy Enabled", diff --git a/tests/unit/audio-provider-nodes-selection.test.ts b/tests/unit/audio-provider-nodes-selection.test.ts new file mode 100644 index 0000000000..cdc6836574 --- /dev/null +++ b/tests/unit/audio-provider-nodes-selection.test.ts @@ -0,0 +1,144 @@ +// Regression tests for provider-node eligibility on the /v1/audio/* routes. +// +// All three audio routes carried an identical filter that accepted only nodes typed +// `chat`/`responses`. A node explicitly typed `audio-transcriptions` was therefore +// rejected by the very route it exists for, its models never entered the dynamic +// provider list, and a bare model name fell through to the hardcoded registry — +// where an unrelated provider owning a model literally named `whisper` silently won. +// +// The second axis is the host guard: loopback nodes stay always-eligible, remote +// nodes are opt-in via AUDIO_REMOTE_PROVIDER_NODES (default OFF) because routing +// audio to an arbitrary remote host changes egress identity. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isLocalAudioNodeHost, + selectAudioProviderNodes, +} from "@/app/api/v1/_shared/audioProviderNodes"; +import type { ProviderNodeRow } from "@omniroute/open-sse/config/audioRegistry.ts"; + +const LOCAL_AUDIO_NODE: ProviderNodeRow = { + id: "openai-compatible-audio-transcriptions-local", + prefix: "localstt", + name: "Local STT", + baseUrl: "http://localhost:9000/v1", + apiType: "audio-transcriptions", +}; + +const REMOTE_AUDIO_NODE: ProviderNodeRow = { + id: "openai-compatible-audio-transcriptions-remote", + prefix: "remotestt", + name: "Remote STT", + baseUrl: "https://stt.example.com/v1", + apiType: "audio-transcriptions", +}; + +const LOCAL_CHAT_NODE: ProviderNodeRow = { + id: "openai-compatible-chat-local", + prefix: "localchat", + name: "Local multimodal gateway", + baseUrl: "http://127.0.0.1:11434/v1", + apiType: "chat", +}; + +const LOCAL_EMBEDDINGS_NODE: ProviderNodeRow = { + id: "openai-compatible-embeddings-local", + prefix: "localembed", + name: "Local embeddings", + baseUrl: "http://localhost:9100/v1", + apiType: "embeddings", +}; + +function select(nodes: ProviderNodeRow[], allowRemote = false) { + return selectAudioProviderNodes(nodes, { + audioPath: "/audio/transcriptions", + nodeApiType: "audio-transcriptions", + allowRemote, + }); +} + +// Each eligible node is registered twice — once under its prefix, once under its row +// id — so both addressing forms parse. Assert on the id set rather than the count. +function idsOf(providers: ReturnType) { + return providers.map((p) => p.id).sort(); +} + +test("audio node types are eligible on the audio route (the bug)", () => { + const selected = select([LOCAL_AUDIO_NODE]); + assert.ok(selected.length > 0, "an audio-transcriptions node must not be filtered out"); + assert.ok(idsOf(selected).includes("localstt")); + assert.equal(selected[0].baseUrl, "http://localhost:9000/v1/audio/transcriptions"); +}); + +test("chat/responses gateways stay eligible (no regression)", () => { + assert.ok(idsOf(select([LOCAL_CHAT_NODE])).includes("localchat")); +}); + +test("unrelated node types are never eligible", () => { + assert.equal(select([LOCAL_EMBEDDINGS_NODE]).length, 0); +}); + +test("remote nodes are excluded by default (fail-closed egress)", () => { + assert.equal(select([REMOTE_AUDIO_NODE], false).length, 0); + // ...and a loopback node alongside it is still selected. + const mixed = idsOf(select([REMOTE_AUDIO_NODE, LOCAL_AUDIO_NODE], false)); + assert.ok(mixed.includes("localstt")); + assert.ok(!mixed.includes("remotestt"), "remote must stay out while the flag is off"); + assert.ok(!mixed.includes(REMOTE_AUDIO_NODE.id!), "not even under its id form"); +}); + +test("remote nodes become eligible when explicitly allowed, and carry real credentials", () => { + const selected = select([REMOTE_AUDIO_NODE], true); + // Addressed by prefix (what the caller types) and by id (what combos store). + assert.deepEqual(idsOf(selected), [REMOTE_AUDIO_NODE.id, "remotestt"].sort()); + for (const provider of selected) { + // Credentials always resolve under the node id, where connections are stored. + assert.equal(provider.credentialProviderId, REMOTE_AUDIO_NODE.id); + // A remote node must present its key — "none" would send an unauthenticated request. + assert.equal(provider.authType, "apikey"); + assert.equal(provider.authHeader, "bearer"); + } +}); + +test("loopback nodes keep authType none so local engines need no key", () => { + const provider = select([LOCAL_AUDIO_NODE])[0]; + assert.equal(provider.authType, "none"); +}); + +test("isLocalAudioNodeHost matches loopback and the Docker private range only", () => { + assert.equal(isLocalAudioNodeHost("http://localhost:1234"), true); + assert.equal(isLocalAudioNodeHost("http://127.0.0.1:1234"), true); + assert.equal(isLocalAudioNodeHost("http://172.17.0.2:1234"), true); + assert.equal(isLocalAudioNodeHost("http://172.15.0.2:1234"), false); + assert.equal(isLocalAudioNodeHost("http://172.32.0.2:1234"), false); + assert.equal(isLocalAudioNodeHost("https://stt.example.com"), false); + // ::1 stays excluded, matching the previous SSRF hardening. + assert.equal(isLocalAudioNodeHost("http://[::1]:1234"), false); + assert.equal(isLocalAudioNodeHost("not-a-url"), false); +}); + +test("a node is addressable by prefix AND by its row id", () => { + // Combos store targets as `/`, and /v1/models advertises that form + // too. Registering only the prefix made the advertised id parse as an unknown + // provider and 400 — including right after a combo was expanded. + const selected = select([LOCAL_AUDIO_NODE]); + const ids = selected.map((p) => p.id).sort(); + assert.deepEqual(ids, [LOCAL_AUDIO_NODE.id, "localstt"].sort()); + // Both entries must reach the same endpoint and share credential resolution. + for (const p of selected) { + assert.equal(p.baseUrl, "http://localhost:9000/v1/audio/transcriptions"); + assert.equal(p.credentialProviderId, LOCAL_AUDIO_NODE.id); + } +}); + +test("no duplicate entry when the prefix already equals the node id", () => { + const same: ProviderNodeRow = { ...LOCAL_AUDIO_NODE, id: "localstt", prefix: "localstt" }; + assert.equal(select([same]).length, 1); +}); + +test("nodes without a baseUrl are skipped instead of throwing", () => { + const broken = { id: "x", prefix: "x", name: "x", baseUrl: "", apiType: "audio-transcriptions" }; + assert.equal(select([broken as ProviderNodeRow]).length, 0); +}); diff --git a/tests/unit/audio-transcriptions-combo-resolution.test.ts b/tests/unit/audio-transcriptions-combo-resolution.test.ts new file mode 100644 index 0000000000..15d7bfe4ea --- /dev/null +++ b/tests/unit/audio-transcriptions-combo-resolution.test.ts @@ -0,0 +1,113 @@ +// Regression test: /v1/audio/transcriptions must resolve combo names. +// +// /v1/models advertises combos, and both /v1/chat/completions and /v1/embeddings +// resolve them — but the transcription route treated the model string as a literal +// `provider/model` id only. A combo name therefore came back as +// `400 Invalid transcription model: . Use format: provider/model`, so any +// client populating a model picker from /v1/models offered an option the endpoint +// rejected, and callers had to hardcode the provider's internal model id. +// +// This asserts the combo is expanded to its target before dispatch, and that a +// literal provider/model string still bypasses combo lookup entirely. + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-audio-combo-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { createCombo } = await import("../../src/lib/db/combos.ts"); +const { createProviderNode } = await import("../../src/lib/db/providers.ts"); +const route = await import("../../src/app/api/v1/audio/transcriptions/route.ts"); + +const originalFetch = globalThis.fetch; + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +/** Minimal but structurally valid WAV so nothing rejects the upload shape. */ +function makeWav(): Blob { + const dataLen = 1600; + const b = Buffer.alloc(44 + dataLen); + b.write("RIFF", 0, "ascii"); + b.writeUInt32LE(36 + dataLen, 4); + b.write("WAVE", 8, "ascii"); + b.write("fmt ", 12, "ascii"); + b.writeUInt32LE(16, 16); + b.writeUInt16LE(1, 20); + b.writeUInt16LE(1, 22); + b.writeUInt32LE(16000, 24); + b.writeUInt32LE(32000, 28); + b.writeUInt16LE(2, 32); + b.writeUInt16LE(16, 34); + b.write("data", 36, "ascii"); + b.writeUInt32LE(dataLen, 40); + return new Blob([b], { type: "audio/wav" }); +} + +function transcriptionRequest(model: string) { + const fd = new FormData(); + fd.set("model", model); + fd.set("file", makeWav(), "t.wav"); + return new Request("http://localhost/v1/audio/transcriptions", { method: "POST", body: fd }); +} + +test("a combo name is expanded to its target instead of being rejected", async () => { + await createProviderNode({ + id: "openai-compatible-audio-transcriptions-test", + type: "openai-compatible", + name: "Local STT", + prefix: "localstt", + apiType: "audio-transcriptions", + baseUrl: "http://localhost:9000/v1", + } as Parameters[0]); + + await createCombo({ + name: "transcricao", + strategy: "priority", + models: [{ provider: "localstt", model: "whisper-1" }], + } as Parameters[0]); + + const upstreamCalls: string[] = []; + globalThis.fetch = (async (url: RequestInfo | URL) => { + upstreamCalls.push(String(url)); + return new Response(JSON.stringify({ text: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + const res = await route.POST(transcriptionRequest("transcricao")); + const body = await res.text(); + + assert.notEqual( + res.status, + 400, + `combo name must not be rejected as an invalid model — got: ${body}` + ); + assert.ok( + !body.includes("Invalid transcription model"), + `combo must be resolved before model parsing — got: ${body}` + ); + assert.ok( + upstreamCalls.some((u) => u.includes("/audio/transcriptions")), + `expected the combo target to be dispatched, calls: ${JSON.stringify(upstreamCalls)}` + ); +}); + +test("an unknown bare name is still rejected with the format hint", async () => { + globalThis.fetch = (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch; + + const res = await route.POST(transcriptionRequest("definitely-not-a-combo-or-model")); + const body = await res.text(); + + assert.equal(res.status, 400); + assert.match(body, /Invalid transcription model/); +}); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index f52f964f69..67fb63951a 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -30,13 +30,13 @@ const { isControlPlaneProxyDirectFallbackEnabled, } = await import("../../src/shared/utils/featureFlags.ts"); -const EXPECTED_FEATURE_FLAG_COUNT = 43; +const EXPECTED_FEATURE_FLAG_COUNT = 44; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 43 flag definitions", () => { + it("has exactly 44 flag definitions", () => { assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, EXPECTED_FEATURE_FLAG_COUNT); }); @@ -161,6 +161,17 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "danger"); }); + it("defines remote audio provider nodes as a network boolean flag disabled by default", () => { + // Guards the egress default: with this on, /v1/audio/* may reach a provider node + // hosted outside localhost. It must never become an implicit default (cf. #3963). + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "AUDIO_REMOTE_PROVIDER_NODES"); + assert.ok(def, "AUDIO_REMOTE_PROVIDER_NODES should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.warningLevel, "danger"); + }); + it("defines CC discovery aliases as a runtime boolean flag disabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "EXPOSE_CC_DISCOVERY_ALIASES"); assert.ok(def, "EXPOSE_CC_DISCOVERY_ALIASES should exist"); @@ -321,7 +332,7 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 43 flags", () => { + it("returns all 44 flags", () => { const all = resolveAllFeatureFlags(); assert.strictEqual(all.length, EXPECTED_FEATURE_FLAG_COUNT); });