diff --git a/changelog.d/fixes/12536-audio-translations-combo-resolution.md b/changelog.d/fixes/12536-audio-translations-combo-resolution.md new file mode 100644 index 0000000000..0f16b8f32e --- /dev/null +++ b/changelog.d/fixes/12536-audio-translations-combo-resolution.md @@ -0,0 +1 @@ +- **fix(audio):** `/v1/audio/translations` now resolves combo names the way `/v1/audio/transcriptions` already does, so a combo that `GET /v1/models` advertises is fanned out to its targets instead of being rejected with `400 Invalid translation model: . Use format: provider/model`; literal `provider/model` ids and unknown bare names behave as before (#12536 — thanks @pacocartones) diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts index f0c0acfa4e..65c45d0268 100644 --- a/src/app/api/v1/audio/translations/route.ts +++ b/src/app/api/v1/audio/translations/route.ts @@ -19,6 +19,25 @@ import { } from "@/app/api/v1/_shared/rateLimit"; import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { getDatabaseSettings } from "@/lib/db/databaseSettings"; +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 @@ -33,30 +52,14 @@ export async function OPTIONS() { } /** - * POST /v1/audio/translations — translate audio to English text - * OpenAI Whisper API compatible (multipart/form-data). Unlike - * /v1/audio/transcriptions, output is always English regardless of the - * source audio language. + * Translate 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; - +async function translateWithModel( + formData: FormData, + modelStr: string, + startTime: number +): Promise { // 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). @@ -65,14 +68,11 @@ export async function POST(request) { "audio-transcriptions" ); - const { provider, model: resolvedModel } = parseTranslationModel( - model as string, - dynamicProviders - ); + const { provider, model: resolvedModel } = parseTranslationModel(modelStr, dynamicProviders); if (!provider) { return errorResponse( HTTP_STATUS.BAD_REQUEST, - `Invalid translation model: ${model}. Use format: provider/model` + `Invalid translation model: ${modelStr}. Use format: provider/model` ); } @@ -84,6 +84,8 @@ export async function POST(request) { let credentials = null; if (providerConfig && providerConfig.authType !== "none") { 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}`); @@ -113,3 +115,67 @@ export async function POST(request) { } return response; } + +/** + * POST /v1/audio/translations — translate audio to English text + * OpenAI Whisper API compatible (multipart/form-data). Unlike + * /v1/audio/transcriptions, output is always English regardless of the + * source audio language. + */ +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, + // embeddings and the sibling /v1/audio/transcriptions all 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 { + 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) => + translateWithModel(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 translateWithModel(formData, modelStr, startTime); +} diff --git a/tests/unit/audio-translations-combo-resolution.test.ts b/tests/unit/audio-translations-combo-resolution.test.ts new file mode 100644 index 0000000000..aa4defe26a --- /dev/null +++ b/tests/unit/audio-translations-combo-resolution.test.ts @@ -0,0 +1,131 @@ +// Regression test: /v1/audio/translations must resolve combo names. +// +// /v1/models advertises combos, and /v1/chat/completions, /v1/embeddings, +// /v1/audio/transcriptions (#9134), /v1/audio/speech and /v1/videos/generations +// (#10469) all resolve them — but the translation route still treated the model +// string as a literal `provider/model` id only. A combo name therefore came back as +// `400 Invalid translation model: . 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 (observed at the +// upstream fetch: URL and multipart `model`), that a literal provider/model id still +// dispatches directly, and that an unknown bare name keeps the format hint. + +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-translations-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/translations/route.ts"); + +const originalFetch = globalThis.fetch; + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +/** 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 translationRequest(model: string) { + const fd = new FormData(); + fd.set("model", model); + fd.set("file", makeWav(), "t.wav"); + return new Request("http://localhost/v1/audio/translations", { method: "POST", body: fd }); +} + +/** Capture every upstream call: URL plus the decoded multipart body the handler built. */ +function captureUpstream(): Array<{ url: string; body: string }> { + const calls: Array<{ url: string; body: string }> = []; + globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit = {}) => { + calls.push({ + url: String(url), + body: new TextDecoder().decode(init.body as Uint8Array), + }); + return new Response(JSON.stringify({ text: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return calls; +} + +test.before(async () => { + await createProviderNode({ + id: "openai-compatible-audio-translations-test", + type: "openai-compatible", + name: "Local STT", + prefix: "localstt", + apiType: "audio-transcriptions", + baseUrl: "http://localhost:9000/v1", + } as Parameters[0]); + + await createCombo({ + name: "traducao", + strategy: "priority", + models: [{ provider: "localstt", model: "whisper-1" }], + } as Parameters[0]); +}); + +test("a combo name is expanded to its target instead of being rejected", async () => { + const calls = captureUpstream(); + + const res = await route.POST(translationRequest("traducao")); + const body = await res.text(); + + assert.equal(res.status, 200, `combo name must not be rejected — got: ${body}`); + assert.deepEqual(JSON.parse(body), { text: "ok" }); + assert.equal(calls.length, 1, `expected exactly one upstream call, got ${calls.length}`); + assert.equal(calls[0].url, "http://localhost:9000/v1/audio/translations"); + assert.match(calls[0].body, /name="model"\r\n\r\nwhisper-1\r\n/); + assert.doesNotMatch(calls[0].body, /name="model"\r\n\r\ntraducao\r\n/); +}); + +test("a literal provider/model id still dispatches directly", async () => { + const calls = captureUpstream(); + + const res = await route.POST(translationRequest("localstt/whisper-1")); + + assert.equal(res.status, 200); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://localhost:9000/v1/audio/translations"); + assert.match(calls[0].body, /name="model"\r\n\r\nwhisper-1\r\n/); +}); + +test("an unknown bare name is still rejected with the format hint", async () => { + const calls = captureUpstream(); + + const res = await route.POST(translationRequest("definitely-not-a-combo-or-model")); + const body = await res.text(); + + assert.equal(res.status, 400); + assert.match(body, /Invalid translation model/); + assert.equal(calls.length, 0); +});