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

Closes #9134
Refs: base-red #9737
fix/9134-c-program-files-git-v1-audio
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-08 11:26:05 -03:00
committed by GitHub
parent faffd0aa31
commit 57ee73451c
4 changed files with 100 additions and 15 deletions

View File

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

View File

@@ -603,7 +603,7 @@ export interface ProviderNodeRow {
}
/** Hosts reachable only from the operator's machine/Docker network. */
function isLoopbackNodeHost(baseUrl: string): boolean {
export function isLoopbackNodeHost(baseUrl: string): boolean {
try {
const hostname = new URL(baseUrl).hostname;
return (

View File

@@ -24,6 +24,7 @@ import { getCachedProviderNodes } from "@/lib/db/readCache";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import {
buildDynamicAudioProvider,
isLoopbackNodeHost,
type AudioProvider,
type ProviderNodeRow,
} from "@omniroute/open-sse/config/audioRegistry.ts";
@@ -35,19 +36,7 @@ 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;
}
}
export { isLoopbackNodeHost as isLocalAudioNodeHost };
/**
* Pure selection step — no DB, no flag lookup, so the policy is directly testable.
@@ -72,7 +61,7 @@ export function selectAudioProviderNodes(
return false;
}
if (!node.baseUrl) return false;
return isLocalAudioNodeHost(node.baseUrl) || allowRemote;
return isLoopbackNodeHost(node.baseUrl) || allowRemote;
});
const providers: AudioProvider[] = [];

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