From c7f1501ad231aba4bf4a968315cba59e003c17b7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 12 Apr 2026 10:38:16 +0300 Subject: [PATCH] fix: harden custom TTS URL handling and availability checks Validate and normalize custom TTS server URLs before proxying requests. Gate server TTS availability by provider mode to avoid OpenAI/custom misrouting. Keep browser and message TTS hooks aligned with the new provider-specific checks. --- packages/ui/src/hooks/useBrowserVoice.ts | 1 + packages/ui/src/hooks/useMessageTTS.ts | 1 + packages/ui/src/hooks/useServerTTS.ts | 16 ++++++++++++++-- packages/web/server/lib/tts/routes.js | 11 +++++++++-- packages/web/server/lib/tts/service.js | 15 +++++++++++---- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index d2d7a5aa..c641816c 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -166,6 +166,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { // Server TTS for mobile (bypasses Safari audio restrictions) const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable, unlockAudio: unlockServerTTSAudio } = useServerTTS({ enabled: shouldCheckOpenAIAvailability, + availabilityMode: voiceProvider === 'openai-compatible' ? 'openai-compatible' : 'openai', }); // macOS Say TTS diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index 62d6b147..fe827a3f 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -44,6 +44,7 @@ export function useMessageTTS(): UseMessageTTSReturn { const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable } = useServerTTS({ enabled: shouldCheckOpenAIAvailability, + availabilityMode: voiceProvider === 'openai-compatible' ? 'openai-compatible' : 'openai', }); const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable } = useSayTTS({ enabled: shouldCheckSayAvailability, diff --git a/packages/ui/src/hooks/useServerTTS.ts b/packages/ui/src/hooks/useServerTTS.ts index 429fe8d4..d8eefc85 100644 --- a/packages/ui/src/hooks/useServerTTS.ts +++ b/packages/ui/src/hooks/useServerTTS.ts @@ -26,6 +26,7 @@ interface ServerTTSStatusCache { interface UseServerTTSOptions { enabled?: boolean; + availabilityMode?: 'auto' | 'openai' | 'openai-compatible'; } const SERVER_TTS_STATUS_TTL_MS = 30000; @@ -125,6 +126,7 @@ function getAudioContext(): AudioContext { export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSReturn { const enabled = options.enabled ?? true; + const availabilityMode = options.availabilityMode ?? 'auto'; const [isPlaying, setIsPlaying] = useState(false); const [isAvailable, setIsAvailable] = useState(false); const [error, setError] = useState(null); @@ -150,7 +152,17 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet const hasClientKey = Boolean(openaiApiKey && openaiApiKey.trim().length > 0); const hasCustomUrl = Boolean(openaiCompatibleUrl && openaiCompatibleUrl.trim().length > 0); - if (hasClientKey || hasCustomUrl) { + if (availabilityMode === 'openai-compatible') { + setIsAvailable(hasCustomUrl); + return hasCustomUrl; + } + + if (hasClientKey) { + setIsAvailable(true); + return true; + } + + if (availabilityMode === 'auto' && hasCustomUrl) { setIsAvailable(true); return true; } @@ -163,7 +175,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet setIsAvailable(false); return false; } - }, [enabled, openaiApiKey, openaiCompatibleUrl]); + }, [availabilityMode, enabled, openaiApiKey, openaiCompatibleUrl]); // Check availability on mount and when API key changes useEffect(() => { diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 84ee2352..5f30424c 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -1,4 +1,5 @@ import express from 'express'; +import { normalizeCustomOpenAIBaseURL } from './base-url.js'; export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { let ttsModulePromise = null; @@ -44,6 +45,12 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { try { const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey, baseURL } = req.body || {}; + const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL); + if (normalizedBaseURLResult.error) { + return res.status(400).json({ error: normalizedBaseURLResult.error }); + } + const normalizedBaseURL = normalizedBaseURLResult.value; + console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey, hasBaseURL: !!baseURL }); if (!text || typeof text !== 'string' || !text.trim()) { @@ -56,7 +63,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { // Check availability - server-configured key, client-provided key, or custom server URL const hasServerKey = ttsService.isAvailable(); const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0; - const hasCustomBaseURL = baseURL && typeof baseURL === 'string' && baseURL.trim().length > 0; + const hasCustomBaseURL = typeof normalizedBaseURL === 'string' && normalizedBaseURL.length > 0; if (!hasServerKey && !hasClientKey && !hasCustomBaseURL) { return res.status(503).json({ @@ -89,7 +96,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { speed, instructions, apiKey: hasClientKey ? apiKey.trim() : undefined, - baseURL: hasCustomBaseURL ? baseURL.trim() : undefined, + baseURL: hasCustomBaseURL ? normalizedBaseURL : undefined, }); res.setHeader('Content-Type', result.contentType); diff --git a/packages/web/server/lib/tts/service.js b/packages/web/server/lib/tts/service.js index 29e1826d..c32df1da 100644 --- a/packages/web/server/lib/tts/service.js +++ b/packages/web/server/lib/tts/service.js @@ -7,6 +7,7 @@ import OpenAI from 'openai'; import { readAuthFile } from '../opencode/auth.js'; +import { normalizeCustomOpenAIBaseURL } from './base-url.js'; // Voice options from OpenAI export const TTS_VOICES = [ @@ -82,13 +83,19 @@ class TTSService { baseURL, } = options; + const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL); + if (normalizedBaseURLResult.error) { + throw new Error(normalizedBaseURLResult.error); + } + const normalizedBaseURL = normalizedBaseURLResult.value; + // Use provided API key / baseURL or fall back to configured key let client; - if (baseURL || apiKey) { + if (normalizedBaseURL || apiKey) { const clientOpts = {}; if (apiKey) clientOpts.apiKey = apiKey; if (!apiKey) clientOpts.apiKey = 'not-required'; - if (baseURL) clientOpts.baseURL = baseURL; + if (normalizedBaseURL) clientOpts.baseURL = normalizedBaseURL; client = new OpenAI(clientOpts); } else { client = this._getClient(); @@ -105,7 +112,7 @@ class TTSService { try { // OpenAI-compatible servers (custom baseURL) may not support `instructions` // or `response_format`, but do support `speed`. Send the safe subset. - const speechParams = baseURL + const speechParams = normalizedBaseURL ? { model, voice, input: text, speed } : { model, @@ -116,7 +123,7 @@ class TTSService { response_format: 'mp3', }; - console.log('[TTSService] Generating speech — model:', model, 'voice:', voice, 'baseURL:', baseURL ?? '(openai)'); + console.log('[TTSService] Generating speech — model:', model, 'voice:', voice, 'baseURL:', normalizedBaseURL ?? '(openai)'); const response = await client.audio.speech.create(speechParams); const arrayBuffer = await response.arrayBuffer();