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.
This commit is contained in:
@@ -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 }) {
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* 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) && (
|
||||
<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) => (
|
||||
{((AI_PROVIDERS[providerId]?.ttsConfig?.models?.length
|
||||
? AI_PROVIDERS[providerId].ttsConfig.models
|
||||
: getModelsByProviderId(config.modelKey)) || []).map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -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) && (
|
||||
<ProviderInfoCard
|
||||
config={
|
||||
kind === "webFetch"
|
||||
? provider.fetchConfig
|
||||
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, costPerQuery: 0 }
|
||||
kind === "webFetch" ? provider.fetchConfig
|
||||
: kind === "tts" ? provider.ttsConfig
|
||||
: kind === "embedding" ? provider.embeddingConfig
|
||||
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, pricingUrl: provider.searchViaChat?.pricingUrl, freeTier: provider.searchViaChat?.freeTier }
|
||||
}
|
||||
provider={provider}
|
||||
title={`${kindConfig.label} Config`}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
|
||||
const langNames = new Intl.DisplayNames(["en"], { type: "language" });
|
||||
|
||||
/**
|
||||
* GET /api/media-providers/tts/deepgram/voices[?lang=en]
|
||||
* Returns { languages, byLang } grouped by language code (same shape as edge-tts/elevenlabs/inworld)
|
||||
* Each Deepgram voice = one model (canonical_name like "aura-2-thalia-en")
|
||||
*/
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const langFilter = searchParams.get("lang");
|
||||
|
||||
const connections = await getProviderConnections({ provider: "deepgram", isActive: true });
|
||||
const apiKey = connections[0]?.apiKey;
|
||||
if (!apiKey) return NextResponse.json({ error: "No Deepgram connection found" }, { status: 400 });
|
||||
|
||||
const res = await fetch("https://api.deepgram.com/v1/models", {
|
||||
headers: { "Authorization": `Token ${apiKey}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
+6
-1
@@ -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 };
|
||||
|
||||
+6
-4
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
@@ -67,6 +70,12 @@ export default function ProviderInfoCard({ config, provider, title = "Provider I
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{noticeText && (
|
||||
<div className="flex items-start gap-3 min-w-0 sm:col-span-2">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0 mt-0.5">Notice</span>
|
||||
<span className="text-sm text-text-main leading-relaxed">{noticeText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user