feat(api): add /v1/audio/translations endpoint (#5809)

Integrated into release/v3.8.44 — /v1/audio/translations endpoint (Whisper-style audio translation) + audioTranslation handler + translation providers in audioRegistry. Re-cut clean onto the release tip (branch was fossilized). Validated: 8 route tests (incl. no-stack-leak), typecheck:core 0, route-guard-membership OK, docs gates pass. UNSTABLE red is the inherited environmental setup-claude base-red.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 22:11:26 -03:00
committed by GitHub
parent a49d34bf49
commit cbd08ef780
4 changed files with 502 additions and 0 deletions

View File

@@ -3,6 +3,7 @@
*
* Defines providers that support audio endpoints:
* - /v1/audio/transcriptions (Whisper API)
* - /v1/audio/translations (Whisper translate-to-English API)
* - /v1/audio/speech (TTS API)
*/
@@ -160,6 +161,30 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
},
};
/**
* Providers that expose an OpenAI-Whisper-compatible /audio/translations
* endpoint (translate-to-English). This is a narrower surface than
* transcription: only Whisper-family models support it, and there is no
* `language` input — output is always English regardless of source audio.
*/
export const AUDIO_TRANSLATION_PROVIDERS: Record<string, AudioProvider> = {
openai: {
id: "openai",
baseUrl: "https://api.openai.com/v1/audio/translations",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "whisper-1", name: "Whisper 1" }],
},
groq: {
id: "groq",
baseUrl: "https://api.groq.com/openai/v1/audio/translations",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "whisper-large-v3", name: "Whisper Large v3" }],
},
};
export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
vertex: {
id: "vertex",
@@ -403,6 +428,13 @@ export function getTranscriptionProvider(providerId: string): AudioProvider | nu
return AUDIO_TRANSCRIPTION_PROVIDERS[providerId] || null;
}
/**
* Get translation provider config by ID
*/
export function getTranslationProvider(providerId: string): AudioProvider | null {
return AUDIO_TRANSLATION_PROVIDERS[providerId] || null;
}
/**
* Get speech provider config by ID
*/
@@ -480,6 +512,13 @@ export function parseSpeechModel(modelStr: string | null, dynamicProviders?: Aud
return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS, dynamicProviders);
}
export function parseTranslationModel(
modelStr: string | null,
dynamicProviders?: AudioProvider[]
) {
return parseAudioModel(modelStr, AUDIO_TRANSLATION_PROVIDERS, dynamicProviders);
}
/**
* Get all audio models as a flat list
*/

View File

@@ -0,0 +1,135 @@
/**
* Audio Translation Handler
*
* Handles POST /v1/audio/translations (Whisper translate-to-English API
* format). Proxies multipart/form-data to upstream providers that expose an
* OpenAI-Whisper-compatible /audio/translations endpoint.
*
* Unlike /v1/audio/transcriptions, translation always outputs English text
* regardless of the source audio language, so there is no `language` input
* field — only `model`, `file`, `prompt`, `response_format`, and
* `temperature` are forwarded upstream.
*/
import {
getTranslationProvider,
parseTranslationModel,
type AudioProvider,
} from "../config/audioRegistry.ts";
import { buildAuthHeaders } from "../config/registryUtils.ts";
import { buildMultipartBody } from "./audioTranscription.ts";
import { errorResponse } from "../utils/error.ts";
type TranslationCredentials = {
apiKey?: string;
accessToken?: string;
};
/**
* Extract a readable error message from an upstream provider's error body.
*/
function extractUpstreamErrorMessage(errText: string, status: number): string {
try {
const parsed = JSON.parse(errText);
const raw =
parsed?.error?.message ||
(typeof parsed?.error === "string" ? parsed.error : null) ||
parsed?.message ||
null;
return raw ? String(raw) : errText || `Upstream error (${status})`;
} catch {
return errText || `Upstream error (${status})`;
}
}
/**
* Handle audio translation request
*
* @param {Object} options
* @param {FormData} options.formData - Multipart form data with file + model
* @param {Object} options.credentials - Provider credentials { apiKey }
* @returns {Response}
*/
export async function handleAudioTranslation({
formData,
credentials,
resolvedProvider = null,
resolvedModel = null,
}: {
formData: FormData;
credentials?: TranslationCredentials | null;
resolvedProvider?: AudioProvider | null;
resolvedModel?: string | null;
}): Promise<Response> {
const model = formData.get("model");
if (typeof model !== "string" || !model) {
return errorResponse(400, "model is required");
}
const fileEntry = formData.get("file");
if (!(fileEntry instanceof Blob)) {
return errorResponse(400, "file is required");
}
const file = fileEntry as Blob & { name?: unknown };
// Use pre-resolved provider/model from route handler if available.
let providerConfig = resolvedProvider;
let modelId = resolvedModel;
if (!providerConfig) {
const parsed = parseTranslationModel(model);
providerConfig = parsed.provider ? getTranslationProvider(parsed.provider) : null;
modelId = parsed.model;
}
if (!providerConfig) {
return errorResponse(
400,
`No translation provider found for model "${model}". Available: openai, groq`
);
}
const token =
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
if (providerConfig.authType !== "none" && !token) {
return errorResponse(401, `No credentials for translation provider: ${providerConfig.id}`);
}
// OpenAI Whisper translate-to-English params — no `language`, output is
// always English regardless of the source audio language.
const extraFields: Record<string, string> = {};
for (const key of ["prompt", "response_format", "temperature"]) {
const val = formData.get(key);
if (val !== null && val !== undefined) {
extraFields[key] = String(val);
}
}
const { body: multipartBody, contentType: multipartCT } = await buildMultipartBody(file, {
model: modelId as string,
...extraFields,
});
try {
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: { ...buildAuthHeaders(providerConfig, token), "Content-Type": multipartCT },
body: multipartBody,
});
if (!res.ok) {
const errText = await res.text();
return errorResponse(res.status, extractUpstreamErrorMessage(errText, res.status));
}
const data = await res.text();
const respContentType = res.headers.get("content-type") || "application/json";
return new Response(data, {
status: 200,
headers: { "Content-Type": respContentType },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
return errorResponse(500, `Translation request failed: ${error.message}`);
}
}

View File

@@ -0,0 +1,129 @@
// Allow large audio/video file uploads — 5min for processing large files (up to 2GB)
export const maxDuration = 300;
import { handleAudioTranslation } from "@omniroute/open-sse/handlers/audioTranslation.ts";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import {
parseTranslationModel,
getTranslationProvider,
buildDynamicAudioProvider,
type ProviderNodeRow,
} from "@omniroute/open-sse/config/audioRegistry.ts";
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 { getProviderNodes } from "@/lib/localDb";
import {
isAllRateLimitedCredentials,
rateLimitedProviderResponse,
} from "@/app/api/v1/_shared/rateLimit";
import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
/**
* Handle CORS preflight
*/
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/**
* 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");
}
// Enforce API key policies (model restrictions + budget limits)
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<typeof buildDynamicAudioProvider>[] = [];
try {
const nodes = await getProviderNodes();
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
}
const { provider, model: resolvedModel } = parseTranslationModel(
model as string,
dynamicProviders
);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid translation model: ${model}. Use format: provider/model`
);
}
// Check provider config — hardcoded first, then dynamic
const providerConfig =
getTranslationProvider(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 getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
if (isAllRateLimitedCredentials(credentials)) {
return rateLimitedProviderResponse(provider, credentials);
}
}
let response = await handleAudioTranslation({
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;
}

View File

@@ -0,0 +1,199 @@
import test from "node:test";
import assert from "node:assert/strict";
const { handleAudioTranslation } = await import("../../open-sse/handlers/audioTranslation.ts");
function buildFile(contents, name, type) {
return new File([Buffer.from(contents)], name, { type });
}
test("handleAudioTranslation requires model", async () => {
const formData = new FormData();
formData.append("file", buildFile("abc", "audio.wav", "audio/wav"));
const response = await handleAudioTranslation({ formData, credentials: { apiKey: "x" } });
const payload = (await response.json()) as any;
assert.equal(response.status, 400);
assert.equal(payload.error.message, "model is required");
});
test("handleAudioTranslation requires a file upload", async () => {
const formData = new FormData();
formData.append("model", "openai/whisper-1");
const response = await handleAudioTranslation({ formData, credentials: { apiKey: "x" } });
const payload = (await response.json()) as any;
assert.equal(response.status, 400);
assert.equal(payload.error.message, "file is required");
});
test("handleAudioTranslation requires credentials for authenticated providers", async () => {
const formData = new FormData();
formData.append("model", "openai/whisper-1");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranslation({ formData, credentials: null });
const payload = (await response.json()) as any;
assert.equal(response.status, 401);
assert.equal(payload.error.message, "No credentials for translation provider: openai");
});
test("handleAudioTranslation rejects unsupported providers", async () => {
const formData = new FormData();
formData.append("model", "unknown/provider");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranslation({
formData,
credentials: { apiKey: "x" },
});
const payload = (await response.json()) as any;
assert.equal(response.status, 400);
assert.match(payload.error.message, /No translation provider found for model "unknown\/provider"/);
});
test("handleAudioTranslation dispatches OpenAI-compatible multipart requests and returns { text }", async () => {
const originalFetch = globalThis.fetch;
let captured;
globalThis.fetch = async (url, options = {}) => {
captured = {
url: String(url),
headers: options.headers,
body: options.body,
};
return new Response(JSON.stringify({ text: "hello world" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
try {
const formData = new FormData();
formData.append("model", "openai/whisper-1");
formData.append("file", buildFile("abc", "clip.webm", "audio/webm"));
formData.append("prompt", "greeting");
formData.append("response_format", "json");
formData.append("temperature", "0.2");
// Translation always outputs English — a caller-supplied `language` must
// NOT be forwarded upstream (differs from /v1/audio/transcriptions).
formData.append("language", "pt");
const response = await handleAudioTranslation({
formData,
credentials: { apiKey: "openai-key" },
});
assert.equal(response.status, 200);
assert.equal(captured.url, "https://api.openai.com/v1/audio/translations");
assert.equal(captured.headers.Authorization, "Bearer openai-key");
assert.match(captured.headers["Content-Type"], /^multipart\/form-data; boundary=/);
const bodyText = new TextDecoder().decode(captured.body);
assert.ok(bodyText.includes('name="model"'));
assert.ok(bodyText.includes("whisper-1"));
assert.ok(bodyText.includes('name="prompt"'));
assert.ok(bodyText.includes("greeting"));
assert.ok(bodyText.includes('name="response_format"'));
assert.ok(bodyText.includes('name="temperature"'));
assert.ok(bodyText.includes("0.2"));
assert.ok(bodyText.includes('name="file"'));
assert.ok(bodyText.includes('filename="clip.webm"'));
assert.ok(!bodyText.includes('name="language"'));
assert.deepEqual(await response.json(), { text: "hello world" });
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioTranslation dispatches Groq-compatible multipart requests", async () => {
const originalFetch = globalThis.fetch;
let captured;
globalThis.fetch = async (url, options = {}) => {
captured = { url: String(url), headers: options.headers };
return new Response(JSON.stringify({ text: "bonjour" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
try {
const formData = new FormData();
formData.append("model", "groq/whisper-large-v3");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranslation({
formData,
credentials: { apiKey: "groq-key" },
});
assert.equal(response.status, 200);
assert.equal(captured.url, "https://api.groq.com/openai/v1/audio/translations");
assert.equal(captured.headers.Authorization, "Bearer groq-key");
assert.deepEqual(await response.json(), { text: "bonjour" });
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioTranslation surfaces parsed upstream errors without leaking internals", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: { message: "too many requests" } }), {
status: 429,
headers: { "content-type": "application/json" },
});
try {
const formData = new FormData();
formData.append("model", "openai/whisper-1");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranslation({
formData,
credentials: { apiKey: "openai-key" },
});
const payload = (await response.json()) as any;
assert.equal(response.status, 429);
assert.equal(payload.error.message, "too many requests");
assert.ok(!payload.error.message.includes("at /"));
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioTranslation sanitizes stack-trace-shaped messages on fetch failure", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("Fetch failed at /home/user/project/src/secure.ts:10:5");
};
try {
const formData = new FormData();
formData.append("model", "openai/whisper-1");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranslation({
formData,
credentials: { apiKey: "openai-key" },
});
const payload = (await response.json()) as any;
assert.equal(response.status, 500);
// Rule: error responses must never leak absolute source paths / stack traces.
assert.ok(!payload.error.message.includes("at /"));
assert.ok(!payload.error.message.includes("secure.ts"));
} finally {
globalThis.fetch = originalFetch;
}
});