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 <token>) - 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 <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
e16097b05d
commit
06526767a2
@@ -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 = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">API Key</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Optional
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
type="password"
|
||||
value={openaiCompatibleApiKey}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenaiCompatibleApiKey('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Icon name="close" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
@@ -896,6 +925,30 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">API Key</span>
|
||||
<span className="typography-meta ml-2 text-muted-foreground">
|
||||
Optional
|
||||
</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
<input
|
||||
type="password"
|
||||
value={sttApiKey}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSttApiKey('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Icon name="close" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.model')}</span>
|
||||
<div className="relative mt-1.5 max-w-xs">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<boolean> => {
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<ConfigStore>()(
|
||||
}
|
||||
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<ConfigStore>()(
|
||||
}
|
||||
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<ConfigStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<ConfigStore>()(
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user