Summary
When one Zod v3 schema object ending in .nullable().optional() is reached
from more than one field, the strict Structured Outputs helpers encode it twice,
differently, in a single document:
- the first occurrence is inlined and is clean;
- the second becomes a
$ref into definitions, and that definitions entry
keeps an anyOf: [{ "not": {} }, …] wrapper.
not is not in the JSON Schema subset strict Structured Outputs accepts. The
SDK's own toStrictJsonSchema() rejects it — src/lib/transform.ts lists not
as unsupported and raises "uses unsupported keyword not" — but only the Zod
v4 and Standard Schema helpers call that function. zodV3ToJsonSchema()
(src/helpers/zod.ts) returns its schema without it, so on the v3 path the
wrapper reaches the request body unchecked.
Zod v4 is unaffected: zodV4ToJsonSchema() goes through z4.toJSONSchema() and
never enters the vendored converter.
Reproduction
No API key, no network.
npm init -y
npm i openai@7.5.0 zod@3
node repro.mjs
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod/v3';
// `.optional()` on its own is rejected by these helpers with
// "uses `.optional()` without `.nullable()`", so `.nullable().optional()` is the
// accepted shape.
const OptionalName = z.string().nullable().optional();
// One schema object, two fields.
console.log(
JSON.stringify(
zodResponseFormat(z.object({ a: OptionalName, b: OptionalName }), 'p').json_schema.schema,
null,
2,
),
);
Actual:
{
"type": "object",
"properties": {
"a": { "type": "string", "nullable": true },
"b": { "$ref": "#/definitions/p_properties_a" }
},
"required": ["a", "b"],
"additionalProperties": false,
"definitions": {
"p_properties_a": {
"anyOf": [
{ "not": {} },
{ "type": "string", "nullable": true }
]
},
"p": { "…": "same as the root" }
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
Expected — properties.a and definitions.p_properties_a are the same Zod
node, so they should be the same JSON Schema:
"definitions": {
"p_properties_a": { "type": "string", "nullable": true }
}
Scope
Measured on openai@7.5.0 (2026-08-17), which already contains the Zod v3
fixes from #2346, #2355, #2357 and #2358.
Affected, in all three of zodResponseFormat, zodTextFormat and
zodFunction:
| shape |
affected |
.nullable().optional() string reached from two fields |
yes |
| the same, object / array / enum inner type |
yes |
| the same, reached from three fields |
yes |
| the same, nested one level down |
yes |
supplied through schemaDefinitions (no sharing needed) |
yes |
.nullable() without .optional(), shared |
no |
.nullable().optional() used once |
no |
| any other shared schema (plain, object, enum, array, union) |
no |
Also reaches zodResponsesFunction and the non-strict Realtime tool path.
Cause
zodToJsonSchema.ts materializes a definition because some property $refs it,
then parses it with currentPath pointing at the definition but no
propertyPath:
definitions[key] =
parseDef(
zodDef(schema),
{ ...refs, currentPath: [...refs.basePath, refs.definitionPath, key] },
true,
) ?? {};
parseOptionalDef (parsers/optional.ts) branches on exactly that field:
if (
refs.propertyPath &&
refs.currentPath.slice(0, refs.propertyPath.length).toString() === refs.propertyPath.toString()
) {
return parseDef(def.innerType._def, { ...refs, currentPath: refs.currentPath }, forceResolution);
}
const innerSchema = parseDef(def.innerType._def, {
...refs,
currentPath: [...refs.currentPath, 'anyOf', '1'],
}, forceResolution);
return innerSchema ? { anyOf: [{ not: {} }, innerSchema] } : {};
Inline, parseObjectDef sets propertyPath and the first branch is taken. In
the definitions loop it is undefined, so the same Zod node takes the second
branch and gains the anyOf/not wrapper.
parseOptionalDef is the only reader of propertyPath in the vendored
converter — grep -rn propertyPath src/_vendor/ returns Refs.ts (declaration),
parsers/object.ts (writer) and parsers/optional.ts (reader) — so this is the
only parser the omission can affect.
Environment
openai@7.5.0, zod@3.25.76, Node v23.1.0
- Also reproduced against
main @ 39c06d1
Not verified
No request was sent to the API. The claim that not is unacceptable rests on
the SDK's own toStrictJsonSchema() treating it as unsupported, not on an
observed 400. If the API in fact tolerates anyOf: [{not: {}}, X], the
inconsistency still stands but the severity is lower than stated here.
Summary
When one Zod v3 schema object ending in
.nullable().optional()is reachedfrom more than one field, the strict Structured Outputs helpers encode it twice,
differently, in a single document:
$refintodefinitions, and thatdefinitionsentrykeeps an
anyOf: [{ "not": {} }, …]wrapper.notis not in the JSON Schema subset strict Structured Outputs accepts. TheSDK's own
toStrictJsonSchema()rejects it —src/lib/transform.tslistsnotas unsupported and raises "uses unsupported keyword
not" — but only the Zodv4 and Standard Schema helpers call that function.
zodV3ToJsonSchema()(
src/helpers/zod.ts) returns its schema without it, so on the v3 path thewrapper reaches the request body unchecked.
Zod v4 is unaffected:
zodV4ToJsonSchema()goes throughz4.toJSONSchema()andnever enters the vendored converter.
Reproduction
No API key, no network.
Actual:
{ "type": "object", "properties": { "a": { "type": "string", "nullable": true }, "b": { "$ref": "#/definitions/p_properties_a" } }, "required": ["a", "b"], "additionalProperties": false, "definitions": { "p_properties_a": { "anyOf": [ { "not": {} }, { "type": "string", "nullable": true } ] }, "p": { "…": "same as the root" } }, "$schema": "http://json-schema.org/draft-07/schema#" }Expected —
properties.aanddefinitions.p_properties_aare the same Zodnode, so they should be the same JSON Schema:
Scope
Measured on
openai@7.5.0(2026-08-17), which already contains the Zod v3fixes from #2346, #2355, #2357 and #2358.
Affected, in all three of
zodResponseFormat,zodTextFormatandzodFunction:.nullable().optional()string reached from two fieldsschemaDefinitions(no sharing needed).nullable()without.optional(), shared.nullable().optional()used onceAlso reaches
zodResponsesFunctionand the non-strict Realtime tool path.Cause
zodToJsonSchema.tsmaterializes a definition because some property$refs it,then parses it with
currentPathpointing at the definition but nopropertyPath:parseOptionalDef(parsers/optional.ts) branches on exactly that field:Inline,
parseObjectDefsetspropertyPathand the first branch is taken. Inthe definitions loop it is
undefined, so the same Zod node takes the secondbranch and gains the
anyOf/notwrapper.parseOptionalDefis the only reader ofpropertyPathin the vendoredconverter —
grep -rn propertyPath src/_vendor/returnsRefs.ts(declaration),parsers/object.ts(writer) andparsers/optional.ts(reader) — so this is theonly parser the omission can affect.
Environment
openai@7.5.0,zod@3.25.76, Node v23.1.0main@39c06d1Not verified
No request was sent to the API. The claim that
notis unacceptable rests onthe SDK's own
toStrictJsonSchema()treating it as unsupported, not on anobserved 400. If the API in fact tolerates
anyOf: [{not: {}}, X], theinconsistency still stands but the severity is lower than stated here.