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:
yangyaofei
2026-05-24 00:58:22 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent e16097b05d
commit 06526767a2
7 changed files with 108 additions and 6 deletions
@@ -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">