Files
openchamber/packages/web/server/lib/tts/base-url.js
T
yangyaofeiandBohdan Triapitsyn a6bf73b245 fix(tts): allow remote custom provider URLs on desktop runtime (#1439)
* 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>
2026-06-08 19:15:54 +03:00

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}` };
};