From 4dc20f58d026a5acf9f74c48b668fcf3d3da5d55 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Feb 2026 13:33:26 +0700 Subject: [PATCH] Harden speech provider reconciliation and startup logging --- .../server/src/server/bootstrap.smoke.test.ts | 43 ++++++ packages/server/src/server/bootstrap.ts | 142 +++++++++++++++--- 2 files changed, 162 insertions(+), 23 deletions(-) diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 1d10a0016..65834a8b6 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,6 +1,12 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import pino from "pino"; import { describe, expect, test } from "vitest"; +import { createPaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; describe("paseo daemon bootstrap", () => { test("starts and serves health endpoint", async () => { @@ -22,4 +28,41 @@ describe("paseo daemon bootstrap", () => { await daemonHandle.close(); } }); + + test("fails fast when OpenAI speech provider is configured without credentials", async () => { + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-openai-config-")); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + await mkdir(paseoHome, { recursive: true }); + + const config: PaseoDaemonConfig = { + listen: "127.0.0.1:0", + paseoHome, + corsAllowedOrigins: [], + allowedHosts: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + relayEnabled: false, + appBaseUrl: "https://app.paseo.sh", + openai: undefined, + speech: { + dictationSttProvider: "openai", + voiceSttProvider: "openai", + voiceTtsProvider: "openai", + }, + openrouterApiKey: null, + }; + + try { + await expect(createPaseoDaemon(config, pino({ level: "silent" }))).rejects.toThrow( + "Missing OpenAI credentials" + ); + } finally { + await rm(paseoHomeRoot, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 5dcfd16d3..b8343abfa 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -427,9 +427,68 @@ export async function createPaseoDaemon( const speechConfig = config.speech ?? null; const sherpaConfig = speechConfig?.sherpaOnnx ?? null; - const wantsLocalDictation = (speechConfig?.dictationSttProvider ?? "openai") === "local"; - const wantsLocalVoiceStt = (speechConfig?.voiceSttProvider ?? "openai") === "local"; - const wantsLocalVoiceTts = (speechConfig?.voiceTtsProvider ?? "openai") === "local"; + const voiceSttProvider = speechConfig?.voiceSttProvider ?? "local"; + const voiceTtsProvider = speechConfig?.voiceTtsProvider ?? "local"; + const dictationSttProvider = speechConfig?.dictationSttProvider ?? "local"; + + const wantsLocalDictation = dictationSttProvider === "local"; + const wantsLocalVoiceStt = voiceSttProvider === "local"; + const wantsLocalVoiceTts = voiceTtsProvider === "local"; + + const openaiSttApiKey = config.openai?.stt?.apiKey ?? openaiApiKey; + const openaiTtsApiKey = config.openai?.tts?.apiKey ?? openaiApiKey; + const openaiDictationApiKey = openaiApiKey; + + const missingOpenAiCredentialsFor: string[] = []; + if (voiceSttProvider === "openai" && !openaiSttApiKey) { + missingOpenAiCredentialsFor.push("voice.stt"); + } + if (voiceTtsProvider === "openai" && !openaiTtsApiKey) { + missingOpenAiCredentialsFor.push("voice.tts"); + } + if (dictationSttProvider === "openai" && !openaiDictationApiKey) { + missingOpenAiCredentialsFor.push("dictation.stt"); + } + + if (missingOpenAiCredentialsFor.length > 0) { + logger.error( + { + requestedProviders: { + dictationStt: dictationSttProvider, + voiceStt: voiceSttProvider, + voiceTts: voiceTtsProvider, + }, + missingOpenAiCredentialsFor, + }, + "Invalid speech configuration: OpenAI provider selected but credentials are missing" + ); + throw new Error( + `Missing OpenAI credentials for configured speech features: ${missingOpenAiCredentialsFor.join(", ")}` + ); + } + + logger.info( + { + requestedProviders: { + dictationStt: dictationSttProvider, + voiceStt: voiceSttProvider, + voiceTts: voiceTtsProvider, + }, + availability: { + openai: { + stt: Boolean(openaiSttApiKey), + tts: Boolean(openaiTtsApiKey), + dictationStt: Boolean(openaiDictationApiKey), + }, + local: { + configured: Boolean(sherpaConfig), + modelsDir: sherpaConfig?.modelsDir ?? null, + autoDownload: sherpaConfig?.autoDownload ?? null, + }, + }, + }, + "Speech provider reconciliation started" + ); if ((wantsLocalDictation || wantsLocalVoiceStt || wantsLocalVoiceTts) && sherpaConfig) { const autoDownload = sherpaConfig.autoDownload ?? (process.env.VITEST ? false : true); @@ -468,6 +527,14 @@ export async function createPaseoDaemon( } try { + logger.info( + { + modelsDir: sherpaConfig.modelsDir, + modelIds, + autoDownload, + }, + "Ensuring local speech models" + ); await ensureSherpaOnnxModels({ modelsDir: sherpaConfig.modelsDir, modelIds, @@ -564,7 +631,7 @@ export async function createPaseoDaemon( } else if (wantsLocalDictation || wantsLocalVoiceStt) { logger.warn( { configured: Boolean(sherpaConfig) }, - "Sherpa STT selected but no sherpaOnnx config found; STT will be unavailable" + "Local STT selected but local provider config is missing; STT will be unavailable" ); } @@ -618,7 +685,7 @@ export async function createPaseoDaemon( } else if (wantsLocalVoiceTts) { logger.warn( { configured: Boolean(sherpaConfig) }, - "Sherpa TTS selected but no sherpaOnnx config found; TTS will be unavailable" + "Local TTS selected but local provider config is missing; TTS will be unavailable" ); } @@ -645,31 +712,29 @@ export async function createPaseoDaemon( }; } - const voiceSttProvider = speechConfig?.voiceSttProvider ?? "openai"; - const voiceTtsProvider = speechConfig?.voiceTtsProvider ?? "openai"; - const dictationSttProvider = speechConfig?.dictationSttProvider ?? "openai"; - const needsOpenAiStt = !sttService && voiceSttProvider === "openai"; const needsOpenAiTts = !ttsService && voiceTtsProvider === "openai"; const needsOpenAiDictation = dictationSttProvider === "openai" || (dictationSttProvider === "local" && !dictationSttService); - const fallbackOpenAiStt = !sttService && voiceSttProvider === "local" && Boolean(openaiApiKey); - const fallbackOpenAiTts = !ttsService && voiceTtsProvider === "local" && Boolean(openaiApiKey); + const fallbackOpenAiStt = !sttService && voiceSttProvider === "local" && Boolean(openaiSttApiKey); + const fallbackOpenAiTts = !ttsService && voiceTtsProvider === "local" && Boolean(openaiTtsApiKey); - if ((needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation || fallbackOpenAiStt || fallbackOpenAiTts) && openaiApiKey) { - logger.info("OpenAI client initialized"); + if ( + (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation || fallbackOpenAiStt || fallbackOpenAiTts) && + (openaiSttApiKey || openaiTtsApiKey || openaiDictationApiKey) + ) { + logger.info("OpenAI speech provider initialized"); if (fallbackOpenAiStt) { - logger.warn("Falling back to OpenAI STT because Sherpa STT is unavailable"); + logger.warn("Falling back to OpenAI STT because local STT is unavailable"); } if (needsOpenAiStt || fallbackOpenAiStt) { - const sttApiKey = config.openai?.stt?.apiKey ?? openaiApiKey; - if (sttApiKey) { + if (openaiSttApiKey) { const { apiKey: _sttApiKey, ...sttConfig } = config.openai?.stt ?? {}; sttService = new OpenAISTT( { - apiKey: sttApiKey, + apiKey: openaiSttApiKey, ...sttConfig, }, logger @@ -678,15 +743,14 @@ export async function createPaseoDaemon( } if (fallbackOpenAiTts) { - logger.warn("Falling back to OpenAI TTS because Sherpa TTS is unavailable"); + logger.warn("Falling back to OpenAI TTS because local TTS is unavailable"); } if (needsOpenAiTts || fallbackOpenAiTts) { - const ttsApiKey = config.openai?.tts?.apiKey ?? openaiApiKey; - if (ttsApiKey) { + if (openaiTtsApiKey) { const { apiKey: _ttsApiKey, ...ttsConfig } = config.openai?.tts ?? {}; ttsService = new OpenAITTS( { - apiKey: ttsApiKey, + apiKey: openaiTtsApiKey, voice: "alloy", model: "tts-1", responseFormat: "pcm", @@ -698,7 +762,6 @@ export async function createPaseoDaemon( } if (needsOpenAiDictation) { - const dictationApiKey = config.openai?.apiKey ?? openaiApiKey; const transcriptionModel = process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe"; @@ -706,7 +769,7 @@ export async function createPaseoDaemon( id: "openai", createSession: ({ logger: sessionLogger, language, prompt }) => new OpenAIRealtimeTranscriptionSession({ - apiKey: dictationApiKey, + apiKey: openaiDictationApiKey!, logger: sessionLogger, transcriptionModel, ...(language ? { language } : {}), @@ -719,6 +782,39 @@ export async function createPaseoDaemon( logger.warn("OPENAI_API_KEY not set - OpenAI STT/TTS/dictation fallback is unavailable"); } + const effectiveProviders = { + dictationStt: dictationSttService?.id ?? "unavailable", + voiceStt: sttService?.id ?? "unavailable", + voiceTts: !ttsService ? "unavailable" : ttsService === sherpaTts ? "local" : "openai", + }; + const unavailableFeatures = [ + !dictationSttService ? "dictation.stt" : null, + !sttService ? "voice.stt" : null, + !ttsService ? "voice.tts" : null, + ].filter((feature): feature is string => feature !== null); + + if (unavailableFeatures.length > 0) { + logger.warn( + { + requestedProviders: { + dictationStt: dictationSttProvider, + voiceStt: voiceSttProvider, + voiceTts: voiceTtsProvider, + }, + effectiveProviders, + unavailableFeatures, + }, + "Speech provider reconciliation completed with unavailable features" + ); + } else { + logger.info( + { + effectiveProviders, + }, + "Speech provider reconciliation completed" + ); + } + const wsServer = new VoiceAssistantWebSocketServer( httpServer, logger,