Skip to content

Commit f36f85e

Browse files
authored
fix(ai-sdk): keep output validation enabled for tool procedures (#1791)
`createToolFactory` disabled both input and output validation on the assumption that the AI SDK re-validates against the tool schemas. That is only true for input: the AI SDK treats `outputSchema` as type metadata and never validates the value `execute` returns against it (confirmed in [vercel/ai#10222](vercel/ai#10222), docs corrected in [vercel/ai#11016](vercel/ai#11016)). Procedures run as tools therefore skipped their `.output()` schemas entirely, including transforms and defaults. ## Fixes - Output validation stays enabled; only input validation remains disabled, since that half is genuinely redundant. - A handler returning invalid output now rejects with an output validation error instead of passing through silently. - Streamed `asyncIteratorObject` outputs validate every yielded event, and a handler returning a non-iterator now errors instead of being yielded once as the final result (the old fallback branch became unreachable and is removed). ## Testing - A new `generateText` test with a mock model proves the AI SDK returns schema-violating `execute` output untouched, so it will start failing if a future `ai` release adds output validation and makes ours redundant. - Remaining tests cover input validation being skipped, invalid output rejection, per-event stream validation, and non-iterator rejection.
1 parent 9f9d664 commit f36f85e

2 files changed

Lines changed: 76 additions & 20 deletions

File tree

packages/ai-sdk/src/tool.test.ts

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { asyncIteratorObject, oc, type } from '@orpc/contract'
22
import { os } from '@orpc/server'
3+
import { generateText } from 'ai'
4+
import { MockLanguageModelV4 } from 'ai/test'
35
import z from 'zod'
46
import { createToolFactory, implementToolFactory } from './tool'
57
import { aiSdkTool } from './tool-meta'
@@ -201,6 +203,39 @@ describe('implementToolFactory', () => {
201203
})
202204
})
203205

206+
it('the AI SDK does not validate execute results against outputSchema, so oRPC must validate output itself', async () => {
207+
const contract = oc.input(inputSchema).output(outputSchema)
208+
209+
const greet = implementToolFactory()(contract, {
210+
execute: async () => ({ greeting: 123 }) as any,
211+
})
212+
213+
const result = await generateText({
214+
model: new MockLanguageModelV4({
215+
doGenerate: async () => ({
216+
content: [{
217+
type: 'tool-call',
218+
toolCallId: 'call-1',
219+
toolName: 'greet',
220+
input: JSON.stringify({ name: 'Alice' }),
221+
}],
222+
finishReason: { unified: 'tool-calls', raw: undefined },
223+
usage: {
224+
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
225+
outputTokens: { total: 20, text: 20, reasoning: undefined },
226+
},
227+
warnings: [],
228+
}),
229+
}),
230+
tools: { greet },
231+
prompt: 'Greet Alice',
232+
})
233+
234+
expect(result.toolResults).toEqual([
235+
expect.objectContaining({ output: { greeting: 123 } }),
236+
])
237+
})
238+
204239
describe('async iterator output schema', () => {
205240
const yieldSchema = z.object({ message: z.string() })
206241
const returnSchema = z.object({ count: z.number() })
@@ -275,15 +310,30 @@ describe('createToolFactory', () => {
275310
expect(tool.metadata).toEqual({ source: 'weather-service' })
276311
})
277312

278-
it('disable validation at oRPC level to avoid twice times validation', async () => {
313+
it('disable input validation at oRPC level to avoid validating twice', async () => {
314+
const handler = vi.fn(() => ({ greeting: 'Hello!' }))
315+
316+
const procedure = os
317+
.input(inputSchema)
318+
.output(outputSchema)
319+
.handler(handler)
320+
321+
const tool = createToolFactory()(procedure)
322+
323+
await expect(tool.execute?.('invalid' as any, { abortSignal } as any)).resolves.toEqual({ greeting: 'Hello!' })
324+
325+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ input: 'invalid' }), 'invalid')
326+
})
327+
328+
it('keeps output validation enabled because the AI SDK does not validate execute results', async () => {
279329
const procedure = os
280330
.input(inputSchema)
281331
.output(outputSchema)
282-
.handler(({ input }) => input as any)
332+
.handler(() => ({ greeting: 123 }) as any)
283333

284334
const tool = createToolFactory()(procedure)
285335

286-
await expect(tool.execute?.('invalid' as any, { abortSignal } as any)).resolves.toEqual('invalid')
336+
await expect(tool.execute?.({ name: 'Alice' }, { abortSignal } as any)).rejects.toThrow('Output validation failed')
287337
})
288338

289339
describe('async iterator output', () => {
@@ -335,20 +385,32 @@ describe('createToolFactory', () => {
335385
expect(finallyCalled).toBe(true)
336386
})
337387

338-
it('yields non-iterator output once when handler ignores the declared iterator schema', async () => {
388+
it('rejects when handler ignores the declared iterator schema', async () => {
339389
const procedure = os
340390
.input(inputSchema)
341391
.output(asyncIteratorObject(yieldSchema))
342392
.handler(async () => ({ message: 'not an iterator' }) as any)
343393

344394
const tool = createToolFactory()(procedure)
345395

346-
const outputs: unknown[] = []
347-
for await (const output of (tool as any).execute({ name: 'Alice' }, { abortSignal })) {
348-
outputs.push(output)
349-
}
396+
const iterator = (tool as any).execute({ name: 'Alice' }, { abortSignal })
397+
await expect(iterator.next()).rejects.toThrow('Output validation failed')
398+
})
399+
400+
it('validates each streamed event against the yield schema', async () => {
401+
const procedure = os
402+
.input(inputSchema)
403+
.output(asyncIteratorObject(yieldSchema))
404+
.handler(async function* () {
405+
yield { message: 'one' }
406+
yield { message: 123 } as any
407+
})
350408

351-
expect(outputs).toEqual([{ message: 'not an iterator' }])
409+
const tool = createToolFactory()(procedure)
410+
411+
const iterator = (tool as any).execute({ name: 'Alice' }, { abortSignal })
412+
await expect(iterator.next()).resolves.toEqual({ done: false, value: { message: 'one' } })
413+
await expect(iterator.next()).rejects.toThrow('AsyncIteratorObject validation failed')
352414
})
353415
})
354416
})

packages/ai-sdk/src/tool.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { FunctionTool } from './tool-meta'
88
import { getAsyncIteratorObjectSchemaDetails } from '@orpc/contract'
99
import { combineJsonSchemasWithComposition } from '@orpc/json-schema'
1010
import { call, Procedure } from '@orpc/server'
11-
import { isAsyncIteratorObject, ORPC_NAME, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
11+
import { ORPC_NAME, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
1212
import { tool } from 'ai'
1313
import { getAiSdkToolMeta } from './tool-meta'
1414

@@ -262,12 +262,13 @@ export function createToolFactory<TInitialContext extends Context = object>(
262262

263263
/**
264264
* The AI SDK already validates input against the tool's `inputSchema`,
265-
* so validation is disabled at the oRPC level to avoid validating twice.
265+
* so input validation is disabled at the oRPC level to avoid validating twice.
266+
* Output validation stays enabled because the AI SDK does not validate
267+
* the value returned from `execute` against the tool's `outputSchema`.
266268
*/
267269
const disabledValidation = new Procedure({
268270
...procedure['~orpc'],
269271
disableInputValidation: true,
270-
disableOutputValidation: true,
271272
})
272273

273274
const isIteratorOutput = getIteratorYieldSchemas(toArray(procedure['~orpc'].outputSchemas)) !== undefined
@@ -281,14 +282,7 @@ export function createToolFactory<TInitialContext extends Context = object>(
281282
*/
282283
execute: isIteratorOutput
283284
? async function* (input, callingOptions) {
284-
const output = await call(disabledValidation, input as any, { signal: callingOptions.abortSignal, ...options })
285-
286-
if (!isAsyncIteratorObject(output)) {
287-
yield output
288-
return
289-
}
290-
291-
yield* output
285+
yield* await call(disabledValidation, input as any, { signal: callingOptions.abortSignal, ...options }) as AsyncIterable<any>
292286
}
293287
: (input, callingOptions) => {
294288
return call(disabledValidation, input as any, { signal: callingOptions.abortSignal, ...options })

0 commit comments

Comments
 (0)