diff --git a/changelog.d/features/10556-elevenlabs-native-routes.md b/changelog.d/features/10556-elevenlabs-native-routes.md new file mode 100644 index 000000000000..3fcb68d0aab7 --- /dev/null +++ b/changelog.d/features/10556-elevenlabs-native-routes.md @@ -0,0 +1 @@ +- **feat(audio):** proxy native ElevenLabs voices, text-to-speech, and speech-to-text HTTP routes through stored OmniRoute credentials, preserving query strings, multipart uploads, binary responses, and upstream errors (#10556). diff --git a/src/app/api/v1/_shared/elevenLabsProxy.ts b/src/app/api/v1/_shared/elevenLabsProxy.ts new file mode 100644 index 000000000000..2388764f87d6 --- /dev/null +++ b/src/app/api/v1/_shared/elevenLabsProxy.ts @@ -0,0 +1,104 @@ +import { + clearRecoveredProviderState, + getProviderCredentialsWithQuotaPreflight, +} from "@/sse/services/auth"; +import { + isAllRateLimitedCredentials, + rateLimitedProviderResponse, +} from "@/app/api/v1/_shared/rateLimit"; +import { + buildErrorBody, + sanitizeErrorMessage, +} from "@omniroute/open-sse/utils/error.ts"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; + +const ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1"; +const ALLOWED_RESPONSE_HEADERS = [ + "content-type", + "content-disposition", + "request-id", + "retry-after", +] as const; + +type ElevenLabsCredentials = { + apiKey?: string | null; + accessToken?: string | null; + allExpired?: boolean; +}; + +export function elevenLabsOptionsResponse(): Response { + return handleCorsOptions(); +} + +export function isSafeElevenLabsVoiceId(value: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(value); +} + +function proxyResponseHeaders(upstream: Response): Headers { + const headers = new Headers(CORS_HEADERS); + for (const name of ALLOWED_RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value) headers.set(name, value); + } + return headers; +} + +export async function proxyElevenLabsRequest( + request: Request, + pathname: string, + init: Omit = {} +): Promise { + const credentials = (await getProviderCredentialsWithQuotaPreflight( + "elevenlabs" + )) as ElevenLabsCredentials | null; + if (credentials && isAllRateLimitedCredentials(credentials)) { + return rateLimitedProviderResponse("elevenlabs", credentials); + } + const apiKey = credentials?.apiKey || credentials?.accessToken; + if (!apiKey || credentials?.allExpired) { + return new Response( + JSON.stringify(buildErrorBody(401, "No credentials for provider: elevenlabs")), + { + status: 401, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + } + ); + } + + const incomingUrl = new URL(request.url); + const upstreamUrl = new URL(`${ELEVENLABS_API_BASE}${pathname}`); + upstreamUrl.search = incomingUrl.search; + const headers = new Headers(); + headers.set("xi-api-key", apiKey); + const contentType = request.headers.get("content-type"); + if (contentType) headers.set("content-type", contentType); + const accept = request.headers.get("accept"); + if (accept) headers.set("accept", accept); + + try { + const upstream = await fetch(upstreamUrl, { ...init, headers }); + if (upstream.ok) { + await clearRecoveredProviderState(credentials as Record); + } + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: proxyResponseHeaders(upstream), + }); + } catch (error) { + return new Response( + JSON.stringify( + buildErrorBody( + 502, + sanitizeErrorMessage( + error instanceof Error ? error.message : "ElevenLabs request failed" + ) + ) + ), + { + status: 502, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + } + ); + } +} diff --git a/src/app/api/v1/speech-to-text/route.ts b/src/app/api/v1/speech-to-text/route.ts new file mode 100644 index 000000000000..97b6b015b05b --- /dev/null +++ b/src/app/api/v1/speech-to-text/route.ts @@ -0,0 +1,16 @@ +import { + elevenLabsOptionsResponse, + proxyElevenLabsRequest, +} from "@/app/api/v1/_shared/elevenLabsProxy"; + +export async function OPTIONS() { + return elevenLabsOptionsResponse(); +} + +export async function POST(request: Request) { + return proxyElevenLabsRequest(request, "/speech-to-text", { + method: "POST", + body: request.body, + duplex: "half", + }); +} diff --git a/src/app/api/v1/text-to-speech/[voiceId]/route.ts b/src/app/api/v1/text-to-speech/[voiceId]/route.ts new file mode 100644 index 000000000000..958c498821e0 --- /dev/null +++ b/src/app/api/v1/text-to-speech/[voiceId]/route.ts @@ -0,0 +1,29 @@ +import { + elevenLabsOptionsResponse, + isSafeElevenLabsVoiceId, + proxyElevenLabsRequest, +} from "@/app/api/v1/_shared/elevenLabsProxy"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { CORS_HEADERS } from "@/shared/utils/cors"; + +export async function OPTIONS() { + return elevenLabsOptionsResponse(); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ voiceId: string }> } +) { + const { voiceId } = await params; + if (!isSafeElevenLabsVoiceId(voiceId)) { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid ElevenLabs voice ID")), { + status: 400, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + }); + } + return proxyElevenLabsRequest(request, `/text-to-speech/${voiceId}`, { + method: "POST", + body: request.body, + duplex: "half", + }); +} diff --git a/src/app/api/v1/voices/route.ts b/src/app/api/v1/voices/route.ts new file mode 100644 index 000000000000..9cf5b2399706 --- /dev/null +++ b/src/app/api/v1/voices/route.ts @@ -0,0 +1,12 @@ +import { + elevenLabsOptionsResponse, + proxyElevenLabsRequest, +} from "@/app/api/v1/_shared/elevenLabsProxy"; + +export async function OPTIONS() { + return elevenLabsOptionsResponse(); +} + +export async function GET(request: Request) { + return proxyElevenLabsRequest(request, "/voices"); +} diff --git a/tests/unit/elevenlabs-native-routes.test.ts b/tests/unit/elevenlabs-native-routes.test.ts new file mode 100644 index 000000000000..ded3468f3102 --- /dev/null +++ b/tests/unit/elevenlabs-native-routes.test.ts @@ -0,0 +1,172 @@ +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-elevenlabs-native-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = + process.env.API_KEY_SECRET || "elevenlabs-native-route-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); +const voicesRoute = await import("../../src/app/api/v1/voices/route.ts"); +const speechRoute = await import( + "../../src/app/api/v1/text-to-speech/[voiceId]/route.ts" +); +const transcriptionRoute = await import( + "../../src/app/api/v1/speech-to-text/route.ts" +); +const originalFetch = globalThis.fetch; +const API_KEY = "test-elevenlabs-key"; + +function seedCredential() { + const now = new Date().toISOString(); + core + .getDbInstance() + .prepare( + `INSERT OR REPLACE INTO provider_connections + (id, provider, auth_type, is_active, api_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run("elevenlabs-native-test", "elevenlabs", "apikey", 1, API_KEY, now, now); + readCache.invalidateDbCache("connections"); +} + +function clearCredentials() { + core.getDbInstance().prepare("DELETE FROM provider_connections WHERE provider = ?").run( + "elevenlabs" + ); + readCache.invalidateDbCache("connections"); +} + +test.beforeEach(async () => { + await core.ensureDbInitialized(); + seedCredential(); +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET /v1/voices forwards query and stored xi-api-key", async () => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + assert.equal(String(input), "https://api.elevenlabs.io/v1/voices?show_legacy=true"); + const headers = new Headers(init?.headers); + assert.equal(headers.get("xi-api-key"), API_KEY); + assert.equal(headers.has("authorization"), false); + return Response.json({ voices: [{ voice_id: "voice_1" }] }); + }) as typeof fetch; + + const response = await voicesRoute.GET( + new Request("http://localhost/v1/voices?show_legacy=true") + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "application/json"); + assert.deepEqual(await response.json(), { voices: [{ voice_id: "voice_1" }] }); +}); + +test("POST /v1/text-to-speech/[voiceId] forwards JSON and binary response", async () => { + const payload = JSON.stringify({ text: "Hello", model_id: "eleven_turbo_v2_5" }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + assert.equal( + String(input), + "https://api.elevenlabs.io/v1/text-to-speech/voice_123?output_format=mp3_44100_128" + ); + assert.equal(init?.method, "POST"); + assert.equal(new Headers(init?.headers).get("content-type"), "application/json"); + assert.equal(await new Response(init?.body).text(), payload); + return new Response(Uint8Array.from([1, 2, 3]), { + headers: { "Content-Type": "audio/mpeg" }, + }); + }) as typeof fetch; + + const response = await speechRoute.POST( + new Request( + "http://localhost/v1/text-to-speech/voice_123?output_format=mp3_44100_128", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + } + ), + { params: Promise.resolve({ voiceId: "voice_123" }) } + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "audio/mpeg"); + assert.deepEqual(new Uint8Array(await response.arrayBuffer()), Uint8Array.from([1, 2, 3])); +}); + +test("POST /v1/speech-to-text forwards multipart body, query, status and error body", async () => { + const form = new FormData(); + form.set("model_id", "scribe_v1"); + form.set("file", new Blob(["audio"], { type: "audio/wav" }), "sample.wav"); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + assert.equal( + String(input), + "https://api.elevenlabs.io/v1/speech-to-text?tag_audio_events=true" + ); + const contentType = new Headers(init?.headers).get("content-type"); + assert.match(contentType || "", /^multipart\/form-data; boundary=/); + const forwarded = await new Response(init?.body, { + headers: { "Content-Type": contentType || "" }, + }).formData(); + assert.equal(forwarded.get("model_id"), "scribe_v1"); + assert.equal(await (forwarded.get("file") as Blob).text(), "audio"); + return Response.json({ detail: { message: "unsupported audio" } }, { status: 422 }); + }) as typeof fetch; + + const response = await transcriptionRoute.POST( + new Request("http://localhost/v1/speech-to-text?tag_audio_events=true", { + method: "POST", + body: form, + }) + ); + assert.equal(response.status, 422); + assert.deepEqual(await response.json(), { detail: { message: "unsupported audio" } }); +}); + +test("native ElevenLabs routes reject missing credentials and traversal", async (t) => { + await t.test("missing credential", async () => { + clearCredentials(); + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response(); + }) as typeof fetch; + + const response = await voicesRoute.GET(new Request("http://localhost/v1/voices")); + assert.equal(response.status, 401); + assert.equal(fetched, false); + assert.match((await response.json()).error.message, /No credentials for provider: elevenlabs/); + }); + + await t.test("traversal voice ID", async () => { + seedCredential(); + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response(); + }) as typeof fetch; + + const response = await speechRoute.POST( + new Request("http://localhost/v1/text-to-speech/..%2Fvoices", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + { params: Promise.resolve({ voiceId: "../voices" }) } + ); + assert.equal(response.status, 400); + assert.equal(fetched, false); + assert.match((await response.json()).error.message, /Invalid ElevenLabs voice ID/); + }); +}); diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 3c76c9551ff5..bebf43c0e6fd 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -20,6 +20,7 @@ const EXPECTED: Record> = { "src/app/api/compression/compare/verify/route.ts": 1, "src/app/api/internal/codex-responses-ws/route.ts": 1, "src/app/api/search/providers/route.ts": 3, + "src/app/api/v1/_shared/elevenLabsProxy.ts": 1, "src/app/api/v1/audio/speech/route.ts": 1, "src/app/api/v1/_shared/videoModelResolution.ts": 1, "src/app/api/v1/audio/transcriptions/route.ts": 2,