Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/features/10556-elevenlabs-native-routes.md
Original file line number Diff line number Diff line change
@@ -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).
104 changes: 104 additions & 0 deletions src/app/api/v1/_shared/elevenLabsProxy.ts
Original file line number Diff line number Diff line change
@@ -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<RequestInit, "headers"> = {}
): Promise<Response> {
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<string, unknown>);
}
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" },
}
);
}
}
16 changes: 16 additions & 0 deletions src/app/api/v1/speech-to-text/route.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}
29 changes: 29 additions & 0 deletions src/app/api/v1/text-to-speech/[voiceId]/route.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}
12 changes: 12 additions & 0 deletions src/app/api/v1/voices/route.ts
Original file line number Diff line number Diff line change
@@ -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");
}
172 changes: 172 additions & 0 deletions tests/unit/elevenlabs-native-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
1 change: 1 addition & 0 deletions tests/unit/hard-session-lease-bypass-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const EXPECTED: Record<InventoryKind, Record<string, number>> = {
"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,
Expand Down
Loading