Feat : tts

This commit is contained in:
decolua
2026-04-10 10:17:53 +07:00
parent 39545cf4c8
commit 3c96e8d6d1
40 changed files with 1896 additions and 147 deletions

View File

@@ -212,6 +212,8 @@ export const PROVIDER_MODELS = {
// Gemini 2.0 series (retiring June 1, 2026)
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gemini-2.0-flash-lite", name: "Gemini 2.0 Flash Lite" },
{ id: "gemma-4-31b-it", name: "Gemma 4 31B IT" },
// Embedding models
{ id: "gemini-embedding-2-preview", name: "Gemini Embedding 2 Preview", type: "embedding" },
{ id: "gemini-embedding-001", name: "Gemini Embedding 001", type: "embedding" },
@@ -364,6 +366,63 @@ export const PROVIDER_MODELS = {
{ id: "qwen/qwen3-next-80b-a3b-instruct-maas", name: "Qwen3 Next 80B Instruct (Vertex)" },
{ id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" },
],
// Free/noAuth TTS providers
"local-device": [
{ id: "default", name: "System Default Voice", type: "tts" },
],
"google-tts": [
{ id: "en", name: "English", type: "tts" },
{ id: "vi", name: "Vietnamese", type: "tts" },
{ id: "zh-CN", name: "Chinese (Simplified)", type: "tts" },
{ id: "fr", name: "French", type: "tts" },
{ id: "de", name: "German", type: "tts" },
{ id: "ja", name: "Japanese", type: "tts" },
{ id: "ko", name: "Korean", type: "tts" },
],
// OpenAI TTS voices (hardcoded — no public API to list them)
// Used by ttsCore.js when provider = openai
"openai-tts-voices": [
{ id: "alloy", name: "Alloy", type: "tts" },
{ id: "ash", name: "Ash", type: "tts" },
{ id: "ballad", name: "Ballad", type: "tts" },
{ id: "cedar", name: "Cedar", type: "tts" },
{ id: "coral", name: "Coral", type: "tts" },
{ id: "echo", name: "Echo", type: "tts" },
{ id: "fable", name: "Fable", type: "tts" },
{ id: "marin", name: "Marin", type: "tts" },
{ id: "nova", name: "Nova", type: "tts" },
{ id: "onyx", name: "Onyx", type: "tts" },
{ id: "sage", name: "Sage", type: "tts" },
{ id: "shimmer", name: "Shimmer", type: "tts" },
{ id: "verse", name: "Verse", type: "tts" },
],
// OpenAI TTS models
"openai-tts-models": [
{ id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
{ id: "tts-1-hd", name: "TTS-1 HD", type: "tts" },
{ id: "tts-1", name: "TTS-1", type: "tts" },
],
// ElevenLabs TTS models
"elevenlabs-tts-models": [
{ id: "eleven_flash_v2_5", name: "Flash v2.5 (Fastest)", type: "tts" },
{ id: "eleven_turbo_v2_5", name: "Turbo v2.5 (Fast)", type: "tts" },
{ id: "eleven_multilingual_v2", name: "Multilingual v2 (Quality)", type: "tts" },
{ id: "eleven_monolingual_v1", name: "Monolingual v1 (English)", type: "tts" },
],
"edge-tts": [
{ id: "en-US-AriaNeural", name: "Aria (en-US)", type: "tts" },
{ id: "en-US-GuyNeural", name: "Guy (en-US)", type: "tts" },
{ id: "en-GB-SoniaNeural", name: "Sonia (en-GB)", type: "tts" },
{ id: "vi-VN-HoaiMyNeural", name: "Hoai My (vi-VN)", type: "tts" },
{ id: "vi-VN-NamMinhNeural", name: "Nam Minh (vi-VN)", type: "tts" },
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (zh-CN)", type: "tts" },
{ id: "zh-CN-YunxiNeural", name: "Yunxi (zh-CN)", type: "tts" },
{ id: "fr-FR-DeniseNeural", name: "Denise (fr-FR)", type: "tts" },
{ id: "de-DE-KatjaNeural", name: "Katja (de-DE)", type: "tts" },
{ id: "ja-JP-NanamiNeural", name: "Nanami (ja-JP)", type: "tts" },
{ id: "ko-KR-SunHiNeural", name: "SunHi (ko-KR)", type: "tts" },
],
};
// Helper functions

View File

@@ -0,0 +1,419 @@
import { createErrorResult } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { execFile } from "child_process";
import { promisify } from "util";
import { mkdtemp, readFile, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
const execFileAsync = promisify(execFile);
// ── Response Formatter (DRY) ───────────────────────────────────
function createTtsResponse(base64Audio, format, responseFormat) {
const audioBuffer = Buffer.from(base64Audio, "base64");
// JSON format: return base64 encoded audio
if (responseFormat === "json") {
return {
success: true,
response: new Response(JSON.stringify({ audio: base64Audio, format }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
// Binary format (default): return raw MP3
return {
success: true,
response: new Response(audioBuffer, {
headers: {
"Content-Type": `audio/${format}`,
"Content-Length": String(audioBuffer.length),
"Access-Control-Allow-Origin": "*",
},
}),
};
}
// ── Token cache per engine ─────────────────────────────────────
const cache = {
google: { token: null, tokenTime: 0 },
bing: { token: null, tokenTime: 0 },
};
const GOOGLE_REFRESH = 11 * 60 * 1000;
const BING_REFRESH = 5 * 60 * 1000; // conservative: token TTL is 1h but refresh early
const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
const SEC_CH_HEADERS = {
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-arch": '"arm"',
"sec-ch-ua-bitness": '"64"',
"sec-ch-ua-full-version": '"146.0.7680.178"',
"sec-ch-ua-full-version-list": '"Chromium";v="146.0.7680.178", "Not-A.Brand";v="24.0.0.0", "Google Chrome";v="146.0.7680.178"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-model": '""',
"sec-ch-ua-platform": '"macOS"',
"sec-ch-ua-platform-version": '"15.1.0"',
};
// ── Google TTS ─────────────────────────────────────────────────
async function getGoogleToken() {
const now = Date.now();
if (cache.google.token && now - cache.google.tokenTime < GOOGLE_REFRESH) {
return cache.google.token;
}
const res = await fetch("https://translate.google.com/", {
headers: { "User-Agent": UA },
});
if (!res.ok) throw new Error(`Google translate fetch failed: ${res.status}`);
const html = await res.text();
const fSid = html.match(/"FdrFJe":"(.*?)"/)?.[ 1];
const bl = html.match(/"cfb2h":"(.*?)"/)?.[ 1];
if (!fSid || !bl) throw new Error("Failed to parse Google token");
cache.google.token = { "f.sid": fSid, bl };
cache.google.tokenTime = now;
return cache.google.token;
}
let _googleIdx = 0;
async function googleTts(text, lang) {
const token = await getGoogleToken();
const cleanText = text.replace(/[@^*()\\/\-_+=><"'\u201c\u201d\u3010\u3011]/g, " ").replaceAll(", ", ". ");
const rpcId = "jQ1olc";
const reqId = (++_googleIdx * 100000) + Math.floor(1000 + Math.random() * 9000);
const query = new URLSearchParams({
rpcids: rpcId,
"f.sid": token["f.sid"],
bl: token.bl,
hl: lang,
"soc-app": 1, "soc-platform": 1, "soc-device": 1,
_reqid: reqId,
rt: "c",
});
const payload = [cleanText, lang, null, "undefined", [0]];
const body = new URLSearchParams();
body.append("f.req", JSON.stringify([[[rpcId, JSON.stringify(payload), null, "generic"]]]));
const res = await fetch(`https://translate.google.com/_/TranslateWebserverUi/data/batchexecute?${query}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "Referer": "https://translate.google.com/" },
body: body.toString(),
});
if (!res.ok) throw new Error(`Google TTS failed: ${res.status}`);
const data = await res.text();
const split = JSON.parse(data.split("\n")[3]);
const base64 = JSON.parse(split[0][2])[0];
if (!base64 || base64.length < 100) throw new Error("Google TTS returned empty audio");
return base64; // base64 MP3
}
// ── Bing TTS ───────────────────────────────────────────────────
async function getBingToken() {
const now = Date.now();
if (cache.bing.token && now - cache.bing.tokenTime < BING_REFRESH) {
return cache.bing.token;
}
const res = await fetch("https://www.bing.com/translator", {
headers: { "User-Agent": UA, "Accept-Language": "vi,en-US;q=0.9,en;q=0.8" },
});
if (!res.ok) throw new Error(`Bing translator fetch failed: ${res.status}`);
const rawCookies = res.headers.getSetCookie?.() || [];
const cookie = rawCookies.map((c) => c.split(";")[0]).join("; ");
const html = await res.text();
const match = html.match(/params_AbusePreventionHelper\s*=\s*\[([^,]+),([^,]+),/);
if (!match) throw new Error("Failed to parse Bing token");
cache.bing.token = { key: match[1], token: match[2].replace(/"/g, ""), cookie };
cache.bing.tokenTime = now;
return cache.bing.token;
}
async function bingTtsRequest(text, voiceId, token) {
const parts = voiceId.split("-");
const xmlLang = parts.slice(0, 2).join("-");
const gender = voiceId.toLowerCase().includes("male") ? "Male" : "Female";
const ssml = `<speak version='1.0' xml:lang='${xmlLang}'><voice xml:lang='${xmlLang}' xml:gender='${gender}' name='${voiceId}'><prosody rate='0.00%'>${text}</prosody></voice></speak>`;
const body = new URLSearchParams();
body.append("ssml", ssml);
body.append("token", token.token);
body.append("key", token.key);
return fetch("https://www.bing.com/tfettts?isVertical=1&&IG=1&IID=translator.5023&SFX=1", {
method: "POST",
body: body.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "*/*",
"Origin": "https://www.bing.com",
"Referer": "https://www.bing.com/translator",
"User-Agent": UA,
...(token.cookie ? { "Cookie": token.cookie } : {}),
},
});
}
async function bingTts(text, voiceId) {
let token = await getBingToken();
let res = await bingTtsRequest(text, voiceId, token);
// On 429/captcha: invalidate cache and retry once with fresh token
if (res.status === 429 || res.status === 403) {
cache.bing.token = null;
cache.bing.tokenTime = 0;
token = await getBingToken();
res = await bingTtsRequest(text, voiceId, token);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Bing TTS failed: ${res.status}${body ? " - " + body : ""}`);
}
const buf = await res.arrayBuffer();
if (buf.byteLength < 1024) throw new Error("Bing TTS returned empty audio");
return Buffer.from(buf).toString("base64"); // base64 MP3
}
// ── Local Device TTS (macOS `say` + ffmpeg) ───────────────────
let _localVoicesCache = null;
export async function fetchLocalDeviceVoices() {
if (_localVoicesCache) return _localVoicesCache;
try {
const { stdout } = await execFileAsync("say", ["-v", "?"]);
const voices = [];
for (const line of stdout.split("\n")) {
// Format: "Name locale # sample"
const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/);
if (!m) continue;
const name = m[1].trim();
const locale = m[2].trim(); // e.g. en_US
const lang = locale.split("_")[0];
const country = locale.split("_")[1];
voices.push({ id: name, name, locale, lang, country, gender: "" });
}
_localVoicesCache = voices;
return voices;
} catch {
return [];
}
}
async function localDeviceTts(text, voiceId) {
const dir = await mkdtemp(join(tmpdir(), "tts-"));
const aiffPath = join(dir, "out.aiff");
const mp3Path = join(dir, "out.mp3");
try {
const args = voiceId ? ["-v", voiceId, "-o", aiffPath, text] : ["-o", aiffPath, text];
await execFileAsync("say", args);
await execFileAsync("ffmpeg", ["-y", "-i", aiffPath, "-codec:a", "libmp3lame", "-qscale:a", "4", mp3Path]);
const buf = await readFile(mp3Path);
return buf.toString("base64");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// ── Voices list (Edge TTS public endpoint) ─────────────────────
let _voicesCache = null;
let _voicesCacheTime = 0;
const VOICES_TTL = 24 * 60 * 60 * 1000;
export async function fetchEdgeTtsVoices() {
const now = Date.now();
if (_voicesCache && now - _voicesCacheTime < VOICES_TTL) return _voicesCache;
const res = await fetch(
"https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4",
{ headers: { "User-Agent": UA } }
);
if (!res.ok) throw new Error(`Edge TTS voices fetch failed: ${res.status}`);
const voices = await res.json();
_voicesCache = voices;
_voicesCacheTime = now;
return voices;
}
// ── ElevenLabs TTS ─────────────────────────────────────────────
const _elevenlabsVoicesCache = new Map(); // Cache by API key
export async function fetchElevenLabsVoices(apiKey) {
if (!apiKey) throw new Error("ElevenLabs API key required");
const now = Date.now();
const cached = _elevenlabsVoicesCache.get(apiKey);
if (cached && now - cached.time < VOICES_TTL) {
return cached.voices;
}
const res = await fetch("https://api.elevenlabs.io/v1/voices", {
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
},
});
if (!res.ok) throw new Error(`ElevenLabs voices fetch failed: ${res.status}`);
const data = await res.json();
// Normalize: add lang from labels.language for grouping
const voices = (data.voices || []).map((v) => ({
...v,
lang: v.labels?.language || "en",
}));
_elevenlabsVoicesCache.set(apiKey, { voices, time: now });
return voices;
}
async function elevenlabsTts(text, voiceId, apiKey, modelId = "eleven_flash_v2_5") {
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
model_id: modelId,
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
},
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail?.message || `ElevenLabs TTS failed: ${res.status}`);
}
const buf = await res.arrayBuffer();
if (buf.byteLength < 1024) throw new Error("ElevenLabs TTS returned empty audio");
return Buffer.from(buf).toString("base64");
}
// ── Voice Fetcher Registry (DRY) ───────────────────────────────
export const VOICE_FETCHERS = {
"edge-tts": fetchEdgeTtsVoices,
"local-device": fetchLocalDeviceVoices,
"elevenlabs": fetchElevenLabsVoices,
// google-tts: uses hardcoded language codes
// openai: uses hardcoded voices from providerModels.js
};
// ── OpenAI TTS ───────────────────────────────────────────────────────────────
async function handleOpenAiTts({ model, input, credentials, responseFormat = "mp3" }) {
if (!credentials?.apiKey) {
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, "No OpenAI API key configured");
}
// model format: "tts-model/voice" e.g. "tts-1/alloy" or "gpt-4o-mini-tts/nova"
let ttsModel = "gpt-4o-mini-tts";
let voice = "alloy";
if (model && model.includes("/")) {
const parts = model.split("/");
if (parts.length === 2) {
[ttsModel, voice] = parts;
}
} else if (model) {
voice = model;
}
const baseUrl = (credentials.baseUrl || "https://api.openai.com").replace(/\/+$/, "");
const res = await fetch(`${baseUrl}/v1/audio/speech`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ model: ttsModel, voice, input }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return createErrorResult(res.status, err?.error?.message || `OpenAI TTS failed: ${res.status}`);
}
const buf = await res.arrayBuffer();
const base64 = Buffer.from(buf).toString("base64");
return createTtsResponse(base64, "mp3", responseFormat);
}
// ── TTS Provider Registry (DRY) ────────────────────────────────
const TTS_PROVIDERS = {
"google-tts": {
synthesize: async (text, model) => {
const base64 = await googleTts(text, model || "en");
return { base64, format: "mp3" };
},
requiresCredentials: false,
},
"edge-tts": {
synthesize: async (text, model) => {
const base64 = await bingTts(text, model || "vi-VN-HoaiMyNeural");
return { base64, format: "mp3" };
},
requiresCredentials: false,
},
"local-device": {
synthesize: async (text, model) => {
const base64 = await localDeviceTts(text, model);
return { base64, format: "mp3" };
},
requiresCredentials: false,
},
"elevenlabs": {
synthesize: async (text, model, credentials) => {
if (!credentials?.apiKey) {
throw new Error("ElevenLabs API key required");
}
// model format: "voice_id" or "model_id/voice_id"
let modelId = "eleven_flash_v2_5";
let voiceId = model;
if (model && model.includes("/")) {
[modelId, voiceId] = model.split("/");
}
const base64 = await elevenlabsTts(text, voiceId, credentials.apiKey, modelId);
return { base64, format: "mp3" };
},
requiresCredentials: true,
},
"openai": {
synthesize: async (text, model, credentials, responseFormat) => {
return await handleOpenAiTts({ model, input: text, credentials, responseFormat });
},
requiresCredentials: true,
},
};
// ── Core handler ───────────────────────────────────────────────
/**
* Synthesize text to audio.
* @param {object} options
* @param {string} options.provider - "google-tts" | "edge-tts" | "local-device" | "openai"
* @param {string} options.model - voice/lang id
* @param {string} options.input - text to synthesize
* @param {object} [options.credentials] - required for openai
* @param {string} [options.responseFormat] - "mp3" (default) | "json" (base64)
* @returns {Promise<{success, response, status?, error?}>}
*/
export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3" }) {
if (!input?.trim()) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
}
const ttsProvider = TTS_PROVIDERS[provider];
if (!ttsProvider) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support TTS via this route.`);
}
try {
const result = await ttsProvider.synthesize(input.trim(), model, credentials, responseFormat);
// OpenAI returns full response object
if (result.success !== undefined) return result;
// Other providers return { base64, format }
return createTtsResponse(result.base64, result.format, responseFormat);
} catch (err) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "TTS synthesis failed");
}
}

View File

@@ -13,6 +13,8 @@ const ALIAS_TO_PROVIDER_ID = {
kmc: "kimi-coding",
cl: "cline",
oc: "opencode",
// TTS providers
el: "elevenlabs",
// API Key providers
openai: "openai",
anthropic: "anthropic",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/providers/playht.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -13,7 +13,9 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [subagentModel, setSubagentModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [subagentModalOpen, setSubagentModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
@@ -46,11 +48,15 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
}
};
// Parse model from config content (don't sync URL - always use baseUrl from props)
// Parse model and subagent settings from config content
useEffect(() => {
if (codexStatus?.config) {
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m);
if (modelMatch) setSelectedModel(modelMatch[1]);
// Parse subagent settings
const subagentModelMatch = codexStatus.config.match(/\[agents\.subagent\]\s*\n\s*model\s*=\s*"([^"]+)"/m);
if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]);
}
}, [codexStatus]);
@@ -96,7 +102,12 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const res = await fetch("/api/cli-tools/codex-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }),
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
subagentModel: subagentModel || selectedModel
}),
});
const data = await res.json();
if (res.ok) {
@@ -121,6 +132,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSubagentModel("");
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -134,6 +146,10 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const handleModelSelect = (model) => {
setSelectedModel(model.value);
// Auto-set subagent model if not set
if (!subagentModel) {
setSubagentModel(model.value);
}
setModalOpen(false);
};
@@ -142,6 +158,8 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const effectiveSubagentModel = subagentModel || selectedModel;
const configContent = `# 9Router Configuration for Codex CLI
model = "${selectedModel}"
model_provider = "9router"
@@ -150,6 +168,9 @@ model_provider = "9router"
name = "9Router"
base_url = "${getEffectiveBaseUrl()}"
wire_api = "responses"
[agents.subagent]
model = "${effectiveSubagentModel}"
`;
const authContent = JSON.stringify({
@@ -290,6 +311,35 @@ wire_api = "responses"
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
{/* Subagent Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Subagent Model</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input
type="text"
value={subagentModel}
onChange={(e) => setSubagentModel(e.target.value)}
placeholder={selectedModel || "provider/model-id (defaults to main model)"}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
onClick={() => setSubagentModalOpen(true)}
disabled={!activeProviders?.length}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
</button>
{subagentModel && (
<button
onClick={() => setSubagentModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear (will use main model)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</div>
{message && (
@@ -325,6 +375,16 @@ wire_api = "responses"
title="Select Model for Codex"
/>
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for Codex"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}

View File

@@ -13,7 +13,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [subagentModel, setSubagentModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [subagentModalOpen, setSubagentModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
@@ -36,11 +38,16 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
// Sync model from existing config
// Sync model and subagent model from existing config
useEffect(() => {
if (status?.config?.model?.startsWith("9router/")) {
setSelectedModel(status.config.model.replace("9router/", ""));
}
// Parse subagent settings from agent.explorer if exists
if (status?.config?.agent?.explorer?.model?.startsWith("9router/")) {
setSubagentModel(status.config.agent.explorer.model.replace("9router/", ""));
}
}, [status]);
const fetchModelAliases = async () => {
@@ -94,7 +101,12 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
const res = await fetch("/api/cli-tools/opencode-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }),
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
subagentModel: subagentModel || selectedModel
}),
});
const data = await res.json();
if (res.ok) {
@@ -119,6 +131,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSubagentModel("");
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -135,6 +148,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const effectiveSubagentModel = subagentModel || selectedModel;
return [{
filename: "~/.config/opencode/opencode.json",
content: JSON.stringify({
@@ -142,10 +157,20 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
"9router": {
npm: "@ai-sdk/openai-compatible",
options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse },
models: { [selectedModel || "provider/model-id"]: { name: selectedModel || "provider/model-id" } },
models: {
[selectedModel || "provider/model-id"]: { name: selectedModel || "provider/model-id" },
[effectiveSubagentModel]: { name: effectiveSubagentModel }
},
},
},
model: `9router/${selectedModel || "provider/model-id"}`,
agent: {
explorer: {
description: "Fast explorer subagent for codebase exploration",
mode: "subagent",
model: `9router/${effectiveSubagentModel}`
}
}
}, null, 2),
}];
};
@@ -262,6 +287,35 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
{/* Subagent Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Subagent Model</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input
type="text"
value={subagentModel}
onChange={(e) => setSubagentModel(e.target.value)}
placeholder={selectedModel || "provider/model-id (defaults to main model)"}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
onClick={() => setSubagentModalOpen(true)}
disabled={!activeProviders?.length}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
</button>
{subagentModel && (
<button
onClick={() => setSubagentModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear (will use main model)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</div>
{message && (
@@ -290,13 +344,30 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
onSelect={(model) => {
setSelectedModel(model.value);
// Auto-set subagent model if not set
if (!subagentModel) {
setSubagentModel(model.value);
}
setModalOpen(false);
}}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for OpenCode"
/>
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for OpenCode"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}

View File

@@ -2,19 +2,727 @@
import { useParams, notFound } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Card, Badge } from "@/shared/components";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
// Shared row layout — defined outside components to avoid re-mount on re-render
function Row({ label, children }) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-text-muted w-20 shrink-0">{label}</span>
<div className="flex-1">{children}</div>
</div>
);
}
const DEFAULT_RESPONSE_EXAMPLE = `{
"object": "list",
"data": [{
"object": "embedding",
"index": 0,
"embedding": [0.002301, -0.019212, 0.004815, -0.031249, ...]
}],
"model": "...",
"usage": { "prompt_tokens": 9, "total_tokens": 9 }
}`;
// EmbeddingExampleCard
function EmbeddingExampleCard({ providerId }) {
const providerAlias = getProviderAlias(providerId);
const embeddingModels = getModelsByProviderId(providerId).filter((m) => m.type === "embedding");
const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? "");
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [result, setResult] = useState(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
useEffect(() => {
setLocalEndpoint(window.location.origin);
fetch("/api/keys")
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
}, []);
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
-d '{"model": "${modelFull}", "input": "${input}"}'`;
const handleRun = async () => {
if (!input.trim() || !modelFull) return;
setRunning(true);
setError("");
setResult(null);
const start = Date.now();
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch("/api/v1/embeddings", {
method: "POST",
headers,
body: JSON.stringify({ model: modelFull, input: input.trim() }),
});
const latencyMs = Date.now() - start;
const data = await res.json();
if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; }
setResult({ data, latencyMs });
} catch (e) {
setError(e.message || "Network error");
} finally {
setRunning(false);
}
};
// Compact embedding array: first 4 values + count
const formatResultJson = (data) => {
if (!data) return DEFAULT_RESPONSE_EXAMPLE;
const clone = JSON.parse(JSON.stringify(data));
(clone.data || []).forEach((item) => {
if (Array.isArray(item.embedding) && item.embedding.length > 4) {
item.embedding = [...item.embedding.slice(0, 4).map((v) => parseFloat(v.toFixed(6))), `... (${item.embedding.length} dims)`];
}
});
return JSON.stringify(clone, null, 2);
};
const resultJson = result ? JSON.stringify(result.data, null, 2) : "";
return (
<Card>
<h2 className="text-lg font-semibold mb-4">Example</h2>
<div className="flex flex-col gap-2.5">
{/* Model */}
<Row label="Model">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
{embeddingModels.map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
</Row>
{/* Endpoint */}
<Row label="Endpoint">
<div className="flex items-center gap-2">
<input
value={endpoint}
onChange={(e) => useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
placeholder="http://localhost:3000"
/>
{/* Tunnel toggle — only show if tunnel URL is available */}
{tunnelEndpoint && (
<button
onClick={() => setUseTunnel((v) => !v)}
title={useTunnel ? "Using tunnel" : "Using local"}
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
}`}
>
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
Tunnel
</button>
)}
</div>
</Row>
{/* API Key */}
<Row label="API Key">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
/>
</Row>
{/* Input */}
<Row label="Input">
<div className="relative">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
{input && (
<button
type="button"
onClick={() => setInput("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</Row>
{/* Curl + Run */}
<div className="mt-1">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
<div className="flex items-center gap-2">
<button
onClick={() => copyCurl(curlSnippet)}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
{copiedCurl ? "Copied" : "Copy"}
</button>
<button
onClick={handleRun}
disabled={running || !input.trim() || !modelFull}
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
{running ? "progress_activity" : "play_arrow"}
</span>
{running ? "Running..." : "Run"}
</button>
</div>
</div>
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
</div>
{/* Error */}
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
{/* Response — default example or real result */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Response {result && <span className="font-normal normal-case">&#9889; {result.latencyMs}ms</span>}
</span>
{result && (
<button
onClick={() => copyRes(resultJson)}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
{copiedRes ? "Copied" : "Copy"}
</button>
)}
</div>
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre opacity-70">
{formatResultJson(result?.data)}
</pre>
</div>
</div>
</Card>
);
}
// ─── TTS Example Card ────────────────────────────────────────────────────────
function TtsExampleCard({ providerId }) {
const providerAlias = getProviderAlias(providerId);
const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"];
// Voice state
const [selectedVoice, setSelectedVoice] = useState("");
const [selectedVoiceName, setSelectedVoiceName] = useState("");
const [voiceId, setVoiceId] = useState(""); // editable voice id (elevenlabs)
// Voices shown below Voice row after language selected
const [countryVoices, setCountryVoices] = useState([]);
const [selectedLang, setSelectedLang] = useState("");
const [selectedModel, setSelectedModel] = useState(() => {
if (config.hasModelSelector && config.modelKey) {
const models = getModelsByProviderId(config.modelKey);
return models?.[0]?.id || "";
}
return "";
});
// Form state
const [input, setInput] = useState("Hello, this is a text to speech test.");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
const [audioUrl, setAudioUrl] = useState("");
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [latency, setLatency] = useState(null);
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
// Country picker modal state
const [modalOpen, setModalOpen] = useState(false);
const [languages, setLanguages] = useState([]);
const [modalLoading, setModalLoading] = useState(false);
const [modalSearch, setModalSearch] = useState("");
const [modalError, setModalError] = useState("");
const [byLang, setByLang] = useState({});
useEffect(() => {
setLocalEndpoint(window.location.origin);
fetch("/api/keys")
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
// Pre-select default voice based on provider config
if (config.voiceSource === "hardcoded") {
const voiceKey = config.voiceKey || providerId;
const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
if (voices.length) {
if (config.hasLanguageDropdown) {
// Google TTS: just set voice
setSelectedVoice(voices[0].id);
setSelectedVoiceName(voices[0].name || voices[0].id);
} else {
// OpenAI: set voice chips
setCountryVoices(voices);
setSelectedVoice(voices[0].id);
setSelectedVoiceName(voices[0].name || voices[0].id);
}
}
}
// api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language
}, [providerId]);
// Open modal — load language list
const openModal = async () => {
setModalOpen(true);
setModalSearch("");
setModalError("");
if (languages.length) return; // already loaded
setModalLoading(true);
try {
// Use provider-specific apiEndpoint if available, else default to edge-tts voices API
const url = config.apiEndpoint
? config.apiEndpoint
: `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`;
const r = await fetch(url);
const d = await r.json();
if (d.error) { setModalError(d.error); return; }
setLanguages(d.languages || []);
setByLang(d.byLang || {});
} catch (e) {
setModalError(e.message);
} finally {
setModalLoading(false);
}
};
// Click language → close modal → show voices below
const handlePickLanguage = (lang) => {
setModalOpen(false);
setSelectedLang(lang.code);
const voices = byLang[lang.code]?.voices || [];
setCountryVoices(voices);
// Auto-select first voice
if (voices.length) {
setSelectedVoice(voices[0].id);
setSelectedVoiceName(voices[0].name);
if (config.hasVoiceIdInput) setVoiceId(voices[0].id);
}
};
const filteredLanguages = modalSearch
? languages.filter((c) =>
c.name.toLowerCase().includes(modalSearch.toLowerCase()) ||
c.code.toLowerCase().includes(modalSearch.toLowerCase())
)
: languages;
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
// For ElevenLabs: use voiceId (editable) instead of selectedVoice
const activeVoiceId = config.hasVoiceIdInput ? voiceId : selectedVoice;
const modelFull = config.hasModelSelector && activeVoiceId && selectedModel
? `${providerAlias}/${selectedModel}/${activeVoiceId}`
: activeVoiceId ? `${providerAlias}/${activeVoiceId}` : "";
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
-d '{"model": "${modelFull}", "input": "${input}"}' \\
${responseFormat === "json" ? "" : "--output speech.mp3"}`;
const handleRun = async () => {
if (!input.trim() || !modelFull) return;
setRunning(true);
setError("");
setAudioUrl("");
setJsonResponse(null);
const start = Date.now();
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`;
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ model: modelFull, input: input.trim() }),
});
setLatency(Date.now() - start);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d?.error?.message || d?.error || `HTTP ${res.status}`);
return;
}
if (responseFormat === "json") {
const data = await res.json();
setJsonResponse(data); // Store full JSON response
const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
setAudioUrl(URL.createObjectURL(audioBlob));
} else {
const blob = await res.blob();
setAudioUrl(URL.createObjectURL(blob));
}
} catch (e) {
setError(e.message || "Network error");
} finally {
setRunning(false);
}
};
return (
<>
<Card>
<h2 className="text-lg font-semibold mb-4">Example</h2>
<div className="flex flex-col gap-2.5">
{/* Endpoint + API Key as read-only text */}
<Row label="Endpoint">
<div className="flex items-center gap-2">
<span className="flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
{endpoint}/v1/audio/speech
</span>
{tunnelEndpoint && (
<button
onClick={() => setUseTunnel((v) => !v)}
title={useTunnel ? "Using tunnel" : "Using local"}
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
}`}
>
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
Tunnel
</button>
)}
</div>
</Row>
<Row label="API Key">
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
{apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
</span>
</Row>
{/* Model selector (OpenAI, ElevenLabs) */}
{config.hasModelSelector && config.modelKey && (
<Row label="Model">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
{(getModelsByProviderId(config.modelKey) || []).map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
</Row>
)}
{/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
{config.hasBrowseButton && (
<Row label="Language">
<div className="flex items-center gap-2">
<button
onClick={openModal}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background font-mono truncate text-left hover:border-primary/40 transition-colors"
>
{selectedLang
? <span className="text-text-main">{languages.find((l) => l.code === selectedLang)?.name || selectedLang}</span>
: <span className="text-text-muted">No language selected</span>}
</button>
<button
onClick={openModal}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[14px]">language</span>
Browse
</button>
</div>
</Row>
)}
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
{countryVoices.length > 0 && (
<Row label="Voice">
<div className="flex flex-wrap gap-1.5">
{countryVoices.map((v) => (
<button
key={v.id}
onClick={() => {
setSelectedVoice(v.id);
setSelectedVoiceName(v.name);
if (config.hasVoiceIdInput) setVoiceId(v.id);
}}
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
selectedVoice === v.id
? "bg-primary/15 border-primary/40 text-primary font-medium"
: "border-border text-text-muted hover:text-primary hover:border-primary/40"
}`}
>
{v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
{v.free_users_allowed === true && (
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
)}
{v.free_users_allowed === false && (
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-amber-500/15 text-amber-600 border border-amber-500/20">Paid</span>
)}
</button>
))}
</div>
</Row>
)}
{/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
{config.hasVoiceIdInput && (
<Row label="Voice ID">
<div className="flex flex-col gap-1">
<div className="relative">
<input
value={voiceId}
onChange={(e) => {
setVoiceId(e.target.value);
setSelectedVoice(e.target.value);
}}
placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17"
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
/>
{voiceId && (
<button
type="button"
onClick={() => { setVoiceId(""); setSelectedVoice(""); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</div>
</Row>
)}
{/* Google TTS: Language dropdown */}
{config.hasLanguageDropdown && (
<Row label="Language">
<select
value={selectedVoice}
onChange={(e) => {
const m = getModelsByProviderId(providerId).filter((m) => m.type === "tts").find((m) => m.id === e.target.value);
setSelectedVoice(e.target.value);
setSelectedVoiceName(m?.name || e.target.value);
}}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
{getModelsByProviderId(providerId).filter((m) => m.type === "tts").map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
</Row>
)}
{/* Input */}
<Row label="Input">
<div className="relative">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
{input && (
<button
type="button"
onClick={() => setInput("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</Row>
{/* Output Format */}
<Row label="Output Format">
<select
value={responseFormat}
onChange={(e) => setResponseFormat(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="mp3">MP3 (Binary)</option>
<option value="json">JSON (Base64)</option>
</select>
</Row>
{/* Curl + Run */}
<div className="mt-1">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
<div className="flex items-center gap-2">
<button
onClick={() => copyCurl(curlSnippet)}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
{copiedCurl ? "Copied" : "Copy"}
</button>
<button
onClick={handleRun}
disabled={running || !input.trim() || !modelFull}
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
{running ? "progress_activity" : "play_arrow"}
</span>
{running ? "Generating..." : "Run"}
</button>
</div>
</div>
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
</div>
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
{/* Audio player */}
{audioUrl ? (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Response {latency && <span className="font-normal normal-case">&#9889; {latency}ms</span>}
</span>
<a href={audioUrl} download="speech.mp3" className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[14px]">download</span>
Download
</a>
</div>
<audio controls src={audioUrl} className="w-full" />
{/* JSON Response (if format is json) */}
{jsonResponse && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">JSON Response</span>
</div>
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify({
format: jsonResponse.format,
audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
}, null, 2)}
</pre>
</div>
)}
</div>
) : (
<p className="text-xs text-text-muted opacity-60">Audio will appear here after running.</p>
)}
</div>
</Card>
{/* Country Picker Modal */}
{modalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: "rgba(0,0,0,0.6)", backdropFilter: "blur(2px)" }}
onClick={() => setModalOpen(false)}
>
<div
className="border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[80vh]"
style={{ backgroundColor: "var(--color-bg)", isolation: "isolate" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 rounded-t-xl">
<h3 className="text-sm font-semibold">Select Language</h3>
<button onClick={() => setModalOpen(false)} className="text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
{/* Search */}
<div className="px-4 py-2.5 border-b border-border shrink-0">
<input
autoFocus
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search language..."
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
{/* Language list */}
<div className="overflow-y-auto flex-1 p-2">
{modalError && <p className="text-xs text-red-500 px-2 py-1">{modalError}</p>}
{modalLoading ? (
<p className="text-xs text-text-muted px-2 py-3">Loading...</p>
) : (
<div className="flex flex-col gap-0.5">
{filteredLanguages.map((c) => (
<button
key={c.code}
onClick={() => handlePickLanguage(c)}
className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-left hover:bg-sidebar transition-colors ${
selectedLang === c.code ? "bg-primary/10 text-primary" : ""
}`}
>
<span className="text-sm">{c.name}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-text-muted">{c.voices.length} voices</span>
{selectedLang === c.code && (
<span className="material-symbols-outlined text-[16px] text-primary">check</span>
)}
</div>
</button>
))}
{filteredLanguages.length === 0 && (
<p className="text-xs text-text-muted px-2 py-3">No languages found.</p>
)}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}
// MediaProviderDetailPage
export default function MediaProviderDetailPage() {
const { kind, id } = useParams();
const { copied, copy } = useCopyToClipboard();
const [headerImgError, setHeaderImgError] = useState(false);
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
if (!kindConfig) return notFound();
@@ -24,8 +732,6 @@ export default function MediaProviderDetailPage() {
const kinds = provider.serviceKinds ?? ["llm"];
if (!kinds.includes(kind)) return notFound();
const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`;
return (
<div className="flex flex-col gap-8">
{/* Back */}
@@ -40,21 +746,15 @@ export default function MediaProviderDetailPage() {
{/* Header */}
<div className="flex items-center gap-4">
<div className="rounded-lg flex items-center justify-center" style={{ backgroundColor: `${provider.color}15` }}>
{headerImgError ? (
<span className="size-12 flex items-center justify-center text-sm font-bold rounded-lg" style={{ color: provider.color }}>
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={`/providers/${provider.id}.png`}
alt={provider.name}
width={48} height={48}
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
sizes="48px"
onError={() => setHeaderImgError(true)}
/>
)}
<div className="size-12 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${provider.color}15` }}>
<ProviderIcon
src={`/providers/${provider.id}.png`}
alt={provider.name}
size={48}
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
fallbackColor={provider.color}
/>
</div>
<div>
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
@@ -69,25 +769,29 @@ export default function MediaProviderDetailPage() {
</div>
</div>
{/* Endpoint block */}
<Card padding="sm">
<p className="text-xs text-text-muted uppercase tracking-wider mb-2 font-semibold">{kindConfig.label} Endpoint</p>
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-primary bg-primary/10 px-2 py-0.5 rounded">
{kindConfig.endpoint.method}
</span>
<code className="text-sm font-mono text-text-main flex-1">{kindConfig.endpoint.path}</code>
<button onClick={() => copy(endpointText)} className="text-text-muted hover:text-text-main transition-colors" title="Copy">
<span className="material-symbols-outlined text-[18px]">{copied ? "check" : "content_copy"}</span>
</button>
</div>
</Card>
{/* Connections */}
{provider.noAuth ? (
<Card>
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div>
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">This provider is ready to use.</p>
</div>
</div>
</Card>
) : (
<ConnectionsCard providerId={id} isOAuth={false} />
)}
{/* Connections — reuse shared component */}
<ConnectionsCard providerId={id} isOAuth={false} />
{/* Models - only for non-tts kinds */}
{kind !== "tts" && <ModelsCard providerId={id} kindFilter={kind} />}
{/* Models — filtered by current kind */}
<ModelsCard providerId={id} kindFilter={kind} />
{/* Example — per kind */}
{kind === "embedding" && <EmbeddingExampleCard providerId={id} />}
{kind === "tts" && <TtsExampleCard providerId={id} />}
</div>
);
}

View File

@@ -2,44 +2,91 @@
import { useParams, notFound } from "next/navigation";
import Link from "next/link";
import { Card } from "@/shared/components";
import { useEffect, useState } from "react";
import { Card, Badge } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
function getEffectiveStatus(conn) {
const isCooldown = Object.entries(conn).some(
([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now()
);
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
}
function MediaProviderCard({ provider, kind, connections }) {
const providerInfo = AI_PROVIDERS[provider.id];
const isNoAuth = !!providerInfo?.noAuth;
const providerConns = connections.filter((c) => c.provider === provider.id);
const connected = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "active" || s === "success"; }).length;
const error = providerConns.filter((c) => { const s = getEffectiveStatus(c); return s === "error" || s === "expired" || s === "unavailable"; }).length;
const total = providerConns.length;
const allDisabled = total > 0 && providerConns.every((c) => c.isActive === false);
const renderStatus = () => {
if (isNoAuth) return <Badge variant="success" size="sm">Ready</Badge>;
if (allDisabled) return <Badge variant="default" size="sm">Disabled</Badge>;
if (total === 0) return <span className="text-xs text-text-muted">No connections</span>;
return (
<>
{connected > 0 && <Badge variant="success" size="sm" dot>{connected} Connected</Badge>}
{error > 0 && <Badge variant="error" size="sm" dot>{error} Error</Badge>}
{connected === 0 && error === 0 && <Badge variant="default" size="sm">{total} Added</Badge>}
</>
);
};
return (
<Link href={`/dashboard/media-providers/${kind}/${provider.id}`} className="group">
<Card
padding="xs"
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-3">
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : (provider.color ?? "#888") + "15"}` }}
>
<ProviderIcon
src={`/providers/${provider.id}.png`}
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
fallbackColor={provider.color}
/>
</div>
<div>
<h3 className="font-semibold text-sm">{provider.name}</h3>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{renderStatus()}
</div>
</div>
</div>
</Card>
</Link>
);
}
export default function MediaProviderKindPage() {
const { kind } = useParams();
const { copied, copy } = useCopyToClipboard();
const [connections, setConnections] = useState([]);
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
if (!kindConfig) return notFound();
const providers = getProvidersByKind(kind);
const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`;
useEffect(() => {
fetch("/api/providers", { cache: "no-store" })
.then((r) => r.json())
.then((d) => setConnections(d.connections || []))
.catch(() => {});
}, []);
return (
<div className="flex flex-col gap-6">
{/* Endpoint block */}
<Card padding="sm">
<p className="text-xs text-text-muted uppercase tracking-wider mb-2 font-semibold">Endpoint</p>
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-primary bg-primary/10 px-2 py-0.5 rounded">
{kindConfig.endpoint.method}
</span>
<code className="text-sm font-mono text-text-main flex-1">{kindConfig.endpoint.path}</code>
<button
onClick={() => copy(endpointText)}
className="text-text-muted hover:text-text-main transition-colors"
title="Copy endpoint"
>
<span className="material-symbols-outlined text-[18px]">
{copied ? "check" : "content_copy"}
</span>
</button>
</div>
</Card>
{/* Provider list */}
{providers.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border rounded-xl text-text-muted text-sm">
No providers support <strong>{kindConfig.label}</strong> yet.
@@ -47,29 +94,12 @@ export default function MediaProviderKindPage() {
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{providers.map((provider) => (
<Link key={provider.id} href={`/dashboard/media-providers/${kind}/${provider.id}`}>
<Card padding="xs" className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer">
<div className="flex items-center gap-3">
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : (provider.color ?? "#888") + "15"}` }}
>
<ProviderIcon
src={`/providers/${provider.id}.png`}
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
fallbackColor={provider.color}
/>
</div>
<div>
<p className="font-semibold text-sm">{provider.name}</p>
<p className="text-xs text-text-muted">{(provider.serviceKinds ?? ["llm"]).join(", ")}</p>
</div>
</div>
</Card>
</Link>
<MediaProviderCard
key={provider.id}
provider={provider}
kind={kind}
connections={connections}
/>
))}
</div>
)}

View File

@@ -549,10 +549,11 @@ export default function ProviderDetailPage() {
);
}
// Combine hardcoded models with Kilo free models (deduplicated)
// Exclude non-llm models (embedding, tts, etc.) — they have dedicated pages under media-providers
const displayModels = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
];
].filter((m) => !m.type || m.type === "llm");
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
const customModels = Object.entries(modelAliases)
.filter(([alias, fullModel]) => {
@@ -1579,7 +1580,7 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov
};
return (
<div className={`group flex items-center justify-between p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}>
<div className={`group flex items-center justify-between p-2 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Priority arrows */}
<div className="flex flex-col">

View File

@@ -104,7 +104,7 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov
};
return (
<div className={`group flex items-center justify-between p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}>
<div className={`group flex items-center justify-between p2 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex flex-col">
<button onClick={onMoveUp} disabled={isFirst} className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}>

View File

@@ -157,7 +157,7 @@ export default function ModelsCard({ providerId, kindFilter }) {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: `${providerAlias}/${modelId}` }),
body: JSON.stringify({ model: `${providerAlias}/${modelId}`, kind: kindFilter }),
});
const data = await res.json();
setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" }));

View File

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

View File

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

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

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

View File

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

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

View File

@@ -8,11 +8,43 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
import { ThemeToggle, LanguageSwitcher } from "@/shared/components";
import NineRemoteButton from "@/shared/components/NineRemoteButton";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
import { translate } from "@/i18n/runtime";
const getPageInfo = (pathname) => {
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
// Media provider detail: /dashboard/media-providers/[kind]/[id]
const mediaDetailMatch = pathname.match(/\/media-providers\/([^/]+)\/([^/]+)$/);
if (mediaDetailMatch) {
const kindId = mediaDetailMatch[1];
const providerId = mediaDetailMatch[2];
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kindId);
const provider = AI_PROVIDERS[providerId];
return {
title: provider?.name || providerId,
description: "",
breadcrumbs: [
{ label: "Media Providers", href: `/dashboard/media-providers/${kindId}` },
{ label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` },
{ label: provider?.name || providerId, image: `/providers/${providerId}.png` },
],
};
}
// Media provider kind: /dashboard/media-providers/[kind]
const mediaKindMatch = pathname.match(/\/media-providers\/([^/]+)$/);
if (mediaKindMatch) {
const kindId = mediaKindMatch[1];
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kindId);
return {
title: kindConfig?.label || kindId,
description: `Manage your ${kindConfig?.label || kindId} providers`,
icon: kindConfig?.icon || "perm_media",
breadcrumbs: [],
};
}
// Provider detail page: /dashboard/providers/[id]
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
if (providerMatch) {
@@ -34,7 +66,7 @@ const getPageInfo = (pathname) => {
}
}
if (pathname.includes("/providers"))
if (pathname.includes("/providers") && !pathname.includes("/media-providers"))
return {
title: "Providers",
description: "Manage your AI provider connections",

View File

@@ -10,6 +10,8 @@ import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
import Button from "./Button";
import { ConfirmModal } from "./Modal";
const VISIBLE_MEDIA_KINDS = ["embedding", "tts"];
const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
@@ -134,49 +136,49 @@ export default function Sidebar({ onClose }) {
</Link>
))}
{/* Media Providers accordion */}
{/* <button
onClick={() => setMediaOpen((v) => !v)}
className={cn(
"w-full flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
pathname.startsWith("/dashboard/media-providers")
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
)}
>
<span className="material-symbols-outlined text-[18px]">perm_media</span>
<span className="text-sm font-medium flex-1 text-left">Media Providers</span>
<span className="material-symbols-outlined text-[14px] transition-transform" style={{ transform: mediaOpen ? "rotate(180deg)" : "rotate(0deg)" }}>
expand_more
</span>
</button> */}
{mediaOpen && (
<div className="pl-4">
{MEDIA_PROVIDER_KINDS.map((kind) => (
<Link
key={kind.id}
href={`/dashboard/media-providers/${kind.id}`}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
)}
>
<span className="material-symbols-outlined text-[16px]">{kind.icon}</span>
<span className="text-sm">{kind.label}</span>
</Link>
))}
</div>
)}
{/* System section */}
<div className="pt-4 mt-2">
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
System
</p>
{/* Media Providers accordion */}
<button
onClick={() => setMediaOpen((v) => !v)}
className={cn(
"w-full flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
pathname.startsWith("/dashboard/media-providers")
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
)}
>
<span className="material-symbols-outlined text-[18px]">perm_media</span>
<span className="text-sm font-medium flex-1 text-left">Media Providers</span>
<span className="material-symbols-outlined text-[14px] transition-transform" style={{ transform: mediaOpen ? "rotate(180deg)" : "rotate(0deg)" }}>
expand_more
</span>
</button>
{mediaOpen && (
<div className="pl-4">
{MEDIA_PROVIDER_KINDS.filter((k) => VISIBLE_MEDIA_KINDS.includes(k.id)).map((kind) => (
<Link
key={kind.id}
href={`/dashboard/media-providers/${kind.id}`}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
)}
>
<span className="material-symbols-outlined text-[16px]">{kind.icon}</span>
<span className="text-sm">{kind.label}</span>
</Link>
))}
</div>
)}
{systemItems.map((item) => (
<Link
key={item.href}

View File

@@ -18,7 +18,7 @@ export const FREE_TIER_PROVIDERS = {
nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim", notice: { text: "Free access for NVIDIA Developer Program members (prototyping & testing).", apiKeyUrl: "https://build.nvidia.com/settings/api-keys" } },
ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo · Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/keys" } },
vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } },
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev" },
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding"] },
};
// OAuth Providers
@@ -62,11 +62,14 @@ export const APIKEY_PROVIDERS = {
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com", serviceKinds: ["stt"] },
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai", serviceKinds: ["image"] },
elevenlabs: { id: "elevenlabs", alias: "el", name: "ElevenLabs", icon: "record_voice_over", color: "#6C47FF", textIcon: "EL", website: "https://elevenlabs.io", serviceKinds: ["tts"] },
cartesia: { id: "cartesia", alias: "cartesia", name: "Cartesia", icon: "spatial_audio", color: "#FF4F8B", textIcon: "CA", website: "https://cartesia.ai", serviceKinds: ["tts"] },
playht: { id: "playht", alias: "playht", name: "PlayHT", icon: "play_circle", color: "#00B4D8", textIcon: "PH", website: "https://play.ht", serviceKinds: ["tts"] },
cartesia: { id: "cartesia", alias: "cartesia", name: "Cartesia", icon: "spatial_audio", color: "#FF4F8B", textIcon: "CA", website: "https://cartesia.ai", serviceKinds: ["tts"], hidden: true },
playht: { id: "playht", alias: "playht", name: "PlayHT", icon: "play_circle", color: "#00B4D8", textIcon: "PH", website: "https://play.ht", serviceKinds: ["tts"], hidden: true },
"local-device": { id: "local-device", alias: "local-device", name: "Local Device", icon: "speaker", color: "#64748B", textIcon: "LD", serviceKinds: ["tts"], noAuth: true },
"google-tts": { id: "google-tts", alias: "google-tts", name: "Google TTS", icon: "record_voice_over", color: "#4285F4", textIcon: "GT", serviceKinds: ["tts"], noAuth: true },
"edge-tts": { id: "edge-tts", alias: "edge-tts", name: "Edge TTS", icon: "record_voice_over", color: "#0078D4", textIcon: "ET", serviceKinds: ["tts"], noAuth: true },
sdwebui: { id: "sdwebui", alias: "sdwebui", name: "SD WebUI", icon: "brush", color: "#FF7043", textIcon: "SD", website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", serviceKinds: ["image"] },
comfyui: { id: "comfyui", alias: "comfyui", name: "ComfyUI", icon: "account_tree", color: "#4CAF50", textIcon: "CF", website: "https://github.com/comfyanonymous/ComfyUI", serviceKinds: ["image"] },
huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", serviceKinds: ["embedding", "image", "tts"] },
huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", serviceKinds: ["image", "tts"], hiddenKinds: ["tts"] },
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" },
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" },
@@ -76,7 +79,7 @@ export const APIKEY_PROVIDERS = {
export const MEDIA_PROVIDER_KINDS = [
{ id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } },
{ id: "image", label: "Image", icon: "image", endpoint: { method: "POST", path: "/v1/images/generations" } },
{ id: "tts", label: "TTS", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } },
{ id: "tts", label: "Text To Speech", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } },
{ id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } },
{ id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } },
@@ -141,7 +144,10 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
export function getProvidersByKind(kind) {
return Object.values(AI_PROVIDERS).filter((p) => {
const kinds = p.serviceKinds ?? ["llm"];
return kinds.includes(kind);
if (!kinds.includes(kind)) return false;
if (p.hidden) return false; // globally hidden
if (p.hiddenKinds?.includes(kind)) return false; // hidden for specific kind
return true;
});
}

View File

@@ -0,0 +1,41 @@
/**
* TTS Provider Configuration
* Centralized config for TTS provider UI behavior
*/
export const TTS_PROVIDER_CONFIG = {
"google-tts": {
hasLanguageDropdown: true,
hasModelSelector: false,
hasBrowseButton: false,
voiceSource: "hardcoded", // from providerModels
},
"openai": {
hasLanguageDropdown: false,
hasModelSelector: true,
hasBrowseButton: false,
voiceSource: "hardcoded", // from providerModels
modelKey: "openai-tts-models",
voiceKey: "openai-tts-voices",
},
"elevenlabs": {
hasLanguageDropdown: false,
hasModelSelector: true,
hasBrowseButton: true,
hasVoiceIdInput: true, // allow manual voice id entry
voiceSource: "api-language", // grouped by language from backend
modelKey: "elevenlabs-tts-models",
apiEndpoint: "/api/media-providers/tts/elevenlabs/voices",
},
"edge-tts": {
hasLanguageDropdown: false,
hasModelSelector: false,
hasBrowseButton: true,
voiceSource: "api-language", // from API with language picker
},
"local-device": {
hasLanguageDropdown: false,
hasModelSelector: false,
hasBrowseButton: true,
voiceSource: "api-language", // from API with language picker
},
};

85
src/sse/handlers/tts.js Normal file
View File

@@ -0,0 +1,85 @@
import {
extractApiKey, isValidApiKey,
getProviderCredentials, markAccountUnavailable,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js";
import { handleTtsCore } from "open-sse/handlers/ttsCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import * as log from "../utils/logger.js";
// Providers that require stored credentials (not noAuth)
const CREDENTIALED_PROVIDERS = new Set(["openai", "elevenlabs"]);
export async function handleTts(request) {
let body;
try {
body = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const url = new URL(request.url);
const modelStr = body.model;
const responseFormat = url.searchParams.get("response_format") || "mp3"; // mp3 (default) | json
log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}`);
const settings = await getSettings();
if (settings.requireApiKey) {
const apiKey = extractApiKey(request);
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
if (!body.input) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
const { provider, model } = modelInfo;
log.info("ROUTING", `Provider: ${provider}, Voice: ${model}`);
// noAuth providers — no credential needed
if (!CREDENTIALED_PROVIDERS.has(provider)) {
const result = await handleTtsCore({ provider, model, input: body.input, responseFormat });
if (result.success) return result.response;
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "TTS failed");
}
// Credentialed providers — fallback loop (same pattern as embeddings)
const excludeConnectionIds = new Set();
let lastError = null;
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const msg = lastError || credentials.lastError || "Unavailable";
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
return unavailableResponse(status, `[${provider}/${model}] ${msg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
if (excludeConnectionIds.size === 0) return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat });
if (result.success) return result.response;
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
if (shouldFallback) {
excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
continue;
}
return result.response || errorResponse(result.status, result.error);
}
}