From a4f1df3aedc49dee97ab3f659ba067aa2a0ddc1f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:10:35 +0200 Subject: [PATCH] Let voice mode TTS use the model and voice an OpenAI-compatible server serves (#6060) * fix(server): accept voice and model names from self-hosted speech servers features.voiceMode.tts.voice and TTS_VOICE were validated against the OpenAI voice catalog, so a server whose voices live under other names could not be configured at all: config.json failed to parse and an env var threw inside the speech config resolver, which config.ts calls without a catch, so the daemon refused to start. TTS_MODEL had the same problem for model ids the server serves itself. Both are free-form now, with model ids keeping their case because rosters use values like kokoro-v1:Q8_0. Regenerate the config JSON schema. * fix(server): send TTS voice names as written and keep the config schema current A self-hosted OpenAI-compatible server owns its voice ids and their case, so lowercasing the configured voice could turn a valid id into one the server does not know. Keep the published config schema in its existing form and change only the voice field. Co-authored-by: khimaros * fix(server): drop the TTS baseUrl from the init log The configured baseUrl can carry a token in its query string, and logging it is not needed to accept self-hosted voice and model names. * test(server): await bounded worktree fixture cleanup on Windows --------- Co-authored-by: khimaros Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/.env.example | 5 +-- .../src/server/persisted-config.test.ts | 13 +++++++ .../server/src/server/persisted-config.ts | 3 +- .../speech/providers/openai/config.test.ts | 36 +++++++++++++++++++ .../server/speech/providers/openai/config.ts | 16 +++------ .../src/server/speech/providers/openai/tts.ts | 4 +-- packages/server/src/utils/worktree.test.ts | 5 +-- .../public/schemas/paseo.config.v1.json | 2 +- 8 files changed, 65 insertions(+), 19 deletions(-) diff --git a/packages/server/.env.example b/packages/server/.env.example index ba031a16b5..55a2bded69 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -11,9 +11,10 @@ OPENAI_TTS_BASE_URL= # TTS Configuration (optional - defaults shown) TTS_VOICE=alloy -# Available voices: alloy, echo, fable, onyx, nova, shimmer +# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer TTS_MODEL=tts-1 -# Available models: tts-1 (faster), tts-1-hd (higher quality) +# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted +# servers accept whatever model id they serve. Case sensitive. # Server Configuration # Location for runtime state (agents.json, etc.). Defaults to ~/.paseo diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 4c626fde8f..6b23a6f365 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -674,6 +674,19 @@ describe("PersistedConfigSchema voice mode config", () => { expect(parsed.features?.dictation?.stt?.language).toBe("fr"); expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); }); + + test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { + const parsed = PersistedConfigSchema.parse({ + features: { + voiceMode: { + tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" }, + }, + }, + }); + + expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + }); }); describe("loadPersistedConfig", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 4998455713..11d94495b1 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -144,7 +144,8 @@ const FeatureVoiceModeSchema = z .object({ provider: SpeechProviderIdSchema.optional(), model: z.string().min(1).optional(), - voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(), + // Free-form: self-hosted OpenAI-compatible servers expose their own voice names. + voice: z.string().trim().min(1).optional(), speakerId: z.number().int().optional(), speed: z.number().optional(), }) 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 55da430edc..1f3433a347 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => { expect(resolved?.stt?.apiKey).toBe("stt-only-key"); expect(resolved?.tts).toBeUndefined(); }); + + test("sends the model and voice an OpenAI-compatible server serves, as written", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { + openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } }, + }, + features: { + voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } }, + }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(resolved?.tts?.voice).toBe("Aiden"); + }); + + test("accepts OpenAI's own gpt-4o-mini-tts model", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { openai: { tts: { apiKey: "tts-key" } } }, + features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts"); + expect(resolved?.tts?.voice).toBe("alloy"); + }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index ac8d2ffa3a..efb25bffaf 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -12,10 +12,6 @@ export interface OpenAiSpeechProviderConfig { tts?: Partial & { apiKey?: string }; } -const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]); - -const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]); - const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( @@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({ sttModel: OptionalTrimmedStringSchema, }); +// Voice and model are free-form so a self-hosted server can use its own ids; the +// OpenAI catalog is only the default. Both are sent as written, because the +// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0). const OpenAiTtsOptionsSchema = z.object({ - ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"), - ttsModel: z - .string() - .trim() - .toLowerCase() - .pipe(OpenAiTtsModelSchema) - .default(DEFAULT_OPENAI_TTS_MODEL), + ttsVoice: z.string().trim().default("alloy"), + ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), }); function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { diff --git a/packages/server/src/server/speech/providers/openai/tts.ts b/packages/server/src/server/speech/providers/openai/tts.ts index 6cc7e5c5bb..31f7b5b263 100644 --- a/packages/server/src/server/speech/providers/openai/tts.ts +++ b/packages/server/src/server/speech/providers/openai/tts.ts @@ -8,8 +8,8 @@ export type { SpeechStreamResult }; export interface TTSConfig { apiKey: string; baseUrl?: string; - model?: "tts-1" | "tts-1-hd"; - voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer"; + model?: "tts-1" | "tts-1-hd" | (string & {}); + voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {}); responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm"; } diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 479d4cfeab..9b0cba2643 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -10,6 +10,7 @@ import { type WorktreeConfig, } from "./worktree"; import { execFileSync } from "child_process"; +import { rm } from "node:fs/promises"; import { mkdtempSync, mkdirSync, @@ -89,8 +90,8 @@ describe("paseo worktree manager", () => { }); }); - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); it("treats a worktree as paseo-owned even when its .git admin is missing", async () => { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 415d307ca0..0ba1cdbc7f 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -711,7 +711,7 @@ }, "voice": { "type": "string", - "enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] + "minLength": 1 }, "speakerId": { "type": "integer",