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",