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:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,3 +1,24 @@
|
||||
# Unreleased
|
||||
|
||||
## Features
|
||||
- **Providers**: add Self-hosted STT / TTS / Embedding — point 9Router at your own
|
||||
OpenAI-compatible speech and embedding servers (whisper.cpp, faster-whisper,
|
||||
Kokoro-FastAPI, llama-server, vLLM, Infinity). Unlike the named cloud providers
|
||||
these read `baseUrl` per connection, so one provider can front several machines
|
||||
|
||||
## Fixes
|
||||
- **TTS**: a bare self-hosted model name is the MODEL, not the voice — `kokoro`
|
||||
was parsed as a voice against a default model, 404ing or synthesising with the
|
||||
wrong one
|
||||
- **Embeddings**: self-hosted embeddings no longer fall back to `api.openai.com`
|
||||
when a connection has no `baseUrl` — that silently sent the input text and API
|
||||
key to OpenAI under a provider named "Self-hosted"
|
||||
- **Embeddings**: an adapter that rejects a misconfigured connection now returns
|
||||
400 with the reason instead of escaping the handler uncaught
|
||||
- **Embeddings**: bound the upstream fetch with `FETCH_CONNECT_TIMEOUT_MS` — an
|
||||
endpoint that drops packets never returns headers, so the request previously
|
||||
hung indefinitely
|
||||
|
||||
# v0.5.45 (2026-07-30)
|
||||
|
||||
## Features
|
||||
|
||||
40
README.md
40
README.md
@@ -467,6 +467,46 @@ Default URLs:
|
||||
<p><i>...and 20+ more providers including Nebius, Chutes, Hyperbolic, and custom OpenAI/Anthropic compatible endpoints</i></p>
|
||||
</div>
|
||||
|
||||
### 🏠 Self-hosted Providers
|
||||
|
||||
For speech and embeddings served from **your own** machine — whisper.cpp,
|
||||
faster-whisper, Speaches, Kokoro-FastAPI, openedai-speech, llama.cpp/llama-server,
|
||||
vLLM, Infinity, text-embeddings-inference, or anything else that speaks the OpenAI
|
||||
shape.
|
||||
|
||||
| Provider | Endpoint used | Typical server |
|
||||
| --- | --- | --- |
|
||||
| **Self-hosted STT** | `/v1/audio/transcriptions` | whisper.cpp, faster-whisper |
|
||||
| **Self-hosted TTS** | `/v1/audio/speech` | Kokoro-FastAPI, openedai-speech |
|
||||
| **Self-hosted Embedding** | `/v1/embeddings` | llama-server, vLLM, Infinity |
|
||||
|
||||
Every other speech provider is a named cloud service with a fixed endpoint. These
|
||||
three read their address from **each connection**, so one provider can front
|
||||
several machines and load-balance across them like any other.
|
||||
|
||||
Set it on the connection as `providerSpecificData.baseUrl`:
|
||||
|
||||
| Provider | Give it | Result |
|
||||
| --- | --- | --- |
|
||||
| Self-hosted STT | the full URL — `http://host:8080/v1/audio/transcriptions` | used as-is |
|
||||
| Self-hosted TTS | the server root — `http://host:8880` | `+ /v1/audio/speech` |
|
||||
| Self-hosted Embedding | the **OpenAI base**, `/v1` included — `http://host:8080/v1` | `+ /embeddings` |
|
||||
|
||||
> **Mind the `/v1` on embeddings.** The adapter appends `/embeddings`, so
|
||||
> `http://host:8080` resolves to `http://host:8080/embeddings` and misses the
|
||||
> OpenAI route — llama-server answers **501**. Give it the same base URL an OpenAI
|
||||
> client would use. A full `.../v1/embeddings` is also accepted, so a value pasted
|
||||
> from a `curl` example works too.
|
||||
|
||||
The API key is not checked by most local servers, but the field must be non-empty:
|
||||
it is what gives the connection a credentials record, and `baseUrl` lives there.
|
||||
Any placeholder works.
|
||||
|
||||
Self-hosted Embedding has **no cloud fallback by design** — a connection saved
|
||||
without a `baseUrl` is reported as a configuration error rather than quietly
|
||||
falling back to `api.openai.com`, which would send your input text and API key to
|
||||
a third party under a provider named "Self-hosted".
|
||||
|
||||
---
|
||||
|
||||
## 💡 Key Features
|
||||
|
||||
@@ -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) {
|
||||
|
||||
46
open-sse/handlers/embeddingProviders/selfhostedEmbedding.js
Normal file
46
open-sse/handlers/embeddingProviders/selfhostedEmbedding.js
Normal 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`;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
69
open-sse/handlers/ttsProviders/selfhostedTts.js
Normal file
69
open-sse/handlers/ttsProviders/selfhostedTts.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
@@ -117,6 +117,9 @@ import p113 from "./morph.js";
|
||||
// import p104 from "./windsurf.js";
|
||||
import p115 from "./poolside.js";
|
||||
import p116 from "./tokenrouter.js";
|
||||
import p117 from "./selfhosted-stt.js";
|
||||
import p118 from "./selfhosted-tts.js";
|
||||
import p119 from "./selfhosted-embedding.js";
|
||||
|
||||
export default [
|
||||
p0,
|
||||
@@ -235,4 +238,7 @@ export default [
|
||||
// p104, // windsurf — hidden, no tool calling
|
||||
p115,
|
||||
p116,
|
||||
p117,
|
||||
p118,
|
||||
p119,
|
||||
];
|
||||
|
||||
73
open-sse/providers/registry/selfhosted-embedding.js
Normal file
73
open-sse/providers/registry/selfhosted-embedding.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// Self-hosted, OpenAI-compatible embeddings (llama.cpp / llama-server, vLLM,
|
||||
// Infinity, text-embeddings-inference, ...) — the embeddings counterpart of
|
||||
// selfhosted-stt and selfhosted-tts.
|
||||
//
|
||||
// Routing a self-hosted embeddings server already WORKS today, via a custom
|
||||
// provider node: getEmbeddingAdapter() matches `openai-compatible-*` and
|
||||
// `custom-embedding-*` and returns openaiCompatNode, whose buildUrl reads
|
||||
// creds.providerSpecificData.baseUrl. What is missing is a first-class provider,
|
||||
// and the gap is visible rather than functional:
|
||||
//
|
||||
// /v1/embeddings on such a node -> 200, correct vectors
|
||||
// the Embedding page in the dashboard -> the node is not listed at all
|
||||
//
|
||||
// The page renders getProvidersByKind("embedding") plus provider nodes filtered
|
||||
// to `type === "custom-embedding"`. A node created as `openai-compatible` — the
|
||||
// natural choice when ONE endpoint serves chat and embeddings behind the same
|
||||
// front door — satisfies neither, so a working self-hosted embeddings endpoint is
|
||||
// invisible on the page whose job is to show embeddings providers. Diagnosed on a
|
||||
// deployment serving Qwen3-Embedding-8B at 4096 dimensions through exactly that
|
||||
// shape (2026-08-04).
|
||||
//
|
||||
// Declaring it as a provider with serviceKinds: ["embedding"] puts it on the page
|
||||
// beside Voyage, Jina and the rest, and keeps the per-connection baseUrl that
|
||||
// makes self-hosting possible at all.
|
||||
//
|
||||
// authType is "apikey" rather than "none" for the same reason as the STT and TTS
|
||||
// entries: it is what gives the connection a credentials record, and
|
||||
// providerSpecificData.baseUrl lives there. Local servers ignore the key itself;
|
||||
// any non-empty value works.
|
||||
export default {
|
||||
id: "selfhosted-embedding",
|
||||
priority: 50,
|
||||
hasFree: true,
|
||||
alias: "selfhosted-embedding",
|
||||
display: {
|
||||
name: "Self-hosted Embedding",
|
||||
icon: "cloud",
|
||||
color: "#ffffffff",
|
||||
textIcon: "SE",
|
||||
website: "https://github.com/ggml-org/llama.cpp",
|
||||
},
|
||||
category: "apikey",
|
||||
auth: {
|
||||
apiKey: {
|
||||
// Note the /v1: the adapter appends "/embeddings" to whatever it is given,
|
||||
// so a bare http://host:8080 resolves to http://host:8080/embeddings and
|
||||
// misses the OpenAI route entirely. Give it the OpenAI base, the same value
|
||||
// an OpenAI client would use. A trailing /embeddings is tolerated.
|
||||
text: "Set providerSpecificData.baseUrl to the OpenAI base URL, e.g. http://host:8080/v1 — /embeddings is appended. The API key is not checked by local servers; any value works.",
|
||||
},
|
||||
},
|
||||
// A self-hosted server serves whatever model it was started with, so the id
|
||||
// here is a placeholder for the UI: the request passes `model` straight
|
||||
// through, and llama-server ignores an unknown value rather than rejecting it.
|
||||
// Dimensions are deliberately NOT declared — they are a property of the loaded
|
||||
// weights, and asserting a number here would be a guess that silently
|
||||
// contradicts the server.
|
||||
models: [
|
||||
{ id: "embedding", name: "Self-hosted embedding model", kind: "embedding" },
|
||||
],
|
||||
serviceKinds: ["embedding"],
|
||||
embeddingConfig: {
|
||||
// Declared for shape-consistency with the other embedding providers, and
|
||||
// read by the UI — but NOT by the request path. openaiCompatNode resolves the
|
||||
// URL purely from creds.providerSpecificData.baseUrl (falling back to
|
||||
// api.openai.com), so unlike a fixed cloud provider this baseUrl never
|
||||
// reaches the wire. Stated plainly because a reader would otherwise
|
||||
// reasonably assume it is the default endpoint.
|
||||
baseUrl: "http://localhost:8080/v1/embeddings",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
},
|
||||
};
|
||||
48
open-sse/providers/registry/selfhosted-stt.js
Normal file
48
open-sse/providers/registry/selfhosted-stt.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Self-hosted, OpenAI-compatible speech-to-text (whisper.cpp, faster-whisper,
|
||||
// Speaches, vLLM-served Whisper, ...).
|
||||
//
|
||||
// Every other STT provider here is a named cloud service with a fixed endpoint.
|
||||
// This one exists so a locally-served /v1/audio/transcriptions can be used at
|
||||
// all: set the connection's providerSpecificData.baseUrl to the full URL of the
|
||||
// endpoint, exactly as the custom embedding providers already work.
|
||||
//
|
||||
// sttCore dispatches on `format`; anything that is not one of the five named
|
||||
// cloud shapes falls through to transcribeOpenAICompatible, which POSTs the
|
||||
// standard multipart body (file, model, and optional language / prompt /
|
||||
// response_format / temperature). That is precisely what whisper.cpp's OpenAI
|
||||
// endpoint accepts.
|
||||
//
|
||||
// authType is "apikey" rather than "none" so the connection carries a
|
||||
// credentials record — which is where providerSpecificData.baseUrl lives. Local
|
||||
// servers ignore the key itself; any non-empty value works.
|
||||
export default {
|
||||
id: "selfhosted-stt",
|
||||
priority: 50,
|
||||
hasFree: true,
|
||||
alias: "selfhosted-stt",
|
||||
display: {
|
||||
name: "Self-hosted STT",
|
||||
icon: "cloud",
|
||||
color: "#ffffffff",
|
||||
textIcon: "ST",
|
||||
website: "https://github.com/ggml-org/whisper.cpp",
|
||||
},
|
||||
category: "apikey",
|
||||
auth: {
|
||||
apiKey: {
|
||||
text: "Set providerSpecificData.baseUrl to the full transcriptions URL, e.g. http://host:8080/v1/audio/transcriptions. The API key is not checked by local servers; any value works.",
|
||||
},
|
||||
},
|
||||
models: [
|
||||
{ id: "whisper-1", name: "Whisper (self-hosted)", params: ["language", "response_format", "temperature", "prompt"], kind: "stt" },
|
||||
],
|
||||
serviceKinds: ["stt"],
|
||||
sttConfig: {
|
||||
// Overridden per connection by providerSpecificData.baseUrl; this default
|
||||
// only makes the provider usable out of the box on a same-host deployment.
|
||||
baseUrl: "http://localhost:8080/v1/audio/transcriptions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "openai",
|
||||
},
|
||||
};
|
||||
44
open-sse/providers/registry/selfhosted-tts.js
Normal file
44
open-sse/providers/registry/selfhosted-tts.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Self-hosted, OpenAI-compatible text-to-speech (Kokoro-FastAPI, openedai-speech,
|
||||
// vLLM-served TTS, ...) — the TTS counterpart of selfhosted-stt.
|
||||
//
|
||||
// Every other self-hostable TTS provider here (coqui, tortoise) carries a FIXED
|
||||
// localhost baseUrl in its registry entry and `authType: "none"`, and the generic
|
||||
// dispatcher reads `ttsConfig.baseUrl` from that entry rather than from the
|
||||
// connection. So there was no way to point TTS at a server on another host.
|
||||
//
|
||||
// `authType: "apikey"` is what makes the override possible at all: it gives the
|
||||
// connection a credentials record, which is where providerSpecificData.baseUrl
|
||||
// lives. Local servers ignore the key; any non-empty value works.
|
||||
export default {
|
||||
id: "selfhosted-tts",
|
||||
priority: 50,
|
||||
hasFree: true,
|
||||
alias: "selfhosted-tts",
|
||||
display: {
|
||||
name: "Self-hosted TTS",
|
||||
icon: "cloud",
|
||||
color: "#ffffffff",
|
||||
textIcon: "TT",
|
||||
website: "https://github.com/remsky/Kokoro-FastAPI",
|
||||
},
|
||||
category: "apikey",
|
||||
auth: {
|
||||
apiKey: {
|
||||
text: "Set providerSpecificData.baseUrl to the server root, e.g. http://host:8080 — /v1/audio/speech is appended. The API key is not checked by local servers; any value works.",
|
||||
},
|
||||
},
|
||||
// Voice is selected as "<model>/<voice>", the same convention the OpenAI TTS
|
||||
// adapter uses, so existing clients need no special casing.
|
||||
models: [
|
||||
{ id: "kokoro", name: "Kokoro (self-hosted)", params: ["voice", "response_format", "speed"], kind: "tts" },
|
||||
],
|
||||
serviceKinds: ["tts"],
|
||||
ttsConfig: {
|
||||
// Overridden per connection by providerSpecificData.baseUrl; this default
|
||||
// only makes the provider usable on a same-host deployment.
|
||||
baseUrl: "http://localhost:8880",
|
||||
defaultModel: "kokoro",
|
||||
authType: "apikey",
|
||||
format: "openai-speech",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user