Feat : tts
This commit is contained in:
@@ -109,7 +109,7 @@ export async function GET() {
|
||||
// POST - Update 9Router settings (merge with existing config)
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
const { baseUrl, apiKey, model, subagentModel } = await request.json();
|
||||
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
|
||||
@@ -141,6 +141,12 @@ export async function POST(request) {
|
||||
wire_api: "responses",
|
||||
});
|
||||
|
||||
// Add subagent configuration
|
||||
const effectiveSubagentModel = subagentModel || model;
|
||||
setNestedSection(parsed, "agents.subagent", {
|
||||
model: effectiveSubagentModel,
|
||||
});
|
||||
|
||||
// Write merged config
|
||||
const configContent = stringifyTOML(parsed);
|
||||
await fs.writeFile(configPath, configContent);
|
||||
@@ -196,6 +202,9 @@ export async function DELETE() {
|
||||
// Remove 9router provider section
|
||||
deleteNestedSection(parsed, "model_providers.9router");
|
||||
|
||||
// Remove subagent configuration
|
||||
deleteNestedSection(parsed, "agents.subagent");
|
||||
|
||||
// Write updated config
|
||||
const configContent = stringifyTOML(parsed);
|
||||
await fs.writeFile(configPath, configContent);
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function GET() {
|
||||
// POST - Apply 9Router as openai-compatible provider
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { baseUrl, apiKey, model } = await request.json();
|
||||
const { baseUrl, apiKey, model, subagentModel } = await request.json();
|
||||
|
||||
if (!baseUrl || !model) {
|
||||
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
|
||||
@@ -97,6 +97,7 @@ export async function POST(request) {
|
||||
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||
const keyToUse = apiKey || "sk_9router";
|
||||
const effectiveSubagentModel = subagentModel || model;
|
||||
|
||||
// Merge 9router provider
|
||||
if (!config.provider) config.provider = {};
|
||||
@@ -108,12 +109,21 @@ export async function POST(request) {
|
||||
},
|
||||
models: {
|
||||
[model]: { name: model },
|
||||
[effectiveSubagentModel]: { name: effectiveSubagentModel },
|
||||
},
|
||||
};
|
||||
|
||||
// Set as active model
|
||||
config.model = `9router/${model}`;
|
||||
|
||||
// Add subagent configuration
|
||||
if (!config.agent) config.agent = {};
|
||||
config.agent.explorer = {
|
||||
description: "Fast explorer subagent for codebase exploration",
|
||||
mode: "subagent",
|
||||
model: `9router/${effectiveSubagentModel}`,
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -149,6 +159,13 @@ export async function DELETE() {
|
||||
// Reset model if it was pointing to 9router
|
||||
if (config.model?.startsWith("9router/")) delete config.model;
|
||||
|
||||
// Remove subagent configuration
|
||||
if (config.agent?.explorer?.model?.startsWith("9router/")) {
|
||||
delete config.agent.explorer;
|
||||
// Clean up empty agent object
|
||||
if (Object.keys(config.agent).length === 0) delete config.agent;
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
71
src/app/api/media-providers/tts/elevenlabs/voices/route.js
Normal file
71
src/app/api/media-providers/tts/elevenlabs/voices/route.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
import { fetchElevenLabsVoices } from "open-sse/handlers/ttsCore.js";
|
||||
|
||||
const langNames = new Intl.DisplayNames(["en"], { type: "language" });
|
||||
|
||||
/**
|
||||
* GET /api/media-providers/tts/elevenlabs/voices[?lang=en]
|
||||
* Returns { languages, byLang } grouped by language - same format as edge-tts
|
||||
* Uses direct DB read (no mutex) to avoid blocking on concurrent TTS requests
|
||||
*/
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const langFilter = searchParams.get("lang");
|
||||
|
||||
// Direct DB read - bypass auth mutex used for TTS inference
|
||||
const connections = await getProviderConnections({ provider: "elevenlabs", isActive: true });
|
||||
const apiKey = connections[0]?.apiKey;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No ElevenLabs connection found" }, { status: 400 });
|
||||
}
|
||||
|
||||
const voices = await fetchElevenLabsVoices(apiKey);
|
||||
|
||||
// Group by all supported languages (verified_languages + labels.language)
|
||||
const byLang = {};
|
||||
const addToLang = (code, voice) => {
|
||||
if (!byLang[code]) {
|
||||
byLang[code] = {
|
||||
code,
|
||||
name: (() => { try { return langNames.of(code); } catch { return code; } })(),
|
||||
voices: [],
|
||||
};
|
||||
}
|
||||
// Avoid duplicate voice in same lang
|
||||
if (!byLang[code].voices.find((v) => v.id === voice.voice_id)) {
|
||||
byLang[code].voices.push({
|
||||
id: voice.voice_id,
|
||||
name: voice.name,
|
||||
gender: voice.labels?.gender || "",
|
||||
lang: code,
|
||||
// premade voices are free; professional library voices added to account may require paid plan
|
||||
free_users_allowed: voice.category === "premade" || voice.is_owner === true
|
||||
});
|
||||
}
|
||||
};
|
||||
for (const v of voices) {
|
||||
// Add to primary language
|
||||
const primaryLang = v.labels?.language || "en";
|
||||
addToLang(primaryLang, v);
|
||||
// Add to all verified languages
|
||||
for (const vl of v.verified_languages || []) {
|
||||
if (vl.language && vl.language !== primaryLang) {
|
||||
addToLang(vl.language, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const languages = Object.values(byLang).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// If lang filter requested, return only that group's voices
|
||||
if (langFilter) {
|
||||
return NextResponse.json({ voices: byLang[langFilter]?.voices || [] });
|
||||
}
|
||||
|
||||
return NextResponse.json({ languages, byLang });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message || "Failed to fetch voices" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
98
src/app/api/media-providers/tts/voices/route.js
Normal file
98
src/app/api/media-providers/tts/voices/route.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { VOICE_FETCHERS } from "open-sse/handlers/ttsCore.js";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Map locale code → country name
|
||||
const LOCALE_NAMES = new Intl.DisplayNames(["en"], { type: "region" });
|
||||
const LANG_NAMES = new Intl.DisplayNames(["en"], { type: "language" });
|
||||
|
||||
function countryName(code) {
|
||||
try { return LOCALE_NAMES.of(code); } catch { return code; }
|
||||
}
|
||||
function langName(code) {
|
||||
try { return LANG_NAMES.of(code); } catch { return code; }
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/media-providers/tts/voices
|
||||
* Query:
|
||||
* ?provider=edge-tts | local-device | elevenlabs (default: edge-tts)
|
||||
* ?lang=en (optional filter by lang code)
|
||||
* ?apiKey=xxx (required for elevenlabs)
|
||||
*/
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider") || "edge-tts";
|
||||
const langFilter = searchParams.get("lang");
|
||||
const apiKey = searchParams.get("apiKey");
|
||||
|
||||
const fetcher = VOICE_FETCHERS[provider];
|
||||
if (!fetcher) {
|
||||
return NextResponse.json({ error: `Provider '${provider}' does not support voice listing` }, { status: 400 });
|
||||
}
|
||||
|
||||
// ElevenLabs requires API key
|
||||
const raw = provider === "elevenlabs" ? await fetcher(apiKey) : await fetcher();
|
||||
let voices;
|
||||
|
||||
if (provider === "local-device") {
|
||||
voices = raw.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
locale: v.locale.replace("_", "-"),
|
||||
lang: v.lang,
|
||||
country: v.country,
|
||||
countryName: countryName(v.country),
|
||||
langName: langName(v.lang),
|
||||
gender: v.gender,
|
||||
}));
|
||||
} else if (provider === "elevenlabs") {
|
||||
voices = raw.map((v) => ({
|
||||
id: v.voice_id,
|
||||
name: v.name,
|
||||
locale: v.labels?.language || "en",
|
||||
lang: (v.labels?.language || "en").split("-")[0],
|
||||
country: "",
|
||||
countryName: "",
|
||||
langName: langName((v.labels?.language || "en").split("-")[0]),
|
||||
gender: v.labels?.gender || "",
|
||||
category: v.category,
|
||||
}));
|
||||
} else {
|
||||
// edge-tts (default)
|
||||
voices = raw.map((v) => {
|
||||
const [lang, country] = v.Locale.split("-");
|
||||
return {
|
||||
id: v.ShortName,
|
||||
name: (v.FriendlyName || v.ShortName)
|
||||
.replace("Microsoft ", "")
|
||||
.replace(/ Online \(Natural\) - /g, " ("),
|
||||
locale: v.Locale,
|
||||
lang,
|
||||
country: country || "",
|
||||
countryName: countryName(country || lang),
|
||||
langName: langName(lang),
|
||||
gender: v.Gender,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
if (langFilter) voices = voices.filter((v) => v.lang === langFilter);
|
||||
|
||||
// Group by language
|
||||
const byLang = {};
|
||||
for (const v of voices) {
|
||||
const key = v.lang;
|
||||
if (!byLang[key]) byLang[key] = { code: key, name: v.langName, voices: [] };
|
||||
byLang[key].voices.push(v);
|
||||
}
|
||||
|
||||
// Sorted language list
|
||||
const languages = Object.values(byLang).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return NextResponse.json({ voices, languages, byLang });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message || "Failed to fetch voices" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
|
||||
// POST /api/models/test - Ping a single model via internal completions
|
||||
// POST /api/models/test - Ping a single model via internal completions or embeddings
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { model } = await request.json();
|
||||
const { model, kind } = await request.json();
|
||||
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
||||
|
||||
const baseUrl = process.env.BASE_URL ||
|
||||
@@ -21,6 +21,32 @@ export async function POST(request) {
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
// Route to appropriate endpoint based on kind
|
||||
if (kind === "embedding") {
|
||||
const res = await fetch(`${baseUrl}/api/v1/embeddings`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ model, input: "test" }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
const rawText = await res.text().catch(() => "");
|
||||
let parsed = null;
|
||||
try { parsed = rawText ? JSON.parse(rawText) : null; } catch {}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = parsed?.error?.message || parsed?.error || rawText;
|
||||
return NextResponse.json({ ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status });
|
||||
}
|
||||
const hasEmbedding = Array.isArray(parsed?.data) && parsed.data.length > 0 && Array.isArray(parsed.data[0]?.embedding);
|
||||
if (!hasEmbedding) {
|
||||
return NextResponse.json({ ok: false, latencyMs, status: res.status, error: "Provider returned no embedding data" });
|
||||
}
|
||||
return NextResponse.json({ ok: true, latencyMs, error: null, status: res.status });
|
||||
}
|
||||
|
||||
// Default: chat completions
|
||||
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
16
src/app/api/v1/audio/speech/route.js
Normal file
16
src/app/api/v1/audio/speech/route.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { handleTts } from "@/sse/handlers/tts.js";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /v1/audio/speech - OpenAI-compatible TTS endpoint */
|
||||
export async function POST(request) {
|
||||
return await handleTts(request);
|
||||
}
|
||||
Reference in New Issue
Block a user