feat(providers): self-hosted OpenAI-compatible STT, TTS and embedding providers

Add Self-hosted STT/TTS/Embedding providers that read baseUrl per connection
instead of a fixed registry endpoint, so 9Router can point at whisper.cpp,
faster-whisper, Kokoro-FastAPI, llama-server, vLLM, Infinity, and similar
OpenAI-compatible local servers.

Self-hosted Embedding refuses to run without a baseUrl rather than falling
back to api.openai.com like openaiCompatNode does, since that fallback would
silently send input text and the API key to OpenAI under a provider named
"Self-hosted". Also fixes embeddingsCore to catch adapter build errors as a
400 instead of letting them escape uncaught, and bounds the upstream fetch
with FETCH_CONNECT_TIMEOUT_MS to avoid hanging forever on a dead endpoint.

Self-hosted TTS treats a bare model value as the model rather than the voice,
since the generic OpenAI TTS convention (bare = voice) is backwards for a
provider where the model is the variable part.
This commit is contained in:
omar-nahhas
2026-08-05 13:33:28 +07:00
committed by decolua
parent b480892952
commit fe547f4dc0
12 changed files with 387 additions and 9 deletions

View File

@@ -170,9 +170,17 @@ export async function handleSttCore({ provider, model, formData, credentials, st
const file = formData.get("file");
if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file");
const cfg = sttConfig;
let cfg = sttConfig;
if (!cfg) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`);
// Per-connection endpoint override. Registry entries carry a fixed baseUrl,
// which is right for a named cloud service but useless for a self-hosted one
// whose address only the operator knows. Opt-in: absent unless the connection
// sets it, so cloud providers are untouched. Mirrors the custom embedding
// providers, which already resolve baseUrl the same way.
const overrideUrl = credentials?.providerSpecificData?.baseUrl;
if (overrideUrl) cfg = { ...cfg, baseUrl: String(overrideUrl).replace(/\/+$/, "") };
const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken);
if (cfg.authType !== "none" && !token) {
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`);