diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index e1905bb57..dea7a1bd8 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -32,44 +32,29 @@ export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId }; const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech"); -const OptionalBooleanFlagSchema = z.preprocess((value) => { - if (typeof value === "boolean") { - return value; - } - if (typeof value !== "string") { - return value; - } - const normalized = value.trim().toLowerCase(); - if (normalized === "1" || normalized === "true" || normalized === "yes") { - return true; - } - if (normalized === "0" || normalized === "false" || normalized === "no") { - return false; - } - return undefined; -}, z.boolean().optional()); +const BooleanStringSchema = z + .string() + .trim() + .toLowerCase() + .pipe(z.enum(["1", "0", "true", "false", "yes", "no"])) + .transform((value) => value === "1" || value === "true" || value === "yes"); -const OptionalFiniteNumberSchema = z.preprocess((value) => { - if (typeof value === "number") { - return Number.isFinite(value) ? value : undefined; - } - if (typeof value !== "string") { - return value; - } - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : undefined; -}, z.number().optional()); +const OptionalBooleanFlagSchema = z + .union([z.boolean(), BooleanStringSchema]) + .optional(); -const OptionalIntegerSchema = z.preprocess((value) => { - if (typeof value === "number") { - return Number.isInteger(value) ? value : undefined; - } - if (typeof value !== "string") { - return value; - } - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; -}, z.number().int().optional()); +const NumberLikeSchema = z.union([ + z.number(), + z.string().trim().min(1), +]); + +const OptionalFiniteNumberSchema = NumberLikeSchema + .pipe(z.coerce.number().finite()) + .optional(); + +const OptionalIntegerSchema = NumberLikeSchema + .pipe(z.coerce.number().int()) + .optional(); const LocalSpeechResolutionSchema = z.object({ includeProviderConfig: z.boolean(), diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts index 62c8bc16f..2390d441b 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts @@ -166,18 +166,20 @@ function buildAliasMap(modelIds: readonly T[]): Record(params: { modelIds: readonly T[]; aliases: Record; -}): z.ZodType { +}): z.ZodType { const validIds = new Set(params.modelIds); - return z.preprocess((value) => { - if (typeof value !== "string") { - return value; - } - const normalized = value.trim().toLowerCase(); - if (!normalized) { - return value; - } - return params.aliases[normalized] ?? normalized; - }, z.string().refine((value): value is T => validIds.has(value as T))) as z.ZodType; + return z + .string() + .trim() + .toLowerCase() + .refine( + (value): value is T => + validIds.has(value as T) || Object.prototype.hasOwnProperty.call(params.aliases, value), + { + message: "Invalid model id", + } + ) + .transform((value) => params.aliases[value] ?? (value as T)); } const STT_MODEL_ALIASES = buildAliasMap(LOCAL_STT_MODEL_IDS); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index 06a88ee27..a849c2855 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -26,43 +26,33 @@ const OpenAiTtsVoiceSchema = z.enum([ const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]); -const OptionalFiniteNumberSchema = z.preprocess((value) => { - if (typeof value === "number") { - return Number.isFinite(value) ? value : undefined; - } - if (typeof value !== "string") { - return value; - } - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : undefined; -}, z.number().optional()); +const NumberLikeSchema = z.union([ + z.number(), + z.string().trim().min(1), +]); -const OptionalTrimmedStringSchema = z.preprocess((value) => { - if (typeof value !== "string") { - return value; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -}, z.string().optional()); +const OptionalFiniteNumberSchema = NumberLikeSchema + .pipe(z.coerce.number().finite()) + .optional(); + +const OptionalTrimmedStringSchema = z.string().trim().min(1).optional(); const OpenAiSpeechResolutionSchema = z.object({ apiKey: OptionalTrimmedStringSchema, sttConfidenceThreshold: OptionalFiniteNumberSchema, sttModel: OptionalTrimmedStringSchema, - ttsVoice: z.preprocess((value) => { - if (typeof value !== "string") { - return value; - } - const normalized = value.trim().toLowerCase(); - return normalized.length > 0 ? normalized : undefined; - }, OpenAiTtsVoiceSchema.default("alloy")), - ttsModel: z.preprocess((value) => { - if (typeof value !== "string") { - return value; - } - const normalized = value.trim().toLowerCase(); - return normalized.length > 0 ? normalized : undefined; - }, OpenAiTtsModelSchema.default(DEFAULT_OPENAI_TTS_MODEL)), + ttsVoice: z + .string() + .trim() + .toLowerCase() + .pipe(OpenAiTtsVoiceSchema) + .default("alloy"), + ttsModel: z + .string() + .trim() + .toLowerCase() + .pipe(OpenAiTtsModelSchema) + .default(DEFAULT_OPENAI_TTS_MODEL), realtimeTranscriptionModel: OptionalTrimmedStringSchema.default( DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL ), diff --git a/packages/server/src/server/speech/speech-config-resolver.ts b/packages/server/src/server/speech/speech-config-resolver.ts index de9071740..c2b53eb15 100644 --- a/packages/server/src/server/speech/speech-config-resolver.ts +++ b/packages/server/src/server/speech/speech-config-resolver.ts @@ -9,13 +9,12 @@ import { type RequestedSpeechProviders, } from "./speech-types.js"; -const OptionalSpeechProviderSchema = z.preprocess((value) => { - if (typeof value !== "string") { - return value; - } - const normalized = value.trim().toLowerCase(); - return normalized.length > 0 ? normalized : undefined; -}, SpeechProviderIdSchema.optional()); +const OptionalSpeechProviderSchema = z + .string() + .trim() + .toLowerCase() + .pipe(SpeechProviderIdSchema) + .optional(); const RequestedSpeechProvidersSchema = z.object({ dictationSttProvider: OptionalSpeechProviderSchema.default("local"),