diff --git a/docs/data-model.md b/docs/data-model.md index 0fb1dc8c5..7becabdc7 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`. root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees }, providers: { - openai: { voice: { apiKey: string, baseUrl: string } }, + openai: { + apiKey?: string, + baseUrl?: string, + stt?: { apiKey?: string, baseUrl?: string }, + tts?: { apiKey?: string, baseUrl?: string } + }, local: { modelsDir: string } }, agents: { @@ -202,13 +207,17 @@ Set these to select OpenAI instead of local speech: | `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider | | `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider | -OpenAI voice can be configured under `providers.openai`: +OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints: ```json { "providers": { "openai": { - "voice": { + "stt": { + "apiKey": "sk-...", + "baseUrl": "https://stt.example.com/v1" + }, + "tts": { "apiKey": "sk-...", "baseUrl": "https://api.openai.com/v1" } @@ -217,7 +226,7 @@ OpenAI voice can be configured under `providers.openai`: } ``` -`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features. +`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools. Paseo uses these paths under the configured OpenAI base URL: diff --git a/packages/server/.env.example b/packages/server/.env.example index 960466c82..ba031a16b 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -1,6 +1,14 @@ # OpenAI API Key (for GPT-4, Whisper STT, and TTS) OPENAI_API_KEY= +# Optional: point speech-to-text and text-to-speech at different OpenAI-compatible +# endpoints. Each falls back to OPENAI_API_KEY / OPENAI_BASE_URL when unset. +# STT covers composer dictation + voice mode STT; TTS covers voice mode TTS. +OPENAI_STT_API_KEY= +OPENAI_STT_BASE_URL= +OPENAI_TTS_API_KEY= +OPENAI_TTS_BASE_URL= + # TTS Configuration (optional - defaults shown) TTS_VOICE=alloy # Available voices: alloy, echo, fable, onyx, nova, shimmer diff --git a/packages/server/scripts/voice-roundtrip-debug.ts b/packages/server/scripts/voice-roundtrip-debug.ts index 392654870..108b3cb03 100644 --- a/packages/server/scripts/voice-roundtrip-debug.ts +++ b/packages/server/scripts/voice-roundtrip-debug.ts @@ -26,7 +26,7 @@ async function main(): Promise { const daemon = await createTestPaseoDaemon({ logger, agentClients: {}, - openai: { apiKey }, + openai: { stt: { apiKey }, tts: { apiKey } }, speech: { providers: { dictationStt: { provider: "openai", explicit: true }, diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index f43a673e7..d4e6bbff5 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -21,7 +21,7 @@ describe("paseo daemon bootstrap", () => { test("starts and serves health endpoint", async () => { const daemonHandle = await createTestPaseoDaemon({ - openai: { apiKey: "test-openai-api-key" }, + openai: { stt: { apiKey: "test-openai-api-key" }, tts: { apiKey: "test-openai-api-key" } }, speech: { providers: { dictationStt: { provider: "openai", explicit: true }, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index e723ab892..d5725e3e4 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -547,7 +547,9 @@ beforeAll(async () => { ctx = await createDaemonTestContext({ dictationFinalTimeoutMs: 5000, - ...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}), + ...(openaiApiKey + ? { openai: { stt: { apiKey: openaiApiKey }, tts: { apiKey: openaiApiKey } } } + : {}), ...(speechConfig ? { speech: speechConfig } : {}), }); }, 60000); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index b66ec6b51..fed19c468 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -116,20 +116,26 @@ describe("PersistedConfigSchema worktrees config", () => { }); describe("PersistedConfigSchema provider credentials", () => { - test("accepts OpenAI voice credentials", () => { + test("accepts separate OpenAI STT and TTS credentials", () => { const parsed = PersistedConfigSchema.parse({ providers: { openai: { - voice: { - apiKey: " voice-secret ", - baseUrl: " https://voice.example.com/v1 ", + stt: { + apiKey: " stt-secret ", + baseUrl: " https://stt.example.com/v1 ", + }, + tts: { + apiKey: " tts-secret ", + baseUrl: " https://tts.example.com/v1 ", }, }, }, }); - expect(parsed.providers?.openai?.voice?.apiKey).toBe("voice-secret"); - expect(parsed.providers?.openai?.voice?.baseUrl).toBe("https://voice.example.com/v1"); + expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret"); + expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1"); + expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret"); + expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1"); }); }); @@ -650,6 +656,38 @@ describe("loadPersistedConfig", () => { rmSync(home, { recursive: true, force: true }); } }); + + test("loads a config that still uses the removed providers.openai.voice block", () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync( + configPath, + `${JSON.stringify( + { + version: 1, + providers: { + openai: { + apiKey: "global-key", + voice: { apiKey: "voice-key", baseUrl: "https://voice.example.com/v1" }, + }, + }, + }, + null, + 2, + )}\n`, + ); + + const config = loadPersistedConfig(home); + + expect(config.providers?.openai?.apiKey).toBe("global-key"); + expect((config.providers?.openai as Record)?.voice).toBeUndefined(); + expect(config.providers?.openai?.stt).toBeUndefined(); + expect(config.providers?.openai?.tts).toBeUndefined(); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); }); describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 48a19e97d..6b9bc3483 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -45,7 +45,7 @@ const LogConfigSchema = z }) .strict(); -const OpenAiVoiceProviderSchema = z +const OpenAiSpeechEndpointSchema = z .object({ apiKey: z.string().trim().min(1).optional(), baseUrl: z.string().trim().min(1).optional(), @@ -55,8 +55,9 @@ const OpenAiVoiceProviderSchema = z const OpenAiProviderSchema = z .object({ apiKey: z.string().min(1).optional(), - voice: OpenAiVoiceProviderSchema.optional(), baseUrl: z.string().trim().min(1).optional(), + stt: OpenAiSpeechEndpointSchema.optional(), + tts: OpenAiSpeechEndpointSchema.optional(), }) .strict(); @@ -346,7 +347,11 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { return logger?.child({ module: "config" }); } -function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown { +// Removed config fields are stripped before parsing so the strict schema does not +// reject a config written by an older release. The stripped values are discarded, +// not migrated — there is no back-compat for the removed `providers.openai.voice` +// block (use `providers.openai.stt` / `providers.openai.tts`). +function stripRemovedConfigFields(parsed: unknown): unknown { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return parsed; } @@ -358,18 +363,25 @@ function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown { } const providersRecord = { ...(providers as Record) }; + const local = providersRecord.local; - if (!local || typeof local !== "object" || Array.isArray(local)) { - root.providers = providersRecord; - return root; - } - - const localRecord = { ...(local as Record) }; - if ("autoDownload" in localRecord) { + if (local && typeof local === "object" && !Array.isArray(local)) { + const localRecord = { ...(local as Record) }; delete localRecord.autoDownload; + providersRecord.local = localRecord; + } + + const openai = providersRecord.openai; + if (openai && typeof openai === "object" && !Array.isArray(openai)) { + const openaiRecord = { ...(openai as Record) }; + // COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30. + // Drop a `providers.openai.voice` block left by an older release so the strict + // schema doesn't reject it. The value is discarded, not migrated — there is no + // back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead. + delete openaiRecord.voice; + providersRecord.openai = openaiRecord; } - providersRecord.local = localRecord; root.providers = providersRecord; return root; } @@ -412,7 +424,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const migrated = stripDeprecatedLocalSpeechConfigFields(parsed); + const migrated = stripRemovedConfigFields(parsed); const result = PersistedConfigSchema.safeParse(migrated); if (!result.success) { const issues = result.error.issues diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index a4eaffc52..55da430ed 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -2,6 +2,14 @@ import { describe, expect, test } from "vitest"; import { PersistedConfigSchema } from "../../../persisted-config.js"; import { resolveOpenAiSpeechConfig } from "./config.js"; +import type { RequestedSpeechProviders } from "../../speech-types.js"; + +const ALL_OPENAI: RequestedSpeechProviders = { + dictationStt: { provider: "openai", explicit: true }, + voiceTurnDetection: { provider: "local", explicit: false }, + voiceStt: { provider: "openai", explicit: true }, + voiceTts: { provider: "openai", explicit: true }, +}; describe("resolveOpenAiSpeechConfig", () => { test("treats empty OPENAI_API_KEY as unset", () => { @@ -14,6 +22,7 @@ describe("resolveOpenAiSpeechConfig", () => { env, persisted, providers: { + ...ALL_OPENAI, dictationStt: { provider: "local", explicit: false }, voiceStt: { provider: "local", explicit: false }, voiceTts: { provider: "local", explicit: false }, @@ -23,138 +32,175 @@ describe("resolveOpenAiSpeechConfig", () => { expect(resolved).toBeUndefined(); }); - test("uses trimmed OPENAI_API_KEY when configured", () => { + test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => { const persisted = PersistedConfigSchema.parse({}); const env = { OPENAI_API_KEY: " sk-test ", } as NodeJS.ProcessEnv; - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, - providers: { - dictationStt: { provider: "openai", explicit: true }, - voiceStt: { provider: "openai", explicit: true }, - voiceTts: { provider: "openai", explicit: true }, - }, - }); + const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - expect(resolved?.apiKey).toBe("sk-test"); expect(resolved?.stt?.apiKey).toBe("sk-test"); expect(resolved?.tts?.apiKey).toBe("sk-test"); }); - test("uses nested voice config before env and non-voice fallbacks", () => { + test("resolves distinct endpoints for STT and TTS", () => { const persisted = PersistedConfigSchema.parse({ providers: { openai: { - apiKey: "fallback-config-key", - voice: { - apiKey: "voice-config-key", - baseUrl: " https://voice.example.com/v1 ", + stt: { + apiKey: "stt-key", + baseUrl: " https://stt.example.com/v1 ", + }, + tts: { + apiKey: "tts-key", + baseUrl: " https://tts.example.com/v1 ", }, - baseUrl: "https://legacy-config.example.com/v1", }, }, }); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_VOICE_API_KEY: "voice-env-key", - OPENAI_VOICE_BASE_URL: "https://voice-env.example.com/v1", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; const resolved = resolveOpenAiSpeechConfig({ - env, + env: {} as NodeJS.ProcessEnv, persisted, - providers: { - dictationStt: { provider: "openai", explicit: true }, - voiceStt: { provider: "openai", explicit: true }, - voiceTts: { provider: "openai", explicit: true }, - }, + providers: ALL_OPENAI, }); - expect(resolved?.apiKey).toBe("voice-config-key"); - expect(resolved?.baseUrl).toBe("https://voice.example.com/v1"); - expect(resolved?.stt?.apiKey).toBe("voice-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://voice.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("voice-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://voice.example.com/v1"); + expect(resolved?.stt?.apiKey).toBe("stt-key"); + expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); + expect(resolved?.tts?.apiKey).toBe("tts-key"); + expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); }); - test("uses voice env config when nested voice config is unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "sk-test", - OPENAI_VOICE_API_KEY: "voice-env-key", - OPENAI_VOICE_BASE_URL: " https://voice-env.example.com/v1 ", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, - providers: { - dictationStt: { provider: "openai", explicit: true }, - voiceStt: { provider: "openai", explicit: true }, - voiceTts: { provider: "openai", explicit: true }, - }, - }); - - expect(resolved?.apiKey).toBe("voice-env-key"); - expect(resolved?.stt?.apiKey).toBe("voice-env-key"); - expect(resolved?.tts?.apiKey).toBe("voice-env-key"); - expect(resolved?.baseUrl).toBe("https://voice-env.example.com/v1"); - expect(resolved?.stt?.baseUrl).toBe("https://voice-env.example.com/v1"); - expect(resolved?.tts?.baseUrl).toBe("https://voice-env.example.com/v1"); - }); - - test("falls back to non-voice OpenAI config", () => { + test("prefers nested STT/TTS config over env and global fallbacks", () => { const persisted = PersistedConfigSchema.parse({ providers: { openai: { apiKey: "fallback-config-key", - baseUrl: " https://legacy-config.example.com/v1 ", + baseUrl: "https://global-config.example.com/v1", + stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " }, + tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " }, }, }, }); const env = { - OPENAI_API_KEY: "sk-test", - OPENAI_BASE_URL: " https://env.example.com/v1 ", + OPENAI_API_KEY: "env-key", + OPENAI_STT_API_KEY: "stt-env-key", + OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1", + OPENAI_TTS_API_KEY: "tts-env-key", + OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1", + OPENAI_BASE_URL: "https://env.example.com/v1", } as NodeJS.ProcessEnv; - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, - providers: { - dictationStt: { provider: "openai", explicit: true }, - voiceStt: { provider: "openai", explicit: true }, - voiceTts: { provider: "openai", explicit: true }, - }, - }); + const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - expect(resolved?.apiKey).toBe("fallback-config-key"); - expect(resolved?.baseUrl).toBe("https://legacy-config.example.com/v1"); + expect(resolved?.stt?.apiKey).toBe("stt-config-key"); + expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); + expect(resolved?.tts?.apiKey).toBe("tts-config-key"); + expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); }); - test("falls back to global OpenAI env config when voice-specific inputs are unset", () => { + test("uses STT/TTS env config when nested config is unset", () => { + const persisted = PersistedConfigSchema.parse({}); + const env = { + OPENAI_API_KEY: "sk-test", + OPENAI_STT_API_KEY: "stt-env-key", + OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ", + OPENAI_TTS_API_KEY: "tts-env-key", + OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ", + OPENAI_BASE_URL: "https://env.example.com/v1", + } as NodeJS.ProcessEnv; + + const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); + + expect(resolved?.stt?.apiKey).toBe("stt-env-key"); + expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1"); + expect(resolved?.tts?.apiKey).toBe("tts-env-key"); + expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1"); + }); + + test("falls back to global OpenAI config for both STT and TTS", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { + openai: { + apiKey: "fallback-config-key", + baseUrl: " https://global-config.example.com/v1 ", + }, + }, + }); + const env = {} as NodeJS.ProcessEnv; + + const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); + + expect(resolved?.stt?.apiKey).toBe("fallback-config-key"); + expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1"); + expect(resolved?.tts?.apiKey).toBe("fallback-config-key"); + expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1"); + }); + + test("falls back to global OpenAI env config when feature inputs are unset", () => { const persisted = PersistedConfigSchema.parse({}); const env = { OPENAI_API_KEY: "env-key", OPENAI_BASE_URL: " https://env.example.com/v1 ", } as NodeJS.ProcessEnv; - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, + const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); + + expect(resolved?.stt?.apiKey).toBe("env-key"); + expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1"); + expect(resolved?.tts?.apiKey).toBe("env-key"); + expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1"); + }); + + test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => { + const persisted = PersistedConfigSchema.parse({}); + const env = { + OPENAI_API_KEY: "global-key", + OPENAI_STT_API_KEY: "", + OPENAI_STT_BASE_URL: " ", + OPENAI_TTS_API_KEY: "", + OPENAI_TTS_BASE_URL: "", + } as NodeJS.ProcessEnv; + + const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); + + expect(resolved?.stt?.apiKey).toBe("global-key"); + expect(resolved?.tts?.apiKey).toBe("global-key"); + }); + + test("omits TTS when only an STT key is configured", () => { + const persisted = PersistedConfigSchema.parse({ providers: { - dictationStt: { provider: "openai", explicit: true }, - voiceStt: { provider: "openai", explicit: true }, - voiceTts: { provider: "openai", explicit: true }, + openai: { + stt: { apiKey: "stt-only-key" }, + }, }, }); - expect(resolved?.apiKey).toBe("env-key"); - expect(resolved?.baseUrl).toBe("https://env.example.com/v1"); + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.stt?.apiKey).toBe("stt-only-key"); + expect(resolved?.tts).toBeUndefined(); + }); + + test("resolves STT even when an unused TTS env var is invalid", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { + openai: { + stt: { apiKey: "stt-only-key" }, + }, + }, + }); + const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv; + + const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); + + expect(resolved?.stt?.apiKey).toBe("stt-only-key"); + expect(resolved?.tts).toBeUndefined(); }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index 6d3352c0d..ac8d2ffa3 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -8,8 +8,6 @@ import type { TTSConfig } from "./tts.js"; export const DEFAULT_OPENAI_TTS_MODEL = "tts-1"; export interface OpenAiSpeechProviderConfig { - apiKey?: string; - baseUrl?: string; stt?: Partial & { apiKey?: string }; tts?: Partial & { apiKey?: string }; } @@ -30,11 +28,23 @@ const OptionalTrimmedStringSchema = z .optional() .transform((value) => (value && value.length > 0 ? value : undefined)); -const OpenAiSpeechResolutionSchema = z.object({ - apiKey: OptionalTrimmedStringSchema, - baseUrl: OptionalTrimmedStringSchema, +// Endpoint credentials only — plain trimmed strings, so this never throws on a +// malformed value. The STT/TTS option groups parse separately and only for the +// endpoint that is actually configured, so a stale env var for an unused endpoint +// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one. +const OpenAiEndpointKeysSchema = z.object({ + sttApiKey: OptionalTrimmedStringSchema, + sttBaseUrl: OptionalTrimmedStringSchema, + ttsApiKey: OptionalTrimmedStringSchema, + ttsBaseUrl: OptionalTrimmedStringSchema, +}); + +const OpenAiSttOptionsSchema = z.object({ sttConfidenceThreshold: OptionalFiniteNumberSchema, sttModel: OptionalTrimmedStringSchema, +}); + +const OpenAiTtsOptionsSchema = z.object({ ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"), ttsModel: z .string() @@ -57,9 +67,15 @@ function pickIfOpenAi( function firstDefined(values: Array): T | undefined { for (const value of values) { - if (value !== undefined && value !== null) { - return value; + if (value === undefined || value === null) { + continue; } + // Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=) + // must not shadow a later fallback such as OPENAI_API_KEY. + if (typeof value === "string" && value.trim().length === 0) { + continue; + } + return value; } return undefined; } @@ -108,18 +124,32 @@ function buildOpenAiResolutionInput(params: { persisted: PersistedConfig; providers: RequestedSpeechProviders; }): Record { + const { env } = params; + const openai = params.persisted.providers?.openai; return { - apiKey: firstDefined([ - params.persisted.providers?.openai?.voice?.apiKey, - params.env.OPENAI_VOICE_API_KEY, - params.persisted.providers?.openai?.apiKey, - params.env.OPENAI_API_KEY, + sttApiKey: firstDefined([ + openai?.stt?.apiKey, + env.OPENAI_STT_API_KEY, + openai?.apiKey, + env.OPENAI_API_KEY, ]), - baseUrl: firstDefined([ - params.persisted.providers?.openai?.voice?.baseUrl, - params.env.OPENAI_VOICE_BASE_URL, - params.persisted.providers?.openai?.baseUrl, - params.env.OPENAI_BASE_URL, + sttBaseUrl: firstDefined([ + openai?.stt?.baseUrl, + env.OPENAI_STT_BASE_URL, + openai?.baseUrl, + env.OPENAI_BASE_URL, + ]), + ttsApiKey: firstDefined([ + openai?.tts?.apiKey, + env.OPENAI_TTS_API_KEY, + openai?.apiKey, + env.OPENAI_API_KEY, + ]), + ttsBaseUrl: firstDefined([ + openai?.tts?.baseUrl, + env.OPENAI_TTS_BASE_URL, + openai?.baseUrl, + env.OPENAI_BASE_URL, ]), ...buildOpenAiSttInput(params), ...buildOpenAiTtsInput(params), @@ -131,29 +161,46 @@ export function resolveOpenAiSpeechConfig(params: { persisted: PersistedConfig; providers: RequestedSpeechProviders; }): OpenAiSpeechProviderConfig | undefined { - const parsed = OpenAiSpeechResolutionSchema.parse(buildOpenAiResolutionInput(params)); + const input = buildOpenAiResolutionInput(params); + const keys = OpenAiEndpointKeysSchema.parse(input); - if (!parsed.apiKey) { + if (!keys.sttApiKey && !keys.ttsApiKey) { return undefined; } return { - apiKey: parsed.apiKey, - ...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}), - stt: { - apiKey: parsed.apiKey, - ...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}), - ...(parsed.sttConfidenceThreshold !== undefined - ? { confidenceThreshold: parsed.sttConfidenceThreshold } - : {}), - ...(parsed.sttModel ? { model: parsed.sttModel } : {}), - }, - tts: { - apiKey: parsed.apiKey, - ...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}), - voice: parsed.ttsVoice, - model: parsed.ttsModel, - responseFormat: "pcm", - }, + ...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}), + ...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}), + }; +} + +function buildSttConfig( + apiKey: string, + baseUrl: string | undefined, + input: Record, +): OpenAiSpeechProviderConfig["stt"] { + const options = OpenAiSttOptionsSchema.parse(input); + return { + apiKey, + ...(baseUrl ? { baseUrl } : {}), + ...(options.sttConfidenceThreshold !== undefined + ? { confidenceThreshold: options.sttConfidenceThreshold } + : {}), + ...(options.sttModel ? { model: options.sttModel } : {}), + }; +} + +function buildTtsConfig( + apiKey: string, + baseUrl: string | undefined, + input: Record, +): OpenAiSpeechProviderConfig["tts"] { + const options = OpenAiTtsOptionsSchema.parse(input); + return { + apiKey, + ...(baseUrl ? { baseUrl } : {}), + voice: options.ttsVoice, + model: options.ttsModel, + responseFormat: "pcm", }; } diff --git a/packages/server/src/server/speech/providers/openai/runtime.test.ts b/packages/server/src/server/speech/providers/openai/runtime.test.ts index e30ca0fb3..c947d8660 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.test.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.test.ts @@ -15,7 +15,6 @@ describe("initializeOpenAiSpeechServices", () => { voiceTts: { provider: "openai", explicit: true }, }, openaiConfig: { - apiKey: "sk-test", stt: { apiKey: "sk-test" }, tts: { apiKey: "sk-test" }, }, diff --git a/packages/server/src/server/speech/providers/openai/runtime.ts b/packages/server/src/server/speech/providers/openai/runtime.ts index 392e90265..a1231ddbe 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.ts @@ -29,11 +29,11 @@ export interface SpeechServices { function resolveOpenAiCredentials( openaiConfig: OpenAiSpeechProviderConfig | undefined, ): OpenAiCredentialState { - const openaiApiKey = openaiConfig?.apiKey; + const sttApiKey = openaiConfig?.stt?.apiKey; return { - openaiSttApiKey: openaiConfig?.stt?.apiKey ?? openaiApiKey, - openaiTtsApiKey: openaiConfig?.tts?.apiKey ?? openaiApiKey, - openaiDictationApiKey: openaiApiKey, + openaiSttApiKey: sttApiKey, + openaiTtsApiKey: openaiConfig?.tts?.apiKey, + openaiDictationApiKey: sttApiKey, }; } diff --git a/packages/server/src/server/speech/speech-config-resolver.test.ts b/packages/server/src/server/speech/speech-config-resolver.test.ts index 84b341382..0ee9df888 100644 --- a/packages/server/src/server/speech/speech-config-resolver.test.ts +++ b/packages/server/src/server/speech/speech-config-resolver.test.ts @@ -129,7 +129,8 @@ describe("resolveSpeechConfig", () => { dictation: "es", voice: "pt", }); - expect(result.openai?.apiKey).toBe("persisted-key"); + expect(result.openai?.stt?.apiKey).toBe("persisted-key"); + expect(result.openai?.tts?.apiKey).toBe("persisted-key"); expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe"); }); diff --git a/packages/server/src/server/voice-local-agent.e2e.test.ts b/packages/server/src/server/voice-local-agent.e2e.test.ts index 72f2fd61f..9dbe49d74 100644 --- a/packages/server/src/server/voice-local-agent.e2e.test.ts +++ b/packages/server/src/server/voice-local-agent.e2e.test.ts @@ -79,7 +79,7 @@ function waitForSignal( beforeAll(async () => { ctx = await createDaemonTestContext({ agentClients: {}, - openai: { apiKey: openaiApiKey! }, + openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } }, speech: { providers: { dictationStt: { provider: "openai", explicit: true }, diff --git a/packages/server/src/server/voice-roundtrip.e2e.test.ts b/packages/server/src/server/voice-roundtrip.e2e.test.ts index 1b692b1c9..8808421b3 100644 --- a/packages/server/src/server/voice-roundtrip.e2e.test.ts +++ b/packages/server/src/server/voice-roundtrip.e2e.test.ts @@ -161,7 +161,7 @@ let ctx: DaemonTestContext; beforeAll(async () => { ctx = await createDaemonTestContext({ agentClients: {}, - openai: { apiKey: openaiApiKey! }, + openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } }, speech: { providers: { dictationStt: { provider: "openai", explicit: true }, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index a1b6115b7..41e2aceb6 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -174,6 +174,38 @@ "apiKey": { "type": "string", "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "stt": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "tts": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/public-docs/configuration.md b/public-docs/configuration.md index 91e133b93..50decc79c 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -204,6 +204,8 @@ In the mobile app, enter the password in the direct connection setup screen. - `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles` - `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported) - `OPENAI_API_KEY`, override OpenAI provider key +- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) +- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS) - `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`) - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`) - `PASEO_LOCAL_MODELS_DIR`, control local model directory diff --git a/public-docs/voice.md b/public-docs/voice.md index 0869028ea..79022c15a 100644 --- a/public-docs/voice.md +++ b/public-docs/voice.md @@ -90,7 +90,11 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider }, "providers": { "openai": { - "voice": { + "stt": { + "apiKey": "...", + "baseUrl": "https://api.openai.com/v1" + }, + "tts": { "apiKey": "...", "baseUrl": "https://api.openai.com/v1" } @@ -99,7 +103,7 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider } ``` -`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` configure only Paseo OpenAI voice traffic, without changing Codex or other OpenAI-backed tools. +`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools. Paseo uses these paths under the configured OpenAI base URL: @@ -111,6 +115,8 @@ Paseo uses these paths under the configured OpenAI base URL: - `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`) +- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) +- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS) - `PASEO_LOCAL_MODELS_DIR`, local model storage directory - `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID - `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs