mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
131
tests/unit/audio-translations-combo-resolution.test.ts
Normal file
131
tests/unit/audio-translations-combo-resolution.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user