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:
Diego Rodrigues de Sa e Souza
2026-03-09 13:43:48 -03:00
committed by GitHub
parent 47445ebf5c
commit 44ee62e391
11 changed files with 535 additions and 90 deletions

View File

@@ -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)) {

View File

@@ -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
View File

@@ -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": [

View File

@@ -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>

View File

@@ -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>

View File

@@ -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")}

View File

@@ -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
View 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;
}

View File

@@ -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] = {

View File

@@ -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"
)}
>

View File

@@ -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"
}`}
>