mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
a49d34bf49
commit
cbd08ef780
@@ -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
|
||||
*/
|
||||
|
||||
135
open-sse/handlers/audioTranslation.ts
Normal file
135
open-sse/handlers/audioTranslation.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
129
src/app/api/v1/audio/translations/route.ts
Normal file
129
src/app/api/v1/audio/translations/route.ts
Normal 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;
|
||||
}
|
||||
199
tests/unit/audio-translations-route.test.ts
Normal file
199
tests/unit/audio-translations-route.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user