feat(providers): Speechmatics STT, gTTS, VibeProxy preset (#6659, #6667, #6874) (#7655)

Validated in merge-train --fast @ 6cafcbb (static gates + 9 changed test files + vitest green, 2m35s; full suite ran today on train 2c tip)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-18 03:16:58 -03:00
committed by GitHub
parent 9e084e18a7
commit 38dd62819b
14 changed files with 951 additions and 12 deletions

View File

@@ -0,0 +1 @@
- **feat(providers):** add Speechmatics as an STT provider — async batch transcription (Enhanced operating point), 8 hours/month free tier, no credit card required. Streaming (real-time) mode is out of scope for v1. (#6659)

View File

@@ -0,0 +1 @@
- **feat(providers):** add gTTS (Google Translate TTS) as a free, no-signup audio-speech provider — routes through Google's current `batchexecute` RPC endpoint (the previously proposed `translate_tts` endpoint is deprecated), splitting input at the 100-char-per-request limit. (#6667)

View File

@@ -0,0 +1 @@
- **feat(providers):** add a `vibeproxy-openai` provider-node preset to `POST /api/provider-nodes` — defaults name/prefix/apiType for VibeProxy's local OpenAI-compatible gateway and normalizes the caller-supplied base URL to its `/v1` root; `baseUrl` remains mandatory. (#6874, idea from #6137 by @KooshaPari)

View File

@@ -1,16 +1,16 @@
---
title: "Provider Reference"
version: 3.8.49
lastUpdated: 2026-07-17
lastUpdated: 2026-07-18
---
# Provider Reference
> **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand.
> Regenerate with: `npm run gen:provider-reference`
> **Last generated:** 2026-07-17
> **Last generated:** 2026-07-18
Total providers: **264**. See category breakdown below.
Total providers: **265**. See category breakdown below.
## Categories
@@ -305,7 +305,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) |
| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard |
## Audio-only Providers (9)
## Audio-only Providers (10)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
@@ -318,6 +318,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — |
| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — |
| `rev-ai` | `revai` | Rev AI | Audio | [link](https://www.rev.ai) | — |
| `speechmatics` | `sm` | Speechmatics | Audio | [link](https://www.speechmatics.com) | Free tier — 8 hours/month, no credit card required. Batch (async) mode only. |
## Upstream Proxy Providers (2)

View File

@@ -189,6 +189,21 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
{ id: "fusion", name: "Fusion ASR" },
],
},
speechmatics: {
id: "speechmatics",
// POST https://asr.api.speechmatics.com/v2/jobs — async batch workflow:
// submit multipart job (audio + JSON config) → poll → fetch transcript.
// Auth: Authorization: Bearer <api-key>
// Free tier: 8 hours/month, no credit card required.
// Streaming (WebSocket real-time) mode is out of scope for v1 — batch only.
baseUrl: "https://asr.api.speechmatics.com/v2/jobs",
authType: "apikey",
authHeader: "bearer",
async: true,
format: "speechmatics",
models: [{ id: "enhanced", name: "Enhanced" }],
},
};
/**
@@ -460,6 +475,20 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
],
},
gtts: {
id: "gtts",
// Google Translate TTS — reverse-engineered, no API key required.
// POST batchexecute RPC (unlike the deprecated GET /translate_tts) —
// handled by open-sse/executors/gtts.ts, dispatched via the "gtts" format.
// No official SLA; per-IP rate-limited by Google without notice.
baseUrl: "https://translate.google.com/_/TranslateWebserverUi/data/batchexecute",
authType: "none",
authHeader: "none",
format: "gtts",
supportedFormats: ["mp3"],
models: [{ id: "default", name: "Google Translate TTS (Free)" }],
},
"xiaomi-mimo": {
id: "xiaomi-mimo",
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",

214
open-sse/executors/gtts.ts Normal file
View File

@@ -0,0 +1,214 @@
/**
* gTTS — Google Translate text-to-speech (#6667).
*
* Reverse-engineered, unofficial, undocumented endpoint (not a published
* Google public API) — the same class of integration this codebase already
* accepts for other "-web"/no-auth style providers (edgeTts.ts, chipotle.ts).
* No user account/API key is required.
*
* The issue's originally proposed endpoint
* (`https://translate.google.com/translate_tts`, GET with `q`/`tl`/`ie` query
* params) has been deprecated by Google. The current, working mechanism —
* verified directly against `pndurette/gTTS`'s `gtts/tts.py` source — is a
* POST RPC call to Google's internal `batchexecute` endpoint:
*
* POST https://translate.google.<tld>/_/TranslateWebserverUi/data/batchexecute
* Content-Type: application/x-www-form-urlencoded;charset=utf-8
* Body: f.req=<urlencoded JSON envelope>
*
* The envelope wraps `[text, lang, true, "null"]` under RPC id `"jQ1olc"`:
* f.req = [[["jQ1olc", '["<text>","<lang>",true,"null"]', null, "generic"]]]
*
* There is a hard 100-character-per-request limit on `text` — longer input
* must be split into multiple RPC calls and the resulting MP3 byte chunks
* concatenated (§ `chunkGttsText`).
*
* The response is a `)]}'`-prefixed "batchexecute" payload; the base64 audio
* lives inside the entry whose outer array starts with `["wrb.fr","jQ1olc",…]`
* (§ `parseBatchExecuteResponse`).
*
* All parsing/chunking above is implemented as pure functions so it can be
* unit-tested without a live upstream connection — only `synthesizeGtts()`
* itself touches the network, and it accepts an injectable `fetch` for tests.
*/
/** Hard per-request character limit enforced by Google's batchexecute endpoint. */
export const GOOGLE_TTS_MAX_CHARS = 100;
const GTTS_RPC_ID = "jQ1olc";
const GTTS_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
const GTTS_REFERER = "http://translate.google.com/";
const DEFAULT_LANG = "en";
const DEFAULT_TLD = "com";
/** Only allow simple BCP-47-ish language codes to keep this untrusted input from injecting RPC payload structure. */
const LANG_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
export class GttsUpstreamError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "GttsUpstreamError";
this.status = status;
}
}
export interface GttsSynthInput {
text: string;
lang?: string;
tld?: string;
}
/** Normalize a caller-supplied language code, falling back to English. */
export function normalizeGttsLang(lang: unknown): string {
const value = typeof lang === "string" ? lang.trim() : "";
return LANG_PATTERN.test(value) ? value : DEFAULT_LANG;
}
/**
* Split `text` into chunks respecting Google's 100-character-per-request
* limit, preferring to break on whitespace so words are not split mid-token.
* A single "word" longer than `maxChars` is hard-split as a last resort.
*/
export function chunkGttsText(text: unknown, maxChars: number = GOOGLE_TTS_MAX_CHARS): string[] {
const trimmed = typeof text === "string" ? text.trim() : "";
if (!trimmed) return [];
if (trimmed.length <= maxChars) return [trimmed];
const chunks: string[] = [];
let remaining = trimmed;
while (remaining.length > maxChars) {
let splitAt = -1;
for (let i = maxChars; i > 0; i--) {
if (/\s/.test(remaining[i])) {
splitAt = i;
break;
}
}
if (splitAt <= 0) splitAt = maxChars;
chunks.push(remaining.slice(0, splitAt).trim());
remaining = remaining.slice(splitAt).trim();
}
if (remaining) chunks.push(remaining);
return chunks.filter((c) => c.length > 0);
}
/** Build the `f.req=`-prefixed, urlencoded RPC body for one text chunk. */
export function buildGttsRpcBody(text: string, lang: string): string {
const innerPayload = JSON.stringify([text, lang, true, "null"]);
const envelope = [[[GTTS_RPC_ID, innerPayload, null, "generic"]]];
return `f.req=${encodeURIComponent(JSON.stringify(envelope))}&`;
}
/**
* Extract the base64 audio payload from one `["wrb.fr","jQ1olc",…]` entry,
* or `null` if this entry isn't a matching audio fragment.
*/
function extractAudioFromWrbFrEntry(entry: unknown): string | null {
if (!Array.isArray(entry) || entry[0] !== "wrb.fr" || entry[1] !== GTTS_RPC_ID) return null;
if (typeof entry[2] !== "string") return null;
try {
const inner = JSON.parse(entry[2]);
if (Array.isArray(inner) && typeof inner[0] === "string" && inner[0].length > 0) {
return inner[0];
}
} catch {
// Not a JSON-parseable payload — treat as "no audio in this entry".
}
return null;
}
/** Parse one newline-delimited JSON fragment, returning its audio payload if present. */
function findAudioInBatchExecuteLine(line: string): string | null {
let outer: unknown;
try {
outer = JSON.parse(line);
} catch {
return null;
}
if (!Array.isArray(outer)) return null;
for (const entry of outer) {
const audio = extractAudioFromWrbFrEntry(entry);
if (audio) return audio;
}
return null;
}
/**
* Extract the base64-encoded audio payload from a `batchexecute` response.
* The response is `)]}'`-prefixed, followed by newline-delimited JSON
* fragments interleaved with numeric length-prefix lines; the audio lives
* in the fragment whose entry starts with `["wrb.fr","jQ1olc",…]`.
*/
export function parseBatchExecuteResponse(raw: string): string {
const cleaned = typeof raw === "string" ? raw.replace(/^\)\]\}'\n?/, "") : "";
const lines = cleaned.split("\n").filter((line) => {
const trimmedLine = line.trim();
return trimmedLine.length > 0 && !/^\d+$/.test(trimmedLine);
});
for (const line of lines) {
const audio = findAudioInBatchExecuteLine(line);
if (audio) return audio;
}
throw new GttsUpstreamError(502, "gTTS response did not contain audio data");
}
type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
/** Synthesize one ≤100-char chunk, returning the decoded MP3 bytes. */
async function synthesizeGttsChunk(
chunk: string,
lang: string,
tld: string,
fetchImpl: FetchLike
): Promise<Buffer> {
const body = buildGttsRpcBody(chunk, lang);
const res = await fetchImpl(
`https://translate.google.${tld}/_/TranslateWebserverUi/data/batchexecute`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"User-Agent": GTTS_USER_AGENT,
Referer: GTTS_REFERER,
},
body,
}
);
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new GttsUpstreamError(res.status, errText || `gTTS upstream error (${res.status})`);
}
const raw = await res.text();
const base64Audio = parseBatchExecuteResponse(raw);
return Buffer.from(base64Audio, "base64");
}
/**
* Synthesize `input.text` end-to-end: chunk to Google's 100-char limit,
* POST each chunk to the batchexecute RPC endpoint, and concatenate the
* decoded MP3 byte chunks into one buffer.
*/
export async function synthesizeGtts(
input: GttsSynthInput,
fetchImpl: FetchLike = fetch
): Promise<Buffer> {
const lang = normalizeGttsLang(input.lang);
const tld = (typeof input.tld === "string" && input.tld.trim()) || DEFAULT_TLD;
const chunks = chunkGttsText(input.text);
if (chunks.length === 0) {
throw new GttsUpstreamError(400, "gTTS requires non-empty input text");
}
const buffers: Buffer[] = [];
for (const chunk of chunks) {
buffers.push(await synthesizeGttsChunk(chunk, lang, tld, fetchImpl));
}
return Buffer.concat(buffers);
}

View File

@@ -23,6 +23,7 @@ import { kieExecutor } from "../executors/kie.ts";
import { vertexGenerateSpeech } from "../executors/vertexMedia.ts";
import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts";
import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts";
import { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts";
import { errorResponse } from "../utils/error.ts";
import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts";
import {
@@ -725,6 +726,28 @@ async function handleTortoiseSpeech(providerConfig, body) {
});
}
/**
* Handle gTTS TTS (local no-auth, Google Translate batchexecute RPC).
* `voice` doubles as the language code since gTTS has no voice concept —
* defaults to English when omitted or unrecognized.
*/
async function handleGttsSpeech(body) {
try {
const audio = await synthesizeGtts({
text: body.input,
lang: normalizeGttsLang(body.voice),
});
return new Response(audio, {
status: 200,
headers: { ...CORS_HEADERS, "Content-Type": "audio/mpeg" },
});
} catch (err) {
const status = err instanceof GttsUpstreamError ? err.status : 502;
const message = err instanceof Error ? err.message : "gTTS synthesis failed";
return errorResponse(status, message);
}
}
/**
* Handle audio speech (TTS) request
*
@@ -761,7 +784,7 @@ export async function handleAudioSpeech({
if (!providerConfig) {
return errorResponse(
400,
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, kie, aws-polly, xiaomi-mimo, edgetts, coqui, tortoise, qwen`
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, kie, aws-polly, xiaomi-mimo, edgetts, gtts, coqui, tortoise, qwen`
);
}
@@ -830,6 +853,10 @@ export async function handleAudioSpeech({
return handleEdgeTtsSpeech(body, clientIp);
}
if (providerConfig.format === "gtts") {
return handleGttsSpeech(body);
}
if (providerConfig.format === "xiaomi-mimo-tts") {
return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials);
}

View File

@@ -531,6 +531,104 @@ async function handleRevAiTranscription(providerConfig, file, modelId, token) {
return errorResponse(504, "Rev AI transcription timed out after 120s");
}
/**
* Speechmatics operating point (accuracy tier). Catalog model ids are the
* real Speechmatics `operating_point` values ("standard", "enhanced",
* "melia-1"), so this passes straight through — kept as a named seam in
* case a future catalog id needs remapping.
*/
function speechmaticsOperatingPoint(modelId: string): string {
return modelId;
}
/**
* Fetch and return the finished Speechmatics transcript once a job reaches
* the "done" state.
*/
async function fetchSpeechmaticsTranscript(jobUrl, authHeaders) {
const transcriptRes = await fetch(`${jobUrl}/transcript?format=txt`, {
headers: { ...authHeaders, Accept: "text/plain" },
});
if (!transcriptRes.ok) {
return upstreamErrorResponse(transcriptRes, await transcriptRes.text());
}
const text = await transcriptRes.text();
return Response.json({ text: text || "" }, { headers: { ...CORS_HEADERS } });
}
function speechmaticsJobErrorMessage(result): string {
const errors = result?.job?.errors;
const first = Array.isArray(errors) ? errors[0] : null;
return first?.message || "Speechmatics transcription failed";
}
/**
* Poll a submitted Speechmatics job until it reaches a terminal state
* (max 120s), then fetch its transcript.
*/
async function pollSpeechmaticsJob(jobUrl, authHeaders) {
const maxWait = 120_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
await new Promise((r) => setTimeout(r, 2000));
const pollRes = await fetch(jobUrl, { headers: authHeaders });
if (!pollRes.ok) continue;
const result = await pollRes.json();
const status = result?.job?.status;
if (status === "done") {
return fetchSpeechmaticsTranscript(jobUrl, authHeaders);
}
if (status === "rejected") {
return errorResponse(500, speechmaticsJobErrorMessage(result));
}
}
return errorResponse(504, "Speechmatics transcription timed out after 120s");
}
/**
* Handle Speechmatics transcription (async batch: submit multipart job → poll → fetch transcript)
*
* Speechmatics batch mode accepts the audio file directly in the job-submission
* multipart body (field "data_file") alongside a JSON "config" field describing
* the requested transcription options. Streaming (real-time WebSocket) mode is
* out of scope for v1 — this handler only implements batch (REST) transcription.
*/
async function handleSpeechmaticsTranscription(providerConfig, file, modelId, token) {
const authHeaders = buildAuthHeaders(providerConfig, token);
const baseUrl = providerConfig.baseUrl.replace(/\/$/, "");
// Step 1: submit the job — multipart body with "data_file" (audio) + "config" (JSON)
const config = JSON.stringify({
type: "transcription",
transcription_config: { operating_point: speechmaticsOperatingPoint(modelId) },
});
const { body, contentType } = await buildMultipartBody(file, { config }, "data_file");
const submitRes = await fetch(baseUrl, {
method: "POST",
headers: { ...authHeaders, "Content-Type": contentType },
body,
});
if (!submitRes.ok) {
return upstreamErrorResponse(submitRes, await submitRes.text());
}
const { id: jobId } = await submitRes.json();
if (!jobId) {
return errorResponse(502, "Speechmatics did not return a job id");
}
// Step 2: poll for completion (max 120s)
return pollSpeechmaticsJob(`${baseUrl}/${jobId}`, authHeaders);
}
/**
* Handle audio transcription request
*
@@ -573,7 +671,7 @@ export async function handleAudioTranscription({
if (!providerConfig) {
return errorResponse(
400,
`No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai, nvidia, huggingface, qwen, gladia, rev-ai`
`No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai, nvidia, huggingface, qwen, gladia, rev-ai, speechmatics`
);
}
@@ -639,6 +737,10 @@ export async function handleAudioTranscription({
return handleRevAiTranscription(providerConfig, file, modelId, token);
}
if (providerConfig.format === "speechmatics") {
return handleSpeechmaticsTranscription(providerConfig, file, modelId, token);
}
// Default: OpenAI/Groq/Qwen3-compatible multipart proxy
const extraFields: Record<string, string> = {};
for (const key of [

View File

@@ -19,6 +19,34 @@ const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
};
// #6874: VibeProxy (github.com/automazeio/vibeproxy) — local OpenAI-compatible
// gateway. baseUrl has no default (operator-specific host/port); name/prefix/
// apiType are filled in when the caller omits them.
const VIBEPROXY_OPENAI_DEFAULTS = {
name: "VibeProxy",
prefix: "vibeproxy",
apiType: "chat" as const,
};
/**
* Normalize a caller-supplied VibeProxy base URL down to its `/v1` root,
* mirroring `sanitizeAnthropicBaseUrl`/`sanitizeClaudeCodeCompatibleBaseUrl`:
* strip trailing slash + known OpenAI-compatible suffixes, then ensure the
* result ends at `/v1` (append if absent, guard against double `/v1/v1`).
*/
function sanitizeVibeProxyBaseUrl(baseUrl: string) {
let base = (baseUrl || "")
.trim()
.replace(/\/$/, "")
.replace(/\/chat\/completions$/i, "")
.replace(/\/completions$/i, "");
// Guard against a literal "scheme://v1" authority so we never strip the host itself.
if (base.endsWith("/v1") && !base.endsWith("://v1")) {
return base;
}
return `${base}/v1`;
}
function sanitizeAnthropicBaseUrl(baseUrl: string) {
return (baseUrl || "")
.trim()
@@ -76,16 +104,40 @@ export async function POST(request) {
baseUrl,
type,
compatMode,
preset,
chatPath,
modelsPath,
customHeaders,
iconUrl,
} = validation.data;
if (preset === "vibeproxy-openai") {
// Schema guarantees baseUrl is non-empty for this preset.
const sanitizedBaseUrl = sanitizeVibeProxyBaseUrl(baseUrl as string);
const baseUrlError = validateProviderNodeBaseUrl(sanitizedBaseUrl);
if (baseUrlError) return baseUrlError;
const node = await createProviderNode({
id: `${OPENAI_COMPATIBLE_PREFIX}${VIBEPROXY_OPENAI_DEFAULTS.apiType}-${generateId()}`,
type: "openai-compatible",
prefix: (prefix?.trim() || VIBEPROXY_OPENAI_DEFAULTS.prefix).trim(),
apiType: apiType || VIBEPROXY_OPENAI_DEFAULTS.apiType,
baseUrl: sanitizedBaseUrl,
name: (name?.trim() || VIBEPROXY_OPENAI_DEFAULTS.name).trim(),
chatPath: chatPath || null,
modelsPath: modelsPath || null,
iconUrl: iconUrl?.trim() || null,
customHeaders: customHeaders || null,
});
return NextResponse.json({ node }, { status: 201 });
}
// Determine type
const nodeType = type || "openai-compatible";
if (nodeType === "openai-compatible") {
const resolvedName = (name || "").trim();
const resolvedPrefix = (prefix || "").trim();
const resolvedBaseUrl = (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim();
const baseUrlError = validateProviderNodeBaseUrl(resolvedBaseUrl);
if (baseUrlError) return baseUrlError;
@@ -93,10 +145,10 @@ export async function POST(request) {
const node = await createProviderNode({
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`,
type: "openai-compatible",
prefix: prefix.trim(),
prefix: resolvedPrefix,
apiType,
baseUrl: resolvedBaseUrl,
name: name.trim(),
name: resolvedName,
chatPath: chatPath || null,
modelsPath: modelsPath || null,
iconUrl: iconUrl?.trim() || null,
@@ -124,9 +176,9 @@ export async function POST(request) {
? `${CLAUDE_CODE_COMPATIBLE_PREFIX}${generateId()}`
: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
type: "anthropic-compatible",
prefix: prefix.trim(),
prefix: (prefix || "").trim(),
baseUrl: sanitizedBaseUrl,
name: name.trim(),
name: (name || "").trim(),
chatPath: chatPath || null,
modelsPath: compatMode === "cc" ? null : modelsPath || null,
iconUrl: iconUrl?.trim() || null,

View File

@@ -87,4 +87,16 @@ export const AUDIO_ONLY_PROVIDERS = {
textIcon: "RV",
website: "https://www.rev.ai",
},
speechmatics: {
id: "speechmatics",
alias: "sm",
name: "Speechmatics",
icon: "record_voice_over",
color: "#0A2540",
textIcon: "SM",
website: "https://www.speechmatics.com",
hasFree: true,
freeNote: "Free tier — 8 hours/month, no credit card required. Batch (async) mode only.",
},
};

View File

@@ -270,8 +270,11 @@ export const removeModelAliasSchema = z.object({
export const createProviderNodeSchema = z
.object({
name: z.string().trim().min(1, "Name is required"),
prefix: z.string().trim().min(1, "Prefix is required"),
// #6874: name/prefix are required in general, but a `preset` (e.g.
// "vibeproxy-openai") supplies both — enforced conditionally below
// instead of unconditionally here.
name: z.string().trim().optional().or(z.literal("")),
prefix: z.string().trim().optional().or(z.literal("")),
apiType: z
.enum([
"chat",
@@ -285,6 +288,10 @@ export const createProviderNodeSchema = z
baseUrl: z.string().trim().min(1).optional(),
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
compatMode: z.enum(["cc"]).optional(),
// #6874: named presets fill in name/prefix/apiType for well-known
// OpenAI-compatible local gateways so the operator only has to paste
// a baseUrl. Currently just VibeProxy (github.com/automazeio/vibeproxy).
preset: z.enum(["vibeproxy-openai"]).optional(),
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
// #2166: optional operator-supplied remote icon URL for the provider node. Empty
@@ -296,6 +303,33 @@ export const createProviderNodeSchema = z
})
.superRefine((value, ctx) => {
const nodeType = value.type || "openai-compatible";
if (value.preset === "vibeproxy-openai") {
// Preset supplies name/prefix/apiType — but baseUrl is still mandatory
// (a local proxy's host/port is operator-specific, unlike the generic
// openai-compatible fallback-to-api.openai.com default).
if (!value.baseUrl || !value.baseUrl.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base URL is required for the VibeProxy preset",
path: ["baseUrl"],
});
}
return;
}
if (!value.name || !value.name.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Name is required",
path: ["name"],
});
}
if (!value.prefix || !value.prefix.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Prefix is required",
path: ["prefix"],
});
}
if (nodeType === "openai-compatible" && !value.apiType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,

View File

@@ -0,0 +1,187 @@
// gTTS (Google Translate TTS) audio-tts provider (#6667).
//
// The originally-proposed `/translate_tts` GET endpoint is deprecated;
// this covers the current `batchexecute` RPC mechanism: chunking to the
// 100-char limit, RPC body construction, response parsing, registry lookup,
// and the handler's dispatch + error path.
import test from "node:test";
import assert from "node:assert/strict";
import {
GOOGLE_TTS_MAX_CHARS,
GttsUpstreamError,
buildGttsRpcBody,
chunkGttsText,
normalizeGttsLang,
parseBatchExecuteResponse,
synthesizeGtts,
} from "../../open-sse/executors/gtts.ts";
import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts";
import { handleAudioSpeech } from "../../open-sse/handlers/audioSpeech.ts";
// ─── chunkGttsText ──────────────────────────────────────────────────────
test("chunkGttsText returns a single chunk for short input", () => {
assert.deepEqual(chunkGttsText("hello world"), ["hello world"]);
});
test("chunkGttsText returns [] for empty/whitespace-only input", () => {
assert.deepEqual(chunkGttsText(""), []);
assert.deepEqual(chunkGttsText(" "), []);
assert.deepEqual(chunkGttsText(undefined), []);
});
test("chunkGttsText splits long input on whitespace within the 100-char limit", () => {
const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" "); // > 100 chars
const chunks = chunkGttsText(text);
assert.ok(chunks.length > 1, "must split into multiple chunks");
for (const chunk of chunks) {
assert.ok(chunk.length <= GOOGLE_TTS_MAX_CHARS, `chunk exceeds limit: "${chunk}"`);
}
// Rejoining chunks must reconstruct the original words (no character loss).
assert.equal(chunks.join(" "), text);
});
test("chunkGttsText hard-splits a single word longer than the limit", () => {
const longWord = "a".repeat(250);
const chunks = chunkGttsText(longWord, 100);
assert.equal(chunks.length, 3);
assert.equal(chunks.join(""), longWord);
for (const chunk of chunks) {
assert.ok(chunk.length <= 100);
}
});
// ─── normalizeGttsLang ──────────────────────────────────────────────────
test("normalizeGttsLang accepts simple language codes and defaults to English", () => {
assert.equal(normalizeGttsLang("pt-BR"), "pt-BR");
assert.equal(normalizeGttsLang("es"), "es");
assert.equal(normalizeGttsLang(""), "en");
assert.equal(normalizeGttsLang(undefined), "en");
assert.equal(normalizeGttsLang("<script>alert(1)</script>"), "en");
});
// ─── buildGttsRpcBody ───────────────────────────────────────────────────
test("buildGttsRpcBody wraps text/lang under the jQ1olc RPC id, urlencoded", () => {
const body = buildGttsRpcBody("hello", "en");
assert.match(body, /^f\.req=/);
assert.ok(body.endsWith("&"));
const encoded = body.slice("f.req=".length, -1);
const envelope = JSON.parse(decodeURIComponent(encoded));
assert.deepEqual(envelope, [[["jQ1olc", JSON.stringify(["hello", "en", true, "null"]), null, "generic"]]]);
});
// ─── parseBatchExecuteResponse ──────────────────────────────────────────
function buildBatchExecuteFixture(base64Audio: string): string {
const inner = JSON.stringify([base64Audio, null, null, null, null, null, []]);
const outer = JSON.stringify([["wrb.fr", "jQ1olc", inner, null, null, null, "generic"]]);
return `)]}'\n\n${outer.length}\n${outer}\n`;
}
test("parseBatchExecuteResponse extracts the base64 audio payload", () => {
const raw = buildBatchExecuteFixture("aGVsbG8=");
assert.equal(parseBatchExecuteResponse(raw), "aGVsbG8=");
});
test("parseBatchExecuteResponse throws GttsUpstreamError when no payload is found", () => {
assert.throws(() => parseBatchExecuteResponse(")]}'\n\nnot json at all"), GttsUpstreamError);
});
// ─── synthesizeGtts (network via injectable fetch) ───────────────────────
test("synthesizeGtts concatenates decoded audio across multiple chunks", async () => {
const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" ");
const expectedChunks = chunkGttsText(text);
assert.ok(expectedChunks.length > 1);
const calls: string[] = [];
const fetchImpl = async (url: string, init: RequestInit) => {
calls.push(String(init.body));
return new Response(buildBatchExecuteFixture(Buffer.from("chunk-audio").toString("base64")), {
status: 200,
headers: { "content-type": "text/plain" },
});
};
const audio = await synthesizeGtts({ text, lang: "en" }, fetchImpl);
assert.equal(calls.length, expectedChunks.length);
assert.equal(audio.toString(), "chunk-audio".repeat(expectedChunks.length));
});
test("synthesizeGtts throws GttsUpstreamError with the upstream status on a non-ok response", async () => {
const fetchImpl = async () =>
new Response("rate limited", { status: 429 });
await assert.rejects(
() => synthesizeGtts({ text: "hi", lang: "en" }, fetchImpl),
(err: unknown) => err instanceof GttsUpstreamError && err.status === 429
);
});
// ─── registry lookup ──────────────────────────────────────────────────
test("gtts is registered as a no-auth speech provider", () => {
const provider = getSpeechProvider("gtts");
assert.ok(provider);
assert.equal(provider?.authType, "none");
assert.equal(provider?.format, "gtts");
const parsed = parseSpeechModel("gtts/default");
assert.equal(parsed.provider, "gtts");
assert.equal(parsed.model, "default");
});
// ─── handleAudioSpeech dispatch ─────────────────────────────────────────
test("handleAudioSpeech routes gtts requests without requiring credentials", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url: string, init: RequestInit) => {
assert.match(String(init.body), /^f\.req=/);
return new Response(buildBatchExecuteFixture(Buffer.from("hi-audio").toString("base64")), {
status: 200,
headers: { "content-type": "text/plain" },
});
}) as typeof fetch;
try {
const providerConfig = getSpeechProvider("gtts");
const response = await handleAudioSpeech({
body: { model: "gtts/default", input: "hi there", voice: "en" },
credentials: null,
resolvedProvider: providerConfig,
resolvedModel: "default",
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "audio/mpeg");
const buf = Buffer.from(await response.arrayBuffer());
assert.equal(buf.toString(), "hi-audio");
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioSpeech surfaces gtts upstream errors without leaking a stack trace", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response("blocked", { status: 429 })) as typeof fetch;
try {
const providerConfig = getSpeechProvider("gtts");
const response = await handleAudioSpeech({
body: { model: "gtts/default", input: "hi there" },
credentials: null,
resolvedProvider: providerConfig,
resolvedModel: "default",
});
const payload = (await response.json()) as { error: { message: string } };
assert.equal(response.status, 429);
assert.ok(!payload.error.message.includes("at /"), "error body must not leak a stack trace");
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,135 @@
// #6874 — VibeProxy (github.com/automazeio/vibeproxy) provider-node preset.
// TDD regression guard for `preset: "vibeproxy-openai"` on
// POST /api/provider-nodes: defaults name/prefix/apiType, still requires an
// explicit baseUrl, and normalizes the caller-supplied baseUrl to its `/v1`
// root the same way the existing Anthropic/Claude-Code-compatible presets do.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-vibeproxy-preset-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts");
const { OPENAI_COMPATIBLE_PREFIX } = await import("../../src/shared/constants/providers.ts");
interface ProviderNodeErrorBody {
error: { message: string; details?: { field: string; message: string }[] };
}
interface ProviderNodeResponseBody {
node: {
id: string;
type: string;
prefix: string;
name: string;
apiType?: string;
baseUrl: string;
};
}
async function resetStorage() {
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function makeRequest(body: unknown) {
return new Request("http://localhost/api/provider-nodes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("vibeproxy-openai preset creates a node with defaulted name/prefix/apiType", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
preset: "vibeproxy-openai",
baseUrl: "http://localhost:8317",
})
);
const body = (await response.json()) as ProviderNodeResponseBody;
assert.equal(response.status, 201);
assert.match(body.node.id, new RegExp(`^${OPENAI_COMPATIBLE_PREFIX}chat-`));
assert.equal(body.node.type, "openai-compatible");
assert.equal(body.node.name, "VibeProxy");
assert.equal(body.node.prefix, "vibeproxy");
assert.equal(body.node.apiType, "chat");
assert.equal(body.node.baseUrl, "http://localhost:8317/v1");
});
test("vibeproxy-openai preset normalizes a baseUrl with a /chat/completions suffix", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
preset: "vibeproxy-openai",
baseUrl: "http://localhost:8317/v1/chat/completions",
})
);
const body = (await response.json()) as ProviderNodeResponseBody;
assert.equal(response.status, 201);
assert.equal(body.node.baseUrl, "http://localhost:8317/v1");
});
test("vibeproxy-openai preset appends /v1 when the caller omits it", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
preset: "vibeproxy-openai",
baseUrl: "http://localhost:9000",
})
);
const body = (await response.json()) as ProviderNodeResponseBody;
assert.equal(response.status, 201);
assert.equal(body.node.baseUrl, "http://localhost:9000/v1");
});
test("vibeproxy-openai preset honors caller-supplied name/prefix instead of the defaults", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
preset: "vibeproxy-openai",
name: "My VibeProxy",
prefix: "my-vibeproxy",
baseUrl: "http://localhost:8317",
})
);
const body = (await response.json()) as ProviderNodeResponseBody;
assert.equal(response.status, 201);
assert.equal(body.node.name, "My VibeProxy");
assert.equal(body.node.prefix, "my-vibeproxy");
});
test("vibeproxy-openai preset rejects a missing baseUrl (no silent default)", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
preset: "vibeproxy-openai",
})
);
const body = (await response.json()) as ProviderNodeErrorBody;
assert.equal(response.status, 400);
assert.equal(body.error.message, "Invalid request");
assert.match(
body.error.details?.find((d) => d.field === "baseUrl")?.message || "",
/Base URL is required for the VibeProxy preset/
);
assert.deepEqual(await providersDb.getProviderNodes(), []);
});

View File

@@ -0,0 +1,143 @@
// #6659 — Speechmatics STT provider: async batch workflow (submit multipart
// job → poll → fetch transcript), mirroring the existing AssemblyAI/Rev AI
// adapters. Streaming (WebSocket) mode is explicitly out of scope for v1.
import test from "node:test";
import assert from "node:assert/strict";
const { handleAudioTranscription } = await import("../../open-sse/handlers/audioTranscription.ts");
const { getTranscriptionProvider } = await import("../../open-sse/config/audioRegistry.ts");
function buildFile(contents: string, name: string, type: string) {
return new File([Buffer.from(contents)], name, { type });
}
function immediateTimeout(callback: (...args: unknown[]) => void, _ms?: number, ...args: unknown[]) {
if (typeof callback === "function") callback(...args);
return 0;
}
test("speechmatics registry entry is async, apikey-bearer, batch-only", () => {
const provider = getTranscriptionProvider("speechmatics");
assert.ok(provider);
assert.equal(provider?.authType, "apikey");
assert.equal(provider?.authHeader, "bearer");
assert.equal(provider?.async, true);
assert.equal(provider?.format, "speechmatics");
assert.ok(provider?.models.some((m) => m.id === "enhanced"));
});
test("handleAudioTranscription routes Speechmatics: submit job → poll → fetch transcript", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const calls: { url: string; method: string }[] = [];
// @ts-expect-error test double swaps the timer signature intentionally
globalThis.setTimeout = immediateTimeout;
globalThis.fetch = (async (url: string, options: RequestInit = {}) => {
const stringUrl = String(url);
calls.push({ url: stringUrl, method: (options?.method as string) || "GET" });
if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs") {
assert.equal(options.method, "POST");
assert.equal((options.headers as Record<string, string>).Authorization, "Bearer sm-key");
return new Response(JSON.stringify({ id: "job-1" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs/job-1") {
return new Response(JSON.stringify({ job: { status: "done" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs/job-1/transcript?format=txt") {
return new Response("speechmatics result", {
status: 200,
headers: { "content-type": "text/plain" },
});
}
throw new Error(`Unexpected URL: ${stringUrl}`);
}) as typeof fetch;
try {
const formData = new FormData();
formData.append("model", "speechmatics/enhanced");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranscription({
formData,
credentials: { apiKey: "sm-key" },
});
assert.deepEqual(await response.json(), { text: "speechmatics result" });
assert.deepEqual(
calls.map((entry) => entry.url),
[
"https://asr.api.speechmatics.com/v2/jobs",
"https://asr.api.speechmatics.com/v2/jobs/job-1",
"https://asr.api.speechmatics.com/v2/jobs/job-1/transcript?format=txt",
]
);
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
});
test("handleAudioTranscription returns an error when Speechmatics rejects the job", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
// @ts-expect-error test double swaps the timer signature intentionally
globalThis.setTimeout = immediateTimeout;
globalThis.fetch = (async (url: string, options: RequestInit = {}) => {
const stringUrl = String(url);
if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs") {
return new Response(JSON.stringify({ id: "job-2" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (stringUrl === "https://asr.api.speechmatics.com/v2/jobs/job-2") {
return new Response(
JSON.stringify({ job: { status: "rejected", errors: [{ message: "bad audio" }] } }),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
throw new Error(`Unexpected URL: ${stringUrl} (${options.method})`);
}) as typeof fetch;
try {
const formData = new FormData();
formData.append("model", "speechmatics/enhanced");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranscription({
formData,
credentials: { apiKey: "sm-key" },
});
const payload = (await response.json()) as { error: { message: string } };
assert.equal(response.status, 500);
assert.equal(payload.error.message, "bad audio");
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
});
test("handleAudioTranscription requires credentials for Speechmatics", async () => {
const formData = new FormData();
formData.append("model", "speechmatics/enhanced");
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
const response = await handleAudioTranscription({ formData, credentials: null });
const payload = (await response.json()) as { error: { message: string } };
assert.equal(response.status, 401);
assert.match(payload.error.message, /speechmatics/);
});