fix(providers): resolve combo names in audio transcriptions route so /v1/models stays honest (#9134)

This commit is contained in:
diegosouzapw
2026-08-04 05:45:48 -03:00
parent 7163081f5e
commit b9606b0bdc
9 changed files with 380 additions and 128 deletions

View File

@@ -0,0 +1 @@
- fix(providers): resolve combo names in audio transcriptions route so /v1/models stays honest (#9134)

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. */
export 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,108 @@
/**
* 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/localDb";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import {
buildDynamicAudioProvider,
isLoopbackNodeHost,
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 { isLoopbackNodeHost as isLocalAudioNodeHost };
/**
* 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 isLoopbackNodeHost(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

@@ -7,13 +7,11 @@ import {
import {
parseSpeechModel,
getSpeechProvider,
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 { v1AudioSpeechSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
@@ -60,29 +58,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 +77,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
@@ -34,6 +50,68 @@ export async function OPTIONS() {
});
}
/**
* Transcribe with one concrete `provider/model` string. Split out of POST so combo
* fan-out can invoke it once per target.
*/
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: ${modelStr}. Use format: provider/model`
);
}
// Check provider config — hardcoded first, then dynamic
const providerConfig =
getTranscriptionProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null;
// 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") {
const credentialKey = providerConfig.credentialProviderId || provider;
credentials = await getProviderCredentialsWithQuotaPreflight(credentialKey);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
if (isAllRateLimitedCredentials(credentials)) {
return rateLimitedProviderResponse(provider, credentials);
}
}
let response = await handleAudioTranscription({
formData,
credentials,
resolvedProvider: providerConfig,
resolvedModel,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
response = attachOmniRouteMetaToResponse(response, {
provider,
model: resolvedModel,
costUsd: 0,
latencyMs: Date.now() - startTime,
requestId: generateRequestId(),
});
}
return response;
}
/**
* POST /v1/audio/transcriptions — transcribe audio files
* OpenAI Whisper API compatible (multipart/form-data)
@@ -52,79 +130,45 @@ export async function POST(request) {
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, model as string);
const policy = await enforceApiKeyPolicy(request, modelStr);
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;
// 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 {
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
}
allCombos = await getCombos();
} catch {}
let settings = {};
try {
settings = getDatabaseSettings();
} catch {}
const { provider, model: resolvedModel } = parseTranscriptionModel(
model as string,
dynamicProviders
);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid transcription model: ${model}. Use format: provider/model`
);
}
// Check provider config — hardcoded first, then dynamic
const providerConfig =
getTranscriptionProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null;
// Get credentials — skip for local providers (authType: "none")
let credentials = null;
if (providerConfig && providerConfig.authType !== "none") {
credentials = await getProviderCredentialsWithQuotaPreflight(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
if (isAllRateLimitedCredentials(credentials)) {
return rateLimitedProviderResponse(provider, credentials);
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}`);
}
}
let response = await handleAudioTranscription({
formData,
credentials,
resolvedProvider: providerConfig,
resolvedModel,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
// No text body / playback duration available from the multipart upload, so
// per-second pricing cannot be applied → cost 0 (ADD-only headers, body intact).
response = attachOmniRouteMetaToResponse(response, {
provider,
model: resolvedModel,
costUsd: 0,
latencyMs: Date.now() - startTime,
requestId: generateRequestId(),
});
}
return response;
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,95 @@
// Repro test for #9134 — /v1/audio/transcriptions rejects combo names.
//
// Run: node --import tsx/esm --test tests/unit/9134-repro-audio-combo-rejection.test.ts
// Expected to PASS once the fix is applied, RED before.
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-9134-repro-"));
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("#9134 combo name is rejected instead of resolved", 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]);
globalThis.fetch = (async () =>
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();
// The bug: the combo name "transcricao" is NOT resolved. The route returns 400
// with "Invalid transcription model: transcricao. Use format: provider/model"
// even though /v1/models advertises this combo and chat/embeddings resolve it.
// Regression guard: combo names must be resolved before model parsing. This
// was failing as `400 Invalid transcription model: transcricao` before the fix.
assert.notEqual(
res.status,
400,
`BUG #9134: combo name "transcricao" was rejected as invalid model — got status ${res.status}: ${body}`
);
assert.ok(
!body.includes("Invalid transcription model"),
`BUG #9134: combo name was not resolved — got: ${body}`
);
});

View File

@@ -30,7 +30,7 @@ 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