From 06526767a2eb947ec6acd1833a055c63aee5abf1 Mon Sep 17 00:00:00 2001 From: yangyaofei Date: Sun, 24 May 2026 05:58:22 +0800 Subject: [PATCH] feat(tts/stt): add API key support for OpenAI-compatible custom providers (#1361) * feat(tts/stt): add API key support for OpenAI-compatible custom providers ## Problem Custom (OpenAI-compatible) TTS/STT provider in Voice Settings has no way to pass an API key or bearer token. Many self-hosted or third-party compatible servers require authentication, making them unreachable from OpenChamber. The server-side TTS route already accepts an `apiKey` parameter, but the frontend never sends it. The STT route hardcodes `'not-required'`. ## Implementation - Add `openaiCompatibleApiKey` to Zustand config store, persisted to localStorage - Add API Key input field in VoiceSettings.tsx under the custom provider section - Wire `openaiCompatibleApiKey` through useServerTTS to the TTS backend - Add `apiKey` field to AudioStreamConfig for STT, forwarded as X-API-Key header - Update server STT route to accept and forward X-API-Key to transcribeAudio - Update stt.js to use client-provided apiKey before falling back to env var ## Files changed - packages/ui/src/stores/useConfigStore.ts - packages/ui/src/components/sections/openchamber/VoiceSettings.tsx - packages/ui/src/hooks/useServerTTS.ts - packages/ui/src/hooks/useBrowserVoice.ts - packages/ui/src/lib/voice/audioStreamService.ts - packages/web/server/lib/tts/routes.js - packages/web/server/lib/tts/stt.js * feat(tts/stt): add separate API key support for custom TTS and STT providers ## Problem Custom (OpenAI-compatible) TTS and STT providers in Voice Settings have no way to pass API keys. Many self-hosted or third-party compatible servers require authentication, making them unreachable from OpenChamber Desktop (Electron). ## Implementation - Add `openaiCompatibleApiKey` for TTS (persisted to localStorage, passed in JSON body) - Add `sttApiKey` for STT (persisted to localStorage, passed via Authorization: Bearer header) - Two independent keys: TTS and STT are configured separately - STT authentication follows OpenAI standard (Authorization: Bearer ) - TTS authentication follows existing pattern (apiKey in JSON body) - Backend STT route extracts bearer token from Authorization header - Backend STT service prefers client-provided key over OPENAI_API_KEY env var ## Fixes - Fixed P1: ConfigStore interface now declares setOpenaiCompatibleApiKey setter - STT API key is only forwarded when sttProvider === 'server' (not leaked to other providers) ## Files changed (7) - packages/ui/src/stores/useConfigStore.ts - packages/ui/src/components/sections/openchamber/VoiceSettings.tsx - packages/ui/src/hooks/useServerTTS.ts - packages/ui/src/hooks/useBrowserVoice.ts - packages/ui/src/lib/voice/audioStreamService.ts - packages/web/server/lib/tts/routes.js - packages/web/server/lib/tts/stt.js * fix: refresh server STT callback when API key changes --------- Co-authored-by: Bohdan Triapitsyn --- .../sections/openchamber/VoiceSettings.tsx | 55 ++++++++++++++++++- packages/ui/src/hooks/useBrowserVoice.ts | 4 +- packages/ui/src/hooks/useServerTTS.ts | 5 +- .../ui/src/lib/voice/audioStreamService.ts | 8 +++ packages/ui/src/stores/useConfigStore.ts | 33 +++++++++++ packages/web/server/lib/tts/routes.js | 4 ++ packages/web/server/lib/tts/stt.js | 5 +- 7 files changed, 108 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 2ddb50ee..6beeaf48 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -153,6 +153,8 @@ export const VoiceSettings: React.FC = () => { const setOpenaiApiKey = useConfigStore((state) => state.setOpenaiApiKey); const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); const setOpenaiCompatibleUrl = useConfigStore((state) => state.setOpenaiCompatibleUrl); + const openaiCompatibleApiKey = useConfigStore((state) => state.openaiCompatibleApiKey); + const setOpenaiCompatibleApiKey = useConfigStore((state) => state.setOpenaiCompatibleApiKey); const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); const setOpenaiCompatibleVoice = useConfigStore((state) => state.setOpenaiCompatibleVoice); const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel); @@ -163,6 +165,8 @@ export const VoiceSettings: React.FC = () => { const setSttProvider = useConfigStore((state) => state.setSttProvider); const sttServerUrl = useConfigStore((state) => state.sttServerUrl); const setSttServerUrl = useConfigStore((state) => state.setSttServerUrl); + const sttApiKey = useConfigStore((state) => state.sttApiKey); + const setSttApiKey = useConfigStore((state) => state.setSttApiKey); const sttModel = useConfigStore((state) => state.sttModel); const setSttModel = useConfigStore((state) => state.setSttModel); const wasmSttModel = useConfigStore((state) => state.wasmSttModel); @@ -446,6 +450,7 @@ export const VoiceSettings: React.FC = () => { model: openaiCompatibleTtsModel || undefined, speed: speechRate, baseURL: openaiCompatibleUrl, + apiKey: openaiCompatibleApiKey || undefined, }), }); @@ -477,7 +482,7 @@ export const VoiceSettings: React.FC = () => { setCompatiblePreviewAudio(null); setIsCompatiblePreviewPlaying(false); } - }, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio, t]); + }, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, openaiCompatibleApiKey, speechRate, compatiblePreviewAudio, t]); useEffect(() => { return () => { @@ -639,6 +644,30 @@ export const VoiceSettings: React.FC = () => { )} +
+ API Key + + Optional + +
+ setOpenaiCompatibleApiKey(e.target.value)} + placeholder="sk-..." + className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70" + /> + {openaiCompatibleApiKey && ( + + )} +
+
{t('settings.voice.page.field.model')}
@@ -896,6 +925,30 @@ export const VoiceSettings: React.FC = () => { )}
+
+ API Key + + Optional + +
+ setSttApiKey(e.target.value)} + placeholder="sk-..." + className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70" + /> + {sttApiKey && ( + + )} +
+
{t('settings.voice.page.field.model')}
diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index 0f481728..539ae567 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -151,6 +151,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel); + const sttApiKey = useConfigStore((state) => state.sttApiKey); const shouldCheckOpenAIAvailability = voiceModeEnabled && (voiceProvider === 'openai' || voiceProvider === 'openai-compatible'); const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say'; @@ -773,6 +774,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { language: sttLanguage || undefined, silenceThresholdDb: sttSilenceThresholdDb, silenceHoldMs: sttSilenceHoldMs, + apiKey: sttApiKey || undefined, }); try { await audioStreamService.startListening(language, handleSpeechResult, handleSpeechError); @@ -858,7 +860,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { isActiveRef.current = false; } } - }, [isSupported, currentSessionId, language, handleSpeechResult, handleSpeechError, isMobile, unlockServerTTSAudio, unlockSayTTSAudio, sttProvider, sttServerUrl, sttModel, wasmSttModel, sttLanguage, sttSilenceThresholdDb, sttSilenceHoldMs]); + }, [isSupported, currentSessionId, language, handleSpeechResult, handleSpeechError, isMobile, unlockServerTTSAudio, unlockSayTTSAudio, sttProvider, sttServerUrl, sttModel, sttApiKey, wasmSttModel, sttLanguage, sttSilenceThresholdDb, sttSilenceHoldMs]); // Stop voice mode const stopVoice = useCallback(() => { diff --git a/packages/ui/src/hooks/useServerTTS.ts b/packages/ui/src/hooks/useServerTTS.ts index 39b34917..e95c83d8 100644 --- a/packages/ui/src/hooks/useServerTTS.ts +++ b/packages/ui/src/hooks/useServerTTS.ts @@ -139,6 +139,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet const currentModelId = useConfigStore((state) => state.currentModelId); const openaiApiKey = useConfigStore((state) => state.openaiApiKey); const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); + const openaiCompatibleApiKey = useConfigStore((state) => state.openaiCompatibleApiKey); // Check if server TTS is available const checkAvailability = useCallback(async (): Promise => { @@ -277,7 +278,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet providerId: options?.providerId || currentProviderId || undefined, modelId: options?.modelId || currentModelId || undefined, // Send API key from settings if available - apiKey: openaiApiKey || undefined, + apiKey: options?.baseURL ? (openaiCompatibleApiKey || undefined) : (openaiApiKey || undefined), // Send custom base URL for OpenAI-compatible servers baseURL: options?.baseURL || undefined, }), @@ -342,7 +343,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet options?.onError?.(errorMsg); setIsPlaying(false); } - }, [stop, currentProviderId, currentModelId, openaiApiKey]); + }, [stop, currentProviderId, currentModelId, openaiApiKey, openaiCompatibleApiKey]); // Cleanup on unmount useEffect(() => { diff --git a/packages/ui/src/lib/voice/audioStreamService.ts b/packages/ui/src/lib/voice/audioStreamService.ts index 1e26ceb6..6e2fa50e 100644 --- a/packages/ui/src/lib/voice/audioStreamService.ts +++ b/packages/ui/src/lib/voice/audioStreamService.ts @@ -39,6 +39,8 @@ export interface AudioStreamConfig { * Default: 1500 */ silenceHoldMs?: number; + /** Optional API key for the STT server. */ + apiKey?: string; } // How often (ms) the VAD samples the analyser @@ -69,6 +71,7 @@ class AudioStreamService { language: '', silenceThresholdDb: -45, silenceHoldMs: 1500, + apiKey: '', }; /** Update service configuration. Can be called before or after startListening. */ @@ -77,8 +80,10 @@ class AudioStreamService { silenceThresholdDb: -45, silenceHoldMs: 1500, language: '', + apiKey: '', ...config, }; + this.cfg.apiKey = config.apiKey ?? ''; } /** Whether the browser supports the required APIs. */ @@ -336,6 +341,9 @@ class AudioStreamService { 'X-Base-URL': this.cfg.baseURL, 'X-Model': this.cfg.model, }; + if (this.cfg.apiKey) { + headers['Authorization'] = `Bearer ${this.cfg.apiKey}`; + } if (this.cfg.language) { headers['X-Language'] = this.cfg.language; } else if (this.lang && this.lang !== 'auto') { diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 7b847904..9661d0b0 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -550,11 +550,13 @@ interface ConfigStore { openaiVoice: string; openaiApiKey: string; openaiCompatibleUrl: string; + openaiCompatibleApiKey: string; openaiCompatibleVoice: string; openaiCompatibleTtsModel: string; // STT (speech-to-text) settings sttProvider: 'browser' | 'server' | 'wasm'; sttServerUrl: string; + sttApiKey: string; sttModel: string; wasmSttModel: string; sttLanguage: string; @@ -576,10 +578,12 @@ interface ConfigStore { setOpenaiVoice: (voice: string) => void; setOpenaiApiKey: (apiKey: string) => void; setOpenaiCompatibleUrl: (url: string) => void; + setOpenaiCompatibleApiKey: (apiKey: string) => void; setOpenaiCompatibleVoice: (voice: string) => void; setOpenaiCompatibleTtsModel: (model: string) => void; setSttProvider: (provider: 'browser' | 'server' | 'wasm') => void; setSttServerUrl: (url: string) => void; + setSttApiKey: (apiKey: string) => void; setSttModel: (model: string) => void; setWasmSttModel: (model: string) => void; setSttLanguage: (lang: string) => void; @@ -748,6 +752,14 @@ export const useConfigStore = create()( } return ''; })(), + // OpenAI-compatible custom server API key + openaiCompatibleApiKey: (() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('openaiCompatibleApiKey'); + if (saved) return saved; + } + return ''; + })(), // OpenAI-compatible custom server voice openaiCompatibleVoice: (() => { if (typeof window !== 'undefined') { @@ -783,6 +795,13 @@ export const useConfigStore = create()( } return 'http://localhost:8001/v1'; })(), + sttApiKey: (() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('sttApiKey'); + if (saved) return saved; + } + return ''; + })(), sttModel: (() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('sttModel'); @@ -1886,6 +1905,13 @@ export const useConfigStore = create()( } }, + setOpenaiCompatibleApiKey: (apiKey: string) => { + set({ openaiCompatibleApiKey: apiKey }); + if (typeof window !== 'undefined') { + localStorage.setItem('openaiCompatibleApiKey', apiKey); + } + }, + setOpenaiCompatibleVoice: (voice: string) => { set({ openaiCompatibleVoice: voice }); if (typeof window !== 'undefined') { @@ -1916,6 +1942,13 @@ export const useConfigStore = create()( updateDesktopSettings({ sttServerUrl: url }).catch(() => {}); }, + setSttApiKey: (apiKey: string) => { + set({ sttApiKey: apiKey }); + if (typeof window !== 'undefined') { + localStorage.setItem('sttApiKey', apiKey); + } + }, + setSttModel: (model: string) => { set({ sttModel: model }); if (typeof window !== 'undefined') { diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 7d28a40d..86f1d0be 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -221,6 +221,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { const language = typeof req.headers['x-language'] === 'string' && req.headers['x-language'].trim().length > 0 ? req.headers['x-language'].trim() : undefined; + const authHeader = typeof req.headers['authorization'] === 'string' ? req.headers['authorization'].trim() : ''; + const apiKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : undefined; if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) { return res.status(400).json({ error: 'Audio data is required' }); @@ -236,6 +238,7 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { model, baseURL, language, + hasApiKey: !!apiKey, }); const transcript = await transcribeAudio({ @@ -243,6 +246,7 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { mimeType, model, baseURL, + apiKey, language, }); diff --git a/packages/web/server/lib/tts/stt.js b/packages/web/server/lib/tts/stt.js index 1136c332..13cbc6ea 100644 --- a/packages/web/server/lib/tts/stt.js +++ b/packages/web/server/lib/tts/stt.js @@ -16,10 +16,11 @@ import { normalizeCustomOpenAIBaseURL } from './base-url.js'; * @param {string} opts.mimeType - MIME type of the audio (e.g. 'audio/webm') * @param {string} opts.model - Model name accepted by the remote server * @param {string} [opts.baseURL] - Base URL of the compatible server (including /v1) + * @param {string} [opts.apiKey] - Optional API key for the compatible server * @param {string} [opts.language] - Optional BCP-47 language hint (e.g. 'en') * @returns {Promise} Transcribed text */ -export async function transcribeAudio({ audioBuffer, mimeType, model, baseURL, language }) { +export async function transcribeAudio({ audioBuffer, mimeType, model, baseURL, apiKey, language }) { const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL); if (normalizedBaseURLResult.error) { throw new Error(normalizedBaseURLResult.error); @@ -31,7 +32,7 @@ export async function transcribeAudio({ audioBuffer, mimeType, model, baseURL, l } const clientOpts = { - apiKey: process.env.OPENAI_API_KEY || 'not-required', + apiKey: apiKey || process.env.OPENAI_API_KEY || 'not-required', }; clientOpts.baseURL = normalizedBaseURL;