From a747b038bc3836aa002142d8cf8324286ffb709e Mon Sep 17 00:00:00 2001 From: Arun Mani J Date: Mon, 15 Jun 2026 18:50:25 +0530 Subject: [PATCH 1/2] feat[api]: add formatZodError Zod prettifier helper Signed-off-by: Arun Mani J --- packages/sdk/utils/zod-error.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/sdk/utils/zod-error.ts diff --git a/packages/sdk/utils/zod-error.ts b/packages/sdk/utils/zod-error.ts new file mode 100644 index 0000000000..b996cce7d4 --- /dev/null +++ b/packages/sdk/utils/zod-error.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export function formatZodError(error: z.ZodError): string { + return z.prettifyError(error); +} From 61c6d2049a1a54673f54ef8162cffa4cf0a64271 Mon Sep 17 00:00:00 2001 From: Arun Mani J Date: Mon, 15 Jun 2026 18:51:21 +0530 Subject: [PATCH 2/2] fix[api]: friendly, field-level validation errors for user input Signed-off-by: Arun Mani J --- packages/sdk/client/api/bci-transcribe.ts | 3 +- packages/sdk/client/api/download-asset.ts | 3 +- packages/sdk/client/api/finetune.ts | 11 +- packages/sdk/client/api/load-model.ts | 11 +- packages/sdk/client/api/text-to-speech.ts | 3 +- .../sdk/client/config-loader/config-utils.ts | 6 +- packages/sdk/client/parse-input.ts | 17 ++ packages/sdk/client/rpc/rpc-client.ts | 60 +++++- packages/sdk/index.ts | 1 + packages/sdk/schemas/error.ts | 8 +- packages/sdk/schemas/load-model.ts | 50 +++-- packages/sdk/schemas/sdk-errors-client.ts | 5 + packages/sdk/schemas/text-to-speech.ts | 6 +- packages/sdk/schemas/translate.ts | 5 +- packages/sdk/server/plugins/registry.ts | 10 +- .../server/rpc/handlers/load-model/handler.ts | 9 +- .../sdk/server/rpc/handlers/plugin-invoke.ts | 33 +-- .../test/unit/zod-error-formatting.test.ts | 196 ++++++++++++++++++ packages/sdk/utils/errors-client.ts | 12 ++ 19 files changed, 372 insertions(+), 77 deletions(-) create mode 100644 packages/sdk/client/parse-input.ts create mode 100644 packages/sdk/test/unit/zod-error-formatting.test.ts diff --git a/packages/sdk/client/api/bci-transcribe.ts b/packages/sdk/client/api/bci-transcribe.ts index 6bf5840f8a..9da246ba92 100644 --- a/packages/sdk/client/api/bci-transcribe.ts +++ b/packages/sdk/client/api/bci-transcribe.ts @@ -17,6 +17,7 @@ import { stream, duplex, type DuplexReadable } from "@/client/rpc/rpc-client"; import { getClientLogger } from "@/logging"; import { TranscriptionFailedError } from "@/utils/errors-client"; import { decoratePromise } from "@/utils/decorate-promise"; +import { parseClientInput } from "@/client/parse-input"; import { generateClientRequestId } from "@/client/api/client-request-id"; const logger = getClientLogger(); @@ -70,7 +71,7 @@ export function bciTranscribe( params: BciTranscribeClientParams, options?: RPCOptions, ): Promise & { requestId: string } { - const parsed = bciTranscribeClientParamsSchema.parse(params); + const parsed = parseClientInput(bciTranscribeClientParamsSchema, params); const requestId = generateClientRequestId(); const inner = runBciTranscribe(parsed, requestId, options); return decoratePromise(inner, { requestId }); diff --git a/packages/sdk/client/api/download-asset.ts b/packages/sdk/client/api/download-asset.ts index 8554d64d93..e5ae6eb161 100644 --- a/packages/sdk/client/api/download-asset.ts +++ b/packages/sdk/client/api/download-asset.ts @@ -10,6 +10,7 @@ import { InvalidResponseError, } from "@/utils/errors-client"; import { decoratePromise } from "@/utils/decorate-promise"; +import { parseClientInput } from "@/client/parse-input"; import { generateClientRequestId } from "@/client/api/client-request-id"; export type DownloadAssetOptions = BaseDownloadAssetOptions; @@ -72,7 +73,7 @@ async function runDownloadAsset( requestId: string, rpcOptions?: RPCOptions, ): Promise { - const request = downloadAssetOptionsToRequestSchema.parse({ + const request = parseClientInput(downloadAssetOptionsToRequestSchema, { ...options, requestId, }); diff --git a/packages/sdk/client/api/finetune.ts b/packages/sdk/client/api/finetune.ts index db4696843f..11243b6bf2 100644 --- a/packages/sdk/client/api/finetune.ts +++ b/packages/sdk/client/api/finetune.ts @@ -19,6 +19,7 @@ import { InvalidResponseError, StreamEndedError, } from "@/utils/errors-client"; +import { parseClientInput } from "@/client/parse-input"; export interface FinetuneHandle { progressStream: AsyncGenerator; @@ -41,14 +42,14 @@ function isFinetuneReplyParams( function createFinetuneReplyRequest(params: FinetuneReplyParams) { if (params.operation === "getState") { - const getStateParams = finetuneGetStateParamsSchema.parse(params); - return finetuneGetStateRequestSchema.parse({ + const getStateParams = parseClientInput(finetuneGetStateParamsSchema, params); + return parseClientInput(finetuneGetStateRequestSchema, { type: "finetune", ...getStateParams, }); } - return finetuneStopRequestSchema.parse({ + return parseClientInput(finetuneStopRequestSchema, { type: "finetune", modelId: params.modelId, operation: params.operation, @@ -172,7 +173,7 @@ export function finetune( return resultPromise; } - const runParams = finetuneRunParamsSchema.parse(params); + const runParams = parseClientInput(finetuneRunParamsSchema, params); let resultResolver: (value: FinetuneResult) => void = () => { }; let resultRejecter: (error: unknown) => void = () => { }; @@ -191,7 +192,7 @@ export function finetune( const processResponses = async () => { try { let sawTerminalResponse = false; - const request = finetuneRunRequestSchema.parse({ + const request = parseClientInput(finetuneRunRequestSchema, { type: "finetune", ...runParams, withProgress: true, diff --git a/packages/sdk/client/api/load-model.ts b/packages/sdk/client/api/load-model.ts index 33736428ac..dbc826be05 100644 --- a/packages/sdk/client/api/load-model.ts +++ b/packages/sdk/client/api/load-model.ts @@ -9,8 +9,10 @@ import { type RPCOptions, type ModelDescriptor, type SdcppConfig, - loadModelOptionsToRequestSchema, + loadBuiltinToRequestSchema, + loadCustomPluginToRequestSchema, reloadConfigOptionsToRequestSchema, + isBuiltInModelType, isModelTypeAlias, normalizeModelType, inferModelTypeFromModelSrc, @@ -23,6 +25,7 @@ import { InvalidResponseError, } from "@/utils/errors-client"; import { assertModelSrcMatchesModelType } from "@/utils/load-model-validation"; +import { parseClientInput } from "@/client/parse-input"; import { getClientLogger } from "@/logging"; import { decoratePromise } from "@/utils/decorate-promise"; import { generateClientRequestId } from "@/client/api/client-request-id"; @@ -305,8 +308,10 @@ async function runLoadModel( resolvedOptions = { ...resolvedOptions, requestId }; const request = isReloadConfig - ? reloadConfigOptionsToRequestSchema.parse(resolvedOptions) - : loadModelOptionsToRequestSchema.parse(resolvedOptions); + ? parseClientInput(reloadConfigOptionsToRequestSchema, resolvedOptions) + : isBuiltInModelType(resolvedOptions["modelType"]) + ? parseClientInput(loadBuiltinToRequestSchema, resolvedOptions) + : parseClientInput(loadCustomPluginToRequestSchema, resolvedOptions); const modelLogger = isReloadConfig ? undefined : (resolvedOptions["logger"] as LoadModelOptions["logger"]); diff --git a/packages/sdk/client/api/text-to-speech.ts b/packages/sdk/client/api/text-to-speech.ts index 2f85e9b1e2..d2af93f490 100644 --- a/packages/sdk/client/api/text-to-speech.ts +++ b/packages/sdk/client/api/text-to-speech.ts @@ -16,6 +16,7 @@ import { import { stream as streamRpc, duplex, type DuplexReadable } from "@/client/rpc/rpc-client"; import { getClientLogger } from "@/logging"; import { TextToSpeechStreamFailedError } from "@/utils/errors-client"; +import { parseClientInput } from "@/client/parse-input"; const logger = getClientLogger(); @@ -211,7 +212,7 @@ export function textToSpeech( params: TtsClientParamsInput, options?: RPCOptions, ): TextToSpeechStreamResult { - const parsed: TtsClientParams = ttsClientParamsSchema.parse(params); + const parsed: TtsClientParams = parseClientInput(ttsClientParamsSchema, params); if (parsed.sentenceStream && !parsed.stream) { throw new TextToSpeechStreamFailedError( diff --git a/packages/sdk/client/config-loader/config-utils.ts b/packages/sdk/client/config-loader/config-utils.ts index c19b4b149a..d74431dcca 100644 --- a/packages/sdk/client/config-loader/config-utils.ts +++ b/packages/sdk/client/config-loader/config-utils.ts @@ -1,5 +1,6 @@ import { qvacConfigSchema, type QvacConfig } from "@/schemas"; import { ConfigValidationFailedError } from "@/utils/errors-client"; +import { formatZodError } from "@/utils/zod-error"; export type { QvacConfig }; @@ -7,10 +8,7 @@ export function validateConfig(config: unknown): QvacConfig { const result = qvacConfigSchema.safeParse(config); if (!result.success) { - const errors = result.error.issues - .map((e) => `${String(e.path.join("."))}: ${e.message}`) - .join(", "); - throw new ConfigValidationFailedError(errors); + throw new ConfigValidationFailedError(formatZodError(result.error)); } return result.data; diff --git a/packages/sdk/client/parse-input.ts b/packages/sdk/client/parse-input.ts new file mode 100644 index 0000000000..c98ca66da8 --- /dev/null +++ b/packages/sdk/client/parse-input.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { formatZodError } from "@/utils/zod-error"; +import { RequestValidationFailedError } from "@/utils/errors-client"; + +export function parseClientInput( + schema: S, + value: unknown, +): z.output { + try { + return schema.parse(value); + } catch (error) { + if (error instanceof z.ZodError) { + throw new RequestValidationFailedError(formatZodError(error)); + } + throw error; + } +} diff --git a/packages/sdk/client/rpc/rpc-client.ts b/packages/sdk/client/rpc/rpc-client.ts index bf9d5a9460..b52b114901 100644 --- a/packages/sdk/client/rpc/rpc-client.ts +++ b/packages/sdk/client/rpc/rpc-client.ts @@ -16,7 +16,12 @@ import { createDuplexSession, getWorkerLifeSignal, } from "#rpc"; -import { WorkerCrashedError } from "@/utils/errors-client"; +import { + WorkerCrashedError, + RequestValidationFailedError, +} from "@/utils/errors-client"; +import { formatZodError } from "@/utils/zod-error"; +import { z } from "zod"; import { nowMs, shouldProfile, @@ -50,6 +55,47 @@ function getNextCommandId() { return commandCounter; } +// On a failed request parse, re-validate against the single `requestSchema` +// member that owns the request's `type` so the error names the actual field +// rather than reporting a generic union failure. `loadModel` (a nested union +// with no top-level `type` literal) is already validated field-level in +// `client/api/load-model.ts`, so it falls back to the union error here. +interface RequestMemberIntrospect { + shape?: { type?: { value?: unknown } }; + options?: RequestMemberIntrospect[]; +} + +function memberDiscriminator(option: RequestMemberIntrospect): string | undefined { + const direct = option.shape?.type?.value; + if (typeof direct === "string") return direct; + const nested = option.options?.[0]?.shape?.type?.value; + return typeof nested === "string" ? nested : undefined; +} + +function pinpointRequestError(request: unknown, fallback: z.ZodError): z.ZodError { + const type = (request as { type?: unknown } | null)?.type; + if (typeof type !== "string") return fallback; + for (const option of requestSchema.options) { + if (memberDiscriminator(option as RequestMemberIntrospect) !== type) continue; + const result = (option as z.ZodType).safeParse(request); + return result.success ? fallback : result.error; + } + return fallback; +} + +function parseRequest(request: T): Request { + try { + return requestSchema.parse(request); + } catch (error) { + if (error instanceof z.ZodError) { + throw new RequestValidationFailedError( + formatZodError(pinpointRequestError(request, error)), + ); + } + throw error; + } +} + // Race in-flight reply/stream pulls against the worker-life signal — // bare-rpc's `_onerror` does not iterate `_outgoingRequests`, so without // this they hang on a dead socket. @@ -234,7 +280,7 @@ async function sendBase( options?: RPCOptions, signalDisable: boolean = false, ): Promise { - const parsedRequest = requestSchema.parse(request); + const parsedRequest = parseRequest(request); const req = rpc.request(getNextCommandId()); logger.debug("RPC Client sending:", summarizeRequest(request)); const payloadObj = signalDisable @@ -272,7 +318,7 @@ async function sendProfiled( try { const zodStart = nowMs(); - const parsedRequest = requestSchema.parse(request); + const parsedRequest = parseRequest(request); timings.requestZodValidationMs = nowMs() - zodStart; const req = rpc.request(getNextCommandId()); @@ -352,7 +398,7 @@ async function* streamBase( options: RPCOptions = {}, signalDisable: boolean = false, ): AsyncGenerator { - const parsedRequest = requestSchema.parse(request); + const parsedRequest = parseRequest(request); const req = rpc.request(getNextCommandId()); logger.debug("RPC Client streaming:", summarizeRequest(request)); const payloadObj = signalDisable @@ -409,7 +455,7 @@ async function* streamProfiled( try { const zodStart = nowMs(); - const parsedRequest = requestSchema.parse(request); + const parsedRequest = parseRequest(request); timings.requestZodValidationMs = nowMs() - zodStart; const req = rpc.request(getNextCommandId()); @@ -523,7 +569,7 @@ async function duplexBase( signalDisable: boolean, timeout?: number, ): Promise { - const parsedRequest = requestSchema.parse(request); + const parsedRequest = parseRequest(request); logger.debug("RPC Client duplex:", summarizeRequest(request)); const payloadObj = signalDisable @@ -554,7 +600,7 @@ async function duplexProfiled( try { const zodStart = nowMs(); - const parsedRequest = requestSchema.parse(request); + const parsedRequest = parseRequest(request); timings.requestZodValidationMs = nowMs() - zodStart; logger.debug("RPC Client duplex:", summarizeRequest(request)); diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index ed0db65248..712fe06d75 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -221,6 +221,7 @@ export { BareRuntimeBinaryNotFoundError, WorkerCrashedError, WorkerShutdownError, + RequestValidationFailedError, } from "./utils/errors-client"; // Logging exports diff --git a/packages/sdk/schemas/error.ts b/packages/sdk/schemas/error.ts index 41f8c54d2f..677c2f0f36 100644 --- a/packages/sdk/schemas/error.ts +++ b/packages/sdk/schemas/error.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { QvacErrorBase } from "@qvac/error"; +import { formatZodError } from "@/utils/zod-error"; /** * Wire shape for errors thrown across the RPC boundary. The fields are @@ -76,7 +77,12 @@ export function createErrorResponse(error: unknown): ErrorResponse { return response; } - const message = error instanceof Error ? error.message : String(error); + const message = + error instanceof z.ZodError + ? formatZodError(error) + : error instanceof Error + ? error.message + : String(error); const stack = error instanceof Error ? error.stack : undefined; return { diff --git a/packages/sdk/schemas/load-model.ts b/packages/sdk/schemas/load-model.ts index 3e138c35c2..2a501cb356 100644 --- a/packages/sdk/schemas/load-model.ts +++ b/packages/sdk/schemas/load-model.ts @@ -47,6 +47,10 @@ const builtInModelTypes = new Set([ ...Object.values(ModelType), ...Object.keys(ModelTypeAliases), ]); + +export function isBuiltInModelType(modelType: unknown): boolean { + return typeof modelType === "string" && builtInModelTypes.has(modelType); +} import type { Logger } from "@/logging"; import { reloadConfigRequestSchema } from "./reload-config"; @@ -166,7 +170,7 @@ export const loadModelOptionsSchema = loadModelOptionsBaseSchema.transform( }), ); -const loadModelOptionsToRequestBaseSchema = z.union([ +export const loadBuiltinToRequestSchema = z.discriminatedUnion("modelType", [ z .object({ ...loadModelRequestCommonFields, @@ -377,25 +381,31 @@ const loadModelOptionsToRequestBaseSchema = z.union([ delegate: data.delegate, ...(data.requestId !== undefined && { requestId: data.requestId }), })), - z - .object({ - ...loadModelRequestCommonFields, - modelType: z.string().refine((val) => !builtInModelTypes.has(val), { - message: "Built-in model types must use their specific schema", - }), - modelConfig: z.record(z.string(), z.unknown()).optional(), - }) - .transform((data) => ({ - type: "loadModel" as const, - modelType: data.modelType, - modelSrc: modelInputToSrcSchema.parse(data.modelSrc), - modelName: modelInputToNameSchema.parse(data.modelSrc), - modelConfig: data.modelConfig ?? {}, - seed: data.seed ?? false, - withProgress: data.withProgress ?? !!data.onProgress, - delegate: data.delegate, - ...(data.requestId !== undefined && { requestId: data.requestId }), - })), +]); + +export const loadCustomPluginToRequestSchema = z + .object({ + ...loadModelRequestCommonFields, + modelType: z.string().refine((val) => !builtInModelTypes.has(val), { + message: "Built-in model types must use their specific schema", + }), + modelConfig: z.record(z.string(), z.unknown()).optional(), + }) + .transform((data) => ({ + type: "loadModel" as const, + modelType: data.modelType, + modelSrc: modelInputToSrcSchema.parse(data.modelSrc), + modelName: modelInputToNameSchema.parse(data.modelSrc), + modelConfig: data.modelConfig ?? {}, + seed: data.seed ?? false, + withProgress: data.withProgress ?? !!data.onProgress, + delegate: data.delegate, + ...(data.requestId !== undefined && { requestId: data.requestId }), + })); + +const loadModelOptionsToRequestBaseSchema = z.union([ + loadBuiltinToRequestSchema, + loadCustomPluginToRequestSchema, ]); export const loadModelOptionsToRequestSchema = diff --git a/packages/sdk/schemas/sdk-errors-client.ts b/packages/sdk/schemas/sdk-errors-client.ts index d4aae3c88e..8841f94b20 100644 --- a/packages/sdk/schemas/sdk-errors-client.ts +++ b/packages/sdk/schemas/sdk-errors-client.ts @@ -12,6 +12,7 @@ export const SDK_CLIENT_ERROR_CODES = { OCR_FAILED: 50007, MODEL_TYPE_REQUIRED: 50008, MODEL_SRC_TYPE_MISMATCH: 50009, + REQUEST_VALIDATION_FAILED: 50010, // RPC Communication Errors (50,200-50,399) RPC_NO_HANDLER: 50200, @@ -93,6 +94,10 @@ const clientErrorDefinitions: ErrorCodesMap = { message: (inferred: string, resolved: string) => `modelSrc describes "${inferred}", but modelType resolves to "${resolved}". Omit modelType to infer it automatically, or pass a matching modelType.`, }, + [SDK_CLIENT_ERROR_CODES.REQUEST_VALIDATION_FAILED]: { + name: "REQUEST_VALIDATION_FAILED", + message: (errors: string) => `Invalid request:\n${errors}`, + }, // RPC Communication Errors (50,200-50,399) [SDK_CLIENT_ERROR_CODES.RPC_NO_HANDLER]: { diff --git a/packages/sdk/schemas/text-to-speech.ts b/packages/sdk/schemas/text-to-speech.ts index 49d54c01a0..993ffd976e 100644 --- a/packages/sdk/schemas/text-to-speech.ts +++ b/packages/sdk/schemas/text-to-speech.ts @@ -56,7 +56,7 @@ export const ttsSupertonicRuntimeConfigSchema = z.object({ useGPU: z.boolean().optional(), }); -export const ttsRuntimeConfigSchema = z.union([ +export const ttsRuntimeConfigSchema = z.discriminatedUnion("ttsEngine", [ ttsChatterboxRuntimeConfigSchema, ttsSupertonicRuntimeConfigSchema, ]); @@ -70,7 +70,7 @@ export const ttsChatterboxLoadConfigSchema = ttsChatterboxRuntimeConfigSchema.ex export const ttsSupertonicLoadConfigSchema = ttsSupertonicRuntimeConfigSchema; -export const ttsLoadConfigSchema = z.union([ +export const ttsLoadConfigSchema = z.discriminatedUnion("ttsEngine", [ ttsChatterboxLoadConfigSchema, ttsSupertonicLoadConfigSchema, ]); @@ -110,7 +110,7 @@ const legacyTtsOnnxFieldsShape = // `loadConfigSchema`. Permits deprecated ONNX field names so // `resolveConfig` can raise LegacyTtsModelDeprecatedError instead of a // generic Zod error; other unknown keys are still rejected by `.strict()`. -export const ttsConfigSchema = z.union([ +export const ttsConfigSchema = z.discriminatedUnion("ttsEngine", [ ttsChatterboxLoadConfigSchema.extend(legacyTtsOnnxFieldsShape).strict(), ttsSupertonicLoadConfigSchema.extend(legacyTtsOnnxFieldsShape).strict(), ]); diff --git a/packages/sdk/schemas/translate.ts b/packages/sdk/schemas/translate.ts index 927b5b0bfc..c8436e15d6 100644 --- a/packages/sdk/schemas/translate.ts +++ b/packages/sdk/schemas/translate.ts @@ -61,8 +61,7 @@ const translateParamsLlmSchema = z.object({ .describe("Optional translation context passed to the LLM as a system hint."), }); -// Using z.union since each modelType accepts multiple values -const translateParamsSchema = z.union([ +const translateParamsSchema = z.discriminatedUnion("modelType", [ translateParamsNmtSchema, translateParamsLlmSchema, ]); @@ -111,7 +110,7 @@ const translateRequestIdField = z "Stable identifier for this in-flight translation, generated by the client at call time. Surfaces on the registry so callers can target it with `cancel({ requestId })`. Optional on the wire — the server falls back to a server-generated id when the field is missing.", ); -export const translateRequestSchema = z.union([ +export const translateRequestSchema = z.discriminatedUnion("modelType", [ translateParamsNmtSchema.extend({ type: z.literal("translate"), requestId: translateRequestIdField, diff --git a/packages/sdk/server/plugins/registry.ts b/packages/sdk/server/plugins/registry.ts index 4681313e08..ec66aacd9b 100644 --- a/packages/sdk/server/plugins/registry.ts +++ b/packages/sdk/server/plugins/registry.ts @@ -11,6 +11,7 @@ import { PluginModelTypeReservedError, } from "@/utils/errors-server"; import { createAddonLoggerCallback } from "@/logging/addon"; +import { formatZodError } from "@/utils/zod-error"; const plugins = new Map(); @@ -27,11 +28,10 @@ function validatePluginDefinition(plugin: QvacPlugin): void { const result = pluginDefinitionRuntimeSchema.safeParse(plugin); if (result.success) return; - const details = result.error.issues - .map((i) => `${String(i.path.join("."))}: ${i.message}`) - .join(", "); - - throw new PluginDefinitionInvalidError(getModelTypeForError(plugin), details); + throw new PluginDefinitionInvalidError( + getModelTypeForError(plugin), + formatZodError(result.error), + ); } export function registerPlugin(plugin: QvacPlugin): void { diff --git a/packages/sdk/server/rpc/handlers/load-model/handler.ts b/packages/sdk/server/rpc/handlers/load-model/handler.ts index c0f73ba729..c56a594d8a 100644 --- a/packages/sdk/server/rpc/handlers/load-model/handler.ts +++ b/packages/sdk/server/rpc/handlers/load-model/handler.ts @@ -34,6 +34,7 @@ import { PluginNotFoundError, } from "@/utils/errors-server"; import { getServerLogger } from "@/logging"; +import { formatZodError } from "@/utils/zod-error"; import { getPlugin } from "@/server/plugins"; import { getRequestRegistry, @@ -86,15 +87,9 @@ export async function handleLoadModel( const parseResult = plugin.loadConfigSchema.safeParse(resolvedModelConfig); if (!parseResult.success) { - const details = parseResult.error.issues - .map( - (i: { path: unknown[]; message: string }) => - `${String(i.path.join("."))}: ${i.message}`, - ) - .join(", "); throw new PluginLoadConfigValidationFailedError( canonicalModelType, - details, + formatZodError(parseResult.error), ); } resolvedModelConfig = parseResult.data as Record; diff --git a/packages/sdk/server/rpc/handlers/plugin-invoke.ts b/packages/sdk/server/rpc/handlers/plugin-invoke.ts index e12ce0490a..a736ca08c6 100644 --- a/packages/sdk/server/rpc/handlers/plugin-invoke.ts +++ b/packages/sdk/server/rpc/handlers/plugin-invoke.ts @@ -20,6 +20,7 @@ import { ModelNotFoundError, } from "@/utils/errors-server"; import { getServerLogger } from "@/logging"; +import { formatZodError } from "@/utils/zod-error"; const logger = getServerLogger(); @@ -71,20 +72,20 @@ export async function handlePluginInvoke( const parseResult = handlerDef.requestSchema.safeParse(params); if (!parseResult.success) { - const details = parseResult.error.issues - .map((i) => `${String(i.path.join("."))}: ${i.message}`) - .join(", "); - throw new PluginRequestValidationFailedError(handlerName, details); + throw new PluginRequestValidationFailedError( + handlerName, + formatZodError(parseResult.error), + ); } const result = await handlerDef.handler(parseResult.data); const responseParseResult = handlerDef.responseSchema.safeParse(result); if (!responseParseResult.success) { - const details = responseParseResult.error.issues - .map((i) => `${String(i.path.join("."))}: ${i.message}`) - .join(", "); - throw new PluginResponseValidationFailedError(handlerName, details); + throw new PluginResponseValidationFailedError( + handlerName, + formatZodError(responseParseResult.error), + ); } return { @@ -118,10 +119,10 @@ export async function* handlePluginInvokeStream( const parseResult = handlerDef.requestSchema.safeParse(params); if (!parseResult.success) { - const details = parseResult.error.issues - .map((i) => `${String(i.path.join("."))}: ${i.message}`) - .join(", "); - throw new PluginRequestValidationFailedError(handlerName, details); + throw new PluginRequestValidationFailedError( + handlerName, + formatZodError(parseResult.error), + ); } const generator = handlerDef.handler( @@ -131,10 +132,10 @@ export async function* handlePluginInvokeStream( for await (const chunk of generator) { const responseParseResult = handlerDef.responseSchema.safeParse(chunk); if (!responseParseResult.success) { - const details = responseParseResult.error.issues - .map((i) => `${String(i.path.join("."))}: ${i.message}`) - .join(", "); - throw new PluginResponseValidationFailedError(handlerName, details); + throw new PluginResponseValidationFailedError( + handlerName, + formatZodError(responseParseResult.error), + ); } yield { diff --git a/packages/sdk/test/unit/zod-error-formatting.test.ts b/packages/sdk/test/unit/zod-error-formatting.test.ts new file mode 100644 index 0000000000..9af3244a9d --- /dev/null +++ b/packages/sdk/test/unit/zod-error-formatting.test.ts @@ -0,0 +1,196 @@ +import test from "brittle"; +import type RPC from "bare-rpc"; +import { z } from "zod"; +import { + createErrorResponse, + loadBuiltinToRequestSchema, + loadCustomPluginToRequestSchema, + isBuiltInModelType, + type Request, +} from "@/schemas"; +import { LLAMA_3_2_1B_INST_Q4_0 } from "@/models/registry"; +import { formatZodError } from "@/utils/zod-error"; +import { parseClientInput } from "@/client/parse-input"; +import { send } from "@/client"; +import { validateConfig } from "@/client/config-loader/config-utils"; +import { + ConfigValidationFailedError, + RequestValidationFailedError, +} from "@/utils/errors-client"; + +// A raw ZodError serialises its `.message` as a JSON array of issues; a friendly +// message does not. Validation errors shown to consumers must never be JSON. +function isJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +// The message a consumer sees when their input fails client-side validation. +function inputError(schema: typeof loadBuiltinToRequestSchema, value: unknown) { + try { + parseClientInput(schema, value); + return null; + } catch (error) { + if (error instanceof RequestValidationFailedError) return error.message; + throw error; + } +} + +const DUMMY_RPC = {} as unknown as RPC; +async function requestError(request: unknown) { + try { + await send(request as Request, {}, DUMMY_RPC); + return null; + } catch (error) { + if (error instanceof RequestValidationFailedError) return error.message; + throw error; + } +} + +// --- Validation errors are readable strings, never raw ZodError JSON --- + +test("formatZodError produces a readable, field-named, non-JSON message", function (t) { + const schema = z.object({ modelConfig: z.object({ nCtx: z.number() }).strict() }); + const result = schema.safeParse({ modelConfig: { nCtxx: 4096 } }); + if (result.success) return t.fail("expected invalid input"); + const message = formatZodError(result.error); + t.absent(isJson(message), "not JSON"); + t.ok(message.includes("modelConfig"), "names the path"); +}); + +test("createErrorResponse never puts raw ZodError JSON on the wire", function (t) { + const result = z.object({ modelId: z.string() }).safeParse({ modelId: 1 }); + if (result.success) return t.fail("expected invalid input"); + const envelope = createErrorResponse(result.error); + t.is(envelope.type, "error"); + t.is(envelope.message, formatZodError(result.error)); + t.absent(isJson(envelope.message), "not JSON"); +}); + +test("validateConfig throws a readable ConfigValidationFailedError", function (t) { + try { + validateConfig(42); + t.fail("expected validateConfig to throw"); + } catch (error) { + t.ok(error instanceof ConfigValidationFailedError); + t.absent(isJson((error as ConfigValidationFailedError).message), "not JSON"); + } +}); + +test("a malformed request rejects with a typed error, never a raw ZodError", async function (t) { + try { + await send({ type: "embed" } as unknown as Request, {}, DUMMY_RPC); + t.fail("expected send to reject"); + } catch (error) { + t.ok(error instanceof RequestValidationFailedError, "typed SDK error"); + t.absent(error instanceof z.ZodError, "raw ZodError does not escape"); + t.absent(isJson((error as RequestValidationFailedError).message), "not JSON"); + } +}); + +// --- loadModel: errors name the offending config field --- + +test("loadModel: an unknown config key is named", function (t) { + const message = inputError(loadBuiltinToRequestSchema, { + modelSrc: LLAMA_3_2_1B_INST_Q4_0, + modelType: "llamacpp-completion", + modelConfig: { ctx_sizee: 4096 }, + }); + t.ok(message?.includes("ctx_sizee"), "names the field"); + t.ok(message?.includes("modelConfig"), "points at modelConfig"); +}); + +test("loadModel: a wrong value type is named with its exact path", function (t) { + const message = inputError(loadBuiltinToRequestSchema, { + modelSrc: LLAMA_3_2_1B_INST_Q4_0, + modelType: "llamacpp-completion", + modelConfig: { ctx_size: "big" }, + }); + t.ok(message?.includes("modelConfig.ctx_size"), "names the exact field"); + t.ok(message?.includes("number"), "explains the expected type"); +}); + +test("loadModel: field-level for any model type, and through aliases", function (t) { + const nonLlm = inputError(loadBuiltinToRequestSchema, { + modelSrc: "some-model.gguf", + modelType: "sdcpp-generation", + modelConfig: { hieght: 512 }, + }); + t.ok(nonLlm?.includes("hieght"), "non-LLM model type is field-level"); + + const viaAlias = inputError(loadBuiltinToRequestSchema, { + modelSrc: LLAMA_3_2_1B_INST_Q4_0, + modelType: "llm", + modelConfig: { ctx_sizee: 4096 }, + }); + t.ok(viaAlias?.includes("ctx_sizee"), "alias resolves to the right branch"); +}); + +test("loadModel: a custom plugin modelType is accepted", function (t) { + t.absent(isBuiltInModelType("my-custom-plugin"), "not a built-in type"); + const result = loadCustomPluginToRequestSchema.safeParse({ + modelSrc: "some-model.gguf", + modelType: "my-custom-plugin", + modelConfig: { anything: 1 }, + }); + t.ok(result.success, "custom plugin config is accepted"); +}); + +// --- tts: its config is itself discriminated, so errors are field-level too --- + +test("tts: an unknown config key is named", function (t) { + const message = inputError(loadBuiltinToRequestSchema, { + modelSrc: "some-model.gguf", + modelType: "tts-ggml", + modelConfig: { ttsEngine: "chatterbox", language: "en", voicee: "x" }, + }); + t.ok(message?.includes("voicee"), "names the field"); +}); + +test("tts: a wrong value type is named", function (t) { + const message = inputError(loadBuiltinToRequestSchema, { + modelSrc: "some-model.gguf", + modelType: "tts-ggml", + modelConfig: { ttsEngine: "supertonic", language: "en", ttsSpeed: "fast" }, + }); + t.ok(message?.includes("ttsSpeed"), "names the field"); + t.ok(message?.includes("number"), "explains the expected type"); +}); + +test("tts: a missing engine points at the ttsEngine discriminator", function (t) { + const message = inputError(loadBuiltinToRequestSchema, { + modelSrc: "some-model.gguf", + modelType: "tts-ggml", + modelConfig: { language: "en" }, + }); + t.ok(message?.includes("ttsEngine"), "names the discriminator"); +}); + +// --- Every operation: errors name the field, resolved via the request `type` --- + +test("embed: a malformed request names the field", async function (t) { + const message = await requestError({ type: "embed", modelId: 123, input: "hi" }); + t.ok(message?.includes("modelId"), "names the field"); + t.ok(message?.includes("→ at"), "field-level path"); +}); + +test("transcribe: a malformed request names the field", async function (t) { + const message = await requestError({ type: "transcribe", modelId: 5 }); + t.ok(message?.includes("modelId"), "names the field"); +}); + +test("translate: a malformed request resolves to the right modelType branch", async function (t) { + const message = await requestError({ + type: "translate", + modelId: "m", + text: "hi", + stream: true, + modelType: "llm", + to: 5, + }); + t.ok(message?.includes("at to"), "names the LLM-branch field"); +}); diff --git a/packages/sdk/utils/errors-client.ts b/packages/sdk/utils/errors-client.ts index 184f7b7d9c..e0e2a781e5 100644 --- a/packages/sdk/utils/errors-client.ts +++ b/packages/sdk/utils/errors-client.ts @@ -458,6 +458,18 @@ export class ConfigValidationFailedError extends QvacErrorBase { } } +export class RequestValidationFailedError extends QvacErrorBase { + constructor(errors: string, cause?: unknown) { + super( + createErrorOptions( + SDK_CLIENT_ERROR_CODES.REQUEST_VALIDATION_FAILED, + [errors], + cause, + ), + ); + } +} + // ============== Operation Errors (Client-side wrappers for server operations) ============== // These are used by client API to throw errors based on server responses // They reference server error codes but are thrown on client side