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