diff --git a/changelog.d/fixes/9134-fix.plan.md b/changelog.d/fixes/9134-fix.plan.md new file mode 100644 index 0000000000..acf04acb1c --- /dev/null +++ b/changelog.d/fixes/9134-fix.plan.md @@ -0,0 +1 @@ +- fix(providers): resolve combo names in audio transcriptions route so /v1/models stays honest (#9134) diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 0419622f36..a7aedf69d1 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -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: [], }; } diff --git a/src/app/api/v1/_shared/audioProviderNodes.ts b/src/app/api/v1/_shared/audioProviderNodes.ts new file mode 100644 index 0000000000..b6d4acf6b0 --- /dev/null +++ b/src/app/api/v1/_shared/audioProviderNodes.ts @@ -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 { + 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 []; + } +} diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 16eff17c77..11849be12b 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -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[] = []; - 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}`); } diff --git a/src/app/api/v1/audio/transcriptions/route.ts b/src/app/api/v1/audio/transcriptions/route.ts index 548f3e8993..0e89fd38b8 100644 --- a/src/app/api/v1/audio/transcriptions/route.ts +++ b/src/app/api/v1/audio/transcriptions/route.ts @@ -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 { + // 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[] = []; - 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> = []; 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); } diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts index 7283dda555..f0c0acfa4e 100644 --- a/src/app/api/v1/audio/translations/route.ts +++ b/src/app/api/v1/audio/translations/route.ts @@ -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[] = []; - 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}`); } diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index d08fc7e329..d6b4a28c3f 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -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", diff --git a/tests/unit/9134-repro-audio-combo-rejection.test.ts b/tests/unit/9134-repro-audio-combo-rejection.test.ts new file mode 100644 index 0000000000..cdf26933cd --- /dev/null +++ b/tests/unit/9134-repro-audio-combo-rejection.test.ts @@ -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[0]); + + await createCombo({ + name: "transcricao", + strategy: "priority", + models: [{ provider: "localstt", model: "whisper-1" }], + } as Parameters[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}` + ); +}); \ No newline at end of file diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index f52f964f69..befd5cd8db 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -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