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 = () => {
)}
+
{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;