* 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 * fix(tts): allow remote custom provider URLs on desktop runtime ## Problem Custom OpenAI-compatible TTS/STT provider URLs are restricted to localhost addresses only. Remote URLs are silently rejected at the server boundary, making custom cloud-based TTS/STT providers unusable in the desktop app. ## Root Cause `base-url.js` rejects non-localhost URLs unless `OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS=true` is set, but this env var is never set by desktop shells and is not exposed in settings. ## Solution Use the existing `OPENCHAMBER_RUNTIME` env var (already set to `'desktop'` by both Electron and Tauri shells) to auto-allow remote custom URLs on desktop. Web deployments retain SSRF protection by default. The explicit env var override still works for either direction. Closes openchamber/openchamber#1438 * fix(tts): respect explicit env var override on desktop runtime ## Problem The previous implementation used `|| isDesktop` which unconditionally allowed remote URLs on desktop, making `OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS=false` a no-op. The PR description documented this as supported but it didn't work. ## Fix Changed precedence logic: explicit env var (set or unset) takes absolute priority in both directions. When no explicit flag exists, desktop runtime defaults to allowing remote URLs. This lets operators tighten desktop deployments with env var =false. ## Test Added test case: desktop + env var =false → remote URLs denied (8 pass, 0 fail). Addresses PR review: P1 (env var precedence) and P2 (missing deny test). --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
66 lines
2.1 KiB
JavaScript
66 lines
2.1 KiB
JavaScript
const LOCAL_BASE_URL_HOSTS = new Set([
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'::1',
|
|
'host.docker.internal',
|
|
]);
|
|
|
|
const isEnvFlagEnabled = (value) => {
|
|
if (value === true || value === 1) return true;
|
|
if (typeof value !== 'string') return false;
|
|
const normalized = value.trim().toLowerCase();
|
|
return normalized === '1' || normalized === 'true';
|
|
};
|
|
|
|
const normalizeHostname = (hostname) => {
|
|
if (typeof hostname !== 'string') return '';
|
|
const trimmed = hostname.trim().toLowerCase();
|
|
if (!trimmed) return '';
|
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
return trimmed;
|
|
};
|
|
|
|
const isAllowedLocalHost = (hostname) => {
|
|
const normalized = normalizeHostname(hostname);
|
|
return LOCAL_BASE_URL_HOSTS.has(normalized);
|
|
};
|
|
|
|
export const normalizeCustomOpenAIBaseURL = (value) => {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
return { value: undefined };
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(value.trim());
|
|
} catch {
|
|
return { error: 'Custom server URL is invalid' };
|
|
}
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return { error: 'Custom server URL must use http or https' };
|
|
}
|
|
|
|
if (parsed.username || parsed.password) {
|
|
return { error: 'Custom server URL must not include credentials' };
|
|
}
|
|
|
|
const isDesktop = (process.env.OPENCHAMBER_RUNTIME || '').trim().toLowerCase() === 'desktop';
|
|
const envFlagRaw = process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
|
|
const hasExplicitFlag = typeof envFlagRaw === 'string' && envFlagRaw.trim().length > 0;
|
|
const allowRemote = hasExplicitFlag ? isEnvFlagEnabled(envFlagRaw) : isDesktop;
|
|
if (!allowRemote && !isAllowedLocalHost(parsed.hostname)) {
|
|
return {
|
|
error: 'Remote custom server URLs are disabled. Set OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS=true to allow this host.',
|
|
};
|
|
}
|
|
|
|
parsed.hash = '';
|
|
parsed.search = '';
|
|
const pathname = parsed.pathname.replace(/\/+$/, '');
|
|
const normalizedPath = pathname.length > 0 ? pathname : '';
|
|
return { value: `${parsed.protocol}//${parsed.host}${normalizedPath}` };
|
|
};
|