Harden speech provider reconciliation and startup logging
This commit is contained in:
@@ -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 { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createPaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
|
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||||
|
|
||||||
describe("paseo daemon bootstrap", () => {
|
describe("paseo daemon bootstrap", () => {
|
||||||
test("starts and serves health endpoint", async () => {
|
test("starts and serves health endpoint", async () => {
|
||||||
@@ -22,4 +28,41 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
await daemonHandle.close();
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -427,9 +427,68 @@ export async function createPaseoDaemon(
|
|||||||
const speechConfig = config.speech ?? null;
|
const speechConfig = config.speech ?? null;
|
||||||
const sherpaConfig = speechConfig?.sherpaOnnx ?? null;
|
const sherpaConfig = speechConfig?.sherpaOnnx ?? null;
|
||||||
|
|
||||||
const wantsLocalDictation = (speechConfig?.dictationSttProvider ?? "openai") === "local";
|
const voiceSttProvider = speechConfig?.voiceSttProvider ?? "local";
|
||||||
const wantsLocalVoiceStt = (speechConfig?.voiceSttProvider ?? "openai") === "local";
|
const voiceTtsProvider = speechConfig?.voiceTtsProvider ?? "local";
|
||||||
const wantsLocalVoiceTts = (speechConfig?.voiceTtsProvider ?? "openai") === "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) {
|
if ((wantsLocalDictation || wantsLocalVoiceStt || wantsLocalVoiceTts) && sherpaConfig) {
|
||||||
const autoDownload = sherpaConfig.autoDownload ?? (process.env.VITEST ? false : true);
|
const autoDownload = sherpaConfig.autoDownload ?? (process.env.VITEST ? false : true);
|
||||||
@@ -468,6 +527,14 @@ export async function createPaseoDaemon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
modelsDir: sherpaConfig.modelsDir,
|
||||||
|
modelIds,
|
||||||
|
autoDownload,
|
||||||
|
},
|
||||||
|
"Ensuring local speech models"
|
||||||
|
);
|
||||||
await ensureSherpaOnnxModels({
|
await ensureSherpaOnnxModels({
|
||||||
modelsDir: sherpaConfig.modelsDir,
|
modelsDir: sherpaConfig.modelsDir,
|
||||||
modelIds,
|
modelIds,
|
||||||
@@ -564,7 +631,7 @@ export async function createPaseoDaemon(
|
|||||||
} else if (wantsLocalDictation || wantsLocalVoiceStt) {
|
} else if (wantsLocalDictation || wantsLocalVoiceStt) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ configured: Boolean(sherpaConfig) },
|
{ 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) {
|
} else if (wantsLocalVoiceTts) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ configured: Boolean(sherpaConfig) },
|
{ 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 needsOpenAiStt = !sttService && voiceSttProvider === "openai";
|
||||||
const needsOpenAiTts = !ttsService && voiceTtsProvider === "openai";
|
const needsOpenAiTts = !ttsService && voiceTtsProvider === "openai";
|
||||||
const needsOpenAiDictation =
|
const needsOpenAiDictation =
|
||||||
dictationSttProvider === "openai" || (dictationSttProvider === "local" && !dictationSttService);
|
dictationSttProvider === "openai" || (dictationSttProvider === "local" && !dictationSttService);
|
||||||
|
|
||||||
const fallbackOpenAiStt = !sttService && voiceSttProvider === "local" && Boolean(openaiApiKey);
|
const fallbackOpenAiStt = !sttService && voiceSttProvider === "local" && Boolean(openaiSttApiKey);
|
||||||
const fallbackOpenAiTts = !ttsService && voiceTtsProvider === "local" && Boolean(openaiApiKey);
|
const fallbackOpenAiTts = !ttsService && voiceTtsProvider === "local" && Boolean(openaiTtsApiKey);
|
||||||
|
|
||||||
if ((needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation || fallbackOpenAiStt || fallbackOpenAiTts) && openaiApiKey) {
|
if (
|
||||||
logger.info("OpenAI client initialized");
|
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation || fallbackOpenAiStt || fallbackOpenAiTts) &&
|
||||||
|
(openaiSttApiKey || openaiTtsApiKey || openaiDictationApiKey)
|
||||||
|
) {
|
||||||
|
logger.info("OpenAI speech provider initialized");
|
||||||
|
|
||||||
if (fallbackOpenAiStt) {
|
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) {
|
if (needsOpenAiStt || fallbackOpenAiStt) {
|
||||||
const sttApiKey = config.openai?.stt?.apiKey ?? openaiApiKey;
|
if (openaiSttApiKey) {
|
||||||
if (sttApiKey) {
|
|
||||||
const { apiKey: _sttApiKey, ...sttConfig } = config.openai?.stt ?? {};
|
const { apiKey: _sttApiKey, ...sttConfig } = config.openai?.stt ?? {};
|
||||||
sttService = new OpenAISTT(
|
sttService = new OpenAISTT(
|
||||||
{
|
{
|
||||||
apiKey: sttApiKey,
|
apiKey: openaiSttApiKey,
|
||||||
...sttConfig,
|
...sttConfig,
|
||||||
},
|
},
|
||||||
logger
|
logger
|
||||||
@@ -678,15 +743,14 @@ export async function createPaseoDaemon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fallbackOpenAiTts) {
|
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) {
|
if (needsOpenAiTts || fallbackOpenAiTts) {
|
||||||
const ttsApiKey = config.openai?.tts?.apiKey ?? openaiApiKey;
|
if (openaiTtsApiKey) {
|
||||||
if (ttsApiKey) {
|
|
||||||
const { apiKey: _ttsApiKey, ...ttsConfig } = config.openai?.tts ?? {};
|
const { apiKey: _ttsApiKey, ...ttsConfig } = config.openai?.tts ?? {};
|
||||||
ttsService = new OpenAITTS(
|
ttsService = new OpenAITTS(
|
||||||
{
|
{
|
||||||
apiKey: ttsApiKey,
|
apiKey: openaiTtsApiKey,
|
||||||
voice: "alloy",
|
voice: "alloy",
|
||||||
model: "tts-1",
|
model: "tts-1",
|
||||||
responseFormat: "pcm",
|
responseFormat: "pcm",
|
||||||
@@ -698,7 +762,6 @@ export async function createPaseoDaemon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needsOpenAiDictation) {
|
if (needsOpenAiDictation) {
|
||||||
const dictationApiKey = config.openai?.apiKey ?? openaiApiKey;
|
|
||||||
const transcriptionModel =
|
const transcriptionModel =
|
||||||
process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
||||||
|
|
||||||
@@ -706,7 +769,7 @@ export async function createPaseoDaemon(
|
|||||||
id: "openai",
|
id: "openai",
|
||||||
createSession: ({ logger: sessionLogger, language, prompt }) =>
|
createSession: ({ logger: sessionLogger, language, prompt }) =>
|
||||||
new OpenAIRealtimeTranscriptionSession({
|
new OpenAIRealtimeTranscriptionSession({
|
||||||
apiKey: dictationApiKey,
|
apiKey: openaiDictationApiKey!,
|
||||||
logger: sessionLogger,
|
logger: sessionLogger,
|
||||||
transcriptionModel,
|
transcriptionModel,
|
||||||
...(language ? { language } : {}),
|
...(language ? { language } : {}),
|
||||||
@@ -719,6 +782,39 @@ export async function createPaseoDaemon(
|
|||||||
logger.warn("OPENAI_API_KEY not set - OpenAI STT/TTS/dictation fallback is unavailable");
|
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(
|
const wsServer = new VoiceAssistantWebSocketServer(
|
||||||
httpServer,
|
httpServer,
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
Reference in New Issue
Block a user