From cbd08ef7803da5c3603d44cf263fbccc2d2d1788 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:11:26 -0300 Subject: [PATCH] feat(api): add /v1/audio/translations endpoint (#5809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- open-sse/config/audioRegistry.ts | 39 ++++ open-sse/handlers/audioTranslation.ts | 135 +++++++++++++ src/app/api/v1/audio/translations/route.ts | 129 +++++++++++++ tests/unit/audio-translations-route.test.ts | 199 ++++++++++++++++++++ 4 files changed, 502 insertions(+) create mode 100644 open-sse/handlers/audioTranslation.ts create mode 100644 src/app/api/v1/audio/translations/route.ts create mode 100644 tests/unit/audio-translations-route.test.ts diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index b281a94e35..cbeaa789e5 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -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 = { }, }; +/** + * 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 = { + 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 = { 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 */ diff --git a/open-sse/handlers/audioTranslation.ts b/open-sse/handlers/audioTranslation.ts new file mode 100644 index 0000000000..7269ffb006 --- /dev/null +++ b/open-sse/handlers/audioTranslation.ts @@ -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 { + 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 = {}; + 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}`); + } +} diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts new file mode 100644 index 0000000000..11dca26ca4 --- /dev/null +++ b/src/app/api/v1/audio/translations/route.ts @@ -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[] = []; + 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; +} diff --git a/tests/unit/audio-translations-route.test.ts b/tests/unit/audio-translations-route.test.ts new file mode 100644 index 0000000000..42cd481b59 --- /dev/null +++ b/tests/unit/audio-translations-route.test.ts @@ -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; + } +});