mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
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:
committed by
GitHub
parent
9e084e18a7
commit
38dd62819b
1
changelog.d/features/6659-speechmatics-stt-provider.md
Normal file
1
changelog.d/features/6659-speechmatics-stt-provider.md
Normal 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)
|
||||
1
changelog.d/features/6667-gtts-audio-tts-provider.md
Normal file
1
changelog.d/features/6667-gtts-audio-tts-provider.md
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
214
open-sse/executors/gtts.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
187
tests/unit/gtts-provider.test.ts
Normal file
187
tests/unit/gtts-provider.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
135
tests/unit/provider-nodes-vibeproxy-preset.test.ts
Normal file
135
tests/unit/provider-nodes-vibeproxy-preset.test.ts
Normal 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(), []);
|
||||
});
|
||||
143
tests/unit/speechmatics-transcription-provider.test.ts
Normal file
143
tests/unit/speechmatics-transcription-provider.test.ts
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user