fix(audio): resolve combo names on /v1/audio/translations (#12536)

Translations was the one audio route the combo-resolution fixes never reached, so the same combo name worked on `/v1/audio/transcriptions` and failed here. Following the #9382 shape rather than inventing a new one is the right call.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the rest of this batch — zero conflicts between the 19 PRs.

- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, all within the frozen baseline); `check:changelog-integrity` OK
- complexity 2802 / baseline 3218 and cognitive-complexity 1267 / baseline 1437 — both under baseline
- 226 of 228 focused assertions green across the batch's 23 test files. The 2 remaining belong to #12551, which is held separately.

Two batch-owned defects were found and fixed in flight, both pure base drift: `173_xp_action_counts.sql` collided with `173_call_logs_video_content_removed.sql` (renumbered to 176 on #12651 — it aborted every DB open, which is what 53 of the first run's failures were), and the feature-flag catalog was missing the `SERVER_OWNED_TOOL_LOOP_ENABLED` row the base gained after #12552 was written.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, which this batch does not touch).

Thanks @pacocartones — the `file:line` citations and the explicit out-of-scope notes on every one of these made a 19-PR batch reviewable in one pass.
This commit is contained in:
Paco Cartones
2026-09-11 22:42:12 +02:00
committed by GitHub
parent b23b0ca68e
commit 0831d487c0
3 changed files with 226 additions and 28 deletions

View File

@@ -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: <combo>. Use format: provider/model`; literal `provider/model` ids and unknown bare names behave as before (#12536 — thanks @pacocartones)

View File

@@ -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<Response> {
// 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<ReturnType<typeof getCombos>> = [];
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);
}

View File

@@ -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: <combo>. 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<typeof createProviderNode>[0]);
await createCombo({
name: "traducao",
strategy: "priority",
models: [{ provider: "localstt", model: "whisper-1" }],
} as Parameters<typeof createCombo>[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);
});