fix(audio): let the audio routes use audio-typed provider nodes, and gate remote ones behind a default-off flag (#9101)

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
This commit is contained in:
Felipe Almeman
2026-08-05 21:43:31 -03:00
committed by GitHub
parent 47349435aa
commit 697c7b96a9
11 changed files with 565 additions and 119 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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: [],
};
}

View File

@@ -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<AudioProvider[]> {
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 [];
}
}

View File

@@ -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<typeof buildDynamicAudioProvider>[] = [];
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}`);
}

View File

@@ -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<typeof buildDynamicAudioProvider>[] = [];
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<Response> {
// 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<ReturnType<typeof getCombos>> = [];
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);
}

View File

@@ -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<typeof buildDynamicAudioProvider>[] = [];
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}`);
}

View File

@@ -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",

View File

@@ -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<typeof select>) {
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 `<node-id>/<model>`, 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);
});

View File

@@ -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: <combo>. 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<typeof createProviderNode>[0]);
await createCombo({
name: "transcricao",
strategy: "priority",
models: [{ provider: "localstt", model: "whisper-1" }],
} as Parameters<typeof createCombo>[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/);
});

View File

@@ -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);
});