From 512e3de371f6b6a9b1012380bd72bbdcdd475eed Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 29 Apr 2026 11:34:39 +0700 Subject: [PATCH] Update version to 0.4.9, enhance README with Trendshift badge, and add new embedding models to providerModels.js. Refactor TTS handling to support additional providers and improve API key validation for media providers. --- README.md | 2 + open-sse/config/providerModels.js | 18 ++ open-sse/handlers/embeddingsCore.js | 37 ++- open-sse/handlers/ttsCore.js | 256 ++++++++++++++++-- package.json | 2 +- public/providers/coqui.png | Bin 0 -> 16367 bytes public/providers/inworld.png | Bin 0 -> 2606 bytes public/providers/tortoise.png | Bin 0 -> 3442 bytes public/providers/voyage-ai.png | Bin 0 -> 1223 bytes .../media-providers/[kind]/[id]/page.js | 36 ++- .../tts/deepgram/voices/route.js | 65 +++++ .../tts/inworld/voices/route.js | 61 +++++ src/app/api/providers/validate/route.js | 46 ++++ src/mitm/config.js | 7 +- src/mitm/server.js | 10 +- src/shared/components/ProviderInfoCard.js | 9 + src/shared/constants/cliTools.js | 2 +- src/shared/constants/providers.js | 48 ++-- src/shared/constants/ttsProviders.js | 61 +++++ src/sse/handlers/tts.js | 9 +- 20 files changed, 586 insertions(+), 83 deletions(-) create mode 100644 public/providers/coqui.png create mode 100644 public/providers/inworld.png create mode 100644 public/providers/tortoise.png create mode 100644 public/providers/voyage-ai.png create mode 100644 src/app/api/media-providers/tts/deepgram/voices/route.js create mode 100644 src/app/api/media-providers/tts/inworld/voices/route.js diff --git a/README.md b/README.md index c5c959c..8fd575e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + decolua%2F9router | Trendshift [๐Ÿš€ Quick Start](#-quick-start) โ€ข [๐Ÿ’ก Features](#-key-features) โ€ข [๐Ÿ“– Setup](#-setup-guide) โ€ข [๐ŸŒ Website](https://9router.com) diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 86043b4..58e2d11 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -105,6 +105,9 @@ export const PROVIDER_MODELS = { { id: "grok-code-fast-1", name: "Grok Code Fast 1" }, { id: "oswe-vscode-prime", name: "Raptor Mini" }, { id: "goldeneye-free-auto", name: "GoldenEye" }, + // GitHub Copilot - Embedding models + { id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", type: "embedding" }, + { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", type: "embedding" }, ], kr: [ // Kiro AI // { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, @@ -378,6 +381,7 @@ export const PROVIDER_MODELS = { { id: "mistral-large-latest", name: "Mistral Large 3" }, { id: "codestral-latest", name: "Codestral" }, { id: "mistral-medium-latest", name: "Mistral Medium 3" }, + { id: "mistral-embed", name: "Mistral Embed", type: "embedding" }, ], perplexity: [ { id: "sonar-pro", name: "Sonar Pro" }, @@ -388,11 +392,14 @@ export const PROVIDER_MODELS = { { id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" }, { id: "Qwen/Qwen3-235B-A22B", name: "Qwen3 235B" }, { id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" }, + { id: "BAAI/bge-large-en-v1.5", name: "BGE Large EN v1.5", type: "embedding" }, + { id: "togethercomputer/m2-bert-80M-8k-retrieval", name: "M2 BERT 80M 8K", type: "embedding" }, ], fireworks: [ { id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1" }, { id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B" }, { id: "accounts/fireworks/models/qwen3-235b-a22b", name: "Qwen3 235B" }, + { id: "nomic-ai/nomic-embed-text-v1.5", name: "Nomic Embed Text v1.5", type: "embedding" }, ], cerebras: [ { id: "gpt-oss-120b", name: "GPT OSS 120B" }, @@ -410,9 +417,20 @@ export const PROVIDER_MODELS = { nvidia: [ { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" }, { id: "z-ai/glm4.7", name: "GLM 4.7" }, + { id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", type: "embedding" }, ], nebius: [ { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" }, + { id: "Qwen/Qwen3-Embedding-8B", name: "Qwen3 Embedding 8B", type: "embedding" }, + ], + "voyage-ai": [ + { id: "voyage-3-large", name: "Voyage 3 Large", type: "embedding" }, + { id: "voyage-3.5", name: "Voyage 3.5", type: "embedding" }, + { id: "voyage-3.5-lite", name: "Voyage 3.5 Lite", type: "embedding" }, + { id: "voyage-code-3", name: "Voyage Code 3", type: "embedding" }, + { id: "voyage-finance-2", name: "Voyage Finance 2", type: "embedding" }, + { id: "voyage-law-2", name: "Voyage Law 2", type: "embedding" }, + { id: "voyage-multilingual-2", name: "Voyage Multilingual 2", type: "embedding" }, ], siliconflow: [ { id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" }, diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js index e6e98fc..b210b68 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.js @@ -7,6 +7,19 @@ import { refreshWithRetry } from "../services/tokenRefresh.js"; // Google AI (Gemini) provider aliases / identifiers const GEMINI_PROVIDERS = new Set(["gemini", "google_ai_studio"]); +// Static map: provider id โ†’ embeddings endpoint (OpenAI-compatible body format) +const EMBEDDING_URLS = { + openai: "https://api.openai.com/v1/embeddings", + openrouter: "https://openrouter.ai/api/v1/embeddings", + mistral: "https://api.mistral.ai/v1/embeddings", + "voyage-ai": "https://api.voyageai.com/v1/embeddings", + fireworks: "https://api.fireworks.ai/inference/v1/embeddings", + together: "https://api.together.xyz/v1/embeddings", + nebius: "https://api.tokenfactory.nebius.com/v1/embeddings", + github: "https://models.github.ai/inference/embeddings", + nvidia: "https://integrate.api.nvidia.com/v1/embeddings", +}; + /** * Check whether a provider targets the Google AI (Gemini) embeddings API. * @param {string} provider @@ -77,22 +90,16 @@ function buildEmbeddingsUrl(provider, model, credentials, input) { return `https://generativelanguage.googleapis.com/v1beta/${modelPath}:embedContent?key=${encodeURIComponent(apiKey)}`; } - switch (provider) { - case "openai": - return "https://api.openai.com/v1/embeddings"; - case "openrouter": - return "https://openrouter.ai/api/v1/embeddings"; - default: - // openai-compatible & custom-embedding providers: use their baseUrl + /embeddings - if (provider?.startsWith?.("openai-compatible-") || provider?.startsWith?.("custom-embedding-")) { - const rawBaseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; - // Defensive: strip trailing slash and accidental /embeddings to avoid double-append - const baseUrl = rawBaseUrl.replace(/\/$/, "").replace(/\/embeddings$/, ""); - return `${baseUrl}/embeddings`; - } - // For other providers, attempt to use their base URL pattern with /embeddings path - return null; + if (EMBEDDING_URLS[provider]) return EMBEDDING_URLS[provider]; + + // openai-compatible & custom-embedding providers: use their baseUrl + /embeddings + if (provider?.startsWith?.("openai-compatible-") || provider?.startsWith?.("custom-embedding-")) { + const rawBaseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; + // Defensive: strip trailing slash and accidental /embeddings to avoid double-append + const baseUrl = rawBaseUrl.replace(/\/$/, "").replace(/\/embeddings$/, ""); + return `${baseUrl}/embeddings`; } + return null; } /** diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js index 9618b7a..233719f 100644 --- a/open-sse/handlers/ttsCore.js +++ b/open-sse/handlers/ttsCore.js @@ -455,7 +455,209 @@ async function handleOpenAiTts({ model, input, credentials, responseFormat = "mp return createTtsResponse(base64, "mp3", responseFormat); } -// โ”€โ”€ TTS Provider Registry (DRY) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ Generic TTS Format Handlers (config-driven via ttsConfig.format) โ”€โ”€โ”€โ”€โ”€โ”€ +// Parse `model` string as "modelId/voiceId" or "modelId" (modelId may contain slashes โ€” match against known list) +function parseModelVoice(model, defaultModel = "", defaultVoice = "", knownModels = []) { + if (!model) return { modelId: defaultModel, voiceId: defaultVoice }; + // Find longest known model id that prefixes `model` + const known = knownModels.map((m) => m.id || m).filter(Boolean).sort((a, b) => b.length - a.length); + for (const id of known) { + if (model === id) return { modelId: id, voiceId: defaultVoice }; + if (model.startsWith(`${id}/`)) return { modelId: id, voiceId: model.slice(id.length + 1) }; + } + // Fallback: split on last "/" so "vendor/model/voice" โ†’ model="vendor/model", voice="voice" + const idx = model.lastIndexOf("/"); + if (idx > 0) return { modelId: model.slice(0, idx), voiceId: model.slice(idx + 1) }; + return { modelId: defaultModel || model, voiceId: defaultVoice || model }; +} + +// Convert upstream Response (binary audio) to { base64, format } +async function responseToBase64(res, defaultFormat = "mp3") { + const buf = await res.arrayBuffer(); + if (buf.byteLength < 100) throw new Error("Upstream returned empty audio"); + const ctype = res.headers.get("content-type") || ""; + let format = defaultFormat; + if (ctype.includes("wav")) format = "wav"; + else if (ctype.includes("mpeg") || ctype.includes("mp3")) format = "mp3"; + else if (ctype.includes("ogg")) format = "ogg"; + return { base64: Buffer.from(buf).toString("base64"), format }; +} + +async function throwUpstreamError(res) { + const text = await res.text().catch(() => ""); + let msg = `Upstream error (${res.status})`; + try { + const parsed = JSON.parse(text); + msg = parsed?.error?.message || parsed?.message || parsed?.detail?.message || (typeof parsed?.detail === "string" ? parsed.detail : null) || text || msg; + } catch { msg = text || msg; } + throw new Error(msg); +} + +// Hyperbolic: POST { text } โ†’ { audio: base64 } +async function ttsHyperbolic({ baseUrl, apiKey, text }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ text }), + }); + if (!res.ok) await throwUpstreamError(res); + const data = await res.json(); + return { base64: data.audio, format: "mp3" }; +} + +// Deepgram: model via query, Token auth, returns binary +async function ttsDeepgram({ baseUrl, apiKey, text, modelId }) { + const url = new URL(baseUrl); + url.searchParams.set("model", modelId || "aura-asteria-en"); + const res = await fetch(url.toString(), { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Token ${apiKey}` }, + body: JSON.stringify({ text }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// Nvidia NIM: POST { input: { text }, voice, model } โ†’ binary +async function ttsNvidia({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ input: { text }, voice: voiceId || "default", model: modelId }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// HuggingFace: POST {baseUrl}/{modelId} { inputs: text } โ†’ binary +async function ttsHuggingFace({ baseUrl, apiKey, text, modelId }) { + if (!modelId || modelId.includes("..")) throw new Error("Invalid HuggingFace model ID"); + const res = await fetch(`${baseUrl}/${modelId}`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ inputs: text }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// Inworld: POST { text, voiceId, modelId, audioConfig } โ†’ JSON { audioContent } +async function ttsInworld({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Basic ${apiKey}` }, + body: JSON.stringify({ + text, + voiceId: voiceId || "Alex", + modelId: modelId || "inworld-tts-1.5-mini", + audioConfig: { audioEncoding: "MP3" }, + }), + }); + if (!res.ok) await throwUpstreamError(res); + const data = await res.json(); + if (!data.audioContent) throw new Error("Inworld TTS returned no audio"); + return { base64: data.audioContent, format: "mp3" }; +} + +// Cartesia: POST { model_id, transcript, voice, output_format } โ†’ binary +async function ttsCartesia({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": apiKey, + "Cartesia-Version": "2024-06-10", + }, + body: JSON.stringify({ + model_id: modelId || "sonic-2", + transcript: text, + ...(voiceId ? { voice: { mode: "id", id: voiceId } } : {}), + output_format: { container: "mp3", bit_rate: 128000, sample_rate: 44100 }, + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// PlayHT: token format "userId:apiKey", voice = s3 URL +async function ttsPlayHt({ baseUrl, apiKey, text, modelId, voiceId }) { + const [userId, key] = (apiKey || ":").split(":"); + const res = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "audio/mpeg", + "X-USER-ID": userId || "", + "Authorization": `Bearer ${key || apiKey}`, + }, + body: JSON.stringify({ + text, + voice: voiceId || "s3://voice-cloning-zero-shot/d9ff78ba-d016-47f6-b0ef-dd630f59414e/female-cs/manifest.json", + voice_engine: modelId || "PlayDialog", + output_format: "mp3", + speed: 1, + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// Coqui (local, noAuth): POST { text, speaker_id } โ†’ WAV +async function ttsCoqui({ baseUrl, text, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, ...(voiceId ? { speaker_id: voiceId } : {}) }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// Tortoise (local, noAuth): POST { text, voice } โ†’ binary +async function ttsTortoise({ baseUrl, text, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, voice: voiceId || "random" }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// OpenAI-compatible (qwen3-tts, openai-compat): POST { model, input, voice } โ†’ binary +async function ttsOpenAiCompat({ baseUrl, apiKey, text, modelId, voiceId }) { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify({ + model: modelId, + input: text, + voice: voiceId || "alloy", + response_format: "mp3", + speed: 1.0, + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// Format โ†’ handler dispatcher (DRY) +const FORMAT_HANDLERS = { + hyperbolic: ttsHyperbolic, + deepgram: ttsDeepgram, + "nvidia-tts": ttsNvidia, + "huggingface-tts": ttsHuggingFace, + inworld: ttsInworld, + cartesia: ttsCartesia, + playht: ttsPlayHt, + coqui: ttsCoqui, + tortoise: ttsTortoise, + openai: ttsOpenAiCompat, +}; + +// โ”€โ”€ TTS Provider Registry (legacy noAuth + special providers) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const TTS_PROVIDERS = { "google-tts": { synthesize: async (text, model) => { @@ -480,15 +682,10 @@ const TTS_PROVIDERS = { }, "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" + if (!credentials?.apiKey) throw new Error("ElevenLabs API key required"); let modelId = "eleven_flash_v2_5"; let voiceId = model; - if (model && model.includes("/")) { - [modelId, voiceId] = model.split("/"); - } + if (model && model.includes("/")) [modelId, voiceId] = model.split("/"); const base64 = await elevenlabsTts(text, voiceId, credentials.apiKey, modelId); return { base64, format: "mp3" }; }, @@ -508,15 +705,24 @@ const TTS_PROVIDERS = { }, }; +// โ”€โ”€ Generic dispatcher: providers with ttsConfig.format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Resolves to TTS_PROVIDERS first; falls back to ttsConfig.format dispatch. +async function synthesizeViaConfig(provider, text, model, credentials) { + const { AI_PROVIDERS } = await import("@/shared/constants/providers"); + const cfg = AI_PROVIDERS[provider]?.ttsConfig; + if (!cfg) return null; + const handler = FORMAT_HANDLERS[cfg.format]; + if (!handler) return null; + const apiKey = credentials?.apiKey; + if (cfg.authType !== "none" && !apiKey) throw new Error(`${provider} API key required`); + const defaultModel = cfg.models?.[0]?.id || ""; + const { modelId, voiceId } = parseModelVoice(model, defaultModel, "", cfg.models || []); + return handler({ baseUrl: cfg.baseUrl, apiKey, text, modelId, voiceId }); +} + // โ”€โ”€ 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" }) { @@ -525,18 +731,20 @@ export async function handleTtsCore({ provider, model, input, credentials, respo } 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); + // Legacy/special providers (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter) + if (ttsProvider) { + const result = await ttsProvider.synthesize(input.trim(), model, credentials, responseFormat); + if (result.success !== undefined) return result; + return createTtsResponse(result.base64, result.format, responseFormat); + } + + // Generic config-driven dispatcher (hyperbolic, deepgram, nvidia, huggingface, inworld, cartesia, playht, coqui, tortoise, qwen, ...) + const result = await synthesizeViaConfig(provider, input.trim(), model, credentials); + if (result) return createTtsResponse(result.base64, result.format, responseFormat); + + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support TTS via this route.`); } catch (err) { return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "TTS synthesis failed"); } diff --git a/package.json b/package.json index 238fe47..cfe1017 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.8", + "version": "0.4.9", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/providers/coqui.png b/public/providers/coqui.png new file mode 100644 index 0000000000000000000000000000000000000000..6e2471ec45356ea032b5ef647c49ea1eb1ec1a0a GIT binary patch literal 16367 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 literal 0 HcmV?d00001 diff --git a/public/providers/inworld.png b/public/providers/inworld.png new file mode 100644 index 0000000000000000000000000000000000000000..579a6c244a9e13401ebda83743e59ee3d3d8e1bb GIT binary patch literal 2606 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 Qr2qf`07*qoM6N<$f|xY-&j0`b literal 0 HcmV?d00001 diff --git a/public/providers/tortoise.png b/public/providers/tortoise.png new file mode 100644 index 0000000000000000000000000000000000000000..70e93fcc50a71c5f032dc69afbdfa0b09c676ef6 GIT binary patch literal 3442 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 literal 0 HcmV?d00001 diff --git a/public/providers/voyage-ai.png b/public/providers/voyage-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..72c41963e5ed62ef7c68ed6e0148b5ed877319cb GIT binary patch literal 1223 zcmV;&1UUPNP)C0001cP)t-s|Ns91 zE;9ftFulge6g)uyC@lacEc^WY05LWIBPjqMCHVRI@AC5J>+9a*aIK44`#TV7Xoc~x(7rLweeiHjgXMRJCTQD7e|QPRI{WtPBkM|w z4Uj}Jc79{>(QO9-xQy95IL(zJt%c3PqD`0ek-y_vpm>=4EfdfAYkdTO@u84_{3(^Q zG3XYxt3d+)!rwupl1*+*Vb?*&4WN)`0of@$Y;0rYO_w7}px}sChlQ*<3y{?UUJZ9% z)1Vm?5Pa;uK61B|OVIcvCXfL)4FWtQ;Ev$z8ivfl2BxWw^^teRsuJAc=MyagAoR;6 zm^L%z`3eM3@1n}_Z$&x8lSaj8A7PyWAUEZnNSbtJ!3t6^QFibu)@Lq3+(3db7eLLu zKH;T&0}U+6`(Xmmq;b`#N3x__|nmbp9#4Hw9enw6A++V zBl3zTSyGL|L)E|+jTw-!;3u)hk^;~Qx9`Qv5$!3)vrj)fvI9f9wW4JROQPEkKl4{? z1IkT&J+3H5_6G?TtP>*Y*O+>a4tM2hOA2-@1Dw15^`9)MQiAaW1khRoa@?c1B~>*V z{)NI3(4K-BYav0^EolY<=;_4X@X|tos#{Vq(zy4Kf*G}zhA#gZ(0IRe0*pG10nOEi}abnvL_efT3= zQ48LLhC7_2KFv}=lHk>n@|J=2Dgd%>Vs7?Qk71hg(JFPY;1ax8Qra_hr}WL(jkVcC-0l(GWVZw6$@mLy34mnWiQB2t5U7nXFl#9tu;`tjjsN-YV7 zVkFjg9jwNZT$x)ERN>Yi2GkIiL<&oS@}N4+ZHpx=sW|l;=EGFaNT{BTxFv1Dl6LG2 zHXEPlK(Ztz0ZdaH&(OLfEQ#C@`#i?pNB=dTz>0+R9mh$(lY$x6=8{~JVS4d$T<^h` lj)1{nFc=I5gTY|X#Xq+GFAra^!002ovPDHLkV1jp(IRO9w literal 0 HcmV?d00001 diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index 3cc8c56..13d2e81 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -364,6 +364,8 @@ function TtsExampleCard({ providerId }) { const [countryVoices, setCountryVoices] = useState([]); const [selectedLang, setSelectedLang] = useState(""); const [selectedModel, setSelectedModel] = useState(() => { + const cfgModels = AI_PROVIDERS[providerId]?.ttsConfig?.models; + if (cfgModels?.length) return cfgModels[0].id; if (config.hasModelSelector && config.modelKey) { const models = getModelsByProviderId(config.modelKey); return models?.[0]?.id || ""; @@ -430,6 +432,8 @@ function TtsExampleCard({ providerId }) { } } // api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language + // config (nvidia, hyperbolic, deepgram, huggingface, cartesia, playht, coqui, tortoise, inworld, qwen): + // use ttsConfig.models for model selector; voice is empty by default (backend uses provider default) }, [providerId]); // Update voices when model changes (voicesPerModel providers) @@ -501,11 +505,14 @@ function TtsExampleCard({ providerId }) { : 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}` : ""; + // For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice + const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice; + const modelFull = (() => { + if (config.hasModelSelector && selectedModel && activeVoiceId) return `${providerAlias}/${selectedModel}/${activeVoiceId}`; + if (config.hasModelSelector && selectedModel) return `${providerAlias}/${selectedModel}`; + if (activeVoiceId) return `${providerAlias}/${activeVoiceId}`; + return ""; + })(); const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\ -H "Content-Type: application/json" \\ @@ -584,15 +591,17 @@ function TtsExampleCard({ providerId }) { - {/* Model selector (OpenAI, ElevenLabs) */} - {config.hasModelSelector && config.modelKey && ( + {/* Model selector โ€” prefer ttsConfig.models, else providerModels via modelKey */} + {config.hasModelSelector && (config.modelKey || AI_PROVIDERS[providerId]?.ttsConfig?.models?.length) && ( @@ -1446,13 +1455,14 @@ export default function MediaProviderDetailPage() { /> )} - {/* Provider Info โ€” config-driven, supports searchConfig, fetchConfig, searchViaChat */} - {!isCustom && (provider.searchConfig || provider.fetchConfig || provider.searchViaChat) && ( + {/* Provider Info โ€” config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, searchViaChat */} + {!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.embeddingConfig || provider.searchViaChat) && ( ""); + return NextResponse.json({ error: `Deepgram API ${res.status}: ${text || "Failed"}` }, { status: 502 }); + } + const data = await res.json(); + const ttsModels = data.tts || []; + + const byLang = {}; + for (const m of ttsModels) { + // Deepgram returns `languages: ["en"]` or sometimes language inferred from canonical_name suffix + const langs = Array.isArray(m.languages) && m.languages.length + ? m.languages + : [m.canonical_name?.split("-").pop() || "en"]; + for (const code of langs) { + if (!byLang[code]) { + byLang[code] = { + code, + name: (() => { try { return langNames.of(code); } catch { return code; } })(), + voices: [], + }; + } + const voiceId = m.canonical_name || m.name; + if (!byLang[code].voices.find((x) => x.id === voiceId)) { + byLang[code].voices.push({ + id: voiceId, + name: m.name || voiceId, + gender: m.metadata?.tags?.find((t) => t === "masculine" || t === "feminine") || "", + lang: code, + }); + } + } + } + + const languages = Object.values(byLang).sort((a, b) => a.name.localeCompare(b.name)); + + 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 }); + } +} diff --git a/src/app/api/media-providers/tts/inworld/voices/route.js b/src/app/api/media-providers/tts/inworld/voices/route.js new file mode 100644 index 0000000..fb904e5 --- /dev/null +++ b/src/app/api/media-providers/tts/inworld/voices/route.js @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections } from "@/lib/localDb"; + +const langNames = new Intl.DisplayNames(["en"], { type: "language" }); + +/** + * GET /api/media-providers/tts/inworld/voices[?lang=en] + * Returns { languages, byLang } grouped by language code (same shape as edge-tts/elevenlabs) + */ +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const langFilter = searchParams.get("lang"); + + const connections = await getProviderConnections({ provider: "inworld", isActive: true }); + const apiKey = connections[0]?.apiKey; + if (!apiKey) return NextResponse.json({ error: "No Inworld connection found" }, { status: 400 }); + + const res = await fetch("https://api.inworld.ai/tts/v1/voices", { + headers: { "Authorization": `Basic ${apiKey}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + return NextResponse.json({ error: `Inworld API ${res.status}: ${text || "Failed"}` }, { status: 502 }); + } + const data = await res.json(); + const voices = data.voices || []; + + const byLang = {}; + for (const v of voices) { + // Each voice has `languages: ["en", "es", ...]` + const langs = Array.isArray(v.languages) && v.languages.length ? v.languages : ["en"]; + for (const code of langs) { + if (!byLang[code]) { + byLang[code] = { + code, + name: (() => { try { return langNames.of(code); } catch { return code; } })(), + voices: [], + }; + } + if (!byLang[code].voices.find((x) => x.id === v.voiceId)) { + byLang[code].voices.push({ + id: v.voiceId, + name: v.displayName || v.voiceId, + gender: v.gender || "", + lang: code, + }); + } + } + } + + const languages = Object.values(byLang).sort((a, b) => a.name.localeCompare(b.name)); + + 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 }); + } +} diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index db9cbb5..1c28327 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -40,6 +40,43 @@ async function probeWebProvider(provider, apiKey) { return res.status !== 401 && res.status !== 403; } +// Probe a tts/embedding provider using ttsConfig/embeddingConfig. +// Returns true if API key is accepted (status !== 401 && !== 403); null to skip. +async function probeMediaProvider(provider, apiKey) { + const p = AI_PROVIDERS[provider]; + if (!p) return null; + // Only probe providers that are media-only (not LLM dual-purpose, let LLM validate handle those) + const kinds = p.serviceKinds || ["llm"]; + const isMediaOnly = kinds.every((k) => k === "tts" || k === "embedding" || k === "stt"); + if (!isMediaOnly) return null; + const cfg = p.ttsConfig || p.embeddingConfig; + if (!cfg) return null; + if (p.noAuth || cfg.authType === "none") return true; + // Skip auth schemes that need provider-specific data + if (cfg.authHeader === "playht" || cfg.authHeader === "aws-sigv4") return null; + + const headers = { "Content-Type": "application/json" }; + + // Apply auth based on authHeader + switch (cfg.authHeader) { + case "bearer": headers["Authorization"] = `Bearer ${apiKey}`; break; + case "x-api-key": headers["x-api-key"] = apiKey; break; + case "xi-api-key": headers["xi-api-key"] = apiKey; break; + case "token": headers["Authorization"] = `Token ${apiKey}`; break; + case "basic": headers["Authorization"] = `Basic ${apiKey}`; break; + default: return null; + } + + // Minimal POST body โ€” server will reject auth before validating body + const res = await fetch(cfg.baseUrl, { + method: "POST", + headers, + body: JSON.stringify({ input: "ping", text: "ping", model: cfg.models?.[0]?.id || "test" }), + signal: AbortSignal.timeout(8000), + }); + return res.status !== 401 && res.status !== 403; +} + // POST /api/providers/validate - Validate API key with provider export async function POST(request) { try { @@ -192,6 +229,15 @@ export async function POST(request) { }); } + // Generic probe for tts/embedding providers (config-driven) + const mediaResult = await probeMediaProvider(provider, apiKey); + if (mediaResult !== null) { + return NextResponse.json({ + valid: mediaResult, + error: mediaResult ? null : "Invalid API key", + }); + } + switch (provider) { case "openai": const openaiRes = await fetch("https://api.openai.com/v1/models", { diff --git a/src/mitm/config.js b/src/mitm/config.js index 29229d9..f8dc7a0 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -15,6 +15,11 @@ const URL_PATTERNS = { cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"], }; +// Synonym map: rawModel from request โ†’ canonical alias key in mitmAlias DB +const MODEL_SYNONYMS = { + antigravity: { "gemini-default": "gemini-3-flash" }, +}; + function getToolForHost(host) { const h = (host || "").split(":")[0]; if (h === "api.individual.githubcopilot.com") return "copilot"; @@ -24,4 +29,4 @@ function getToolForHost(host) { return null; } -module.exports = { TARGET_HOSTS, URL_PATTERNS, getToolForHost }; +module.exports = { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, getToolForHost }; diff --git a/src/mitm/server.js b/src/mitm/server.js index 78e3362..cc81497 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -5,14 +5,14 @@ const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); const { log, err } = require("./logger"); -const { TARGET_HOSTS, URL_PATTERNS, getToolForHost } = require("./config"); +const { TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { getCertForDomain } = require("./cert/generate"); const DB_FILE = path.join(DATA_DIR, "db.json"); const LOCAL_PORT = 443; const IS_WIN = process.platform === "win32"; -const ENABLE_FILE_LOG = false; +const ENABLE_FILE_LOG = true; const LOG_DIR = path.join(DATA_DIR, "logs", "mitm"); const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; @@ -107,9 +107,11 @@ function getMappedModel(tool, model) { const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); const aliases = db.mitmAlias?.[tool]; if (!aliases) return null; - if (aliases[model]) return aliases[model]; + // Normalize via synonym map (e.g., gemini-default โ†’ gemini-3-flash) + const lookup = MODEL_SYNONYMS?.[tool]?.[model] || model; + if (aliases[lookup]) return aliases[lookup]; // Prefix match fallback - const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (model.startsWith(k) || k.startsWith(model))); + const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (lookup.startsWith(k) || k.startsWith(lookup))); return prefixKey ? aliases[prefixKey] : null; } catch { return null; } } diff --git a/src/shared/components/ProviderInfoCard.js b/src/shared/components/ProviderInfoCard.js index 6150fdd..1957f89 100644 --- a/src/shared/components/ProviderInfoCard.js +++ b/src/shared/components/ProviderInfoCard.js @@ -8,6 +8,8 @@ const FIELD_SCHEMA = { defaultModel: { label: "Model", format: (v) => v, mono: true }, baseUrl: { label: "Endpoint", format: (v) => v, isLink: true, mono: true }, costPerQuery: { label: "Cost / call", format: (v) => v === 0 ? "Free" : `$${v.toFixed(4)}` }, + pricingUrl: { label: "Pricing", format: () => "View pricing", isLink: true }, + freeTier: { label: "Free tier", format: (v) => v }, freeMonthlyQuota: { label: "Free quota", format: (v) => v === 0 ? "โ€”" : v >= 999999 ? "Unlimited" : `${v.toLocaleString()} / mo` }, searchTypes: { label: "Types", format: (v) => v.join(", ") }, formats: { label: "Formats", format: (v) => v.join(", ") }, @@ -30,6 +32,7 @@ export default function ProviderInfoCard({ config, provider, title = "Provider I })); const signupUrl = provider?.notice?.apiKeyUrl || provider?.website; + const noticeText = provider?.notice?.text; return ( @@ -67,6 +70,12 @@ export default function ProviderInfoCard({ config, provider, title = "Provider I )} ))} + {noticeText && ( +
+ Notice + {noticeText} +
+ )}
); diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 43cb41c..8ff1f11 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -12,7 +12,7 @@ export const MITM_TOOLS = { defaultModels: [ { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high" }, { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", alias: "gemini-3.1-pro-low" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash / Default", alias: "gemini-3-flash" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "claude-sonnet-4-6" }, { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", alias: "claude-opus-4-6-thinking" }, { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" }, diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 54f0bf5..ec03057 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -3,7 +3,7 @@ // Free Providers (kiro first, iflow last) export const FREE_PROVIDERS = { kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" }, - qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work." }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", serviceKinds: ["llm", "tts", "stt"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } }, "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." }, // gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" }, // codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" }, @@ -14,11 +14,11 @@ export const FREE_PROVIDERS = { // Free Tier Providers (has free access but may require account/API key) export const FREE_TIER_PROVIDERS = { - openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true, serviceKinds: ["llm", "embedding", "tts", "imageToText"] }, - 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" } }, + openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true, serviceKinds: ["llm", "embedding", "tts", "imageToText"], embeddingConfig: { baseUrl: "https://openrouter.ai/api/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "openai/text-embedding-3-small", name: "Text Embedding 3 Small (OpenRouter)", dimensions: 1536 }, { id: "openai/text-embedding-3-large", name: "Text Embedding 3 Large (OpenRouter)", dimensions: 3072 }, { id: "openai/text-embedding-ada-002", name: "Text Embedding Ada 002 (OpenRouter)", dimensions: 1536 }] } }, + 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" }, serviceKinds: ["llm", "tts", "embedding", "stt"], ttsConfig: { baseUrl: "https://integrate.api.nvidia.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "nvidia-tts", models: [{ id: "fastpitch", name: "FastPitch" }, { id: "tacotron2", name: "Tacotron2" }] }, embeddingConfig: { baseUrl: "https://integrate.api.nvidia.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", dimensions: 1024 }] } }, 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", serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "gemini-2.5-flash" } }, + gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "gemini-2.5-flash", pricingUrl: "https://ai.google.dev/pricing", freeTier: "Free tier: 15 RPM, 1M tokens/day on gemini-2.5-flash via AI Studio." }, embeddingConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", models: [{ id: "text-embedding-004", name: "Text Embedding 004", dimensions: 768 }, { id: "embedding-001", name: "Embedding 001", dimensions: 768 }] } }, byteplus: { id: "byteplus", alias: "bpm", name: "BytePlus ModelArk", icon: "cloud", color: "#2563EB", textIcon: "BP", website: "https://console.byteplus.com/ark", notice: { text: "Free credits for new accounts. Access to Seed 2.0, Kimi K2 Thinking, GLM 4.7, GPT-OSS-120B models.", apiKeyUrl: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey" }, serviceKinds: ["llm"] }, }; @@ -44,7 +44,7 @@ export const OAUTH_PROVIDERS = { claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" }, antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." }, codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." } }, - github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" }, + github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] } }, cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" }, // "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" }, kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" }, @@ -55,41 +55,45 @@ export const OAUTH_PROVIDERS = { export const APIKEY_PROVIDERS = { glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL", website: "https://open.bigmodel.cn" }, "glm-cn": { id: "glm-cn", alias: "glm-cn", name: "GLM (China)", icon: "code", color: "#DC2626", textIcon: "GC", website: "https://open.bigmodel.cn" }, - kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn", serviceKinds: ["llm", "webSearch"], searchViaChat: { defaultModel: "kimi-k2.5" } }, - minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com", serviceKinds: ["llm", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "MiniMax-M2.7" } }, + kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn", serviceKinds: ["llm", "webSearch"], searchViaChat: { defaultModel: "kimi-k2.5", pricingUrl: "https://platform.moonshot.ai/docs/pricing/chat" } }, + minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com", serviceKinds: ["llm", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "MiniMax-M2.7", pricingUrl: "https://www.minimaxi.com/document/price" } }, "minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" }, alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, "alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, "volcengine-ark": { id: "volcengine-ark", alias: "ark", name: "Volcengine Ark", icon: "cloud", color: "#1677FF", textIcon: "ARK", website: "https://ark.cn-beijing.volces.com" }, - openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchViaChat: { defaultModel: "gpt-4o-mini" } }, + openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchViaChat: { defaultModel: "gpt-4o-mini", pricingUrl: "https://openai.com/api/pricing" }, ttsConfig: { baseUrl: "https://api.openai.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "tts-1", name: "TTS-1" }, { id: "tts-1-hd", name: "TTS-1 HD" }, { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS" }] }, embeddingConfig: { baseUrl: "https://api.openai.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large", dimensions: 3072 }, { id: "text-embedding-ada-002", name: "Text Embedding Ada 002", dimensions: 1536 }] } }, anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm", "imageToText"] }, "opencode-go": { id: "opencode-go", alias: "ocg", name: "OpenCode Go", icon: "terminal", color: "#E87040", textIcon: "OC", website: "https://opencode.ai/auth", notice: { text: "OpenCode Go subscription: $5/mo (then $10/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", apiKeyUrl: "https://opencode.ai/auth" } }, azure: { id: "azure", alias: "azure", name: "Azure OpenAI", icon: "cloud", color: "#0078D4", textIcon: "AZ", website: "https://azure.microsoft.com/en-us/products/ai-services/openai-service", hasProviderSpecificData: true }, deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" }, groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", serviceKinds: ["llm", "imageToText"] }, - xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText", "webSearch"], searchViaChat: { defaultModel: "grok-4.20-reasoning" } }, - mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", serviceKinds: ["llm", "imageToText"] }, + xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText", "webSearch"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" } }, + mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", serviceKinds: ["llm", "imageToText", "embedding"], embeddingConfig: { baseUrl: "https://api.mistral.ai/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "mistral-embed", name: "Mistral Embed", dimensions: 1024 }] } }, perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.perplexity.ai/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } }, - together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai" }, - fireworks: { id: "fireworks", alias: "fireworks", name: "Fireworks AI", icon: "local_fire_department", color: "#7B2EF2", textIcon: "FW", website: "https://fireworks.ai" }, + together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://api.together.xyz/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "BAAI/bge-large-en-v1.5", name: "BGE Large EN v1.5", dimensions: 1024 }, { id: "togethercomputer/m2-bert-80M-8k-retrieval", name: "M2 BERT 80M 8K", dimensions: 768 }] } }, + fireworks: { id: "fireworks", alias: "fireworks", name: "Fireworks AI", icon: "local_fire_department", color: "#7B2EF2", textIcon: "FW", website: "https://fireworks.ai", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://api.fireworks.ai/inference/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "nomic-ai/nomic-embed-text-v1.5", name: "Nomic Embed Text v1.5", dimensions: 768 }] } }, cerebras: { id: "cerebras", alias: "cerebras", name: "Cerebras", icon: "memory", color: "#FF4F00", textIcon: "CB", website: "https://www.cerebras.ai" }, cohere: { id: "cohere", alias: "cohere", name: "Cohere", icon: "hub", color: "#39594D", textIcon: "CO", website: "https://cohere.com" }, - nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" }, + nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://api.tokenfactory.nebius.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "Qwen/Qwen3-Embedding-8B", name: "Qwen3 Embedding 8B", dimensions: 4096 }] } }, siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" }, - hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" }, - deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", serviceKinds: ["stt", "imageToText"] }, + hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz", serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "https://api.hyperbolic.xyz/v1/audio/generation", authType: "apikey", authHeader: "bearer", format: "hyperbolic", models: [{ id: "melo-tts", name: "Melo TTS" }] } }, + deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", notice: { text: "$200 free credit on signup (no card required). Aura-1: $0.015/1k chars, Aura-2: $0.030/1k chars (Pay-As-You-Go).", apiKeyUrl: "https://console.deepgram.com/api-keys" }, serviceKinds: ["stt", "imageToText", "tts"], ttsConfig: { baseUrl: "https://api.deepgram.com/v1/speak", authType: "apikey", authHeader: "token", format: "deepgram", models: [] } }, 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"], 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 }, + elevenlabs: { id: "elevenlabs", alias: "el", name: "ElevenLabs", icon: "record_voice_over", color: "#6C47FF", textIcon: "EL", website: "https://elevenlabs.io", serviceKinds: ["tts"], ttsConfig: { baseUrl: "https://api.elevenlabs.io/v1/text-to-speech", authType: "apikey", authHeader: "xi-api-key", format: "elevenlabs", models: [{ id: "eleven_multilingual_v2", name: "Eleven Multilingual v2" }, { id: "eleven_turbo_v2_5", name: "Eleven Turbo v2.5" }] } }, + cartesia: { id: "cartesia", alias: "cartesia", name: "Cartesia", icon: "spatial_audio", color: "#FF4F8B", textIcon: "CA", website: "https://cartesia.ai", serviceKinds: ["tts"], hidden: true, ttsConfig: { baseUrl: "https://api.cartesia.ai/tts/bytes", authType: "apikey", authHeader: "x-api-key", format: "cartesia", models: [{ id: "sonic-2", name: "Sonic 2" }, { id: "sonic-3", name: "Sonic 3" }] } }, + playht: { id: "playht", alias: "playht", name: "PlayHT", icon: "play_circle", color: "#00B4D8", textIcon: "PH", website: "https://play.ht", serviceKinds: ["tts"], hidden: true, ttsConfig: { baseUrl: "https://api.play.ht/api/v2/tts/stream", authType: "apikey", authHeader: "playht", format: "playht", models: [{ id: "PlayDialog", name: "PlayDialog" }, { id: "Play3.0-mini", name: "Play 3.0 Mini" }] } }, + "local-device": { id: "local-device", alias: "local-device", name: "Local Device", icon: "speaker", color: "#64748B", textIcon: "LD", serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "local-device", authType: "none", authHeader: "none", format: "local-device", models: [] } }, + "google-tts": { id: "google-tts", alias: "google-tts", name: "Google TTS", icon: "record_voice_over", color: "#4285F4", textIcon: "GT", serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "google-tts", authType: "none", authHeader: "none", format: "google-tts", models: [] } }, + "edge-tts": { id: "edge-tts", alias: "edge-tts", name: "Edge TTS", icon: "record_voice_over", color: "#0078D4", textIcon: "ET", serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "edge-tts", authType: "none", authHeader: "none", format: "edge-tts", models: [] } }, + coqui: { id: "coqui", alias: "coqui", name: "Coqui TTS", icon: "record_voice_over", color: "#10B981", textIcon: "CQ", website: "https://github.com/coqui-ai/TTS", serviceKinds: ["tts"], hidden: true, noAuth: true, ttsConfig: { baseUrl: "http://localhost:5002/api/tts", authType: "none", authHeader: "none", format: "coqui", models: [{ id: "tts_models/en/ljspeech/tacotron2-DDC", name: "Tacotron2 DDC (LJSpeech)" }] } }, + tortoise: { id: "tortoise", alias: "tortoise", name: "Tortoise TTS", icon: "record_voice_over", color: "#7C3AED", textIcon: "TT", website: "https://github.com/neonbjb/tortoise-tts", serviceKinds: ["tts"], hidden: true, noAuth: true, ttsConfig: { baseUrl: "http://localhost:5000/api/tts", authType: "none", authHeader: "none", format: "tortoise", models: [{ id: "tortoise-v2", name: "Tortoise v2" }] } }, + inworld: { id: "inworld", alias: "inworld", name: "Inworld TTS", icon: "record_voice_over", color: "#FF6B6B", textIcon: "IW", website: "https://inworld.ai", notice: { text: "Free tier: 40 minutes/month TTS. Paid: TTS-1.5 Mini $0.01/min ($15/1M chars), TTS-1.5 Max $0.025/min ($30/1M chars). 270+ voices, 15 languages.", apiKeyUrl: "https://platform.inworld.ai/api-keys" }, serviceKinds: ["tts"], ttsConfig: { baseUrl: "https://api.inworld.ai/tts/v1/voice", authType: "apikey", authHeader: "basic", format: "inworld", models: [{ id: "inworld-tts-1.5-mini", name: "Inworld TTS 1.5 Mini ($0.01/min)" }, { id: "inworld-tts-1.5-max", name: "Inworld TTS 1.5 Max ($0.025/min)" }] } }, + "voyage-ai": { id: "voyage-ai", alias: "voyage", name: "Voyage AI", icon: "data_array", color: "#0EA5E9", textIcon: "VG", website: "https://www.voyageai.com", notice: { apiKeyUrl: "https://dash.voyageai.com/api-keys" }, serviceKinds: ["embedding"], embeddingConfig: { baseUrl: "https://api.voyageai.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "voyage-3-large", name: "Voyage 3 Large", dimensions: 1024 }, { id: "voyage-3.5", name: "Voyage 3.5", dimensions: 1024 }, { id: "voyage-3.5-lite", name: "Voyage 3.5 Lite", dimensions: 1024 }, { id: "voyage-code-3", name: "Voyage Code 3", dimensions: 1024 }, { id: "voyage-finance-2", name: "Voyage Finance 2", dimensions: 1024 }, { id: "voyage-law-2", name: "Voyage Law 2", dimensions: 1024 }, { id: "voyage-multilingual-2", name: "Voyage Multilingual 2", dimensions: 1024 }] } }, 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: ["image", "imageToText", "tts"], hiddenKinds: ["tts"] }, + huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", serviceKinds: ["image", "imageToText", "tts"], hiddenKinds: ["tts"], ttsConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-tts", models: [{ id: "facebook/mms-tts-eng", name: "MMS TTS English" }, { id: "microsoft/speecht5_tts", name: "SpeechT5 TTS" }] } }, blackbox: { id: "blackbox", alias: "bb", name: "Blackbox AI", icon: "smart_toy", color: "#5B5FEF", textIcon: "BB", website: "https://blackbox.ai", serviceKinds: ["llm"] }, 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" }, diff --git a/src/shared/constants/ttsProviders.js b/src/shared/constants/ttsProviders.js index b75bfe7..bc2e032 100644 --- a/src/shared/constants/ttsProviders.js +++ b/src/shared/constants/ttsProviders.js @@ -48,4 +48,65 @@ export const TTS_PROVIDER_CONFIG = { hasBrowseButton: true, voiceSource: "api-language", // from API with language picker }, + // โ”€โ”€ Config-driven providers (load models from providers.js โ†’ ttsConfig.models) โ”€โ”€ + "nvidia": { + hasModelSelector: true, + hasBrowseButton: false, + hasVoiceIdInput: true, + voiceSource: "config", + }, + "hyperbolic": { + hasModelSelector: true, + hasBrowseButton: false, + voiceSource: "config", + }, + "deepgram": { + hasModelSelector: false, + hasBrowseButton: true, + voiceSource: "api-language", + apiEndpoint: "/api/media-providers/tts/deepgram/voices", + }, + "huggingface": { + hasModelSelector: true, + hasBrowseButton: false, + voiceSource: "config", + }, + "cartesia": { + hasModelSelector: true, + hasBrowseButton: false, + hasVoiceIdInput: true, + voiceSource: "config", + }, + "playht": { + hasModelSelector: true, + hasBrowseButton: false, + hasVoiceIdInput: true, + voiceSource: "config", + }, + "coqui": { + hasModelSelector: true, + hasBrowseButton: false, + hasVoiceIdInput: true, + voiceSource: "config", + }, + "tortoise": { + hasModelSelector: true, + hasBrowseButton: false, + hasVoiceIdInput: true, + voiceSource: "config", + }, + "inworld": { + hasModelSelector: true, + hasBrowseButton: true, + hasVoiceIdInput: true, + voiceSource: "api-language", + modelKey: "inworld-tts-models", + apiEndpoint: "/api/media-providers/tts/inworld/voices", + }, + "qwen": { + hasModelSelector: true, + hasBrowseButton: false, + hasVoiceIdInput: true, + voiceSource: "config", + }, }; diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js index f861209..c8a6c73 100644 --- a/src/sse/handlers/tts.js +++ b/src/sse/handlers/tts.js @@ -7,10 +7,15 @@ 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 { AI_PROVIDERS } from "@/shared/constants/providers"; import * as log from "../utils/logger.js"; -// Providers that require stored credentials (not noAuth) -const CREDENTIALED_PROVIDERS = new Set(["openai", "elevenlabs", "openrouter"]); +// Derived from providers.js: any TTS provider not noAuth requires stored credentials +const CREDENTIALED_PROVIDERS = new Set( + Object.entries(AI_PROVIDERS) + .filter(([, p]) => p.serviceKinds?.includes("tts") && !p.noAuth && p.ttsConfig?.authType !== "none") + .map(([id]) => id) +); export async function handleTts(request) { let body;