Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 23 additions & 0 deletions .changeset/schema-compat-optional-default-null.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@mastra/schema-compat': patch
---

Fixed tool-call and structured-output validation failing with "expected <type>, received null" when a tool or output schema used `.default()` or `.optional()` fields and the model left them out.

In strict structured-output / tool-calling mode, providers return `null` for a non-required field instead of omitting it. The AI SDK schema produced by `processToAISDKSchema` was built from Zod's *output* projection, where `.default()` fields are required, and only the OpenAI compat layer normalized the returned `null` back to `undefined`. As a result `.default()` fields broke on every provider, and `.optional()` fields broke on Google, Anthropic, DeepSeek, and Meta.

The schema is now built from the *input* projection (so defaulted fields are optional, matching what the model is asked to produce), and a shared `convertOptionalNullsToUndefined` helper normalizes `null` → `undefined` for optional/defaulted fields across all providers, while preserving explicit `null` for `.nullable()` fields.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
```ts
// Before: this tool failed when the model omitted `limit`
const search = createTool({
id: 'search',
inputSchema: z.object({
query: z.string(),
limit: z.number().default(10), // model returns null → "expected number, received null"
}),
// ...
});

// After: `limit` correctly falls back to 10
```
2 changes: 1 addition & 1 deletion packages/schema-compat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export { SchemaCompatLayer } from './schema-compatibility';

// Utility functions
export { convertZodSchemaToAISDKSchema, applyCompatLayer, convertSchemaToZod, isZodType } from './utils';
export { wrapSchemaWithNullTransform } from './null-to-undefined';
export { wrapSchemaWithNullTransform, convertOptionalNullsToUndefined } from './null-to-undefined';
export { ensureAllPropertiesRequired, prepareJsonSchemaForOpenAIStrictMode } from './zod-to-json';

// Standard Schema compatibility utilities
Expand Down
81 changes: 81 additions & 0 deletions packages/schema-compat/src/null-to-undefined.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,87 @@ export function transformNullToUndefined(value: unknown, jsonSchema: Record<stri
return result;
}

/**
* Resolve a nullable wrapper (`anyOf: [<schema>, { type: 'null' }]`) down to its
* non-null variant, matching the resolution the provider `#traverse` helpers do.
*/
function resolveNullableVariant(schema: Record<string, unknown>): Record<string, unknown> {
if (Array.isArray(schema.anyOf)) {
const nonNull = (schema.anyOf as unknown[]).find(
(s): s is Record<string, unknown> =>
!!s && typeof s === 'object' && (s as Record<string, unknown>).type !== 'null',
);
if (nonNull) {
return nonNull;
}
}
return schema;
}

/**
* Recursively convert `null` → `undefined` for the optional fields of a
* processed schema.
*
* Providers return `null` for an "absent" optional/defaulted field, but the
* different compat layers express optionality two ways:
*
* - **Standard JSON Schema** (Anthropic, Google, DeepSeek, Meta): the field is
* simply omitted from the object's `required` array.
* - **OpenAI strict mode**: every property must appear in `required`, so the
* originally optional / defaulted fields are instead tracked in `x-optional`.
*
* A field is therefore treated as optional when it is listed in `x-optional`
* **or** not present in `required`. Genuinely required fields — including
* `.nullable()` ones, which stay in `required` — keep their `null` so explicit
* null values survive. Converting optional nulls back to `undefined` lets the
* original Zod schema apply `.optional()` / `.default()` semantics when the
* tool-call / structured-output result is validated; without it the result
* fails with "expected <type>, received null".
*
* Mutates and returns `value` (matching the provider `#traverse` convention).
*/
export function convertOptionalNullsToUndefined(value: unknown, jsonSchema: Record<string, unknown>): unknown {
if (value === null || value === undefined || typeof jsonSchema !== 'object' || jsonSchema === null) {
return value;
}

const resolved = resolveNullableVariant(jsonSchema);

const isArrayType =
resolved.type === 'array' || (Array.isArray(resolved.type) && (resolved.type as string[]).includes('array'));
if (isArrayType) {
const items = resolved.items;
if (Array.isArray(value) && items && typeof items === 'object') {
const itemSchema = items as Record<string, unknown>;
for (let i = 0; i < value.length; i++) {
value[i] = convertOptionalNullsToUndefined(value[i], itemSchema);
}
}
return value;
}

const isObjectType =
resolved.type === 'object' || (Array.isArray(resolved.type) && (resolved.type as string[]).includes('object'));
if (!isObjectType || typeof value !== 'object' || Array.isArray(value)) {
return value;
}

const properties = resolved.properties as Record<string, Record<string, unknown>> | undefined;
const requiredKeys = new Set((resolved.required as string[] | undefined) ?? []);
const optionalKeys = new Set((resolved['x-optional'] as string[] | undefined) ?? []);
const isOptional = (key: string) => optionalKeys.has(key) || !requiredKeys.has(key);
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (obj[key] === null && isOptional(key)) {
obj[key] = undefined;
} else if (properties?.[key]) {
obj[key] = convertOptionalNullsToUndefined(obj[key], properties[key]);
}
}

return obj;
}

/**
* Wraps a StandardSchemaWithJSON to transform null values to undefined for
* optional fields before validation. This is a schema-agnostic solution for
Expand Down
12 changes: 10 additions & 2 deletions packages/schema-compat/src/provider-compats/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isStringSchema,
isUnionSchema,
} from '../json-schema/utils';
import { convertOptionalNullsToUndefined } from '../null-to-undefined';
import { SchemaCompatLayer } from '../schema-compatibility';
import type { PublicSchema, ZodType } from '../schema.types';
import { standardSchemaToJSONSchema, toStandardSchema } from '../standard-schema/standard-schema';
Expand Down Expand Up @@ -67,11 +68,18 @@ export class AnthropicSchemaCompatLayer extends SchemaCompatLayer {

processToAISDKSchema(zodSchema: ZodTypeV3 | ZodTypeV4) {
const compat = this.processToCompatSchema(zodSchema);
const transformedJsonSchema = standardSchemaToJSONSchema(compat);
// Use the 'input' projection so fields with `.default()` are optional.
const transformedJsonSchema = standardSchemaToJSONSchema(compat, { io: 'input' });

return jsonSchema(transformedJsonSchema, {
validate: (value: unknown) => {
const transformed = this.#traverse(value, transformedJsonSchema as Record<string, unknown>);
const dateNormalized = this.#traverse(value, transformedJsonSchema as Record<string, unknown>);
// Strict-mode schemas return `null` for absent optional/defaulted fields;
// convert those back to `undefined` so Zod can apply optional/default semantics.
const transformed = convertOptionalNullsToUndefined(
dateNormalized,
transformedJsonSchema as Record<string, unknown>,
);
const result = zodSchema.safeParse(transformed);
return result.success ? { success: true, value: result.data } : { success: false, error: result.error };
},
Expand Down
12 changes: 10 additions & 2 deletions packages/schema-compat/src/provider-compats/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ZodType as ZodTypeV4 } from 'zod/v4';
import type { Targets } from 'zod-to-json-schema';
import { jsonSchema } from '../json-schema';
import { isAllOfSchema, isArraySchema, isObjectSchema, isStringSchema, isUnionSchema } from '../json-schema/utils';
import { convertOptionalNullsToUndefined } from '../null-to-undefined';
import { SchemaCompatLayer } from '../schema-compatibility';
import type { PublicSchema } from '../schema.types';
import { standardSchemaToJSONSchema, toStandardSchema } from '../standard-schema/standard-schema';
Expand Down Expand Up @@ -48,11 +49,18 @@ export class DeepSeekSchemaCompatLayer extends SchemaCompatLayer {

processToAISDKSchema(zodSchema: ZodTypeV3 | ZodTypeV4) {
const compat = this.processToCompatSchema(zodSchema);
const transformedJsonSchema = standardSchemaToJSONSchema(compat);
// Use the 'input' projection so fields with `.default()` are optional.
const transformedJsonSchema = standardSchemaToJSONSchema(compat, { io: 'input' });

return jsonSchema(transformedJsonSchema, {
validate: (value: unknown) => {
const transformed = this.#traverse(value, transformedJsonSchema as Record<string, unknown>);
const dateNormalized = this.#traverse(value, transformedJsonSchema as Record<string, unknown>);
// Strict-mode schemas return `null` for absent optional/defaulted fields;
// convert those back to `undefined` so Zod can apply optional/default semantics.
const transformed = convertOptionalNullsToUndefined(
dateNormalized,
transformedJsonSchema as Record<string, unknown>,
);
const result = zodSchema.safeParse(transformed);
return result.success ? { success: true, value: result.data } : { success: false, error: result.error };
},
Expand Down
9 changes: 7 additions & 2 deletions packages/schema-compat/src/provider-compats/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isStringSchema,
isUnionSchema,
} from '../json-schema/utils';
import { convertOptionalNullsToUndefined } from '../null-to-undefined';
import { SchemaCompatLayer } from '../schema-compatibility';
import type { PublicSchema } from '../schema.types';
import { standardSchemaToJSONSchema, toStandardSchema } from '../standard-schema/standard-schema';
Expand Down Expand Up @@ -244,12 +245,16 @@ export class GoogleSchemaCompatLayer extends SchemaCompatLayer {

processToAISDKSchema(zodSchema: ZodTypeV3 | ZodTypeV4): Schema {
const compat = this.processToCompatSchema(zodSchema);
const transformedJsonSchema = standardSchemaToJSONSchema(compat);
// Use the 'input' projection so fields with `.default()` are optional.
const transformedJsonSchema = standardSchemaToJSONSchema(compat, { io: 'input' });
const fixedJsonSchema = fixAISDKNullableUnionTypes(transformedJsonSchema as Record<string, any>) as JSONSchema7;

return jsonSchema(fixedJsonSchema, {
validate: (value: unknown) => {
const transformed = this.#traverse(value, fixedJsonSchema as Record<string, unknown>);
const dateNormalized = this.#traverse(value, fixedJsonSchema as Record<string, unknown>);
// Strict-mode schemas return `null` for absent optional/defaulted fields;
// convert those back to `undefined` so Zod can apply optional/default semantics.
const transformed = convertOptionalNullsToUndefined(dateNormalized, fixedJsonSchema as Record<string, unknown>);
const result = zodSchema.safeParse(transformed);
return result.success ? { success: true, value: result.data } : { success: false, error: result.error };
},
Expand Down
12 changes: 10 additions & 2 deletions packages/schema-compat/src/provider-compats/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ZodType as ZodTypeV4 } from 'zod/v4';
import type { Targets } from 'zod-to-json-schema';
import { jsonSchema } from '../json-schema';
import { isAllOfSchema, isArraySchema, isObjectSchema, isStringSchema, isUnionSchema } from '../json-schema/utils';
import { convertOptionalNullsToUndefined } from '../null-to-undefined';
import { SchemaCompatLayer } from '../schema-compatibility';
import type { PublicSchema } from '../schema.types';
import { standardSchemaToJSONSchema, toStandardSchema } from '../standard-schema/standard-schema';
Expand Down Expand Up @@ -49,11 +50,18 @@ export class MetaSchemaCompatLayer extends SchemaCompatLayer {

processToAISDKSchema(zodSchema: ZodTypeV3 | ZodTypeV4) {
const compat = this.processToCompatSchema(zodSchema);
const transformedJsonSchema = standardSchemaToJSONSchema(compat);
// Use the 'input' projection so fields with `.default()` are optional.
const transformedJsonSchema = standardSchemaToJSONSchema(compat, { io: 'input' });

return jsonSchema(transformedJsonSchema, {
validate: (value: unknown) => {
const transformed = this.#traverse(value, transformedJsonSchema as Record<string, unknown>);
const dateNormalized = this.#traverse(value, transformedJsonSchema as Record<string, unknown>);
// Strict-mode schemas return `null` for absent optional/defaulted fields;
// convert those back to `undefined` so Zod can apply optional/default semantics.
const transformed = convertOptionalNullsToUndefined(
dateNormalized,
transformedJsonSchema as Record<string, unknown>,
);
const result = zodSchema.safeParse(transformed);
return result.success ? { success: true, value: result.data } : { success: false, error: result.error };
},
Expand Down
7 changes: 5 additions & 2 deletions packages/schema-compat/src/provider-compats/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,11 @@ export class OpenAISchemaCompatLayer extends SchemaCompatLayer {
processToAISDKSchema(zodSchema: ZodTypeV3 | ZodTypeV4): Schema {
const compat = this.processToCompatSchema(zodSchema);

// Apply the same JSON Schema fixes as processToJSONSchema
const transformedJsonSchema = standardSchemaToJSONSchema(compat);
// Apply the same JSON Schema fixes as processToJSONSchema.
// Use the 'input' projection so fields with `.default()` are treated as
// optional (and marked `x-optional`) instead of required — matching what
// the model is expected to produce. See `#traverse` for the null handling.
const transformedJsonSchema = standardSchemaToJSONSchema(compat, { io: 'input' });

// Post-process the raw LLM value: strip falsy optional fields and convert
// date strings back to Date objects, then validate against the original Zod schema.
Expand Down
61 changes: 61 additions & 0 deletions packages/schema-compat/src/provider-compats/test-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,67 @@ export function createSuite(layer: SchemaCompatLayer) {
expect(layer.processToJSONSchema(schema)).toMatchSnapshot();
});
});

// Models return `null` for non-required fields in strict structured-output /
// tool-calling mode. The AI SDK schema produced by `processToAISDKSchema` must
// round-trip those nulls so the underlying Zod schema can apply `.optional()` /
// `.default()` semantics. This block runs for every provider — previously only
// OpenAI exercised null handling, which let the bug hide for the others.
describe('AI SDK schema null handling for optional/default fields', () => {
const validate = (schema: z.ZodTypeAny, value: unknown) => {
const aiSchema = layer.processToAISDKSchema(schema as any);
return aiSchema.validate!(value) as { success: boolean; value?: unknown };
};

it('treats null as undefined for an optional field', () => {
const schema = z.object({ keep: z.string(), maybe: z.string().optional() });
const result = validate(schema, { keep: 'k', maybe: null });
expect(result.success).toBe(true);
expect(result.value).toEqual({ keep: 'k' });
});

it('applies a string default when the model returns null', () => {
const schema = z.object({ keep: z.string(), label: z.string().default('fallback') });
const result = validate(schema, { keep: 'k', label: null });
expect(result.success).toBe(true);
expect(result.value).toEqual({ keep: 'k', label: 'fallback' });
});

it('applies a numeric default when the model returns null', () => {
const schema = z.object({ keep: z.string(), confidence: z.number().default(1) });
const result = validate(schema, { keep: 'k', confidence: null });
expect(result.success).toBe(true);
expect(result.value).toEqual({ keep: 'k', confidence: 1 });
});

it('applies a default for a nested defaulted field', () => {
const schema = z.object({ inner: z.object({ theme: z.string().default('light') }) });
const result = validate(schema, { inner: { theme: null } });
expect(result.success).toBe(true);
expect(result.value).toEqual({ inner: { theme: 'light' } });
});

it('applies a default for a defaulted field inside an array element', () => {
const schema = z.object({ items: z.array(z.object({ tag: z.string().default('x') })) });
const result = validate(schema, { items: [{ tag: null }, { tag: 'set' }] });
expect(result.success).toBe(true);
expect(result.value).toEqual({ items: [{ tag: 'x' }, { tag: 'set' }] });
});

it('preserves an explicit null for a required nullable field', () => {
const schema = z.object({ deletedAt: z.string().nullable() });
const result = validate(schema, { deletedAt: null });
expect(result.success).toBe(true);
expect(result.value).toEqual({ deletedAt: null });
});

it('preserves provided values for default fields', () => {
const schema = z.object({ label: z.string().default('fallback') });
const result = validate(schema, { label: 'real' });
expect(result.success).toBe(true);
expect(result.value).toEqual({ label: 'real' });
});
});
}

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/schema-compat/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,13 @@ export function applyCompatLayer({
if (compat.shouldApply()) {
const compatSchema = compat.processToCompatSchema(standardSchema);

return standardSchemaToJSONSchema(compatSchema);
// Use the 'input' projection so fields with `.default()` are optional
// (matches the schema the model is expected to produce).
return standardSchemaToJSONSchema(compatSchema, { io: 'input' });
}
}

return standardSchemaToJSONSchema(standardSchema);
return standardSchemaToJSONSchema(standardSchema, { io: 'input' });
} else {
let zodSchema: ZodSchema;

Expand Down