Skip to content

Commit fb930b7

Browse files
committed
fix[api]: friendly, field-level validation errors for user input
Signed-off-by: Arun Mani J <j.arunmani@proton.me>
1 parent 1481a19 commit fb930b7

19 files changed

Lines changed: 372 additions & 77 deletions

packages/sdk/client/api/bci-transcribe.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { stream, duplex, type DuplexReadable } from "@/client/rpc/rpc-client";
1717
import { getClientLogger } from "@/logging";
1818
import { TranscriptionFailedError } from "@/utils/errors-client";
1919
import { decoratePromise } from "@/utils/decorate-promise";
20+
import { parseClientInput } from "@/client/parse-input";
2021
import { generateClientRequestId } from "@/client/api/client-request-id";
2122

2223
const logger = getClientLogger();
@@ -70,7 +71,7 @@ export function bciTranscribe(
7071
params: BciTranscribeClientParams,
7172
options?: RPCOptions,
7273
): Promise<string | TranscribeSegment[]> & { requestId: string } {
73-
const parsed = bciTranscribeClientParamsSchema.parse(params);
74+
const parsed = parseClientInput(bciTranscribeClientParamsSchema, params);
7475
const requestId = generateClientRequestId();
7576
const inner = runBciTranscribe(parsed, requestId, options);
7677
return decoratePromise(inner, { requestId });

packages/sdk/client/api/download-asset.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
InvalidResponseError,
1111
} from "@/utils/errors-client";
1212
import { decoratePromise } from "@/utils/decorate-promise";
13+
import { parseClientInput } from "@/client/parse-input";
1314
import { generateClientRequestId } from "@/client/api/client-request-id";
1415

1516
export type DownloadAssetOptions = BaseDownloadAssetOptions;
@@ -72,7 +73,7 @@ async function runDownloadAsset(
7273
requestId: string,
7374
rpcOptions?: RPCOptions,
7475
): Promise<string> {
75-
const request = downloadAssetOptionsToRequestSchema.parse({
76+
const request = parseClientInput(downloadAssetOptionsToRequestSchema, {
7677
...options,
7778
requestId,
7879
});

packages/sdk/client/api/finetune.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
InvalidResponseError,
2020
StreamEndedError,
2121
} from "@/utils/errors-client";
22+
import { parseClientInput } from "@/client/parse-input";
2223

2324
export interface FinetuneHandle {
2425
progressStream: AsyncGenerator<FinetuneProgress>;
@@ -41,14 +42,14 @@ function isFinetuneReplyParams(
4142

4243
function createFinetuneReplyRequest(params: FinetuneReplyParams) {
4344
if (params.operation === "getState") {
44-
const getStateParams = finetuneGetStateParamsSchema.parse(params);
45-
return finetuneGetStateRequestSchema.parse({
45+
const getStateParams = parseClientInput(finetuneGetStateParamsSchema, params);
46+
return parseClientInput(finetuneGetStateRequestSchema, {
4647
type: "finetune",
4748
...getStateParams,
4849
});
4950
}
5051

51-
return finetuneStopRequestSchema.parse({
52+
return parseClientInput(finetuneStopRequestSchema, {
5253
type: "finetune",
5354
modelId: params.modelId,
5455
operation: params.operation,
@@ -172,7 +173,7 @@ export function finetune(
172173
return resultPromise;
173174
}
174175

175-
const runParams = finetuneRunParamsSchema.parse(params);
176+
const runParams = parseClientInput(finetuneRunParamsSchema, params);
176177

177178
let resultResolver: (value: FinetuneResult) => void = () => { };
178179
let resultRejecter: (error: unknown) => void = () => { };
@@ -191,7 +192,7 @@ export function finetune(
191192
const processResponses = async () => {
192193
try {
193194
let sawTerminalResponse = false;
194-
const request = finetuneRunRequestSchema.parse({
195+
const request = parseClientInput(finetuneRunRequestSchema, {
195196
type: "finetune",
196197
...runParams,
197198
withProgress: true,

packages/sdk/client/api/load-model.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
type RPCOptions,
1010
type ModelDescriptor,
1111
type SdcppConfig,
12-
loadModelOptionsToRequestSchema,
12+
loadBuiltinToRequestSchema,
13+
loadCustomPluginToRequestSchema,
1314
reloadConfigOptionsToRequestSchema,
15+
isBuiltInModelType,
1416
isModelTypeAlias,
1517
normalizeModelType,
1618
inferModelTypeFromModelSrc,
@@ -23,6 +25,7 @@ import {
2325
InvalidResponseError,
2426
} from "@/utils/errors-client";
2527
import { assertModelSrcMatchesModelType } from "@/utils/load-model-validation";
28+
import { parseClientInput } from "@/client/parse-input";
2629
import { getClientLogger } from "@/logging";
2730
import { decoratePromise } from "@/utils/decorate-promise";
2831
import { generateClientRequestId } from "@/client/api/client-request-id";
@@ -305,8 +308,10 @@ async function runLoadModel(
305308
resolvedOptions = { ...resolvedOptions, requestId };
306309

307310
const request = isReloadConfig
308-
? reloadConfigOptionsToRequestSchema.parse(resolvedOptions)
309-
: loadModelOptionsToRequestSchema.parse(resolvedOptions);
311+
? parseClientInput(reloadConfigOptionsToRequestSchema, resolvedOptions)
312+
: isBuiltInModelType(resolvedOptions["modelType"])
313+
? parseClientInput(loadBuiltinToRequestSchema, resolvedOptions)
314+
: parseClientInput(loadCustomPluginToRequestSchema, resolvedOptions);
310315
const modelLogger = isReloadConfig
311316
? undefined
312317
: (resolvedOptions["logger"] as LoadModelOptions["logger"]);

packages/sdk/client/api/text-to-speech.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { stream as streamRpc, duplex, type DuplexReadable } from "@/client/rpc/rpc-client";
1717
import { getClientLogger } from "@/logging";
1818
import { TextToSpeechStreamFailedError } from "@/utils/errors-client";
19+
import { parseClientInput } from "@/client/parse-input";
1920

2021
const logger = getClientLogger();
2122

@@ -211,7 +212,7 @@ export function textToSpeech(
211212
params: TtsClientParamsInput,
212213
options?: RPCOptions,
213214
): TextToSpeechStreamResult {
214-
const parsed: TtsClientParams = ttsClientParamsSchema.parse(params);
215+
const parsed: TtsClientParams = parseClientInput(ttsClientParamsSchema, params);
215216

216217
if (parsed.sentenceStream && !parsed.stream) {
217218
throw new TextToSpeechStreamFailedError(

packages/sdk/client/config-loader/config-utils.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import { qvacConfigSchema, type QvacConfig } from "@/schemas";
22
import { ConfigValidationFailedError } from "@/utils/errors-client";
3+
import { formatZodError } from "@/utils/zod-error";
34

45
export type { QvacConfig };
56

67
export function validateConfig(config: unknown): QvacConfig {
78
const result = qvacConfigSchema.safeParse(config);
89

910
if (!result.success) {
10-
const errors = result.error.issues
11-
.map((e) => `${String(e.path.join("."))}: ${e.message}`)
12-
.join(", ");
13-
throw new ConfigValidationFailedError(errors);
11+
throw new ConfigValidationFailedError(formatZodError(result.error));
1412
}
1513

1614
return result.data;

packages/sdk/client/parse-input.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from "zod";
2+
import { formatZodError } from "@/utils/zod-error";
3+
import { RequestValidationFailedError } from "@/utils/errors-client";
4+
5+
export function parseClientInput<S extends z.ZodType>(
6+
schema: S,
7+
value: unknown,
8+
): z.output<S> {
9+
try {
10+
return schema.parse(value);
11+
} catch (error) {
12+
if (error instanceof z.ZodError) {
13+
throw new RequestValidationFailedError(formatZodError(error));
14+
}
15+
throw error;
16+
}
17+
}

packages/sdk/client/rpc/rpc-client.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ import {
1616
createDuplexSession,
1717
getWorkerLifeSignal,
1818
} from "#rpc";
19-
import { WorkerCrashedError } from "@/utils/errors-client";
19+
import {
20+
WorkerCrashedError,
21+
RequestValidationFailedError,
22+
} from "@/utils/errors-client";
23+
import { formatZodError } from "@/utils/zod-error";
24+
import { z } from "zod";
2025
import {
2126
nowMs,
2227
shouldProfile,
@@ -50,6 +55,47 @@ function getNextCommandId() {
5055
return commandCounter;
5156
}
5257

58+
// On a failed request parse, re-validate against the single `requestSchema`
59+
// member that owns the request's `type` so the error names the actual field
60+
// rather than reporting a generic union failure. `loadModel` (a nested union
61+
// with no top-level `type` literal) is already validated field-level in
62+
// `client/api/load-model.ts`, so it falls back to the union error here.
63+
interface RequestMemberIntrospect {
64+
shape?: { type?: { value?: unknown } };
65+
options?: RequestMemberIntrospect[];
66+
}
67+
68+
function memberDiscriminator(option: RequestMemberIntrospect): string | undefined {
69+
const direct = option.shape?.type?.value;
70+
if (typeof direct === "string") return direct;
71+
const nested = option.options?.[0]?.shape?.type?.value;
72+
return typeof nested === "string" ? nested : undefined;
73+
}
74+
75+
function pinpointRequestError(request: unknown, fallback: z.ZodError): z.ZodError {
76+
const type = (request as { type?: unknown } | null)?.type;
77+
if (typeof type !== "string") return fallback;
78+
for (const option of requestSchema.options) {
79+
if (memberDiscriminator(option as RequestMemberIntrospect) !== type) continue;
80+
const result = (option as z.ZodType).safeParse(request);
81+
return result.success ? fallback : result.error;
82+
}
83+
return fallback;
84+
}
85+
86+
function parseRequest<T extends Request>(request: T): Request {
87+
try {
88+
return requestSchema.parse(request);
89+
} catch (error) {
90+
if (error instanceof z.ZodError) {
91+
throw new RequestValidationFailedError(
92+
formatZodError(pinpointRequestError(request, error)),
93+
);
94+
}
95+
throw error;
96+
}
97+
}
98+
5399
// Race in-flight reply/stream pulls against the worker-life signal —
54100
// bare-rpc's `_onerror` does not iterate `_outgoingRequests`, so without
55101
// this they hang on a dead socket.
@@ -234,7 +280,7 @@ async function sendBase<T extends Request>(
234280
options?: RPCOptions,
235281
signalDisable: boolean = false,
236282
): Promise<Response> {
237-
const parsedRequest = requestSchema.parse(request);
283+
const parsedRequest = parseRequest(request);
238284
const req = rpc.request(getNextCommandId());
239285
logger.debug("RPC Client sending:", summarizeRequest(request));
240286
const payloadObj = signalDisable
@@ -272,7 +318,7 @@ async function sendProfiled<T extends Request>(
272318

273319
try {
274320
const zodStart = nowMs();
275-
const parsedRequest = requestSchema.parse(request);
321+
const parsedRequest = parseRequest(request);
276322
timings.requestZodValidationMs = nowMs() - zodStart;
277323

278324
const req = rpc.request(getNextCommandId());
@@ -352,7 +398,7 @@ async function* streamBase<T extends Request>(
352398
options: RPCOptions = {},
353399
signalDisable: boolean = false,
354400
): AsyncGenerator<Response> {
355-
const parsedRequest = requestSchema.parse(request);
401+
const parsedRequest = parseRequest(request);
356402
const req = rpc.request(getNextCommandId());
357403
logger.debug("RPC Client streaming:", summarizeRequest(request));
358404
const payloadObj = signalDisable
@@ -409,7 +455,7 @@ async function* streamProfiled<T extends Request>(
409455

410456
try {
411457
const zodStart = nowMs();
412-
const parsedRequest = requestSchema.parse(request);
458+
const parsedRequest = parseRequest(request);
413459
timings.requestZodValidationMs = nowMs() - zodStart;
414460

415461
const req = rpc.request(getNextCommandId());
@@ -523,7 +569,7 @@ async function duplexBase<T extends Request>(
523569
signalDisable: boolean,
524570
timeout?: number,
525571
): Promise<DuplexSession> {
526-
const parsedRequest = requestSchema.parse(request);
572+
const parsedRequest = parseRequest(request);
527573
logger.debug("RPC Client duplex:", summarizeRequest(request));
528574

529575
const payloadObj = signalDisable
@@ -554,7 +600,7 @@ async function duplexProfiled<T extends Request>(
554600

555601
try {
556602
const zodStart = nowMs();
557-
const parsedRequest = requestSchema.parse(request);
603+
const parsedRequest = parseRequest(request);
558604
timings.requestZodValidationMs = nowMs() - zodStart;
559605

560606
logger.debug("RPC Client duplex:", summarizeRequest(request));

packages/sdk/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export {
221221
BareRuntimeBinaryNotFoundError,
222222
WorkerCrashedError,
223223
WorkerShutdownError,
224+
RequestValidationFailedError,
224225
} from "./utils/errors-client";
225226

226227
// Logging exports

packages/sdk/schemas/error.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod";
22
import { QvacErrorBase } from "@qvac/error";
3+
import { formatZodError } from "@/utils/zod-error";
34

45
/**
56
* Wire shape for errors thrown across the RPC boundary. The fields are
@@ -76,7 +77,12 @@ export function createErrorResponse(error: unknown): ErrorResponse {
7677
return response;
7778
}
7879

79-
const message = error instanceof Error ? error.message : String(error);
80+
const message =
81+
error instanceof z.ZodError
82+
? formatZodError(error)
83+
: error instanceof Error
84+
? error.message
85+
: String(error);
8086
const stack = error instanceof Error ? error.stack : undefined;
8187

8288
return {

0 commit comments

Comments
 (0)