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
+4
View File
@@ -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,
});
+3 -2
View File
@@ -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<string>} 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;