diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 606f8260d..522498e59 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -41,10 +41,13 @@ typescript: acceptHeaderEnum: false additionalDependencies: dependencies: + '@standard-schema/spec': ^1.1.0 zod: ^3.25.0 || ^4.0.0 devDependencies: '@types/node': ^22.13.12 + '@valibot/to-json-schema': ^1.7.1 dotenv: ^16.4.7 + valibot: ^1.4.2 vitest: ^3.2.4 peerDependencies: {} additionalPackageJSON: diff --git a/CLAUDE.md b/CLAUDE.md index 49edfaa8e..0df9d154d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ Speakeasy's [persistent edits](https://www.speakeasy.com/docs/sdks/customize/cod - Uses `ReusableReadableStream` to enable multiple parallel consumers **Tool System** (`src/lib/tool.ts`, `src/lib/tool-types.ts`, `src/lib/tool-executor.ts`) -- `tool()` helper creates type-safe tools with Zod schemas +- `tool()` accepts Zod or Standard Schema v1 validators; provider schemas use Zod conversion, the Standard JSON Schema trait, or explicit `inputJsonSchema` - Three tool types: - **Regular tools** (`execute: function`) - auto-executed, return final result - **Generator tools** (`execute: async generator`) - stream preliminary results diff --git a/README.md b/README.md index 7411a9be6..a49231312 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ yarn add @openrouter/sdk > [!IMPORTANT] > `callModel` and its associated types have moved to the [`@openrouter/agent`](https://www.npmjs.com/package/@openrouter/agent) package. If you are using `callModel`, tool definitions, or related types from `@openrouter/sdk`, you should migrate to `@openrouter/agent`. > +> The legacy SDK tool API accepts Zod schemas or any [Standard Schema v1](https://standardschema.dev/) validator for `inputSchema`, `outputSchema`, and `eventSchema`. Provider JSON Schema uses three tiers: Zod schemas use the built-in `z4.toJSONSchema` path; non-Zod schemas implementing [Standard JSON Schema v1](https://standardschema.dev/json-schema) use their `~standard.jsonSchema.input` converter; otherwise callers provide `inputJsonSchema`. An explicit `inputJsonSchema` overrides the Standard JSON Schema trait, and all paths remove `~`-prefixed metadata. The trait is available in Zod 4.2+, ArkType 2.1.28+, Zod Mini, VineJS, and Sury; Valibot adds it with `toStandardJsonSchema()` from `@valibot/to-json-schema`. +> > To assist with the migration, run: > > ```bash diff --git a/examples/call-model-typed-tool-calling.example.ts b/examples/call-model-typed-tool-calling.example.ts index ae94b7515..9f7740b36 100644 --- a/examples/call-model-typed-tool-calling.example.ts +++ b/examples/call-model-typed-tool-calling.example.ts @@ -3,7 +3,7 @@ * * This example demonstrates how to use the tool() function for * fully-typed tool definitions where execute params, return types, and event - * types are automatically inferred from Zod schemas. + * types are automatically inferred from Zod or any Standard Schema v1 validator. * * Tool types are auto-detected based on configuration: * - Generator tool: When `eventSchema` is provided @@ -17,7 +17,10 @@ import dotenv from "dotenv"; dotenv.config(); -import { OpenRouter, tool } from "../src/index.js"; +import { toStandardJsonSchema } from "@valibot/to-json-schema"; +import * as v from "valibot"; +import { OpenRouter } from "../src/index.js"; +import { tool } from "../src/lib/tool.js"; import z from "zod"; const openRouter = new OpenRouter({ @@ -27,15 +30,18 @@ const openRouter = new OpenRouter({ // Create a typed regular tool using tool() // The execute function params are automatically typed as z.infer // The return type is enforced based on outputSchema +const weatherInputSchema = toStandardJsonSchema( + v.object({ location: v.string() }), +); + const weatherTool = tool({ name: "get_weather", description: "Get the current weather for a location", - inputSchema: z.object({ - location: z.string().describe("The city and country, e.g. San Francisco, CA"), - }), - outputSchema: z.object({ - temperature: z.number(), - description: z.string(), + // This wrapper implements validation plus Standard JSON Schema conversion. + inputSchema: weatherInputSchema, + outputSchema: v.object({ + temperature: v.number(), + description: v.string(), }), // params is automatically typed as { location: string } execute: async (params) => { diff --git a/examples/package.json b/examples/package.json index 410efafd6..a9012bae2 100644 --- a/examples/package.json +++ b/examples/package.json @@ -13,6 +13,8 @@ "tsx": "^4.19.2" }, "dependencies": { - "@openrouter/sdk": "file:.." + "@openrouter/sdk": "file:..", + "@valibot/to-json-schema": "^1.7.1", + "valibot": "^1.4.2" } } \ No newline at end of file diff --git a/examples/pnpm-lock.yaml b/examples/pnpm-lock.yaml index b08c0e315..808b39fc4 100644 --- a/examples/pnpm-lock.yaml +++ b/examples/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@openrouter/sdk': specifier: file:.. version: file:.. + '@valibot/to-json-schema': + specifier: ^1.7.1 + version: 1.7.1(valibot@1.4.2) + valibot: + specifier: ^1.4.2 + version: 1.4.2 devDependencies: '@types/node': specifier: ^20.0.0 @@ -183,9 +189,17 @@ packages: '@openrouter/sdk@file:..': resolution: {directory: .., type: directory} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + '@valibot/to-json-schema@1.7.1': + resolution: {integrity: sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A==} + peerDependencies: + valibot: ^1.4.0 + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -214,6 +228,14 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + zod@4.1.13: resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} @@ -299,12 +321,19 @@ snapshots: '@openrouter/sdk@file:..': dependencies: + '@standard-schema/spec': 1.1.0 zod: 4.1.13 + '@standard-schema/spec@1.1.0': {} + '@types/node@20.19.25': dependencies: undici-types: 6.21.0 + '@valibot/to-json-schema@1.7.1(valibot@1.4.2)': + dependencies: + valibot: 1.4.2 + dotenv@16.6.1: {} esbuild@0.27.0: @@ -354,4 +383,6 @@ snapshots: undici-types@6.21.0: {} + valibot@1.4.2: {} + zod@4.1.13: {} diff --git a/examples/tools-example.ts b/examples/tools-example.ts index d5ec52760..dcf4aa9ea 100644 --- a/examples/tools-example.ts +++ b/examples/tools-example.ts @@ -3,7 +3,7 @@ * * This file demonstrates the automatic tool execution feature. * When you provide tools with `execute` functions, they are automatically: - * 1. Validated using Zod schemas + * 1. Validated using Zod or any Standard Schema v1 validator * 2. Executed when the model calls them * 3. Results sent back to the model * 4. Process repeats until stopWhen condition is met (default: stepCountIs(5)) diff --git a/package.json b/package.json index 4e7a6558e..d1f1a15b7 100644 --- a/package.json +++ b/package.json @@ -87,14 +87,17 @@ "devDependencies": { "@eslint/js": "^9.26.0", "@types/node": "^22.13.12", + "@valibot/to-json-schema": "^1.7.1", "dotenv": "^16.4.7", "eslint": "^9.26.0", "globals": "^15.14.0", "typescript": "~5.8.3", "typescript-eslint": "^8.26.0", + "valibot": "^1.4.2", "vitest": "^3.2.4" }, "dependencies": { + "@standard-schema/spec": "^1.1.0", "zod": "^3.25.0 || ^4.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fac9ead9..842fea49f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 zod: specifier: ^3.25.0 || ^4.0.0 version: 4.2.1 @@ -18,6 +21,9 @@ importers: '@types/node': specifier: ^22.13.12 version: 22.18.13 + '@valibot/to-json-schema': + specifier: ^1.7.1 + version: 1.7.1(valibot@1.4.2(typescript@5.8.3)) dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -33,6 +39,9 @@ importers: typescript-eslint: specifier: ^8.26.0 version: 8.46.2(eslint@9.38.0)(typescript@5.8.3) + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.8.3) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@22.18.13) @@ -374,6 +383,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -448,6 +460,11 @@ packages: resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@valibot/to-json-schema@1.7.1': + resolution: {integrity: sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A==} + peerDependencies: + valibot: ^1.4.0 + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -958,6 +975,14 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1269,6 +1294,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true + '@standard-schema/spec@1.1.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1377,6 +1404,10 @@ snapshots: '@typescript-eslint/types': 8.46.2 eslint-visitor-keys: 4.2.1 + '@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@5.8.3))': + dependencies: + valibot: 1.4.2(typescript@5.8.3) + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1894,6 +1925,10 @@ snapshots: dependencies: punycode: 2.3.1 + valibot@1.4.2(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + vite-node@3.2.4(@types/node@22.18.13): dependencies: cac: 6.7.14 diff --git a/src/lib/tool-executor.ts b/src/lib/tool-executor.ts index 02bc49a09..eba4446ea 100644 --- a/src/lib/tool-executor.ts +++ b/src/lib/tool-executor.ts @@ -1,3 +1,4 @@ +import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'; import type { $ZodType } from 'zod/v4/core'; import type { $ZodObject, $ZodShape } from 'zod/v4/core'; import type { @@ -7,6 +8,8 @@ import type { ToolExecutionResult, TurnContext, ToolExecuteContext, + ToolSchema, + InferSchemaOutput, } from './tool-types.js'; import * as z4 from 'zod/v4'; @@ -16,6 +19,32 @@ import { type ToolContextStore, buildToolExecuteContext } from './tool-context.j // Re-export ZodError for convenience export const ZodError = z4.ZodError; +export interface StandardSchemaIssue { + readonly message: string; + readonly path: (string | number)[]; +} + +export class StandardSchemaError extends Error { + readonly issues: StandardSchemaIssue[]; + + constructor(issues: ReadonlyArray) { + const normalized = issues.map((issue) => ({ + message: issue.message, + path: (issue.path ?? []).map((segment) => { + const key = + typeof segment === 'object' && segment !== null && 'key' in segment + ? segment.key + : segment; + // Symbols are legal PropertyKeys but break JSON.stringify and join('.') + return typeof key === 'symbol' ? key.toString() : key; + }), + })); + super(JSON.stringify(normalized)); + this.name = 'StandardSchemaError'; + this.issues = normalized; + } +} + /** * Typeguard to check if a value is a non-null object (not an array). */ @@ -73,6 +102,28 @@ function isZodSchema(value: unknown): value is z4.ZodType { return typeof value._zod === 'object'; } +function isStandardSchema(value: unknown): value is StandardSchemaV1 { + if (typeof value !== 'object' || value === null || !('~standard' in value)) { + return false; + } + const standard = value['~standard'] as { + version?: unknown; + validate?: unknown; + }; + return standard.version === 1 && typeof standard.validate === 'function'; +} + +function isStandardJsonSchema(value: unknown): value is StandardJSONSchemaV1 { + if (typeof value !== 'object' || value === null || !('~standard' in value)) { + return false; + } + const standard = value['~standard'] as { + version?: unknown; + jsonSchema?: { input?: unknown }; + }; + return standard.version === 1 && typeof standard.jsonSchema?.input === 'function'; +} + /** * Convert a Zod schema to JSON Schema using Zod v4's toJSONSchema function. * Accepts ZodType from the main zod package for user compatibility. @@ -97,38 +148,98 @@ export function convertZodToJsonSchema(zodSchema: $ZodType): Record ({ - type: 'function' as const, - name: tool.function.name, - description: tool.function.description || null, - strict: null, - parameters: convertZodToJsonSchema(tool.function.inputSchema), - })); + return tools.map((tool) => { + let parameters: Record | undefined; + + // An explicit inputJsonSchema is the escape hatch and always wins, + // including over the Zod fast path. + if (tool.function.inputJsonSchema) { + parameters = tool.function.inputJsonSchema; + } else if (isZodSchema(tool.function.inputSchema)) { + parameters = convertZodToJsonSchema(tool.function.inputSchema); + } else if (isStandardJsonSchema(tool.function.inputSchema)) { + try { + parameters = tool.function.inputSchema['~standard'].jsonSchema.input({ + target: 'draft-07', + }); + } catch { + // Fall through to the explicit-schema requirement below. + } + } + + if (!parameters) { + throw new Error( + `Tool "${tool.function.name}" inputSchema must implement StandardJSONSchemaV1 or provide inputJsonSchema`, + ); + } + + return { + type: 'function' as const, + name: tool.function.name, + description: tool.function.description || null, + strict: null, + parameters: sanitizeJsonSchema(parameters), + }; + }); } /** * Validate tool input against Zod schema * @throws ZodError if validation fails */ -export function validateToolInput(schema: $ZodType, args: unknown): T { - return z4.parse(schema, args); +function validateSchema( + schema: T, + value: unknown, +): InferSchemaOutput | Promise> { + if (isZodSchema(schema)) { + return z4.parse(schema, value) as InferSchemaOutput; + } + if (!isStandardSchema(schema)) { + throw new Error('Invalid tool schema provided'); + } + + return Promise.resolve(schema['~standard'].validate(value)).then((result) => { + if (result.issues) { + throw new StandardSchemaError(result.issues); + } + return result.value as InferSchemaOutput; + }); } -/** - * Validate tool output against Zod schema - * @throws ZodError if validation fails - */ -export function validateToolOutput(schema: $ZodType, result: unknown): T { - return z4.parse(schema, result); +/** Validate tool input against its schema. Zod validation remains synchronous. */ +export function validateToolInput(schema: $ZodType, args: unknown): T; +export function validateToolInput( + schema: T, + args: unknown, +): Promise>; +export function validateToolInput( + schema: T, + args: unknown, +): InferSchemaOutput | Promise> { + return validateSchema(schema, args); } -/** - * Try to validate a value against a Zod schema without throwing - * @returns true if validation succeeds, false otherwise - */ -function tryValidate(schema: $ZodType, value: unknown): boolean { - const result = z4.safeParse(schema, value); - return result.success; +/** Validate tool output against its schema. Zod validation remains synchronous. */ +export function validateToolOutput(schema: $ZodType, result: unknown): T; +export function validateToolOutput( + schema: T, + result: unknown, +): Promise>; +export function validateToolOutput( + schema: T, + result: unknown, +): InferSchemaOutput | Promise> { + return validateSchema(schema, result); +} + +/** Try to validate a value against a schema without throwing. */ +async function tryValidate(schema: ToolSchema, value: unknown): Promise { + try { + await validateSchema(schema, value); + return true; + } catch { + return false; + } } /** @@ -187,7 +298,7 @@ export async function executeRegularTool( } try { - const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); + const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx(tool, context, contextStore, sharedSchema); // Execute tool with context @@ -195,7 +306,7 @@ export async function executeRegularTool( // Validate output if schema is provided if (tool.function.outputSchema) { - const validatedOutput = validateToolOutput(tool.function.outputSchema, result); + const validatedOutput = await validateToolOutput(tool.function.outputSchema, result); return { toolCallId: toolCall.id, @@ -238,7 +349,7 @@ export async function executeGeneratorTool( } try { - const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); + const validatedInput = await validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx(tool, context, contextStore, sharedSchema); const preliminaryResults: unknown[] = []; @@ -255,14 +366,14 @@ export async function executeGeneratorTool( lastEmittedValue = event; hasEmittedValue = true; - const matchesOutputSchema = tryValidate(tool.function.outputSchema, event); - const matchesEventSchema = tryValidate(tool.function.eventSchema, event); + const matchesOutputSchema = await tryValidate(tool.function.outputSchema, event); + const matchesEventSchema = await tryValidate(tool.function.eventSchema, event); if (matchesOutputSchema && !matchesEventSchema && !hasFinalResult) { - finalResult = validateToolOutput(tool.function.outputSchema, event); + finalResult = await validateToolOutput(tool.function.outputSchema, event); hasFinalResult = true; } else { - const validatedPreliminary = validateToolOutput(tool.function.eventSchema, event); + const validatedPreliminary = await validateToolOutput(tool.function.eventSchema, event); preliminaryResults.push(validatedPreliminary); if (onPreliminaryResult) { onPreliminaryResult(toolCall.id, validatedPreliminary); @@ -273,7 +384,7 @@ export async function executeGeneratorTool( } if (iterResult.value !== undefined) { - finalResult = validateToolOutput(tool.function.outputSchema, iterResult.value); + finalResult = await validateToolOutput(tool.function.outputSchema, iterResult.value); hasFinalResult = true; } @@ -283,7 +394,7 @@ export async function executeGeneratorTool( `Generator tool "${toolCall.name}" completed without emitting any values or returning a result`, ); } - finalResult = validateToolOutput(tool.function.outputSchema, lastEmittedValue); + finalResult = await validateToolOutput(tool.function.outputSchema, lastEmittedValue); } return { @@ -359,5 +470,14 @@ export function formatToolExecutionError(error: Error, toolCall: ParsedToolCall< return `Tool "${toolCall.name}" validation error:\n${JSON.stringify(issues, null, 2)}`; } + if (error instanceof StandardSchemaError) { + const issues = error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })); + + return `Tool "${toolCall.name}" validation error:\n${JSON.stringify(issues, null, 2)}`; + } + return `Tool "${toolCall.name}" execution error: ${error.message}`; } diff --git a/src/lib/tool-types.ts b/src/lib/tool-types.ts index a6272f310..6553fc894 100644 --- a/src/lib/tool-types.ts +++ b/src/lib/tool-types.ts @@ -1,8 +1,24 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; import type * as models from '../models/index.js'; import type { StreamEvents } from '../models/index.js'; import type { ModelResult } from './model-result.js'; +export type ToolInputSchema = $ZodObject<$ZodShape> | StandardSchemaV1; +export type ToolSchema = $ZodType | StandardSchemaV1; + +export type InferSchemaInput = T extends $ZodType + ? zodInfer + : T extends StandardSchemaV1 + ? StandardSchemaV1.InferInput + : never; + +export type InferSchemaOutput = T extends $ZodType + ? zodInfer + : T extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : never; + /** * Tool type enum for enhanced tools */ @@ -136,20 +152,22 @@ export type ToolApprovalCheck = ( /** * Base tool function interface with inputSchema - * @template TInput - Zod schema for tool input + * @template TInput - Schema for tool input */ -export interface BaseToolFunction> { +export interface BaseToolFunction { name: string; description?: string; inputSchema: TInput; + /** Explicit JSON Schema override or fallback for non-Zod input schemas. */ + inputJsonSchema?: Record; /** Zod schema declaring the context data this tool needs */ contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; + nextTurnParams?: NextTurnParamsFunctions>; /** * Whether this tool requires human approval before execution * Can be a boolean or an async function that receives the tool's input params and context */ - requireApproval?: boolean | ToolApprovalCheck>; + requireApproval?: boolean | ToolApprovalCheck>; } /** @@ -158,16 +176,16 @@ export interface BaseToolFunction> { * @template TName - The tool's literal name string */ export interface ToolFunctionWithExecute< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ToolInputSchema, + TOutput extends ToolSchema = $ZodType, TContext extends Record = Record, TName extends string = string, > extends BaseToolFunction { outputSchema?: TOutput; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext, - ) => Promise> | zodInfer; + ) => Promise> | InferSchemaInput; } /** @@ -193,26 +211,29 @@ export interface ToolFunctionWithExecute< * ``` */ export interface ToolFunctionWithGenerator< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType = $ZodType, - TOutput extends $ZodType = $ZodType, + TInput extends ToolInputSchema, + TEvent extends ToolSchema = $ZodType, + TOutput extends ToolSchema = $ZodType, TContext extends Record = Record, TName extends string = string, > extends BaseToolFunction { eventSchema: TEvent; outputSchema: TOutput; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext, - ) => AsyncGenerator | zodInfer, zodInfer | void>; + ) => AsyncGenerator< + InferSchemaInput | InferSchemaInput, + InferSchemaInput | void + >; } /** * Manual tool without execute function - requires manual handling by developer */ export interface ManualToolFunction< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ToolInputSchema, + TOutput extends ToolSchema = $ZodType, > extends BaseToolFunction { outputSchema?: TOutput; } @@ -221,8 +242,8 @@ export interface ManualToolFunction< * Tool with execute function (regular or generator) */ export type ToolWithExecute< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ToolInputSchema = $ZodObject<$ZodShape>, + TOutput extends ToolSchema = $ZodType, TContext extends Record = Record, > = { type: ToolType.Function; @@ -233,9 +254,9 @@ export type ToolWithExecute< * Tool with generator execute function */ export type ToolWithGenerator< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TEvent extends $ZodType = $ZodType, - TOutput extends $ZodType = $ZodType, + TInput extends ToolInputSchema = $ZodObject<$ZodShape>, + TEvent extends ToolSchema = $ZodType, + TOutput extends ToolSchema = $ZodType, TContext extends Record = Record, > = { type: ToolType.Function; @@ -246,8 +267,8 @@ export type ToolWithGenerator< * Tool without execute function (manual handling) */ export type ManualTool< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, + TInput extends ToolInputSchema = $ZodObject<$ZodShape>, + TOutput extends ToolSchema = $ZodType, > = { type: ToolType.Function; function: ManualToolFunction; @@ -257,26 +278,26 @@ export type ManualTool< * Union type of all enhanced tool types */ export type Tool = - | ToolWithExecute<$ZodObject<$ZodShape>, $ZodType> - | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, $ZodType> - | ManualTool<$ZodObject<$ZodShape>, $ZodType>; + | ToolWithExecute + | ToolWithGenerator + | ManualTool; /** * Extracts the input type from a tool definition */ -export type InferToolInput = T extends { function: { inputSchema: infer S } } - ? S extends $ZodType - ? zodInfer - : unknown +export type InferToolInput = T extends { function: { inputSchema: infer S extends ToolSchema } } + ? InferSchemaOutput : unknown; /** * Extracts the output type from a tool definition */ -export type InferToolOutput = T extends { function: { outputSchema: infer S } } - ? S extends $ZodType - ? zodInfer - : unknown +// outputSchema is optional on regular/manual tools, so the pattern must +// tolerate a missing property and reject the undefined-only case. +export type InferToolOutput = T extends { function: { outputSchema?: infer S } } + ? S extends ToolSchema + ? InferSchemaOutput + : unknown : unknown; /** @@ -314,10 +335,8 @@ export type InferToolOutputsUnion = { * Extracts the event type from a generator tool definition * Returns `never` for non-generator tools */ -export type InferToolEvent = T extends { function: { eventSchema: infer S } } - ? S extends $ZodType - ? zodInfer - : never +export type InferToolEvent = T extends { function: { eventSchema: infer S extends ToolSchema } } + ? InferSchemaOutput : never; /** @@ -375,12 +394,8 @@ export interface ParsedToolCall { export interface ToolExecutionResult { toolCallId: string; toolName: string; - result: T extends ToolWithExecute<$ZodObject<$ZodShape>, infer O> | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> - ? zodInfer - : unknown; // Final result (sent to model) - preliminaryResults?: T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> - ? zodInfer[] - : undefined; // All yielded values from generator + result: InferToolOutput; // Final result (sent to model) + preliminaryResults?: InferToolEvent[]; // All yielded values from generator error?: Error; } diff --git a/src/lib/tool.ts b/src/lib/tool.ts index afea78b4b..cabc4c6d9 100644 --- a/src/lib/tool.ts +++ b/src/lib/tool.ts @@ -1,4 +1,5 @@ -import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; +import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec'; +import type { $ZodObject, $ZodShape, $ZodType } from 'zod/v4/core'; import { ToolType, SHARED_CONTEXT_KEY, @@ -9,54 +10,81 @@ import { type ManualTool, type NextTurnParamsFunctions, type ToolApprovalCheck, + type ToolInputSchema, + type ToolSchema, + type InferSchemaInput, + type InferSchemaOutput, } from "./tool-types.js"; //#region Config Types +type InputJsonSchemaRequirement = + TInput extends $ZodObject<$ZodShape> | StandardJSONSchemaV1 + ? { inputJsonSchema?: Record } + : { inputJsonSchema: Record }; + +type InputSchemaConfig = + { inputSchema: TInput } & InputJsonSchemaRequirement; + +/** + * Non-generic equivalent of InputSchemaConfig for contexts where the schema + * type cannot be inferred (e.g. the tool() overload, where explicit + * type arguments disable inference for the remaining parameters). + */ +type LooseInputSchemaConfig = + | { + inputSchema: + | $ZodObject<$ZodShape> + | (StandardSchemaV1 & StandardJSONSchemaV1); + inputJsonSchema?: Record; + } + | { + inputSchema: StandardSchemaV1; + inputJsonSchema: Record; + }; + /** * Configuration for a regular tool with outputSchema */ type RegularToolConfigWithOutput< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, + TInput extends ToolInputSchema, + TOutput extends ToolSchema, TContext extends Record = Record, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; outputSchema: TOutput; eventSchema?: undefined; /** Zod schema declaring the context data this tool needs */ contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext - ) => Promise> | zodInfer; + ) => Promise> | InferSchemaInput; }; /** * Configuration for a regular tool without outputSchema (infers return type from execute) */ type RegularToolConfigWithoutOutput< - TInput extends $ZodObject<$ZodShape>, + TInput extends ToolInputSchema, TReturn, TContext extends Record = Record, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; outputSchema?: undefined; eventSchema?: undefined; /** Zod schema declaring the context data this tool needs */ contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext ) => Promise | TReturn; }; @@ -65,40 +93,38 @@ type RegularToolConfigWithoutOutput< * Configuration for a generator tool (with eventSchema) */ type GeneratorToolConfig< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType, - TOutput extends $ZodType, + TInput extends ToolInputSchema, + TEvent extends ToolSchema, + TOutput extends ToolSchema, TContext extends Record = Record, TName extends string = string, -> = { +> = InputSchemaConfig & { name: TName; description?: string; - inputSchema: TInput; eventSchema: TEvent; outputSchema: TOutput; /** Zod schema declaring the context data this tool needs */ contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; execute: ( - params: zodInfer, + params: InferSchemaOutput, context?: ToolExecuteContext - ) => AsyncGenerator | zodInfer>; + ) => AsyncGenerator | InferSchemaInput>; }; /** * Configuration for a manual tool (execute: false, no eventSchema or outputSchema) */ type ManualToolConfig< - TInput extends $ZodObject<$ZodShape>, -> = { + TInput extends ToolInputSchema, +> = InputSchemaConfig & { name: string; // Manual tools don't use TName since they have no execute description?: string; - inputSchema: TInput; /** Zod schema declaring the context data this tool needs */ contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; + nextTurnParams?: NextTurnParamsFunctions>; + requireApproval?: boolean | ToolApprovalCheck>; execute: false; }; @@ -109,9 +135,8 @@ type ManualToolConfig< type ToolConfigWithSharedContext> = { name: string; description?: string; - inputSchema: $ZodObject<$ZodShape>; - outputSchema?: $ZodType; - eventSchema?: $ZodType; + outputSchema?: ToolSchema; + eventSchema?: ToolSchema; contextSchema?: $ZodObject<$ZodShape>; nextTurnParams?: NextTurnParamsFunctions>; requireApproval?: boolean | ToolApprovalCheck>; @@ -125,7 +150,7 @@ type ToolConfigWithSharedContext> = { context?: ToolExecuteContext, TShared>, ) => AsyncGenerator) | false; -}; +} & LooseInputSchemaConfig; //#endregion @@ -135,8 +160,8 @@ type ToolConfigWithSharedContext> = { * Union type for all regular tool configs */ type RegularToolConfig< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, + TInput extends ToolInputSchema, + TOutput extends ToolSchema, TReturn, TContext extends Record = Record, TName extends string = string, @@ -149,7 +174,7 @@ type RegularToolConfig< //#region tool() Factory /** - * Creates a tool with full type inference from Zod schemas. + * Creates a tool with full type inference from Zod or Standard Schema validators. * * The tool type is automatically determined based on the configuration: * - **Generator tool**: When `eventSchema` is provided @@ -176,9 +201,9 @@ type RegularToolConfig< */ // Overload for generator tools (when eventSchema is provided) export function tool< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType, - TOutput extends $ZodType, + TInput extends ToolInputSchema, + TEvent extends ToolSchema, + TOutput extends ToolSchema, TContext extends Record = Record, TName extends string = string, >( @@ -186,21 +211,21 @@ export function tool< ): ToolWithGenerator; // Overload for manual tools (execute: false) -export function tool>( +export function tool( config: ManualToolConfig ): ManualTool; // Overload for regular tools with outputSchema export function tool< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, + TInput extends ToolInputSchema, + TOutput extends ToolSchema, TContext extends Record = Record, TName extends string = string, >(config: RegularToolConfigWithOutput): ToolWithExecute; // Overload for regular tools without outputSchema (infers return type) export function tool< - TInput extends $ZodObject<$ZodShape>, + TInput extends ToolInputSchema, TReturn, TContext extends Record = Record, TName extends string = string, @@ -217,9 +242,9 @@ export function tool>( // Implementation export function tool( config: - | GeneratorToolConfig<$ZodObject<$ZodShape>, $ZodType, $ZodType> - | RegularToolConfig<$ZodObject<$ZodShape>, $ZodType, unknown> - | ManualToolConfig<$ZodObject<$ZodShape>> + | GeneratorToolConfig + | RegularToolConfig + | ManualToolConfig | ToolConfigWithSharedContext> ): Tool { // 'shared' is reserved for shared context — forbid it as a tool name @@ -231,11 +256,15 @@ export function tool( // Check for manual tool first (execute === false) if (config.execute === false) { - const fn: ManualTool<$ZodObject<$ZodShape>>["function"] = { + const fn: ManualTool["function"] = { name: config.name, inputSchema: config.inputSchema, }; + if (config.inputJsonSchema !== undefined) { + fn.inputJsonSchema = config.inputJsonSchema; + } + if (config.description !== undefined) { fn.description = config.description; } @@ -245,11 +274,11 @@ export function tool( } if (config.nextTurnParams !== undefined) { - fn.nextTurnParams = config.nextTurnParams; + fn.nextTurnParams = config.nextTurnParams as NextTurnParamsFunctions; } if (config.requireApproval !== undefined) { - fn.requireApproval = config.requireApproval; + fn.requireApproval = config.requireApproval as boolean | ToolApprovalCheck; } return { @@ -266,7 +295,11 @@ export function tool( eventSchema: config.eventSchema, outputSchema: config.outputSchema, execute: config.execute, - } as ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, $ZodType>["function"]; + } as ToolWithGenerator["function"]; + + if (config.inputJsonSchema !== undefined) { + fn.inputJsonSchema = config.inputJsonSchema; + } if (config.description !== undefined) { fn.description = config.description; @@ -277,11 +310,11 @@ export function tool( } if (config.nextTurnParams !== undefined) { - fn.nextTurnParams = config.nextTurnParams; + fn.nextTurnParams = config.nextTurnParams as NextTurnParamsFunctions; } if (config.requireApproval !== undefined) { - fn.requireApproval = config.requireApproval; + fn.requireApproval = config.requireApproval as boolean | ToolApprovalCheck; } return { @@ -296,6 +329,7 @@ export function tool( inputSchema: config.inputSchema, execute: config.execute, ...(config.description !== undefined && { description: config.description }), + ...(config.inputJsonSchema !== undefined && { inputJsonSchema: config.inputJsonSchema }), ...(config.outputSchema !== undefined && { outputSchema: config.outputSchema }), ...(config.contextSchema !== undefined && { contextSchema: config.contextSchema }), ...(config.nextTurnParams !== undefined && { nextTurnParams: config.nextTurnParams }), @@ -304,7 +338,7 @@ export function tool( return { type: ToolType.Function, - function: functionObj, + function: functionObj as ToolWithExecute["function"], }; } diff --git a/tests/unit/standard-schema-tools.test.ts b/tests/unit/standard-schema-tools.test.ts new file mode 100644 index 000000000..dfc32d72d --- /dev/null +++ b/tests/unit/standard-schema-tools.test.ts @@ -0,0 +1,337 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { toJsonSchema, toStandardJsonSchema } from '@valibot/to-json-schema'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import * as v from 'valibot'; +import { z } from 'zod/v4'; +import { + StandardSchemaError, + convertToolsToAPIFormat, + executeGeneratorTool, + executeRegularTool, + formatToolExecutionError, + validateToolInput, + validateToolOutput, +} from '../../src/lib/tool-executor.js'; +import { tool } from '../../src/lib/tool.js'; +import type { InferToolEvent, InferToolInput, InferToolOutput } from '../../src/lib/tool-types.js'; +import { assertNoTildeKeys } from '../utils/schema-test-helpers.js'; + +const inputSchema = v.object({ + name: v.string(), + count: v.number(), +}); +const outputSchema = v.object({ message: v.string() }); +const inputJsonSchema = toJsonSchema(inputSchema) as Record; + +const valibotTool = tool({ + name: 'valibot_tool' as const, + inputSchema, + inputJsonSchema, + outputSchema, + execute: ({ name, count }) => ({ message: name.repeat(count) }), +}); + +describe('Standard Schema tools', () => { + it('infers Standard Schema input and output types', () => { + expectTypeOf>().toEqualTypeOf<{ + name: string; + count: number; + }>(); + expectTypeOf(valibotTool.function.name).toEqualTypeOf<'valibot_tool'>(); + expectTypeOf>().toEqualTypeOf<{ + message: string; + }>(); + expectTypeOf(valibotTool.function.execute).parameter(0).toEqualTypeOf<{ + name: string; + count: number; + }>(); + }); + + it('keeps the Zod path working without an explicit JSON Schema', async () => { + const zodTool = tool({ + name: 'zod_tool', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ length: z.number() }), + execute: ({ value }) => ({ length: value.length }), + }); + + expect(convertToolsToAPIFormat([zodTool])[0]?.parameters).toMatchObject({ + type: 'object', + required: ['value'], + }); + expect(validateToolInput(zodTool.function.inputSchema, { value: 'test' })).toEqual({ + value: 'test', + }); + expect(validateToolOutput(zodTool.function.outputSchema, { length: 4 })).toEqual({ + length: 4, + }); + + const result = await executeRegularTool( + zodTool, + { id: 'zod-call', name: 'zod_tool', arguments: { value: 'test' } }, + { numberOfTurns: 1 }, + ); + expect(result).toMatchObject({ result: { length: 4 } }); + expect(result.error).toBeUndefined(); + }); + + it('validates input and output through Valibot', async () => { + const result = await executeRegularTool( + valibotTool, + { id: 'valid-call', name: 'valibot_tool', arguments: { name: 'hi', count: 2 } }, + { numberOfTurns: 1 }, + ); + + expect(result).toMatchObject({ result: { message: 'hihi' } }); + expect(result.error).toBeUndefined(); + + const invalidInput = await executeRegularTool( + valibotTool, + { id: 'invalid-input', name: 'valibot_tool', arguments: { name: 'hi', count: 'two' } }, + { numberOfTurns: 1 }, + ); + expect(invalidInput.error).toBeInstanceOf(StandardSchemaError); + + const invalidOutputTool = tool({ + name: 'invalid_output', + inputSchema: v.object({}), + inputJsonSchema: toJsonSchema(v.object({})) as Record, + outputSchema, + execute: () => ({ message: 42 } as never), + }); + const invalidOutput = await executeRegularTool( + invalidOutputTool, + { id: 'invalid-output', name: 'invalid_output', arguments: {} }, + { numberOfTurns: 1 }, + ); + expect(invalidOutput.error).toBeInstanceOf(StandardSchemaError); + }); + + it('uses the Standard JSON Schema trait without inputJsonSchema', () => { + const schema = toStandardJsonSchema(v.object({ query: v.string() })); + const apiTool = convertToolsToAPIFormat([ + tool({ + name: 'standard_json_schema', + inputSchema: schema, + execute: false, + }), + ])[0]; + + expect(apiTool?.parameters).toMatchObject({ + type: 'object', + required: ['query'], + }); + assertNoTildeKeys(apiTool?.parameters); + }); + + it('uses and sanitizes the explicit JSON Schema for providers', () => { + const apiTool = convertToolsToAPIFormat([ + tool({ + name: 'manual_standard_schema', + inputSchema, + inputJsonSchema: { + ...inputJsonSchema, + '~standard': { vendor: 'test' }, + properties: { + ...(inputJsonSchema['properties'] as Record), + hidden: { type: 'string', '~metadata': true }, + }, + }, + execute: false, + }), + ])[0]; + + expect(apiTool?.parameters).toMatchObject({ type: 'object' }); + assertNoTildeKeys(apiTool?.parameters); + }); + + it('uses inputJsonSchema when the Standard JSON Schema converter throws', () => { + const throwingSchema = { + '~standard': { + version: 1 as const, + vendor: 'throwing-test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => { + throw new Error('Cannot convert'); + }, + output: () => { + throw new Error('Cannot convert'); + }, + }, + }, + }; + + const apiTool = convertToolsToAPIFormat([ + tool({ + name: 'throwing_converter', + inputSchema: throwingSchema, + inputJsonSchema: { type: 'string', description: 'fallback' }, + execute: false, + }), + ])[0]; + + expect(apiTool?.parameters).toEqual({ type: 'string', description: 'fallback' }); + }); + + it('prefers inputJsonSchema over the Standard JSON Schema trait', () => { + const schema = toStandardJsonSchema(v.object({ query: v.string() })); + const apiTool = convertToolsToAPIFormat([ + tool({ + name: 'explicit_override', + inputSchema: schema, + inputJsonSchema: { type: 'integer', description: 'override' }, + execute: false, + }), + ])[0]; + + expect(apiTool?.parameters).toEqual({ type: 'integer', description: 'override' }); + }); + + it('prefers inputJsonSchema over the Zod fast path', () => { + const apiTool = convertToolsToAPIFormat([ + tool({ + name: 'zod_explicit_override', + inputSchema: z.object({ query: z.string() }), + inputJsonSchema: { type: 'object', description: 'explicit override' }, + execute: false, + }), + ])[0]; + + expect(apiTool?.parameters).toEqual({ type: 'object', description: 'explicit override' }); + }); + + it('handles symbol path segments without crashing error formatting', async () => { + const symbolSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'symbol-test', + validate: () => ({ + issues: [{ message: 'bad symbol', path: [{ key: Symbol('nested') }] }], + }), + }, + }; + + const error = await validateToolInput(symbolSchema, 'x').catch((caught) => caught); + expect(error).toBeInstanceOf(StandardSchemaError); + expect((error as StandardSchemaError).issues).toEqual([ + { message: 'bad symbol', path: ['Symbol(nested)'] }, + ]); + expect((error as Error).message).toBe( + '[{"message":"bad symbol","path":["Symbol(nested)"]}]', + ); + expect( + formatToolExecutionError(error as Error, { + id: 'symbol-call', + name: 'symbol_tool', + arguments: 'x', + }), + ).toContain('Symbol(nested)'); + }); + + it('requires inputJsonSchema for plain Standard Schemas in the tool() overload', () => { + tool<{ sessionId?: string }>({ + name: 'shared_plain_standard', + // @ts-expect-error plain Standard Schema without the JSON Schema trait must provide inputJsonSchema + inputSchema, + execute: () => ({ ok: true }), + }); + + tool<{ sessionId?: string }>({ + name: 'shared_zod', + inputSchema: z.object({ q: z.string() }), + execute: () => ({ ok: true }), + }); + + tool<{ sessionId?: string }>({ + name: 'shared_trait', + inputSchema: toStandardJsonSchema(v.object({ q: v.string() })), + execute: () => ({ ok: true }), + }); + }); + + it('requires Standard JSON Schema or explicit JSON Schema at runtime', () => { + const manuallyConstructedTool = { + type: 'function' as const, + function: { + name: 'missing_json_schema', + inputSchema, + }, + }; + + expect(() => convertToolsToAPIFormat([manuallyConstructedTool])).toThrow( + 'must implement StandardJSONSchemaV1 or provide inputJsonSchema', + ); + }); + + it('awaits asynchronous Standard Schema validators and maps their issues', async () => { + const asyncSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'async-test', + validate: async (value) => { + await Promise.resolve(); + return value === 'valid' + ? { value } + : { issues: [{ message: 'Expected valid', path: [{ key: 'value' }] }] }; + }, + }, + }; + + await expect(validateToolInput(asyncSchema, 'valid')).resolves.toBe('valid'); + const error = await validateToolInput(asyncSchema, 'invalid').catch((caught) => caught); + expect(error).toBeInstanceOf(StandardSchemaError); + expect((error as StandardSchemaError).issues).toEqual([ + { message: 'Expected valid', path: ['value'] }, + ]); + expect((error as Error).message).toBe( + '[{"message":"Expected valid","path":["value"]}]', + ); + expect(formatToolExecutionError(error as Error, { + id: 'async-call', + name: 'async_tool', + arguments: 'invalid', + })).toContain('"path": "value"'); + }); + + it('rejects validators that do not declare Standard Schema v1', async () => { + const invalidVersionSchema = { + '~standard': { + version: 2, + vendor: 'future-test', + validate: (value: unknown) => ({ value }), + }, + } as unknown as StandardSchemaV1; + + expect(() => validateToolInput(invalidVersionSchema, 'value')).toThrow( + 'Invalid tool schema provided', + ); + }); + + it('validates generator events and output with Standard Schema', async () => { + const eventSchema = v.object({ progress: v.number() }); + const finalSchema = v.object({ result: v.string() }); + const generatorTool = tool({ + name: 'standard_generator', + inputSchema: v.object({ query: v.string() }), + inputJsonSchema: toJsonSchema(v.object({ query: v.string() })) as Record, + eventSchema, + outputSchema: finalSchema, + execute: async function* ({ query }) { + yield { progress: 50 }; + yield { result: query }; + }, + }); + + expectTypeOf>().toEqualTypeOf<{ progress: number }>(); + + const result = await executeGeneratorTool( + generatorTool, + { id: 'generator-call', name: 'standard_generator', arguments: { query: 'done' } }, + { numberOfTurns: 1 }, + ); + expect(result.result).toEqual({ result: 'done' }); + expect(result.preliminaryResults).toEqual([{ progress: 50 }]); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/tests/unit/tool-types.test-d.ts b/tests/unit/tool-types.test-d.ts new file mode 100644 index 000000000..a81705b3f --- /dev/null +++ b/tests/unit/tool-types.test-d.ts @@ -0,0 +1,100 @@ +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { describe, expectTypeOf, it } from 'vitest'; +import * as v from 'valibot'; +import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.js'; +import type { + InferToolEvent, + InferToolInput, + InferToolOutput, + ToolExecutionResult, +} from '../../src/lib/tool-types.js'; + +const regularZodTool = tool({ + name: 'regular_zod', + inputSchema: z.object({ q: z.string() }), + outputSchema: z.object({ length: z.number() }), + execute: ({ q }) => ({ length: q.length }), +}); + +const valibotTool = tool({ + name: 'valibot_tool', + inputSchema: v.object({ name: v.string(), count: v.number() }), + inputJsonSchema: { type: 'object' }, + outputSchema: v.object({ message: v.string() }), + execute: ({ name, count }) => ({ message: name.repeat(count) }), +}); + +const generatorTool = tool({ + name: 'generator_tool', + inputSchema: v.object({ query: v.string() }), + inputJsonSchema: { type: 'object' }, + eventSchema: v.object({ progress: v.number() }), + outputSchema: v.object({ result: v.string() }), + execute: async function* ({ query }) { + yield { progress: 50 }; + return { result: query }; + }, +}); + +describe('tool type inference', () => { + it('infers Zod tool types', () => { + expectTypeOf>().toEqualTypeOf<{ q: string }>(); + expectTypeOf>().toEqualTypeOf<{ length: number }>(); + expectTypeOf['result']>().toEqualTypeOf<{ + length: number; + }>(); + }); + + it('infers Standard Schema tool types', () => { + expectTypeOf>().toEqualTypeOf<{ + name: string; + count: number; + }>(); + expectTypeOf>().toEqualTypeOf<{ message: string }>(); + expectTypeOf['result']>().toEqualTypeOf<{ + message: string; + }>(); + }); + + it('infers generator tool event and output types', () => { + expectTypeOf>().toEqualTypeOf<{ progress: number }>(); + expectTypeOf>().toEqualTypeOf<{ result: string }>(); + }); + + it('keeps the Standard JSON Schema trait optional for inputJsonSchema', () => { + tool({ + name: 'trait_no_explicit', + inputSchema: toStandardJsonSchema(v.object({ q: v.string() })), + execute: () => ({ ok: true }), + }); + + tool<{ sessionId?: string }>({ + name: 'shared_trait', + inputSchema: toStandardJsonSchema(v.object({ q: v.string() })), + execute: () => ({ ok: true }), + }); + + tool<{ sessionId?: string }>({ + name: 'shared_zod', + inputSchema: z.object({ q: z.string() }), + execute: () => ({ ok: true }), + }); + }); + + it('requires inputJsonSchema for plain Standard Schemas', () => { + tool({ + name: 'plain_standard', + // @ts-expect-error no JSON Schema trait and no explicit inputJsonSchema + inputSchema: v.object({ q: v.string() }), + execute: () => ({ ok: true }), + }); + + tool<{ sessionId?: string }>({ + name: 'shared_plain_standard', + // @ts-expect-error no JSON Schema trait and no explicit inputJsonSchema + inputSchema: v.object({ q: v.string() }), + execute: () => ({ ok: true }), + }); + }); +});