mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
119
src/app/api/v1/_shared/audioProviderNodes.ts
Normal file
119
src/app/api/v1/_shared/audioProviderNodes.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
144
tests/unit/audio-provider-nodes-selection.test.ts
Normal file
144
tests/unit/audio-provider-nodes-selection.test.ts
Normal 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);
|
||||
});
|
||||
113
tests/unit/audio-transcriptions-combo-resolution.test.ts
Normal file
113
tests/unit/audio-transcriptions-combo-resolution.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user