mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(#248,#260): TTS provider support + friendly display names (#262)
* feat(#248,#260): TTS provider support + friendly display names ## #248 — Text to Speech endpoint expansion - audioRegistry: add Inworld, Cartesia, PlayHT providers with correct auth/format tags - audioSpeech handler: add handleInworldSpeech (base64 decode), handleCartesiaSpeech (X-API-Key + transcript), handlePlayHtSpeech (X-USER-ID) - /dashboard/media: add Speech tab with voice selector, format selector, and inline <audio> player (Blob URL) - /dashboard/playground: add Text to Speech option; detect audio/* Content-Type and render inline audio player ## #260 — Friendly provider/account display names - src/lib/display/names.ts: new centralized helper module with getAccountDisplayName() and getProviderDisplayName() - src/lib/usage/usageStats.ts: replace raw connectionId.slice(0,8) fallbacks with getAccountDisplayName() - src/app/api/rate-limits/route.ts: replace raw connectionId.slice(0,8) fallback with getAccountDisplayName() * feat(#248,#261): ElevenLabs voice presets + mobile UX fixes ## #248 — ElevenLabs/Cartesia/Deepgram/Inworld voice presets - MediaPageClient: replace static SPEECH_VOICES with provider-aware PROVIDER_VOICE_PRESETS - ElevenLabs: 9 premade voices with name+type (Rachel, Domi, Bella, Antoni, Elli, Josh, Arnold, Adam, Sam) - Cartesia: 3 built-in voices (Barbershop Man, Friendly Reading Man, California Girl) - Deepgram: 5 Aura voices (Asteria, Luna, Stella, Zeus, Orion) - Inworld: 2 voices (Eva, Marcus) - default: OpenAI 6 standard voices (alloy, echo, fable, onyx, nova, shimmer) - Voice dropdown updates automatically when model input prefix changes ## #261 — Mobile UX improvements - DashboardLayout: add h-dvh to mobile sidebar wrapper for proper scroll containment - Sidebar: add h-full to aside element so nav can scroll on short screens - ProvidersPage: change all 4 section headers from flex justify-between to flex flex-wrap so actions wrap on narrow screens instead of overflowing --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
47445ebf5c
commit
44ee62e391
@@ -77,9 +77,7 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "nvidia-asr",
|
||||
models: [
|
||||
{ id: "nvidia/parakeet-ctc-1.1b-asr", name: "Parakeet CTC 1.1B" },
|
||||
],
|
||||
models: [{ id: "nvidia/parakeet-ctc-1.1b-asr", name: "Parakeet CTC 1.1B" }],
|
||||
},
|
||||
|
||||
huggingface: {
|
||||
@@ -100,9 +98,7 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
format: "openai",
|
||||
models: [
|
||||
{ id: "qwen3-asr", name: "Qwen3 ASR" },
|
||||
],
|
||||
models: [{ id: "qwen3-asr", name: "Qwen3 ASR" }],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -183,9 +179,7 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
format: "coqui",
|
||||
models: [
|
||||
{ id: "tts_models/en/ljspeech/tacotron2-DDC", name: "Tacotron2 DDC (LJSpeech)" },
|
||||
],
|
||||
models: [{ id: "tts_models/en/ljspeech/tacotron2-DDC", name: "Tacotron2 DDC (LJSpeech)" }],
|
||||
},
|
||||
|
||||
tortoise: {
|
||||
@@ -194,9 +188,7 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
format: "tortoise",
|
||||
models: [
|
||||
{ id: "tortoise-v2", name: "Tortoise v2" },
|
||||
],
|
||||
models: [{ id: "tortoise-v2", name: "Tortoise v2" }],
|
||||
},
|
||||
|
||||
qwen: {
|
||||
@@ -205,8 +197,53 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
format: "openai",
|
||||
models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }],
|
||||
},
|
||||
|
||||
// ── Cloud TTS Providers (#248) ────────────────────────────────────────────
|
||||
|
||||
inworld: {
|
||||
id: "inworld",
|
||||
// POST https://api.inworld.ai/tts/v1/voice
|
||||
// Auth: Authorization: Basic <api-key>
|
||||
// Response: JSON { audioContent: "<base64>", contentType, sampleRateHertz }
|
||||
baseUrl: "https://api.inworld.ai/tts/v1/voice",
|
||||
authType: "apikey",
|
||||
authHeader: "basic",
|
||||
format: "inworld",
|
||||
models: [
|
||||
{ id: "qwen3-tts", name: "Qwen3 TTS" },
|
||||
{ id: "inworld-tts-1.5-max", name: "Inworld TTS 1.5 Max" },
|
||||
{ id: "inworld-tts-1.5-mini", name: "Inworld TTS 1.5 Mini" },
|
||||
],
|
||||
},
|
||||
|
||||
cartesia: {
|
||||
id: "cartesia",
|
||||
// POST https://api.cartesia.ai/tts/bytes
|
||||
// Auth: X-API-Key header, Cartesia-Version: 2024-06-10
|
||||
// Response: binary audio bytes
|
||||
baseUrl: "https://api.cartesia.ai/tts/bytes",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
format: "cartesia",
|
||||
models: [
|
||||
{ id: "sonic-2", name: "Sonic 2" },
|
||||
{ id: "sonic-3", name: "Sonic 3" },
|
||||
],
|
||||
},
|
||||
|
||||
playht: {
|
||||
id: "playht",
|
||||
// POST https://api.play.ht/api/v2/tts/stream
|
||||
// Auth: X-USER-ID + Authorization: Bearer <api-key>
|
||||
// Response: audio stream (mp3/wav)
|
||||
baseUrl: "https://api.play.ht/api/v2/tts/stream",
|
||||
authType: "apikey",
|
||||
authHeader: "playht",
|
||||
format: "playht",
|
||||
models: [
|
||||
{ id: "PlayDialog", name: "PlayDialog" },
|
||||
{ id: "Play3.0-mini", name: "Play3.0 Mini" },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -228,7 +265,10 @@ export function getSpeechProvider(providerId: string): AudioProvider | null {
|
||||
/**
|
||||
* Parse audio model string (format: "provider/model" or just "model")
|
||||
*/
|
||||
function parseAudioModel(modelStr: string | null, registry: Record<string, AudioProvider>): { provider: string | null; model: string | null } {
|
||||
function parseAudioModel(
|
||||
modelStr: string | null,
|
||||
registry: Record<string, AudioProvider>
|
||||
): { provider: string | null; model: string | null } {
|
||||
if (!modelStr) return { provider: null, model: null };
|
||||
|
||||
for (const [providerId, config] of Object.entries(registry)) {
|
||||
|
||||
@@ -192,6 +192,115 @@ async function handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token)
|
||||
return audioStreamResponse(res, "audio/wav");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Inworld TTS
|
||||
* POST { text, voiceId, modelId, audioConfig } → JSON { audioContent: "<base64>" }
|
||||
* Docs: https://docs.inworld.ai/api-reference/ttsAPI/texttospeech/synthesize-speech
|
||||
*/
|
||||
async function handleInworldSpeech(providerConfig, body, modelId, token) {
|
||||
const res = await fetch(providerConfig.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: body.input,
|
||||
voiceId: body.voice || undefined,
|
||||
modelId,
|
||||
audioConfig: {
|
||||
audioEncoding: body.response_format === "wav" ? "LINEAR16" : "MP3",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return upstreamErrorResponse(res, await res.text());
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Decode base64 audioContent to binary
|
||||
const audioBuffer = Uint8Array.from(atob(data.audioContent ?? ""), (c) => c.charCodeAt(0));
|
||||
const mimeType = body.response_format === "wav" ? "audio/wav" : "audio/mpeg";
|
||||
|
||||
return new Response(audioBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Access-Control-Allow-Origin": getCorsOrigin(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Cartesia TTS
|
||||
* POST { model_id, transcript, voice, output_format } → binary audio bytes
|
||||
* Docs: https://docs.cartesia.ai/api-reference/tts/bytes
|
||||
*/
|
||||
async function handleCartesiaSpeech(providerConfig, body, modelId, token) {
|
||||
const outputFormat =
|
||||
body.response_format === "wav"
|
||||
? { container: "wav", sample_rate: 44100 }
|
||||
: { container: "mp3", bit_rate: 128000, sample_rate: 44100 };
|
||||
|
||||
const res = await fetch(providerConfig.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": token,
|
||||
"Cartesia-Version": "2024-06-10",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model_id: modelId,
|
||||
transcript: body.input,
|
||||
...(body.voice ? { voice: { mode: "id", id: body.voice } } : {}),
|
||||
output_format: outputFormat,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return upstreamErrorResponse(res, await res.text());
|
||||
}
|
||||
|
||||
return audioStreamResponse(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PlayHT TTS
|
||||
* POST { text, voice, voice_engine, output_format } → audio stream
|
||||
* Auth: X-USER-ID header (from token string "userId:apiKey")
|
||||
* Docs: https://docs.play.ht/reference/api-generate-tts-audio-stream
|
||||
*/
|
||||
async function handlePlayHtSpeech(providerConfig, body, modelId, token) {
|
||||
// PlayHT tokens are stored as "userId:apiKey"
|
||||
const [userId, apiKey] = (token || ":").split(":");
|
||||
|
||||
const res = await fetch(providerConfig.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "audio/mpeg",
|
||||
"X-USER-ID": userId || "",
|
||||
Authorization: `Bearer ${apiKey || token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: body.input,
|
||||
voice:
|
||||
body.voice ||
|
||||
"s3://voice-cloning-zero-shot/d9ff78ba-d016-47f6-b0ef-dd630f59414e/female-cs/manifest.json",
|
||||
voice_engine: modelId || "PlayDialog",
|
||||
output_format: body.response_format || "mp3",
|
||||
speed: body.speed || 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return upstreamErrorResponse(res, await res.text());
|
||||
}
|
||||
|
||||
return audioStreamResponse(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Coqui TTS (local, no auth)
|
||||
* POST {baseUrl} with { text, speaker_id } → WAV audio
|
||||
@@ -271,7 +380,7 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
400,
|
||||
`No speech provider found for model "${body.model}". Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, coqui, tortoise, qwen`
|
||||
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, coqui, tortoise, qwen`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,6 +413,18 @@ export async function handleAudioSpeech({ body, credentials }) {
|
||||
return handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "inworld") {
|
||||
return handleInworldSpeech(providerConfig, body, modelId, token);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "cartesia") {
|
||||
return handleCartesiaSpeech(providerConfig, body, modelId, token);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "playht") {
|
||||
return handlePlayHtSpeech(providerConfig, body, modelId, token);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "coqui") {
|
||||
return handleCoquiSpeech(providerConfig, body);
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Modality = "image" | "video" | "music";
|
||||
type Modality = "image" | "video" | "music" | "speech";
|
||||
type GenerationResult = {
|
||||
type: Modality;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
audioUrl?: string;
|
||||
};
|
||||
|
||||
const MODALITY_CONFIG: Record<
|
||||
@@ -35,8 +36,70 @@ const MODALITY_CONFIG: Record<
|
||||
placeholder: "Upbeat electronic music with synth pads...",
|
||||
color: "from-orange-500 to-yellow-500",
|
||||
},
|
||||
speech: {
|
||||
icon: "record_voice_over",
|
||||
endpoint: "/api/v1/audio/speech",
|
||||
label: "Text to Speech",
|
||||
placeholder: "Hello! Welcome to OmniRoute, your intelligent AI gateway...",
|
||||
color: "from-green-500 to-teal-500",
|
||||
},
|
||||
};
|
||||
|
||||
// Provider-aware voice options for the TTS speech tab
|
||||
const PROVIDER_VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
|
||||
// OpenAI standard voices (also used as defaults)
|
||||
default: [
|
||||
{ id: "alloy", label: "Alloy" },
|
||||
{ id: "echo", label: "Echo" },
|
||||
{ id: "fable", label: "Fable" },
|
||||
{ id: "onyx", label: "Onyx" },
|
||||
{ id: "nova", label: "Nova" },
|
||||
{ id: "shimmer", label: "Shimmer" },
|
||||
],
|
||||
// ElevenLabs — popular premade voice IDs
|
||||
elevenlabs: [
|
||||
{ id: "21m00Tcm4TlvDq8ikWAM", label: "Rachel (EN, calm)" },
|
||||
{ id: "AZnzlk1XvdvUeBnXmlld", label: "Domi (EN, strong)" },
|
||||
{ id: "EXAVITQu4vr4xnSDxMaL", label: "Bella (EN, soft)" },
|
||||
{ id: "ErXwobaYiN019PkySvjV", label: "Antoni (EN, well-rounded)" },
|
||||
{ id: "MF3mGyEYCl7XYWbV9V6O", label: "Elli (EN, young)" },
|
||||
{ id: "TxGEqnHWrfWFTfGW9XjX", label: "Josh (EN, deep)" },
|
||||
{ id: "VR6AewLTigWG4xSOukaG", label: "Arnold (EN, crisp)" },
|
||||
{ id: "pNInz6obpgDQGcFmaJgB", label: "Adam (EN, deep)" },
|
||||
{ id: "yoZ06aMxZJJ28mfd3POQ", label: "Sam (EN, raspy)" },
|
||||
],
|
||||
// Cartesia — Sonic model default voice
|
||||
cartesia: [
|
||||
{ id: "a0e99841-438c-4a64-b679-ae501e7d6091", label: "Barbershop Man" },
|
||||
{ id: "694f9389-aac1-45b6-b726-9d9369183238", label: "Friendly Reading Man" },
|
||||
{ id: "b7d50908-b17c-442d-ad8d-810c63997ed9", label: "California Girl" },
|
||||
],
|
||||
// Deepgram Aura voices
|
||||
deepgram: [
|
||||
{ id: "aura-asteria-en", label: "Asteria (EN)" },
|
||||
{ id: "aura-luna-en", label: "Luna (EN)" },
|
||||
{ id: "aura-stella-en", label: "Stella (EN)" },
|
||||
{ id: "aura-zeus-en", label: "Zeus (EN)" },
|
||||
{ id: "aura-orion-en", label: "Orion (EN)" },
|
||||
],
|
||||
// Inworld TTS voices
|
||||
inworld: [
|
||||
{ id: "Eva", label: "Eva (EN)" },
|
||||
{ id: "Marcus", label: "Marcus (EN)" },
|
||||
],
|
||||
};
|
||||
|
||||
function getVoicePresets(modelId: string) {
|
||||
for (const prefix of Object.keys(PROVIDER_VOICE_PRESETS)) {
|
||||
if (prefix !== "default" && modelId.startsWith(prefix + "/")) {
|
||||
return PROVIDER_VOICE_PRESETS[prefix];
|
||||
}
|
||||
}
|
||||
return PROVIDER_VOICE_PRESETS.default;
|
||||
}
|
||||
|
||||
const SPEECH_FORMATS = ["mp3", "wav", "opus", "flac", "pcm"];
|
||||
|
||||
export default function MediaPageClient() {
|
||||
const t = useTranslations("media");
|
||||
const [activeTab, setActiveTab] = useState<Modality>("image");
|
||||
@@ -47,9 +110,17 @@ export default function MediaPageClient() {
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [result, setResult] = useState<GenerationResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Speech-specific state
|
||||
const [speechVoice, setSpeechVoice] = useState("alloy");
|
||||
const [speechFormat, setSpeechFormat] = useState("mp3");
|
||||
|
||||
// Fetch available models for each modality
|
||||
const fetchModels = async (modality: Modality) => {
|
||||
if (modality === "speech") {
|
||||
// Models come from /v1/models filtered by speech providers
|
||||
setModels([]);
|
||||
return;
|
||||
}
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const res = await fetch(MODALITY_CONFIG[modality].endpoint);
|
||||
@@ -81,6 +152,37 @@ export default function MediaPageClient() {
|
||||
|
||||
try {
|
||||
const config = MODALITY_CONFIG[activeTab];
|
||||
|
||||
if (activeTab === "speech") {
|
||||
// TTS: returns binary audio stream — create a Blob URL for <audio>
|
||||
const res = await fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: model || "openai/tts-1",
|
||||
input: prompt.trim(),
|
||||
voice: speechVoice,
|
||||
response_format: speechFormat,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData?.error?.message || `TTS failed (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
setResult({
|
||||
type: "speech",
|
||||
data: { format: speechFormat },
|
||||
timestamp: Date.now(),
|
||||
audioUrl,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -143,34 +245,86 @@ export default function MediaPageClient() {
|
||||
|
||||
{/* Generation Form */}
|
||||
<div className="bg-surface/30 rounded-xl border border-black/5 dark:border-white/5 p-6 space-y-4">
|
||||
{/* Model selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">{t("model")}</label>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
|
||||
{t("loadingModels")}
|
||||
</div>
|
||||
) : models.length > 0 ? (
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-text-muted text-sm">{t("noModels")}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Model selector (not shown for speech — use manual model input) */}
|
||||
{activeTab !== "speech" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">{t("model")}</label>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin text-[16px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingModels")}
|
||||
</div>
|
||||
) : models.length > 0 ? (
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-text-muted text-sm">{t("noModels")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
{/* Speech: model input + voice + format */}
|
||||
{activeTab === "speech" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">{t("model")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="openai/tts-1 · elevenlabs/eleven_multilingual_v2 · cartesia/sonic-3"
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">Voice</label>
|
||||
<select
|
||||
value={speechVoice}
|
||||
onChange={(e) => setSpeechVoice(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
>
|
||||
{getVoicePresets(model).map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">Format</label>
|
||||
<select
|
||||
value={speechFormat}
|
||||
onChange={(e) => setSpeechFormat(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
>
|
||||
{SPEECH_FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Prompt / Text */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">{t("prompt")}</label>
|
||||
<label className="block text-sm font-medium text-text-main mb-2">
|
||||
{activeTab === "speech" ? "Text" : t("prompt")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={prompt}
|
||||
@@ -185,18 +339,24 @@ export default function MediaPageClient() {
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !prompt.trim()}
|
||||
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-white font-medium transition-all bg-gradient-to-r ${config.color} ${
|
||||
loading || !prompt.trim() ? "opacity-50 cursor-not-allowed" : "hover:opacity-90 hover:shadow-lg"
|
||||
loading || !prompt.trim()
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:opacity-90 hover:shadow-lg"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined animate-spin text-[18px]">progress_activity</span>
|
||||
{t("generating")}
|
||||
<span className="material-symbols-outlined animate-spin text-[18px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{activeTab === "speech" ? "Synthesizing..." : t("generating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[18px]">auto_awesome</span>
|
||||
{t("generate")} {config.label}
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{activeTab === "speech" ? "volume_up" : "auto_awesome"}
|
||||
</span>
|
||||
{activeTab === "speech" ? "Synthesize Speech" : `${t("generate")} ${config.label}`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -217,7 +377,9 @@ export default function MediaPageClient() {
|
||||
{result && (
|
||||
<div className="bg-surface/30 rounded-xl border border-black/5 dark:border-white/5 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className={`material-symbols-outlined text-[20px] bg-gradient-to-r ${config.color} bg-clip-text text-transparent`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[20px] bg-gradient-to-r ${config.color} bg-clip-text text-transparent`}
|
||||
>
|
||||
{config.icon}
|
||||
</span>
|
||||
<h3 className="text-sm font-medium text-text-main">{t("result")}</h3>
|
||||
@@ -225,14 +387,30 @@ export default function MediaPageClient() {
|
||||
{new Date(result.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="bg-surface rounded-lg p-4 text-xs text-text-muted overflow-auto max-h-96 custom-scrollbar">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
|
||||
{/* Audio player for TTS results */}
|
||||
{result.type === "speech" && result.audioUrl ? (
|
||||
<div className="space-y-3">
|
||||
<audio controls src={result.audioUrl} className="w-full rounded-lg" autoPlay />
|
||||
<a
|
||||
href={result.audioUrl}
|
||||
download={`speech.${result.data?.format || "mp3"}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">download</span>
|
||||
Download {result.data?.format?.toUpperCase() || "MP3"}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-surface rounded-lg p-4 text-xs text-text-muted overflow-auto max-h-96 custom-scrollbar">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{(Object.keys(MODALITY_CONFIG) as Modality[]).map((key) => {
|
||||
const cfg = MODALITY_CONFIG[key];
|
||||
return (
|
||||
@@ -241,14 +419,16 @@ export default function MediaPageClient() {
|
||||
className="bg-surface/30 rounded-xl border border-black/5 dark:border-white/5 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={`flex items-center justify-center size-8 rounded-lg bg-gradient-to-r ${cfg.color}`}>
|
||||
<span className="material-symbols-outlined text-white text-[16px]">{cfg.icon}</span>
|
||||
<div
|
||||
className={`flex items-center justify-center size-8 rounded-lg bg-gradient-to-r ${cfg.color}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-white text-[16px]">
|
||||
{cfg.icon}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text-main">{cfg.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t(`${key}Description`)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t(`${key}Description`)}</p>
|
||||
<code className="block mt-2 text-xs text-primary/70 bg-primary/5 rounded px-2 py-1">
|
||||
POST {cfg.endpoint}
|
||||
</code>
|
||||
|
||||
@@ -22,6 +22,7 @@ const ENDPOINT_OPTIONS = [
|
||||
{ value: "responses", label: "Responses" },
|
||||
{ value: "images", label: "Image Generation" },
|
||||
{ value: "embeddings", label: "Embeddings" },
|
||||
{ value: "speech", label: "Text to Speech" },
|
||||
];
|
||||
|
||||
const DEFAULT_BODIES: Record<string, object> = {
|
||||
@@ -47,6 +48,12 @@ const DEFAULT_BODIES: Record<string, object> = {
|
||||
input: "Hello world",
|
||||
encoding_format: "float",
|
||||
},
|
||||
speech: {
|
||||
model: "openai/tts-1",
|
||||
input: "Hello, this is a test of the text-to-speech endpoint.",
|
||||
voice: "alloy",
|
||||
response_format: "mp3",
|
||||
},
|
||||
};
|
||||
|
||||
const ENDPOINT_PATHS: Record<string, string> = {
|
||||
@@ -54,6 +61,7 @@ const ENDPOINT_PATHS: Record<string, string> = {
|
||||
responses: "/v1/responses",
|
||||
images: "/v1/images/generations",
|
||||
embeddings: "/v1/embeddings",
|
||||
speech: "/v1/audio/speech",
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
@@ -64,6 +72,7 @@ export default function PlaygroundPage() {
|
||||
const [selectedEndpoint, setSelectedEndpoint] = useState("chat");
|
||||
const [requestBody, setRequestBody] = useState("");
|
||||
const [responseBody, setResponseBody] = useState("");
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [responseStatus, setResponseStatus] = useState<number | null>(null);
|
||||
const [responseDuration, setResponseDuration] = useState<number | null>(null);
|
||||
@@ -142,6 +151,7 @@ export default function PlaygroundPage() {
|
||||
if (!requestBody.trim()) return;
|
||||
setLoading(true);
|
||||
setResponseBody("");
|
||||
setAudioUrl(null);
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
|
||||
@@ -163,7 +173,13 @@ export default function PlaygroundPage() {
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
if (contentType.startsWith("audio/")) {
|
||||
// TTS binary response — create a Blob URL and show inline audio player
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setResponseBody(`// Audio response (${contentType})\n// Click play below to listen.`);
|
||||
} else if (contentType.includes("text/event-stream")) {
|
||||
// Handle streaming
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -376,21 +392,35 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={responseBody}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
{audioUrl ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
|
||||
<a
|
||||
href={audioUrl}
|
||||
download="speech.mp3"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">download</span>
|
||||
Download audio
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={responseBody}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -242,8 +242,8 @@ export default function ProvidersPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* OAuth Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("oauthProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-blue-500" title={t("oauthLabel")} />
|
||||
</h2>
|
||||
@@ -283,8 +283,8 @@ export default function ProvidersPage() {
|
||||
|
||||
{/* Free Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("freeProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-green-500" title={tc("free")} />
|
||||
</h2>
|
||||
@@ -321,8 +321,8 @@ export default function ProvidersPage() {
|
||||
|
||||
{/* API Key Providers — fixed list */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("apiKeyProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-amber-500" title={t("apiKeyLabel")} />
|
||||
</h2>
|
||||
@@ -359,12 +359,12 @@ export default function ProvidersPage() {
|
||||
|
||||
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("compatibleProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-orange-500" title={t("compatibleLabel")} />
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
|
||||
<button
|
||||
onClick={() => handleBatchTest("compatible")}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
getRateLimitStatus,
|
||||
getAllRateLimitStatus,
|
||||
} from "@omniroute/open-sse/services/rateLimitManager.ts";
|
||||
import { getAccountDisplayName } from "@/lib/display/names";
|
||||
|
||||
import { toggleRateLimitSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -36,7 +38,7 @@ export async function GET() {
|
||||
const name =
|
||||
(typeof conn.name === "string" && conn.name.trim()) ||
|
||||
(typeof conn.email === "string" && conn.email.trim()) ||
|
||||
(connectionId ? connectionId.slice(0, 8) : "unknown");
|
||||
getAccountDisplayName({ id: connectionId });
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
|
||||
69
src/lib/display/names.ts
Normal file
69
src/lib/display/names.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Centralized display name helpers for provider and account/connection labels.
|
||||
*
|
||||
* Prevents raw internal IDs (connection UUIDs, dynamic provider IDs) from
|
||||
* leaking into user-facing dashboards (Health, Analytics, Sessions, Rate-limits,
|
||||
* Quota, Compatible Provider pages, etc.).
|
||||
*
|
||||
* Priority order:
|
||||
* — Account: name → displayName → email → short readble label
|
||||
* — Provider: node.name → node.prefix → alias → readable ID
|
||||
*
|
||||
* @module lib/display/names
|
||||
*/
|
||||
|
||||
export interface ConnectionLike {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderNodeLike {
|
||||
name?: string | null;
|
||||
prefix?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly display name for an account/connection.
|
||||
*
|
||||
* Priority: name → displayName → email → "Account #<6-char ID>"
|
||||
*/
|
||||
export function getAccountDisplayName(conn: ConnectionLike): string {
|
||||
if (!conn) return "Unknown Account";
|
||||
const name =
|
||||
(typeof conn.name === "string" && conn.name.trim()) ||
|
||||
(typeof conn.displayName === "string" && conn.displayName.trim()) ||
|
||||
(typeof conn.email === "string" && conn.email.trim());
|
||||
if (name) return name;
|
||||
if (typeof conn.id === "string" && conn.id) {
|
||||
return `Account #${conn.id.slice(0, 6)}`;
|
||||
}
|
||||
return "Unknown Account";
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly display name for a provider node/ID.
|
||||
*
|
||||
* Priority: node.name → node.prefix → de-UUIDed providerId
|
||||
*
|
||||
* Dynamic compatible provider IDs like
|
||||
* "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"
|
||||
* are rendered as "Compatible (openai)".
|
||||
*/
|
||||
export function getProviderDisplayName(
|
||||
providerId: string | null | undefined,
|
||||
providerNode?: ProviderNodeLike | null
|
||||
): string {
|
||||
if (providerNode?.name?.trim()) return providerNode.name.trim();
|
||||
if (providerNode?.prefix?.trim()) return providerNode.prefix.trim();
|
||||
if (!providerId) return "Unknown Provider";
|
||||
|
||||
// Simplify dynamic compatible provider IDs
|
||||
const match = providerId.match(
|
||||
/^(openai|anthropic)-compatible-(?:chat|responses)-[0-9a-f-]{10,}$/i
|
||||
);
|
||||
if (match) return `Compatible (${match[1]})`;
|
||||
|
||||
return providerId;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { getPendingRequests } from "./usageHistory";
|
||||
import { getAccountDisplayName } from "@/lib/display/names";
|
||||
import { calculateCost } from "./costCalculator";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -73,7 +74,8 @@ export async function getUsageStats() {
|
||||
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
||||
for (const [modelKey, count] of Object.entries(models)) {
|
||||
if (count > 0) {
|
||||
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
||||
const accountName =
|
||||
connectionMap[connectionId] || getAccountDisplayName({ id: connectionId });
|
||||
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
||||
stats.activeRequests.push({
|
||||
model: match ? match[1] : modelKey,
|
||||
@@ -173,7 +175,8 @@ export async function getUsageStats() {
|
||||
|
||||
// By Account
|
||||
if (connectionId) {
|
||||
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
||||
const accountName =
|
||||
connectionMap[connectionId] || getAccountDisplayName({ id: connectionId });
|
||||
const accountKey = `${model} (${provider} - ${accountName})`;
|
||||
if (!stats.byAccount[accountKey]) {
|
||||
stats.byAccount[accountKey] = {
|
||||
|
||||
@@ -173,7 +173,7 @@ export default function Sidebar({
|
||||
<>
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col border-r border-black/5 dark:border-white/5 bg-vibrancy backdrop-blur-xl transition-all duration-300 ease-in-out",
|
||||
"flex flex-col h-full border-r border-black/5 dark:border-white/5 bg-vibrancy backdrop-blur-xl transition-all duration-300 ease-in-out",
|
||||
collapsed ? "w-16" : "w-72"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -41,9 +41,9 @@ export default function DashboardLayout({ children }) {
|
||||
<Sidebar collapsed={collapsed} onToggleCollapse={handleToggleCollapse} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Mobile */}
|
||||
{/* Sidebar - Mobile: full viewport height with proper scroll containment */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 transform lg:hidden transition-transform duration-300 ease-in-out ${
|
||||
className={`fixed inset-y-0 left-0 z-50 transform lg:hidden transition-transform duration-300 ease-in-out h-dvh overflow-y-auto ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user