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.
This commit is contained in:
@@ -166,6 +166,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
|||||||
// Server TTS for mobile (bypasses Safari audio restrictions)
|
// Server TTS for mobile (bypasses Safari audio restrictions)
|
||||||
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable, unlockAudio: unlockServerTTSAudio } = useServerTTS({
|
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable, unlockAudio: unlockServerTTSAudio } = useServerTTS({
|
||||||
enabled: shouldCheckOpenAIAvailability,
|
enabled: shouldCheckOpenAIAvailability,
|
||||||
|
availabilityMode: voiceProvider === 'openai-compatible' ? 'openai-compatible' : 'openai',
|
||||||
});
|
});
|
||||||
|
|
||||||
// macOS Say TTS
|
// macOS Say TTS
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function useMessageTTS(): UseMessageTTSReturn {
|
|||||||
|
|
||||||
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable } = useServerTTS({
|
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable } = useServerTTS({
|
||||||
enabled: shouldCheckOpenAIAvailability,
|
enabled: shouldCheckOpenAIAvailability,
|
||||||
|
availabilityMode: voiceProvider === 'openai-compatible' ? 'openai-compatible' : 'openai',
|
||||||
});
|
});
|
||||||
const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable } = useSayTTS({
|
const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable } = useSayTTS({
|
||||||
enabled: shouldCheckSayAvailability,
|
enabled: shouldCheckSayAvailability,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface ServerTTSStatusCache {
|
|||||||
|
|
||||||
interface UseServerTTSOptions {
|
interface UseServerTTSOptions {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
availabilityMode?: 'auto' | 'openai' | 'openai-compatible';
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVER_TTS_STATUS_TTL_MS = 30000;
|
const SERVER_TTS_STATUS_TTL_MS = 30000;
|
||||||
@@ -125,6 +126,7 @@ function getAudioContext(): AudioContext {
|
|||||||
|
|
||||||
export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSReturn {
|
export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSReturn {
|
||||||
const enabled = options.enabled ?? true;
|
const enabled = options.enabled ?? true;
|
||||||
|
const availabilityMode = options.availabilityMode ?? 'auto';
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isAvailable, setIsAvailable] = useState(false);
|
const [isAvailable, setIsAvailable] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -150,7 +152,17 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
|
|||||||
|
|
||||||
const hasClientKey = Boolean(openaiApiKey && openaiApiKey.trim().length > 0);
|
const hasClientKey = Boolean(openaiApiKey && openaiApiKey.trim().length > 0);
|
||||||
const hasCustomUrl = Boolean(openaiCompatibleUrl && openaiCompatibleUrl.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);
|
setIsAvailable(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -163,7 +175,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
|
|||||||
setIsAvailable(false);
|
setIsAvailable(false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [enabled, openaiApiKey, openaiCompatibleUrl]);
|
}, [availabilityMode, enabled, openaiApiKey, openaiCompatibleUrl]);
|
||||||
|
|
||||||
// Check availability on mount and when API key changes
|
// Check availability on mount and when API key changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
||||||
|
|
||||||
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
||||||
let ttsModulePromise = null;
|
let ttsModulePromise = null;
|
||||||
@@ -44,6 +45,12 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
|||||||
try {
|
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 { 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 });
|
console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey, hasBaseURL: !!baseURL });
|
||||||
|
|
||||||
if (!text || typeof text !== 'string' || !text.trim()) {
|
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
|
// Check availability - server-configured key, client-provided key, or custom server URL
|
||||||
const hasServerKey = ttsService.isAvailable();
|
const hasServerKey = ttsService.isAvailable();
|
||||||
const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
|
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) {
|
if (!hasServerKey && !hasClientKey && !hasCustomBaseURL) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
@@ -89,7 +96,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
|||||||
speed,
|
speed,
|
||||||
instructions,
|
instructions,
|
||||||
apiKey: hasClientKey ? apiKey.trim() : undefined,
|
apiKey: hasClientKey ? apiKey.trim() : undefined,
|
||||||
baseURL: hasCustomBaseURL ? baseURL.trim() : undefined,
|
baseURL: hasCustomBaseURL ? normalizedBaseURL : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', result.contentType);
|
res.setHeader('Content-Type', result.contentType);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { readAuthFile } from '../opencode/auth.js';
|
import { readAuthFile } from '../opencode/auth.js';
|
||||||
|
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
||||||
|
|
||||||
// Voice options from OpenAI
|
// Voice options from OpenAI
|
||||||
export const TTS_VOICES = [
|
export const TTS_VOICES = [
|
||||||
@@ -82,13 +83,19 @@ class TTSService {
|
|||||||
baseURL,
|
baseURL,
|
||||||
} = options;
|
} = 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
|
// Use provided API key / baseURL or fall back to configured key
|
||||||
let client;
|
let client;
|
||||||
if (baseURL || apiKey) {
|
if (normalizedBaseURL || apiKey) {
|
||||||
const clientOpts = {};
|
const clientOpts = {};
|
||||||
if (apiKey) clientOpts.apiKey = apiKey;
|
if (apiKey) clientOpts.apiKey = apiKey;
|
||||||
if (!apiKey) clientOpts.apiKey = 'not-required';
|
if (!apiKey) clientOpts.apiKey = 'not-required';
|
||||||
if (baseURL) clientOpts.baseURL = baseURL;
|
if (normalizedBaseURL) clientOpts.baseURL = normalizedBaseURL;
|
||||||
client = new OpenAI(clientOpts);
|
client = new OpenAI(clientOpts);
|
||||||
} else {
|
} else {
|
||||||
client = this._getClient();
|
client = this._getClient();
|
||||||
@@ -105,7 +112,7 @@ class TTSService {
|
|||||||
try {
|
try {
|
||||||
// OpenAI-compatible servers (custom baseURL) may not support `instructions`
|
// OpenAI-compatible servers (custom baseURL) may not support `instructions`
|
||||||
// or `response_format`, but do support `speed`. Send the safe subset.
|
// or `response_format`, but do support `speed`. Send the safe subset.
|
||||||
const speechParams = baseURL
|
const speechParams = normalizedBaseURL
|
||||||
? { model, voice, input: text, speed }
|
? { model, voice, input: text, speed }
|
||||||
: {
|
: {
|
||||||
model,
|
model,
|
||||||
@@ -116,7 +123,7 @@ class TTSService {
|
|||||||
response_format: 'mp3',
|
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 response = await client.audio.speech.create(speechParams);
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
|||||||
Reference in New Issue
Block a user