Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 71 additions & 9 deletions packages/ai-sdk/src/tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { asyncIteratorObject, oc, type } from '@orpc/contract'
import { os } from '@orpc/server'
import { generateText } from 'ai'
import { MockLanguageModelV4 } from 'ai/test'
import z from 'zod'
import { createToolFactory, implementToolFactory } from './tool'
import { aiSdkTool } from './tool-meta'
Expand Down Expand Up @@ -201,6 +203,39 @@ describe('implementToolFactory', () => {
})
})

it('the AI SDK does not validate execute results against outputSchema, so oRPC must validate output itself', async () => {
const contract = oc.input(inputSchema).output(outputSchema)

const greet = implementToolFactory()(contract, {
execute: async () => ({ greeting: 123 }) as any,
})

const result = await generateText({
model: new MockLanguageModelV4({
doGenerate: async () => ({
content: [{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'greet',
input: JSON.stringify({ name: 'Alice' }),
}],
finishReason: { unified: 'tool-calls', raw: undefined },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 20, text: 20, reasoning: undefined },
},
warnings: [],
}),
}),
tools: { greet },
prompt: 'Greet Alice',
})

expect(result.toolResults).toEqual([
expect.objectContaining({ output: { greeting: 123 } }),
])
})

describe('async iterator output schema', () => {
const yieldSchema = z.object({ message: z.string() })
const returnSchema = z.object({ count: z.number() })
Expand Down Expand Up @@ -275,15 +310,30 @@ describe('createToolFactory', () => {
expect(tool.metadata).toEqual({ source: 'weather-service' })
})

it('disable validation at oRPC level to avoid twice times validation', async () => {
it('disable input validation at oRPC level to avoid validating twice', async () => {
const handler = vi.fn(() => ({ greeting: 'Hello!' }))

const procedure = os
.input(inputSchema)
.output(outputSchema)
.handler(handler)

const tool = createToolFactory()(procedure)

await expect(tool.execute?.('invalid' as any, { abortSignal } as any)).resolves.toEqual({ greeting: 'Hello!' })

expect(handler).toHaveBeenCalledWith(expect.objectContaining({ input: 'invalid' }), 'invalid')
})

it('keeps output validation enabled because the AI SDK does not validate execute results', async () => {
const procedure = os
.input(inputSchema)
.output(outputSchema)
.handler(({ input }) => input as any)
.handler(() => ({ greeting: 123 }) as any)

const tool = createToolFactory()(procedure)

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

describe('async iterator output', () => {
Expand Down Expand Up @@ -335,20 +385,32 @@ describe('createToolFactory', () => {
expect(finallyCalled).toBe(true)
})

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

const tool = createToolFactory()(procedure)

const outputs: unknown[] = []
for await (const output of (tool as any).execute({ name: 'Alice' }, { abortSignal })) {
outputs.push(output)
}
const iterator = (tool as any).execute({ name: 'Alice' }, { abortSignal })
await expect(iterator.next()).rejects.toThrow('Output validation failed')
})

it('validates each streamed event against the yield schema', async () => {
const procedure = os
.input(inputSchema)
.output(asyncIteratorObject(yieldSchema))
.handler(async function* () {
yield { message: 'one' }
yield { message: 123 } as any
})

expect(outputs).toEqual([{ message: 'not an iterator' }])
const tool = createToolFactory()(procedure)

const iterator = (tool as any).execute({ name: 'Alice' }, { abortSignal })
await expect(iterator.next()).resolves.toEqual({ done: false, value: { message: 'one' } })
await expect(iterator.next()).rejects.toThrow('AsyncIteratorObject validation failed')
})
})
})
16 changes: 5 additions & 11 deletions packages/ai-sdk/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { FunctionTool } from './tool-meta'
import { getAsyncIteratorObjectSchemaDetails } from '@orpc/contract'
import { combineJsonSchemasWithComposition } from '@orpc/json-schema'
import { call, Procedure } from '@orpc/server'
import { isAsyncIteratorObject, ORPC_NAME, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
import { ORPC_NAME, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
import { tool } from 'ai'
import { getAiSdkToolMeta } from './tool-meta'

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

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

const isIteratorOutput = getIteratorYieldSchemas(toArray(procedure['~orpc'].outputSchemas)) !== undefined
Expand All @@ -281,14 +282,7 @@ export function createToolFactory<TInitialContext extends Context = object>(
*/
execute: isIteratorOutput
? async function* (input, callingOptions) {
const output = await call(disabledValidation, input as any, { signal: callingOptions.abortSignal, ...options })

if (!isAsyncIteratorObject(output)) {
yield output
return
}

yield* output
yield* await call(disabledValidation, input as any, { signal: callingOptions.abortSignal, ...options }) as AsyncIterable<any>
}
: (input, callingOptions) => {
return call(disabledValidation, input as any, { signal: callingOptions.abortSignal, ...options })
Expand Down
Loading