mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
Compare commits
1 Commits
fix/9305-o
...
fix/9134-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9606b0bdc |
1
changelog.d/fixes/9134-fix.plan.md
Normal file
1
changelog.d/fixes/9134-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): resolve combo names in audio transcriptions route so /v1/models stays honest (#9134)
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
108
src/app/api/v1/_shared/audioProviderNodes.ts
Normal file
108
src/app/api/v1/_shared/audioProviderNodes.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
95
tests/unit/9134-repro-audio-combo-rejection.test.ts
Normal file
95
tests/unit/9134-repro-audio-combo-rejection.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user