mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-11 01:13:02 +03:00
Compare commits
1 Commits
fix/12783-
...
fix/12574-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aba29a532 |
1
changelog.d/fixes/12574-elevenlabs-policy-enforcement.md
Normal file
1
changelog.d/fixes/12574-elevenlabs-policy-enforcement.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): enforce API key policy (budget/rate-limit/schedule/endpoint scoping) on the ElevenLabs speech-to-text, text-to-speech and voices proxy routes (#12574)
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sanitizeErrorMessage,
|
||||
} from "@omniroute/open-sse/utils/error.ts";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
const ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1";
|
||||
const ALLOWED_RESPONSE_HEADERS = [
|
||||
@@ -48,6 +49,9 @@ export async function proxyElevenLabsRequest(
|
||||
pathname: string,
|
||||
init: Omit<RequestInit, "headers"> = {}
|
||||
): Promise<Response> {
|
||||
const policy = await enforceApiKeyPolicy(request, null);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const credentials = (await getProviderCredentialsWithQuotaPreflight(
|
||||
"elevenlabs"
|
||||
)) as ElevenLabsCredentials | null;
|
||||
|
||||
@@ -48,6 +48,12 @@ export const ENDPOINT_CATEGORIES: readonly EndpointCategory[] = [
|
||||
description: "Text-to-speech and speech-to-text",
|
||||
prefixes: ["/v1/audio"],
|
||||
},
|
||||
{
|
||||
id: "elevenlabs",
|
||||
label: "ElevenLabs Voice",
|
||||
description: "Native ElevenLabs speech-to-text, text-to-speech and voices",
|
||||
prefixes: ["/v1/speech-to-text", "/v1/text-to-speech", "/v1/voices"],
|
||||
},
|
||||
{
|
||||
id: "video",
|
||||
label: "Video",
|
||||
|
||||
138
tests/unit/elevenlabs-policy.test.ts
Normal file
138
tests/unit/elevenlabs-policy.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Regression tests for #12574: the ElevenLabs proxy routes (speech-to-text,
|
||||
* text-to-speech/[voiceId], voices) must go through enforceApiKeyPolicy()
|
||||
* like every other billable /v1/* route, and their endpoint category must
|
||||
* be resolvable so allowedEndpoints scoping takes effect.
|
||||
*/
|
||||
|
||||
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-policy-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "elevenlabs-policy-test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const readCache = await import("../../src/lib/db/readCache.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const costRules = await import("../../src/domain/costRules.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 ELEVENLABS_API_KEY = "test-elevenlabs-key";
|
||||
|
||||
function seedElevenLabsCredential() {
|
||||
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-policy-test", "elevenlabs", "apikey", 1, ELEVENLABS_API_KEY, now, now);
|
||||
readCache.invalidateDbCache("connections");
|
||||
}
|
||||
|
||||
function stubUpstream() {
|
||||
let called = false;
|
||||
globalThis.fetch = (async () => {
|
||||
called = true;
|
||||
return Response.json({ voices: [] });
|
||||
}) as typeof fetch;
|
||||
return () => called;
|
||||
}
|
||||
|
||||
async function callAllThreeRoutes(apiKey: string) {
|
||||
const auth = { Authorization: `Bearer ${apiKey}` };
|
||||
const voicesResponse = await voicesRoute.GET(
|
||||
new Request("http://localhost/v1/voices", { headers: auth })
|
||||
);
|
||||
const speechResponse = await speechRoute.POST(
|
||||
new Request("http://localhost/v1/text-to-speech/voice_123", {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
}),
|
||||
{ params: Promise.resolve({ voiceId: "voice_123" }) }
|
||||
);
|
||||
const transcriptionResponse = await transcriptionRoute.POST(
|
||||
new Request("http://localhost/v1/speech-to-text", {
|
||||
method: "POST",
|
||||
headers: auth,
|
||||
body: new FormData(),
|
||||
})
|
||||
);
|
||||
return [voicesResponse, speechResponse, transcriptionResponse];
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await core.ensureDbInitialized();
|
||||
seedElevenLabsCredential();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
apiKeysDb.resetApiKeyState();
|
||||
costRules.resetCostData();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("an over-budget key is rejected with 429 on all three ElevenLabs routes without reaching upstream", async () => {
|
||||
const key = await apiKeysDb.createApiKey("EL Budget Key", "machine-elevenlabs-budget");
|
||||
costRules.setBudget(key.id, { dailyLimitUsd: 1, warningThreshold: 0.5 });
|
||||
costRules.recordCost(key.id, 2);
|
||||
|
||||
const upstreamWasCalled = stubUpstream();
|
||||
const [voicesResponse, speechResponse, transcriptionResponse] = await callAllThreeRoutes(
|
||||
key.key
|
||||
);
|
||||
|
||||
for (const response of [voicesResponse, speechResponse, transcriptionResponse]) {
|
||||
assert.equal(response.status, 429);
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
assert.match(body.error?.message ?? "", /Daily budget exceeded/);
|
||||
}
|
||||
assert.equal(upstreamWasCalled(), false);
|
||||
});
|
||||
|
||||
test("a key scoped away from the elevenlabs category is rejected with 403 on all three routes", async () => {
|
||||
const key = await apiKeysDb.createApiKey("EL Scoped Key", "machine-elevenlabs-scope");
|
||||
await apiKeysDb.updateApiKeyPermissions(key.id, { allowedEndpoints: ["chat"] });
|
||||
|
||||
const upstreamWasCalled = stubUpstream();
|
||||
const [voicesResponse, speechResponse, transcriptionResponse] = await callAllThreeRoutes(
|
||||
key.key
|
||||
);
|
||||
|
||||
for (const response of [voicesResponse, speechResponse, transcriptionResponse]) {
|
||||
assert.equal(response.status, 403);
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
assert.match(body.error?.message ?? "", /elevenlabs.*not allowed/i);
|
||||
}
|
||||
assert.equal(upstreamWasCalled(), false);
|
||||
});
|
||||
|
||||
test("an unrestricted key still proxies through to the upstream successfully", async () => {
|
||||
const key = await apiKeysDb.createApiKey("EL Unrestricted Key", "machine-elevenlabs-ok");
|
||||
|
||||
const upstreamWasCalled = stubUpstream();
|
||||
const response = await voicesRoute.GET(
|
||||
new Request("http://localhost/v1/voices", {
|
||||
headers: { Authorization: `Bearer ${key.key}` },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(upstreamWasCalled(), true);
|
||||
});
|
||||
@@ -104,6 +104,18 @@ test("resolveEndpointCategory: maps /v1/agents/tasks to 'agents'", () => {
|
||||
assert.equal(resolveEndpointCategory("/v1/agents/tasks"), "agents");
|
||||
});
|
||||
|
||||
test("resolveEndpointCategory: maps /v1/speech-to-text to 'elevenlabs'", () => {
|
||||
assert.equal(resolveEndpointCategory("/v1/speech-to-text"), "elevenlabs");
|
||||
});
|
||||
|
||||
test("resolveEndpointCategory: maps /v1/text-to-speech/voice_123 to 'elevenlabs'", () => {
|
||||
assert.equal(resolveEndpointCategory("/v1/text-to-speech/voice_123"), "elevenlabs");
|
||||
});
|
||||
|
||||
test("resolveEndpointCategory: maps /v1/voices to 'elevenlabs'", () => {
|
||||
assert.equal(resolveEndpointCategory("/v1/voices"), "elevenlabs");
|
||||
});
|
||||
|
||||
test("resolveEndpointCategory: returns null for unknown path", () => {
|
||||
assert.equal(resolveEndpointCategory("/v1/unknown"), null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user