@tanstack/ai
Version:
Core TanStack AI library - Open source AI SDK
333 lines (299 loc) • 10.6 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import type {
StandardJSONSchemaV1,
StandardSchemaV1,
} from '@standard-schema/spec'
import type { JSONSchema, SchemaInput } from '../../../types'
/**
* Check if a value is a Standard JSON Schema compliant schema.
* Standard JSON Schema compliant libraries (Zod v4+, ArkType, Valibot with toStandardJsonSchema, etc.)
* implement the '~standard' property with jsonSchema converter methods.
*/
export function isStandardJSONSchema(
schema: unknown,
): schema is StandardJSONSchemaV1 {
return (
typeof schema === 'object' &&
schema !== null &&
'~standard' in schema &&
typeof (schema as StandardJSONSchemaV1)['~standard'] === 'object' &&
(schema as StandardJSONSchemaV1)['~standard'].version === 1 &&
typeof (schema as StandardJSONSchemaV1)['~standard'].jsonSchema ===
'object' &&
typeof (schema as StandardJSONSchemaV1)['~standard'].jsonSchema.input ===
'function'
)
}
/**
* Check if a value is a Standard Schema compliant schema (for validation).
* Standard Schema compliant libraries implement the '~standard' property with a validate function.
*/
export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
return (
typeof schema === 'object' &&
schema !== null &&
'~standard' in schema &&
typeof schema['~standard'] === 'object' &&
schema !== null &&
schema['~standard'] !== null &&
'version' in schema['~standard'] &&
schema['~standard'].version === 1 &&
'validate' in schema['~standard'] &&
typeof schema['~standard'].validate === 'function'
)
}
/**
* Transform a JSON schema to be compatible with OpenAI's structured output requirements.
* OpenAI requires:
* - All properties must be in the `required` array
* - Optional fields should have null added to their type union
* - additionalProperties must be false for objects
*
* @param schema - JSON schema to transform
* @param originalRequired - Original required array (to know which fields were optional)
* @returns Transformed schema compatible with OpenAI structured output
*/
function makeStructuredOutputCompatible(
schema: Record<string, any>,
originalRequired: Array<string> = [],
): Record<string, any> {
const result = { ...schema }
// Handle object types
if (result.type === 'object' && result.properties) {
const properties = { ...result.properties }
const allPropertyNames = Object.keys(properties)
// Transform each property
for (const propName of allPropertyNames) {
const prop = properties[propName]
const wasOptional = !originalRequired.includes(propName)
// Recursively transform nested objects/arrays
if (prop.type === 'object' && prop.properties) {
properties[propName] = makeStructuredOutputCompatible(
prop,
prop.required || [],
)
} else if (prop.type === 'array' && prop.items) {
properties[propName] = {
...prop,
items: makeStructuredOutputCompatible(
prop.items,
prop.items.required || [],
),
}
} else if (wasOptional) {
// Make optional fields nullable by adding null to the type
if (prop.type && !Array.isArray(prop.type)) {
properties[propName] = {
...prop,
type: [prop.type, 'null'],
}
} else if (Array.isArray(prop.type) && !prop.type.includes('null')) {
properties[propName] = {
...prop,
type: [...prop.type, 'null'],
}
}
}
}
result.properties = properties
// ALL properties must be required for OpenAI structured output
result.required = allPropertyNames
// additionalProperties must be false
result.additionalProperties = false
}
// Handle array types with object items
if (result.type === 'array' && result.items) {
result.items = makeStructuredOutputCompatible(
result.items,
result.items.required || [],
)
}
return result
}
/**
* Options for schema conversion
*/
export interface ConvertSchemaOptions {
/**
* When true, transforms the schema to be compatible with OpenAI's structured output requirements:
* - All properties are added to the `required` array
* - Optional fields get null added to their type union
* - additionalProperties is set to false for all objects
*
* @default false
*/
forStructuredOutput?: boolean
}
/**
* Converts a Standard JSON Schema compliant schema or plain JSONSchema to JSON Schema format
* compatible with LLM providers.
*
* Supports any schema library that implements the Standard JSON Schema spec (v1):
* - Zod v4+ (natively supports StandardJSONSchemaV1)
* - ArkType (natively supports StandardJSONSchemaV1)
* - Valibot (via `toStandardJsonSchema()` from `@valibot/to-json-schema`)
*
* If the input is already a plain JSONSchema object, it is returned as-is.
*
* @param schema - Standard JSON Schema compliant schema or plain JSONSchema object to convert
* @param options - Conversion options
* @returns JSON Schema object that can be sent to LLM providers
*
* @example
* ```typescript
* // Using Zod v4+ (natively supports Standard JSON Schema)
* import * as z from 'zod';
*
* const zodSchema = z.object({
* location: z.string().describe('City name'),
* unit: z.enum(['celsius', 'fahrenheit']).optional()
* });
*
* const jsonSchema = convertSchemaToJsonSchema(zodSchema);
*
* @example
* // Using ArkType (natively supports Standard JSON Schema)
* import { type } from 'arktype';
*
* const arkSchema = type({
* location: 'string',
* unit: "'celsius' | 'fahrenheit'"
* });
*
* const jsonSchema = convertSchemaToJsonSchema(arkSchema);
*
* @example
* // Using Valibot (via toStandardJsonSchema)
* import * as v from 'valibot';
* import { toStandardJsonSchema } from '@valibot/to-json-schema';
*
* const valibotSchema = toStandardJsonSchema(v.object({
* location: v.string(),
* unit: v.optional(v.picklist(['celsius', 'fahrenheit']))
* }));
*
* const jsonSchema = convertSchemaToJsonSchema(valibotSchema);
*
* @example
* // Using JSONSchema directly (passes through unchanged)
* const rawSchema = {
* type: 'object',
* properties: { location: { type: 'string' } },
* required: ['location']
* };
* const result = convertSchemaToJsonSchema(rawSchema);
* ```
*/
export function convertSchemaToJsonSchema(
schema: SchemaInput | undefined,
options: ConvertSchemaOptions = {},
): JSONSchema | undefined {
if (!schema) return undefined
const { forStructuredOutput = false } = options
// If it's a Standard JSON Schema compliant schema, use the standard interface
if (isStandardJSONSchema(schema)) {
const jsonSchema = schema['~standard'].jsonSchema.input({
target: 'draft-07',
})
let result = jsonSchema
if (typeof result === 'object' && '$schema' in result) {
// Remove $schema property as it's not needed for LLM providers
const { $schema, ...rest } = result
result = rest
}
// Ensure object schemas always have type: "object"
if (typeof result === 'object') {
// If it has properties (even empty), it should be an object type
if ('properties' in result && !result.type) {
result.type = 'object'
}
// Ensure properties exists for object types (even if empty)
if (result.type === 'object' && !('properties' in result)) {
result.properties = {}
}
// Ensure required exists for object types (even if empty array)
if (result.type === 'object' && !('required' in result)) {
result.required = []
}
// Apply structured output transformation if requested
if (forStructuredOutput) {
result = makeStructuredOutputCompatible(
result,
(result.required as Array<string>) || [],
)
}
}
return result as JSONSchema
}
// If it's not a Standard JSON Schema, assume it's already a JSONSchema and pass through
// Still apply structured output transformation if requested
if (forStructuredOutput && typeof schema === 'object') {
return makeStructuredOutputCompatible(
schema as Record<string, any>,
((schema as JSONSchema).required as Array<string>) || [],
) as JSONSchema
}
return schema as JSONSchema
}
/**
* Validates data against a Standard Schema compliant schema.
*
* @param schema - Standard Schema compliant schema
* @param data - Data to validate
* @returns Validation result with success status, data or issues
*/
export async function validateWithStandardSchema<T>(
schema: unknown,
data: unknown,
): Promise<
| { success: true; data: T }
| { success: false; issues: Array<{ message: string; path?: Array<string> }> }
> {
if (!isStandardSchema(schema)) {
// If it's not a Standard Schema, just return the data as-is
return { success: true, data: data as T }
}
const result = await schema['~standard'].validate(data)
if (!result.issues) {
return { success: true, data: result.value as T }
}
return {
success: false,
issues: result.issues.map((issue) => ({
message: issue.message || 'Validation failed',
path: issue.path?.map(String),
})),
}
}
/**
* Synchronously validates data against a Standard Schema compliant schema.
* Note: Some Standard Schema implementations may only support async validation.
* In those cases, this function will throw.
*
* @param schema - Standard Schema compliant schema
* @param data - Data to validate
* @returns Parsed/validated data
* @throws Error if validation fails or if the schema only supports async validation
*/
export function parseWithStandardSchema<T>(schema: unknown, data: unknown): T {
if (!isStandardSchema(schema)) {
// If it's not a Standard Schema, just return the data as-is
return data as T
}
const result = schema['~standard'].validate(data)
// Handle async result (Promise)
if (result instanceof Promise) {
throw new Error(
'Schema validation returned a Promise. Use validateWithStandardSchema for async validation.',
)
}
// Standard Schema validation returns { value } for success or { issues } for failure
if (!result.issues) {
return result.value as T
}
// invalid validation, throw error with all issues
const errorMessages = result.issues
.map((issue) => issue.message || 'Validation failed')
.join(', ')
throw new Error(`Validation failed: ${errorMessages}`)
}