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

@@ -2,6 +2,7 @@
import createOpenAIEmbeddingAdapter from "./openai.js";
import gemini from "./gemini.js";
import openaiCompatNode from "./openaiCompatNode.js";
import selfhostedEmbedding from "./selfhostedEmbedding.js";
const OPENAI_COMPAT_PROVIDERS = [
"openai", "openrouter", "mistral", "voyage-ai", "fireworks",
@@ -13,6 +14,12 @@ const ADAPTERS = {
...Object.fromEntries(OPENAI_COMPAT_PROVIDERS.map((id) => [id, createOpenAIEmbeddingAdapter(id)])),
gemini,
google_ai_studio: gemini,
// Self-hosted reads creds.providerSpecificData.baseUrl (one provider, many
// servers) — but via its OWN adapter, not openaiCompatNode: that one falls back
// to api.openai.com when no baseUrl is set, which under a provider called
// "Self-hosted Embedding" means silently shipping the input and API key to
// OpenAI. selfhostedEmbedding refuses instead.
"selfhosted-embedding": selfhostedEmbedding,
};
export function getEmbeddingAdapter(provider) {

View File

@@ -0,0 +1,46 @@
// Self-hosted embeddings — like openaiCompatNode, but the baseUrl is REQUIRED.
//
// openaiCompatNode falls back to https://api.openai.com/v1 when a connection
// carries no providerSpecificData.baseUrl. For a custom NODE that default is
// defensible: the node was created by pointing at some OpenAI-compatible URL, and
// OpenAI is the archetype. For a provider whose entire purpose is "my own
// server", it is actively harmful — a connection saved without a baseUrl sends
// the INPUT TEXT and the API KEY to OpenAI, silently, under a provider named
// "Self-hosted Embedding".
//
// Observed exactly that with a placeholder connection (2026-08-04):
//
// [selfhosted-embedding/embedding] [401]: Incorrect API key provided: abc.
// You can find your API key at https://platform.openai.com/account/api-keys.
//
// The key "abc" was typed as a throwaway for a LOCAL server and left the network.
// A self-hosted provider must never have a cloud fallback, so this one refuses
// instead: no baseUrl means a configuration error, reported as such.
import createOpenAIEmbeddingAdapter from "./openai.js";
const baseAdapter = createOpenAIEmbeddingAdapter("openai");
export class MissingBaseUrlError extends Error {
constructor() {
super(
"Self-hosted Embedding needs an endpoint: set this connection's baseUrl to " +
"the OpenAI base URL of your server, e.g. http://host:8080/v1 (note the /v1 — " +
"\"/embeddings\" is appended to it). Refusing to fall back to api.openai.com, " +
"which would send your input and API key to OpenAI."
);
this.name = "MissingBaseUrlError";
this.isConfigError = true;
}
}
export default {
...baseAdapter,
buildUrl: (_model, creds) => {
const rawBaseUrl = creds?.providerSpecificData?.baseUrl;
if (!rawBaseUrl || !String(rawBaseUrl).trim()) throw new MissingBaseUrlError();
// Accept either the OpenAI base or a full embeddings URL, so a value pasted
// from a curl example works as well as one typed from the help text.
const baseUrl = String(rawBaseUrl).trim().replace(/\/$/, "").replace(/\/embeddings$/, "");
return `${baseUrl}/embeddings`;
},
};

View File

@@ -1,5 +1,5 @@
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { HTTP_STATUS, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { getExecutor } from "../executors/index.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { getEmbeddingAdapter } from "./embeddingProviders/index.js";
@@ -38,13 +38,24 @@ export async function handleEmbeddingsCore({
}
const ctx = { input };
const url = adapter.buildUrl(model, credentials, ctx);
const headers = adapter.buildHeaders(credentials, ctx);
const requestBody = adapter.buildBody(model, {
input,
encoding_format: body.encoding_format || "float",
dimensions: body.dimensions,
});
// buildUrl/buildHeaders/buildBody were called bare. An adapter that rejects a
// misconfigured connection — selfhosted-embedding throws when no baseUrl is set
// rather than silently falling back to api.openai.com — would have escaped this
// function uncaught, surfacing as a 500 or a request that never settles. A
// configuration mistake is a 400 with the reason in it.
let url, headers, requestBody;
try {
url = adapter.buildUrl(model, credentials, ctx);
headers = adapter.buildHeaders(credentials, ctx);
requestBody = adapter.buildBody(model, {
input,
encoding_format: body.encoding_format || "float",
dimensions: body.dimensions,
});
} catch (error) {
log?.debug?.("EMBEDDINGS", `Request build failed: ${error.message}`);
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}/${model}] ${error.message}`);
}
log?.debug?.("EMBEDDINGS", `${provider.toUpperCase()} | ${model} | input_type=${Array.isArray(input) ? `array[${input.length}]` : "string"}`);
@@ -54,6 +65,9 @@ export async function handleEmbeddingsCore({
method: "POST",
headers,
body: JSON.stringify(requestBody),
...(typeof AbortSignal?.timeout === "function"
? { signal: AbortSignal.timeout(FETCH_CONNECT_TIMEOUT_MS) }
: {}),
});
} catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);

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

View File

@@ -7,6 +7,7 @@ import openai from "./openai.js";
import openrouter from "./openrouter.js";
import gemini, { fetchGeminiVoices } from "./gemini.js";
import xiaomiMimo from "./xiaomi-mimo.js";
import selfhostedTts from "./selfhostedTts.js";
import { FORMAT_HANDLERS } from "./genericFormats.js";
import { parseModelVoice } from "./_base.js";
@@ -20,6 +21,7 @@ const SPECIAL_ADAPTERS = {
openrouter,
gemini,
"xiaomi-mimo": xiaomiMimo,
"selfhosted-tts": selfhostedTts,
};
export function getTtsAdapter(provider) {

View File

@@ -0,0 +1,69 @@
// Self-hosted OpenAI-compatible TTS — POST {baseUrl}/v1/audio/speech.
//
// A SPECIAL_ADAPTER rather than a genericFormats handler on purpose: the generic
// dispatcher resolves baseUrl from the static registry entry
// (`synthesizeViaConfig` reads `cfg.baseUrl`) and never looks at the connection,
// which is exactly the limitation this provider exists to lift.
import { Buffer } from "node:buffer";
const DEFAULT_BASE_URL = "http://localhost:8880";
const DEFAULT_MODEL = "kokoro";
const DEFAULT_VOICE = "af_heart";
export default {
async synthesize(text, model, credentials, responseFormat = "mp3") {
// Accept either providerSpecificData.baseUrl (how the custom embedding and
// STT providers carry it) or a bare credentials.baseUrl (how the OpenAI TTS
// adapter does), so a connection configured either way works.
const raw = credentials?.providerSpecificData?.baseUrl || credentials?.baseUrl || DEFAULT_BASE_URL;
// Tolerate a baseUrl given as the full endpoint or with a trailing /v1 —
// both are natural things to paste, and silently double-appending the path
// would 404 with nothing pointing at the cause.
const base = String(raw)
.replace(/\/+$/, "")
.replace(/\/v1\/audio\/speech$/, "")
.replace(/\/v1$/, "");
// The provider prefix is already stripped by getModelInfo, so `model` here is
// "kokoro" or "kokoro/af_heart" — NOT "selfhosted-tts/...".
//
// A bare value is the MODEL, not the voice. The OpenAI adapter reads a bare
// value as a voice, which is right for a service whose model is fixed
// ("tts-1") and whose voice varies — but wrong here, where the model is the
// variable part. Treating it as a voice sent voice="kokoro" upstream and
// Kokoro answered 400, so `selfhosted-tts/kokoro` — the obvious way to
// address this provider — was the one form that did not work (verified
// against a live Kokoro through 9router, 2026-08-03).
let ttsModel = DEFAULT_MODEL;
let voice = DEFAULT_VOICE;
if (model) {
const parts = String(model).split("/").filter(Boolean);
if (parts.length >= 2) {
ttsModel = parts[0];
voice = parts.slice(1).join("/");
} else if (parts.length === 1) {
ttsModel = parts[0];
}
}
const res = await fetch(`${base}/v1/audio/speech`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(credentials?.apiKey ? { Authorization: `Bearer ${credentials.apiKey}` } : {}),
},
body: JSON.stringify({
model: ttsModel,
voice,
input: text,
response_format: responseFormat,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error?.message || `Self-hosted TTS failed: ${res.status}`);
}
const buf = await res.arrayBuffer();
return { base64: Buffer.from(buf).toString("base64"), format: responseFormat };
},
};