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
+33
View File
@@ -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') {