speech: replace preprocess parsing with strict zod schemas

This commit is contained in:
Mohamed Boudra
2026-02-06 22:22:49 +07:00
parent afb1f1580d
commit cdb7ef12ae
4 changed files with 61 additions and 85 deletions

View File

@@ -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(),

View File

@@ -166,18 +166,20 @@ function buildAliasMap<T extends string>(modelIds: readonly T[]): Record<string,
function createAliasedModelIdSchema<T extends string>(params: {
modelIds: readonly T[];
aliases: Record<string, T>;
}): z.ZodType<T> {
}): z.ZodType<T, z.ZodTypeDef, string> {
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<T>;
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);

View File

@@ -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
),

View File

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