diff --git a/packages/web/server/lib/tts/DOCUMENTATION.md b/packages/web/server/lib/tts/DOCUMENTATION.md index e80dc746..d39caf69 100644 --- a/packages/web/server/lib/tts/DOCUMENTATION.md +++ b/packages/web/server/lib/tts/DOCUMENTATION.md @@ -5,10 +5,12 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API, ## Entrypoints and structure - `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`. -- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*` and `/api/tts/*` endpoints. +- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*`, `/api/tts/*`, and `/api/stt/*` endpoints. - `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability. - `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration. - `packages/web/server/lib/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API. +- `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints. +- `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints. ## Public exports diff --git a/packages/web/server/lib/tts/base-url.js b/packages/web/server/lib/tts/base-url.js new file mode 100644 index 00000000..493d18e4 --- /dev/null +++ b/packages/web/server/lib/tts/base-url.js @@ -0,0 +1,62 @@ +const LOCAL_BASE_URL_HOSTS = new Set([ + 'localhost', + '127.0.0.1', + '::1', + 'host.docker.internal', +]); + +const isEnvFlagEnabled = (value) => { + if (value === true || value === 1) return true; + if (typeof value !== 'string') return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true'; +}; + +const normalizeHostname = (hostname) => { + if (typeof hostname !== 'string') return ''; + const trimmed = hostname.trim().toLowerCase(); + if (!trimmed) return ''; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed.slice(1, -1); + } + return trimmed; +}; + +const isAllowedLocalHost = (hostname) => { + const normalized = normalizeHostname(hostname); + return LOCAL_BASE_URL_HOSTS.has(normalized); +}; + +export const normalizeCustomOpenAIBaseURL = (value) => { + if (typeof value !== 'string' || !value.trim()) { + return { value: undefined }; + } + + let parsed; + try { + parsed = new URL(value.trim()); + } catch { + return { error: 'Custom server URL is invalid' }; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { error: 'Custom server URL must use http or https' }; + } + + if (parsed.username || parsed.password) { + return { error: 'Custom server URL must not include credentials' }; + } + + const allowRemote = isEnvFlagEnabled(process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS); + if (!allowRemote && !isAllowedLocalHost(parsed.hostname)) { + return { + error: 'Remote custom server URLs are disabled. Set OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS=true to allow this host.', + }; + } + + parsed.hash = ''; + parsed.search = ''; + const pathname = parsed.pathname.replace(/\/+$/, ''); + const normalizedPath = pathname.length > 0 ? pathname : ''; + return { value: `${parsed.protocol}//${parsed.host}${normalizedPath}` }; +}; diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 217e0d96..e4235aca 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -234,9 +234,13 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { const { transcribeAudio } = await import('./stt.js'); const mimeType = (req.headers['content-type'] || 'audio/webm').split(',')[0].trim(); - const baseURL = req.headers['x-base-url']; - const model = req.headers['x-model'] || 'deepdml/faster-whisper-large-v3-turbo-ct2'; - const language = req.headers['x-language'] || undefined; + const baseURL = typeof req.headers['x-base-url'] === 'string' ? req.headers['x-base-url'].trim() : ''; + const model = typeof req.headers['x-model'] === 'string' && req.headers['x-model'].trim().length > 0 + ? req.headers['x-model'].trim() + : 'deepdml/faster-whisper-large-v3-turbo-ct2'; + const language = typeof req.headers['x-language'] === 'string' && req.headers['x-language'].trim().length > 0 + ? req.headers['x-language'].trim() + : undefined; if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) { return res.status(400).json({ error: 'Audio data is required' }); diff --git a/packages/web/server/lib/tts/stt.js b/packages/web/server/lib/tts/stt.js index a947ae6a..1136c332 100644 --- a/packages/web/server/lib/tts/stt.js +++ b/packages/web/server/lib/tts/stt.js @@ -6,6 +6,7 @@ */ import OpenAI, { toFile } from 'openai'; +import { normalizeCustomOpenAIBaseURL } from './base-url.js'; /** * Transcribe an audio buffer via an OpenAI-compatible /v1/audio/transcriptions endpoint. @@ -19,12 +20,20 @@ import OpenAI, { toFile } from 'openai'; * @returns {Promise} Transcribed text */ export async function transcribeAudio({ audioBuffer, mimeType, model, baseURL, language }) { + const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL); + if (normalizedBaseURLResult.error) { + throw new Error(normalizedBaseURLResult.error); + } + + const normalizedBaseURL = normalizedBaseURLResult.value; + if (!normalizedBaseURL) { + throw new Error('Custom server URL is required'); + } + const clientOpts = { apiKey: process.env.OPENAI_API_KEY || 'not-required', }; - if (baseURL) { - clientOpts.baseURL = baseURL; - } + clientOpts.baseURL = normalizedBaseURL; const client = new OpenAI(clientOpts);