@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
314 lines (313 loc) • 12.8 kB
JavaScript
//#region src/activities/chat/tools/schema-converter.ts
/**
* Build a JSONSchema object from any plain key/value source. The `JSONSchema`
* interface's `[key: string]: any` index signature makes every property
* assignable through bracket access without a type cast — copying keys here
* lets us narrow either `Record<string, unknown>` (returned by
* `~standard.jsonSchema.input()`) or a `JSONSchema` (from the SchemaInput
* pass-through arm) into the typed view used by the rest of this module.
*
* Accepts `object` so callers don't need a cast when narrowing from union
* types like `SchemaInput`.
*/
function toJsonSchema(obj) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
if (key === "$schema") continue;
result[key] = value;
}
return result;
}
/**
* Whether a value can carry a `~standard` property. Most schema libraries
* (Zod, Valibot) return plain objects, but ArkType's `type()` returns a
* *callable function* with `~standard` attached — so `typeof` must accept
* both `'object'` and `'function'` or ArkType schemas are missed entirely
* (issue #276).
*/
function isPropertyCarrier(schema) {
return (typeof schema === "object" || typeof schema === "function") && schema !== null;
}
/**
* 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.
*/
function isStandardJSONSchema(schema) {
if (!isPropertyCarrier(schema) || !("~standard" in schema)) return false;
const standard = schema["~standard"];
if (typeof standard !== "object" || standard === null || !("version" in standard) || standard.version !== 1 || !("jsonSchema" in standard) || typeof standard.jsonSchema !== "object" || standard.jsonSchema === null || !("input" in standard.jsonSchema)) return false;
return typeof 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.
*/
function isStandardSchema(schema) {
return isPropertyCarrier(schema) && "~standard" in schema && typeof schema["~standard"] === "object" && schema["~standard"] !== null && "version" in schema["~standard"] && schema["~standard"].version === 1 && "validate" in schema["~standard"] && typeof schema["~standard"].validate === "function";
}
/** Drop an empty map to `undefined` so leaf/no-op subtrees don't litter it. */
function pruneMap(map) {
return Object.keys(map).length > 0 ? map : void 0;
}
/**
* 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
*
* Alongside the transformed schema it returns a {@link NullWideningMap} marking
* exactly the positions where `null` was added, so `undoNullWidening` can strip
* those synthesized nulls (and only those) from the provider's response.
*
* @param schema - JSON schema to transform
* @param originalRequired - Original required array (to know which fields were optional)
* @returns Transformed schema + the null-widening map for the round trip
*/
function makeStructuredOutputCompatible(schema, originalRequired = []) {
const result = { ...schema };
const map = {};
if (result.type === "object" && result.properties) {
const properties = { ...result.properties };
const allPropertyNames = Object.keys(properties);
const propertyMaps = {};
for (const propName of allPropertyNames) {
const prop = properties[propName];
if (!prop) continue;
const wasOptional = !originalRequired.includes(propName);
let widenedHere = false;
let childMap;
if (prop.type === "object" && prop.properties) {
const nested = makeStructuredOutputCompatible(prop, prop.required || []);
properties[propName] = wasOptional ? {
...nested.schema,
type: ["object", "null"]
} : nested.schema;
widenedHere = wasOptional;
childMap = nested.nullWidening;
} else if (prop.type === "array" && prop.items) {
const items = Array.isArray(prop.items) ? prop.items[0] : prop.items;
const nestedItems = items ? makeStructuredOutputCompatible(items, items.required || []) : void 0;
properties[propName] = {
...prop,
items: nestedItems ? nestedItems.schema : prop.items,
...wasOptional ? { type: ["array", "null"] } : {}
};
widenedHere = wasOptional;
childMap = nestedItems?.nullWidening ? { items: nestedItems.nullWidening } : void 0;
} else if (wasOptional) {
if (prop.type && !Array.isArray(prop.type)) {
properties[propName] = {
...prop,
type: [prop.type, "null"]
};
widenedHere = true;
} else if (Array.isArray(prop.type) && !prop.type.includes("null")) {
properties[propName] = {
...prop,
type: [...prop.type, "null"]
};
widenedHere = true;
}
}
if (widenedHere || childMap) propertyMaps[propName] = {
...childMap ?? {},
...widenedHere ? { widened: true } : {}
};
}
result.properties = properties;
result.required = allPropertyNames;
result.additionalProperties = false;
if (Object.keys(propertyMaps).length > 0) map.properties = propertyMaps;
}
if (result.type === "array" && result.items) {
const items = Array.isArray(result.items) ? result.items[0] : result.items;
if (items) {
const nestedItems = makeStructuredOutputCompatible(items, items.required || []);
result.items = nestedItems.schema;
if (nestedItems.nullWidening) map.items = nestedItems.nullWidening;
}
}
return {
schema: result,
nullWidening: pruneMap(map)
};
}
/**
* Normalize any supported schema input to a typed, UN-widened `JSONSchema` —
* the shared first half of conversion, before any structured-output widening.
*
* - Standard JSON Schemas are rebuilt structurally (dropping `$schema`, which
* LLM providers ignore) and given the explicit `type`/`properties`/`required`
* defaults object shapes need downstream.
* - Plain `JSONSchema` inputs are rebuilt into the typed view; non-object inputs
* are surfaced untouched (they can't be widened).
* - Standard Schema validators lacking a `~standard.jsonSchema` converter throw
* with actionable guidance, rather than shipping `{ '~standard': … }` to the
* provider and producing an opaque downstream error.
*/
function toTypedJsonSchema(schema) {
if (isStandardJSONSchema(schema)) {
const result = toJsonSchema(schema["~standard"].jsonSchema.input({ target: "draft-07" }));
if ("properties" in result && !result.type) result.type = "object";
if (result.type === "object" && !("properties" in result)) result.properties = {};
if (result.type === "object" && !("required" in result)) result.required = [];
return result;
}
if (isStandardSchema(schema)) throw new Error("Schema is a Standard Schema validator but does not expose a JSON Schema converter on `~standard.jsonSchema`. Use Zod v4.2+, ArkType v2.1.28+, or wrap a Valibot schema with `toStandardJsonSchema()` from `@valibot/to-json-schema` before passing it as `outputSchema`.");
if (typeof schema !== "object") return schema;
return toJsonSchema(schema);
}
/**
* 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);
* ```
*/
function convertSchemaToJsonSchema(schema, options = {}) {
if (!schema) return void 0;
const { forStructuredOutput = false } = options;
if (!forStructuredOutput && !isStandardJSONSchema(schema) && !isStandardSchema(schema)) return schema;
const base = toTypedJsonSchema(schema);
if (!base || typeof base !== "object") return base;
if (!forStructuredOutput) return base;
return makeStructuredOutputCompatible(base, base.required || []).schema;
}
/**
* Convert a schema for structured output AND capture the {@link NullWideningMap}
* recording every `null` the strict-mode widening synthesized. The map lets the
* caller undo that widening on the provider's response (via `undoNullWidening`)
* before validating against the original schema — optional fields read back as
* absent while genuine `.nullable()` nulls survive. The map is `undefined` when
* the schema isn't a widenable object or when no field needed widening.
*/
function convertSchemaForStructuredOutput(schema) {
if (!schema) return {
jsonSchema: void 0,
nullWideningMap: void 0
};
const base = toTypedJsonSchema(schema);
if (!base || typeof base !== "object") return {
jsonSchema: base,
nullWideningMap: void 0
};
const { schema: jsonSchema, nullWidening } = makeStructuredOutputCompatible(base, base.required || []);
return {
jsonSchema,
nullWideningMap: nullWidening
};
}
/**
* 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
*/
async function validateWithStandardSchema(schema, data) {
if (!isStandardSchema(schema)) return {
success: true,
data
};
const result = await schema["~standard"].validate(data);
if (!result.issues) return {
success: true,
data: result.value
};
return {
success: false,
issues: result.issues.map((issue) => ({
message: issue.message || "Validation failed",
path: issue.path?.map(String)
}))
};
}
/**
* Error thrown when Standard Schema validation fails. Carries the original
* `issues` array so consumers (middleware `onError`, callers catching from
* `chat({ outputSchema })`) can programmatically inspect each failure.
*/
var StandardSchemaValidationError = class extends Error {
name = "StandardSchemaValidationError";
issues;
constructor(issues) {
super(`Validation failed: ${issues.map((i) => i.message || "Validation failed").join(", ")}`);
this.issues = issues;
}
};
/**
* 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 StandardSchemaValidationError if validation fails; Error if the
* schema only supports async validation.
*/
function parseWithStandardSchema(schema, data) {
if (!isStandardSchema(schema)) return data;
const result = schema["~standard"].validate(data);
if (result instanceof Promise) throw new Error("Schema validation returned a Promise. Use validateWithStandardSchema for async validation.");
if (!result.issues) return result.value;
throw new StandardSchemaValidationError(result.issues);
}
//#endregion
export { StandardSchemaValidationError, convertSchemaForStructuredOutput, convertSchemaToJsonSchema, isStandardJSONSchema, isStandardSchema, parseWithStandardSchema, validateWithStandardSchema };
//# sourceMappingURL=schema-converter.js.map