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>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
f45fe05f33
commit
a6bf73b245
@@ -47,7 +47,10 @@ export const normalizeCustomOpenAIBaseURL = (value) => {
|
||||
return { error: 'Custom server URL must not include credentials' };
|
||||
}
|
||||
|
||||
const allowRemote = isEnvFlagEnabled(process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS);
|
||||
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.',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { registerTtsRoutes } from './routes.js';
|
||||
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
||||
|
||||
const createApp = () => {
|
||||
const app = express();
|
||||
@@ -51,3 +52,74 @@ describe('tts routes', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCustomOpenAIBaseURL', () => {
|
||||
const originalRuntime = process.env.OPENCHAMBER_RUNTIME;
|
||||
const originalAllowRemote = process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env vars after each test
|
||||
if (originalRuntime === undefined) {
|
||||
delete process.env.OPENCHAMBER_RUNTIME;
|
||||
} else {
|
||||
process.env.OPENCHAMBER_RUNTIME = originalRuntime;
|
||||
}
|
||||
if (originalAllowRemote === undefined) {
|
||||
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
|
||||
} else {
|
||||
process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS = originalAllowRemote;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects remote URLs when OPENCHAMBER_RUNTIME is not set (web)', () => {
|
||||
delete process.env.OPENCHAMBER_RUNTIME;
|
||||
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
|
||||
|
||||
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
|
||||
expect(result.error).toMatch(/Remote custom server URLs are disabled/);
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows remote URLs when OPENCHAMBER_RUNTIME is desktop', () => {
|
||||
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
||||
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
|
||||
|
||||
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toBe('https://my-tts-server.example.com/v1');
|
||||
});
|
||||
|
||||
it('allows remote URLs when OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS is true', () => {
|
||||
delete process.env.OPENCHAMBER_RUNTIME;
|
||||
process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS = 'true';
|
||||
|
||||
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toBe('https://my-tts-server.example.com/v1');
|
||||
});
|
||||
|
||||
it('allows localhost URLs regardless of runtime', () => {
|
||||
delete process.env.OPENCHAMBER_RUNTIME;
|
||||
delete process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS;
|
||||
|
||||
const result = normalizeCustomOpenAIBaseURL('http://localhost:8880/v1');
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toBe('http://localhost:8880/v1');
|
||||
});
|
||||
|
||||
it('strips query strings and trailing slashes', () => {
|
||||
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
||||
|
||||
const result = normalizeCustomOpenAIBaseURL('https://my-server.com/v1/?key=123');
|
||||
expect(result.value).toBe('https://my-server.com/v1');
|
||||
});
|
||||
|
||||
it('denies remote URLs on desktop when env var is explicitly false', () => {
|
||||
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
||||
process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS = 'false';
|
||||
|
||||
const result = normalizeCustomOpenAIBaseURL('https://my-tts-server.example.com/v1');
|
||||
expect(result.error).toMatch(/Remote custom server URLs are disabled/);
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user