Skip to content

fix(zod): parse extracted definitions in their property position - #2463

Closed
cmun2 wants to merge 30 commits into
openai:mainfrom
cmun2:fix/zod-shared-optional-definitions
Closed

cmun2 wants to merge 30 commits into
openai:mainfrom
cmun2:fix/zod-shared-optional-definitions

Conversation

@cmun2

@cmun2 cmun2 commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor
  • I understand that this repository is auto-generated and my pull request may not be merged

Summary

Fixes #2462.

Sharing a .nullable().optional() Zod v3 schema across more than one field makes the Zod v3 strict helpers emit anyOf: [{ "not": {} }, …] into the extracted definition, while the inline occurrence of the same schema is emitted without it. not is outside the subset strict Structured Outputs accepts — src/lib/transform.ts lists it as unsupported — so a supported way of writing the schema produces a request the API rejects.

Approach

zodToJsonSchema materializes a definition because some property $refs it, then parses it with currentPath set to the definition and propertyPath left undefined. parseOptionalDef branches on propertyPath: inside a property it parses the inner type directly, otherwise it wraps in anyOf: [{ not: {} }, inner]. The inline occurrence takes the first branch and the extracted definition takes the second.

When openaiStrictMode is set, a definition that every emitted reference reaches from inside a property is rewritten to the encoding the inline occurrence gets. The decision is read off the finished document rather than inferred from refs.seen, which is keyed by Zod def and so cannot tell two names for one schema apart.

Everything outside the strict helpers is unchanged. Ten non-strict shapes — shared optionals, supplied definitions, $refStrategy: 'relative', 'none', recursion, openApi3, aliases, tuples — were dumped before and after and are byte-identical to the base revision.

Where it declines to rewrite

The rewrite fires only where it can be shown to preserve meaning. Each of these keeps the previous output:

  • a relative reference, whose depth is part of what it resolves to
  • a toJSON anywhere reachable, which can emit a pointer the scan cannot see
  • an accessor anywhere reachable, including on an entry inside a schema map or list — reading it would run caller code, and what it returns is not knowable without doing so
  • a definition reached from outside a property as well as from inside one, where neither encoding is owed to every reference
  • a recursive definition, whose materialized shape differs from the inline occurrence by more than the wrapper

Verification

  • tests/helpers/zod-shared-optional-definitions.test.ts — 62 passed. With the source change reverted, the tests asserting the new behaviour fail; the rest are controls that pass either way.
  • Full suite: 6,253 passed. The 2 failing tests are ecosystem-cloudflare-credential-lifecycle-security and fail identically with this change reverted — a filesystem-inode limitation of this container.
  • ./scripts/lint, ./scripts/format, tsc --noEmit — clean for both changed files.

A Zod v3 schema ending in .nullable().optional() that is reached from more
than one field was encoded two different ways in one document: inline it came
out clean, while the definitions entry the second reference points at kept an
anyOf: [{ not: {} }, ...] wrapper.

zodToJsonSchema materialises a definition because some property $refs it, but
parsed it with currentPath set to the definition and propertyPath left
undefined. parseOptionalDef branches on exactly that field, so the same Zod
node took the standalone branch and gained the wrapper.

not is outside the subset strict Structured Outputs accepts, and the v3 path
is the one helper path that does not run toStrictJsonSchema(), so it reached
the request body unchecked.

parseOptionalDef is the only reader of propertyPath in the vendored converter,
so no other parser is affected.

Fixes openai#2462
@cmun2
cmun2 requested a review from a team as a code owner August 25, 2026 16:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ecf273d4a3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

cmun2 added a commit to cmun2/openai-node that referenced this pull request Aug 25, 2026
Review on openai#2463 caught this: with the definitions loop now passing
propertyPath, parseOptionalDef takes its property branch, which returns
parseDef's result directly instead of falling back to {}. When the inner type
produces no schema the element became undefined, and parseTupleDef filtered it
out of items while minItems and maxItems still described the original arity.

items is positional, so dropping an entry shifts every later element onto the
wrong index. For zod.tuple([emptySlot, zod.string()]) the generated schema
demanded a string at index 0, rejecting [123, 'valid'] that Zod accepts and
accepting ['valid', 123] that Zod rejects.

Substitute an unconstrained {} instead of filtering. That keeps positions
aligned and constrains nothing, which is what the element already said.

This also fixes the same drop for a tuple under an object property, where
parseObjectDef has always set propertyPath - that case was broken before openai#2463.
Review on openai#2463 caught this: with the definitions loop now passing
propertyPath, parseOptionalDef takes its property branch, which returns
parseDef's result directly instead of falling back to {}. When the inner type
produces no schema the element became undefined, and parseTupleDef filtered it
out of items while minItems and maxItems still described the original arity.

items is positional, so dropping an entry shifts every later element onto the
wrong index. For zod.tuple([emptySlot, zod.string()]) the generated schema
demanded a string at index 0, rejecting [123, 'valid'] that Zod accepts and
accepting ['valid', 123] that Zod rejects.

Substitute an unconstrained {} instead of filtering. That keeps positions
aligned and constrains nothing, which is what the element already said.

This also fixes the same drop for a tuple under an object property, where
parseObjectDef has always set propertyPath - that case was broken before openai#2463.
@cmun2
cmun2 force-pushed the fix/zod-shared-optional-definitions branch from d5166dc to 1a26b13 Compare August 25, 2026 17:23
Review caught that the previous commit marked every materialized definition as
a property. Only some are: a definition referenced from an array item, or one
the caller supplied outright, was parsed with a propertyPath it never had, so
parseOptionalDef dropped its standalone anyOf: [{ not: {} }, ...] encoding, and
in a tuple it returned undefined instead of {} and parseTupleDef filtered that
positional entry out while minItems and maxItems still described the arity.

Seen now records the propertyPath in effect where a def was first reached, and
the definitions loop reuses it: property context only for definitions that came
from a property.

Strict mode keeps the property encoding for every definition including supplied
ones, because not is not in the subset strict Structured Outputs accepts, so the
standalone form is not representable there. ZodTuple, the shape that makes
positions matter, is rejected before conversion in that mode.
github-merge-queue Bot pushed a commit that referenced this pull request Aug 25, 2026
## Summary

GitHub omits `workflow_run.pull_requests` for external-fork runs, and
querying the upstream repository's commit-association endpoint returns
no pull requests for those fork commits. That leaves the required
`Castiron / budget-only change` and `Castiron / custom-code budget`
contexts permanently expected even when the candidate workflow succeeds.

- Centralize trusted Python pull-request association in
`custom_code_report.py` and reuse it from trusted report generation,
comment publication, and budget evaluation.
- Resolve missing associations from the authenticated source run's
`head_repository`, including legitimately renamed forks, then
independently re-fetch every candidate PR from `openai/openai-node`.
- Apply equivalent validation in both privileged JavaScript publishers:
required commit statuses and failure-report comments.
- Refresh the candidate workflow's pinned reporter SHA-256 after the
trusted reporter change.

## Security model

Fork-side associations and PR numbers are discovery hints, never
authorization. The trusted paths:

1. Re-fetch the workflow run from the upstream Actions API and verify
its repository, immutable candidate SHA, workflow path, completion, and
run attempt as applicable.
2. Strictly validate the source repository's owner/name syntax and
consistency with authenticated `head_repository` metadata; reject
traversal-like components and spoofed identities.
3. Re-fetch each hinted PR from the upstream repository and require an
open PR with the exact candidate SHA, the exact source head repository,
the intended upstream repository and `main` base ref, and exactly one
valid current association.
4. Require the current `main` base SHA wherever budget evaluation or
status publication needs freshness; preserve existing stale-run behavior
and merge-group validation.

The existing trusted `workflow_run`/main-checkout boundary, bare Git
object store, candidate-artifact isolation, least-privilege job
permissions, merge-queue protections, and exact required status names
remain unchanged. No candidate workflow definition, mutable ref,
contributor artifact, or fork-supplied PR number is trusted.

## Affected contributor PRs

- #2463: live fork workflow run `32877584723` has `pull_requests: []`;
the updated resolver correctly finds its upstream PR through
`cmun2/openai-node`.
- #2444 and #2431: independently reproduced the same fork-only
association behavior; both PRs merged while this fix was being prepared.

## Verification

- `env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s
scripts/castiron -p 'test_custom_code*.py'` — 60 tests pass, with one
pre-existing skip.
- `go run github.com/rhysd/actionlint/cmd/actionlint@latest
.github/workflows/castiron-custom-code.yml
.github/workflows/castiron-custom-code-comment.yml` — both workflows
pass actionlint v1.7.12.
- `ruff format --check scripts/castiron/custom_code_report.py
scripts/castiron/custom_code_budget.py
scripts/castiron/test_custom_code_report.py
scripts/castiron/test_custom_code_budget.py`.
- Ruff lint passes when ignoring only the same pre-existing baseline
rule findings; `git diff --check` passes.
- Executable publisher and Python regression coverage includes external
forks with empty run associations, fork-side lookup, same-repository and
renamed-fork PRs, malformed/spoofed repositories, unrelated source
heads, ambiguous/duplicate/invalid associations, stale heads/bases/run
attempts, exact required contexts, and merge groups.
Review caught that the strict-mode branch recreated the drop for callers of the
exported converter. The comment justified it by saying ZodTuple is rejected
before conversion, but that rejection lives in assertSupportedZodV3Schema,
which the helpers call and zodToJsonSchema does not. zodToJsonSchema is
exported, so openaiStrictMode with a tuple definition reaches the converter
directly.

Fix the drop at its source rather than reasoning about which callers can
produce one. items is positional and minItems/maxItems come from the declared
arity, so an element that parses to undefined becomes an unconstrained {}
instead of being filtered out. That holds for every position: supplied
definition, strict or not, under an object property, and with a rest element.

Without it the generated document accepted and rejected the opposite arrays
from the Zod schema it came from -- rejecting [123, 'valid'] that Zod accepts
as [0, 'valid'], accepting ['valid', 123] that Zod rejects.

@HAYDEN-OAI HAYDEN-OAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two remaining schema-conversion regressions are called out inline.

Two more containers were losing entries, and a definition was losing its
description. Both come from the same place: strict mode was handed a property
context for every definition, which changes how everything nested inside it
parses, and parseOptionalDef returns undefined rather than {} in that branch.
A union dropped its unconstrained anyOf entry, an array's items became
undefined and vanished in serialization, and the exported converter reaches
both without the helpers' unsupported-schema check.

Drop the strict-mode context entirely. The reason it was there was that not is
outside the subset strict Structured Outputs accepts, and that is now handled by
rewriting the finished definition -- anyOf: [{ not: {} }, X] reduces to X, an
identity -- instead of changing how its contents parse.

parseOptionalDef still cannot return undefined for a definition being
materialized: something already holds a  to it, and parseDef only attaches
.describe() text to a schema it actually got, so the annotation disappeared
before the outer ?? {} could run. It now falls back to {} under forceResolution,
which is set only on that path, so a plain optional property is still omitted
the way parseObjectDef expects.

Reverting parsers/optional.ts, parsers/tuple.ts or zodToJsonSchema.ts fails 1, 1
and 15 of the new tests respectively.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9b04296a7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

… optional

Three more places where a definition came out wrong, all from the same two
mistakes.

The never-branch reduction ran on the definition's root object only. A
description sits beside the generated anyOf, so the lone-key guard skipped it
and left not in strict output; and a container supplied through
schemaDefinitions holds its optional elements nested, where the reduction never
looked. It now walks the whole materialized definition and carries annotation
siblings onto the result.

The property context was a path prefix, which matches the entire subtree, so a
union or array inside a property-derived definition was parsed as though it sat
directly in a property and lost the entries it holds by branch or by position.
The treatment such a definition actually needs is one thing -- drop the outer
optional wrapper -- so that is now done directly, and nothing below it is
touched. The wrapper's own .describe() is reapplied, as it would be inline.

parsers/optional.ts is back to its original form: unwrapping at the definition
makes the fallback it carried unnecessary.

Containers checked as supplied definitions, strict and not, at one and two
levels of nesting: tuple, union, array, record, intersection, object, an object
holding a tuple, and an array of tuples. Every one is byte-identical to clean
origin/main apart from the intended differences, and no not survives anywhere in
strict output.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1104eb244

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

…e path

Three regressions from the last commit.

The reduction recursed into every object-valued child, so a default, const,
enum or examples payload shaped like a schema was rewritten -- a declared
default of { anyOf: [{ not: {} }, { value: 'kept' }] } came out as
{ value: 'kept' }. It now descends only through keywords whose value is a
schema, a list of schemas or a map of schemas, the same line toStrictJsonSchema
draws.

Unwrapping the outer optional took the definition off the normal parseDef path,
so the public override hook never saw the ZodOptional and markdownDescription
was dropped -- the manual restoration copied description only. The def goes
through parseDef unchanged again; the outer wrapper is now dropped afterwards by
the same never-branch identity the strict reduction uses, applied at the root
only for a property-derived definition. override and addMeta behave exactly as
before.

Containers checked as supplied definitions, strict and not, at one and two
levels: tuple, union, array, record, intersection, object, an object holding a
tuple, an array of tuples, and an optional carrying a default. Every one is
byte-identical to clean origin/main apart from the intended differences.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83f0ddfe33

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

cmun2 added 2 commits August 26, 2026 04:13
Two more, both in the collapse itself.

Spreading the siblings over the branch let an outer validation keyword win. An
override returning { anyOf: [{ not: {} }, { type: 'string', maxLength: 3 }],
maxLength: 5 } means both limits apply; the merge produced maxLength: 5 alone
and the document started accepting four-character strings. Only annotations move
across now -- they constrain nothing, and addMeta is what puts them there -- and
a union sitting beside a validation keyword is left standing rather than merged.

Rebuilding a schema map assigned each entry into a plain object, so a
__proto__ key reached the inherited setter: the entry vanished and the supplied
schema became the object's prototype. The map is built with a null prototype and
spread back at the end.

Both fail against the previous head.
tsc --noEmit flagged the three new override callbacks: the local JsonSchema
helper in this file is not the converter's JsonSchema7Type, and casting the
whole callback to never hid that rather than fixing it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb862fffc6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

A shared .default() makes parseDefaultDef emit default beside the union, and
default was missing from the annotation set, so the collision guard left the
whole schema standing and the not survived. JSON Schema files both default and
examples under annotations; neither narrows what a document accepts, so both
move across the collapse like description does.

They are still not descended into -- their values are literal JSON, and a
default shaped like a schema is left exactly as declared.

The strict helpers reject ZodDefault before conversion, so zodResponseFormat
never reaches this; the exported converter with openaiStrictMode does, which is
where it was measured.
@github-actions

Copy link
Copy Markdown
Contributor

Castiron custom code

✅ No new custom-code files detected.

32 mixed files remain; 0 existing customizations changed.

Compared 39c06d100d98 → d7c5503dfe78. Generated baselines verified.

32 existing customizations unchanged
  • api.md
  • scripts/castiron/README.md
  • scripts/castiron/custom_code_report.py
  • scripts/castiron/test_custom_code_report.py
  • src/client.ts
  • src/resources/audio/transcriptions.ts
  • src/resources/audio/translations.ts
  • src/resources/beta/assistants.ts
  • src/resources/beta/beta.ts
  • src/resources/beta/index.ts
  • src/resources/beta/responses/internal-base.ts
  • src/resources/beta/responses/responses.ts
  • src/resources/beta/threads/index.ts
  • src/resources/beta/threads/runs/index.ts
  • src/resources/beta/threads/runs/runs.ts
  • src/resources/beta/threads/threads.ts
  • src/resources/chat/completions/completions.ts
  • src/resources/chat/completions/index.ts
  • src/resources/conversations/index.ts
  • src/resources/embeddings.ts
  • src/resources/files.ts
  • src/resources/fine-tuning/checkpoints/permissions.ts
  • src/resources/images.ts
  • src/resources/responses/internal-base.ts
  • src/resources/responses/responses.ts
  • src/resources/skills/skills.ts
  • src/resources/skills/versions/versions.ts
  • src/resources/vector-stores/file-batches.ts
  • src/resources/vector-stores/files.ts
  • src/resources/webhooks/index.ts
  • src/resources/webhooks/webhooks.ts
  • tests/lib/data-residency.test.ts

A changed generated baseline means this report cannot reliably identify which handwritten lines changed.

Inspect the custom-code diff

Download the exact patch produced by this run (requires repository access):

gh run download 32890823669 --repo openai/openai-node \
  --name castiron-custom-code-32890823669-1 --dir /tmp/castiron-custom-code-32890823669-1
git apply --stat /tmp/castiron-custom-code-32890823669-1/custom-code.patch
cat /tmp/castiron-custom-code-32890823669-1/custom-code.patch

Or reproduce it from an SDK checkout containing the vendored reporter:

git fetch --no-tags origin cc532b3f173e64c0dbc9975516bbf4f6195c9b21 d7c5503dfe78a94db50b9f07da2f2d7bd5695a1f
python3 scripts/castiron/custom_code_report.py report \
  --base cc532b3f173e64c0dbc9975516bbf4f6195c9b21 \
  --head d7c5503dfe78a94db50b9f07da2f2d7bd5695a1f --fetch --require-head-hash --public \
  --out /tmp/castiron-custom-code-d7c5503dfe78
cat /tmp/castiron-custom-code-d7c5503dfe78/custom-code.patch

This is the current full custom patch for mixed files, not an attribution of only the handwritten lines changed by this PR.

Full report and patch

@HAYDEN-OAI HAYDEN-OAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two remaining schema-conversion regressions are called out inline.

refs.openaiStrictMode ?
// `not` is outside the subset strict Structured Outputs accepts, at any
// depth (see `toStrictJsonSchema` in `lib/transform`).
(collapseNeverBranchesDeep(materialized) as JsonSchema7Type)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Rewrite references when collapsing optional definitions

With the exported converter's default $refStrategy: 'root', this rewrite removes the anyOf/1 path after parseDef has already generated references into it:

const shared = z.string().min(2);
const maybe = z.object({ first: shared, second: shared }).optional();

const schema = zodToJsonSchema(z.object({ value: z.number() }), {
  openaiStrictMode: true,
  definitions: { Maybe: maybe },
});

schema.definitions.Maybe.properties.second.$ref remains #/definitions/Maybe/anyOf/1/properties/first, but Maybe now directly owns properties, so that pointer no longer resolves. On the base revision the same reference resolves correctly. $refStrategy: 'relative' likewise retains stale traversal depths after the schema moves.

Please rewrite affected references when collapsing wrappers, or preserve stable paths for referenced schema nodes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c4e6637ea22c5b6908da53717f1048de005bdefb. Where collapsing would leave a pointer dangling, the wrapper stays. A redundant anyOf still means what it says; a broken $ref does not, and rewriting pointers would have to handle relative depths as well.

Your reproduction now keeps Maybe as { anyOf: [{ not: {} }, …] }, and #/definitions/Maybe/anyOf/1/properties/first resolves — the regression follows the pointer through the document rather than just comparing its text.

if (!isPlainObject(value)) {
return value;
}
const walked: Record<string, unknown> = { ...value };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject schema accessors without invoking them

This object spread and the following Object.entries(value) each invoke enumerable accessors on materialized schemas returned by the exported converter's public override callback:

let calls = 0;
const definition = z.string();
const custom = { type: 'object' };

Object.defineProperty(custom, 'properties', {
  enumerable: true,
  get() {
    calls += 1;
    return { value: { type: 'string' } };
  },
});

zodToJsonSchema(z.object({ value: z.string() }), {
  openaiStrictMode: true,
  definitions: { D: definition },
  override: (def, _refs, _seen, forceResolution) =>
    forceResolution && def === definition._def ? custom : ignoreOverride,
});

calls is 0 on the base revision and 2 on this head. A throwing accessor now aborts conversion, while a side-effecting accessor executes before its value is validated. The existing strict-root boundary explicitly rejects accessors without invoking them.

Please inspect own property descriptors and reject accessors, or snapshot data-descriptor values once before traversing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c4e6637ea22c5b6908da53717f1048de005bdefb. The walk reads own data descriptors and leaves an object carrying any accessor exactly as it is, so nothing installed by an override is invoked. Your counter stays at 0, matching the base revision.

Both are on top of a merge with origin/main: Castiron / baseline consistency pins a hash of scripts/castiron/custom_code_report.py, which moved twice since this branch was cut.

I also cleared the CI lint failure. oxlint rejects Node 23 when loading the TypeScript config, which is why I could not run it locally before; on Node 22 it reproduces, and oxlint, oxfmt --check and tsc --noEmit are now clean for every changed file.

Verified: 41 focused tests, 844 across tests/helpers, full suite 5,115 — 0 regressions.

cmun2 added 2 commits August 26, 2026 04:53
Two from review and the CI lint failure, in one pass on top of main.

Collapsing the wrapper removes an anyOf/1 segment from every JSON pointer below
it, and references were generated against the uncollapsed shape, so a
$ref aiming inside was left dangling and relative references shifted a level.
Where that would happen the wrapper now stays: a redundant anyOf still means
what it says, a broken $ref does not.

The deep walk spread and enumerated materialized schemas, which invoked
enumerable accessors an override may have installed -- caller code running
before anything validated it, twice per schema, and a throwing getter taking
the conversion down. It reads own data descriptors now and leaves an object
carrying any accessor exactly as it is.

Lint could not run locally before -- oxlint rejects Node 23 when loading the
TypeScript config -- so this also clears what CI found: no-use-before-define,
no-thenable on the keyword table, and the non-null assertions, forEach, sort,
reduce and spread patterns in the tests. oxlint, oxfmt --check and
tsc --noEmit are all clean for the changed files.

Merged origin/main as well: Castiron pins a hash of custom_code_report.py,
which moved twice since this branch was cut.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c4e6637ea2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

continue;
}
const child = descriptor.value;
if (key === '$ref' && typeof child === 'string' && child.includes(wrapperPath)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve refs into every collapsed nested wrapper

When openaiStrictMode uses the default root ref strategy and a supplied definition contains a nested standalone optional—such as z.array(z.object({ a: shared, b: shared }).optional())—the generated second-property ref targets #/definitions/Nested/items/anyOf/1/properties/a. The deep collapse removes that nested anyOf/1, but this guard checks only the definition-root path #/definitions/Nested/anyOf/1, so it accepts the rewrite and emits a dangling $ref. Track each collapsed wrapper path, including nested and relative paths, before committing the rewritten definition.

AGENTS.md reference: AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b3d2446d23f410ca70eb70e543e21c92ee8aca1, by removing the thing that produced it rather than guarding it.

Reducing not at every depth in strict mode was scope I added; issue #2462 is about a schema shared between two fields being encoded twice, which is the property-derived case and needs only the outer wrapper dropped. The deep reduction had to walk arbitrary caller-supplied objects, and six of the findings on this PR came from that one addition — literal payloads, accessors, colliding constraints, nested pointers.

The collapse now applies to a property-derived definition, at its root, and only where no reference aims inside it. Nothing below a definition is touched and no other definition is rewritten, so nested wrappers are never removed and the pointer you described cannot arise.

What that gives up: a standalone optional supplied through schemaDefinitions keeps its anyOf: [{ not: {} }, …] under openaiStrictMode. Clean main does the same, so it is unfixed rather than broken, and the tests asserting otherwise are removed with it.

continue;
}
const child = descriptor.value;
if (key === '$ref' && typeof child === 'string' && child.includes(wrapperPath)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore reference-shaped values in literal annotations

When a default or examples payload contains a literal $ref string matching the wrapper path, this unrestricted recursive scan mistakes it for an active schema reference and restores the uncollapsed definition. For example, an optional object definition whose valid default is { $ref: '#/definitions/Maybe/anyOf/1' } retains the unsupported { not: {} } branch under openaiStrictMode. Fresh evidence after the earlier default fix is that the new pointer guard descends into literal annotations even though the collapse walker correctly does not; restrict this scan to JSON Schema child positions.

AGENTS.md reference: AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b3d2446d23f410ca70eb70e543e21c92ee8aca1. The pointer guard no longer scans literal payloads: it descends only through JSON Schema keywords, so a reference-shaped string inside a default is data and does not block anything. It is also a much smaller surface now — the guard covers a single root-level collapse rather than a rewrite at every depth.

Verified across all three: 36 focused tests, full suite 5,110 — 0 regressions, and oxlint, oxfmt --check and tsc --noEmit clean for every changed file. The lint failures CI reported are fixed too; oxlint rejects Node 23 when loading the TypeScript config, which is why I could not run it locally before.

Comment on lines +192 to +193
isPlainObject(first['not']) &&
Object.keys(first['not'] as object).length === 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid invoking accessors nested in override schemas

When an override returns an anyOf whose first branch has an enumerable accessor—for example, a throwing getter for not—collapseNeverBranchesDeep initially leaves that branch unchanged because dataProperties rejects it, but this direct lookup then invokes the getter anyway and aborts conversion. The same problem occurs for accessors inside schema maps traversed with Object.entries; inspect descriptors throughout nested override output and leave any container with accessors untouched rather than executing caller code.

AGENTS.md reference: AGENTS.md:L98-L101

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b3d2446d23f410ca70eb70e543e21c92ee8aca1. The deep walk is gone, so nothing traverses override output. The one remaining rewrite reads own data descriptors through a single helper and returns the schema untouched if any key on it — or on the branch it would collapse into — is an accessor. Your nested-getter case leaves the counter at 0.

Issue openai#2462 is one thing: a schema shared between two fields is encoded twice,
differently, and one of the two carries not. That is the property-derived case,
and it needs the outer optional wrapper dropped from the extracted definition --
nothing more.

Everything beyond that came from me widening the scope, and it was what kept
breaking: reducing not at every depth in strict mode has to walk arbitrary
caller-supplied objects, which meant not rewriting literal payloads, not
invoking accessors, not merging constraints, and not stranding pointers at any
nesting level. Six of the review findings came from that one addition.

So it is gone. A definition that came from a property has its wrapper collapsed
at the root, and only where no reference aims inside it. Nothing below a
definition is touched, and no other definition is rewritten at all.

What that gives up: a standalone optional supplied through schemaDefinitions
keeps its anyOf: [{ not: {} }, ...] under openaiStrictMode. That is what clean
main does too, so it is unfixed rather than broken, and the five tests asserting
otherwise are removed with it.

The narrower shape still covers every reported case: standalone encoding kept
for non-property definitions, tuple positions kept everywhere including strict,
union branches intact, description and markdownDescription preserved, override
still called for the wrapper, references still resolving at any depth, and no
accessor invoked. oxlint, oxfmt --check and tsc --noEmit are clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b3d2446d2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@cmun2

cmun2 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Stack overflow fixed in e5710e9. The other two I would rather answer than patch, because both are choices about the contract rather than gaps in the implementation.

Deep override schemas. You are right that the scans ran whether or not anything depended on them. The candidates are decided first now, and the scans only run when a collapse is pending; the walk is an explicit stack rather than recursion, so a chain that reaches it later is bounded by memory rather than the call stack. Two regressions: a deep not chain with no collapse candidate, and the same chain alongside one.

Override result identity. I do not think this one can be fixed while the change does what it is for. The definition being rewritten is the override's object — { anyOf: [{ not: {} }, survivor] } becomes survivor — so its content changes by construction, and an object whose content changed is not the same object. Preserving identity and collapsing are the same decision asked twice.

That leaves a real question, which is yours rather than mine: should a definition an override produced be collapsed at all? Leaving caller-owned objects untouched is defensible, and I can gate the rewrite on provenance if that is the contract you want. It needs a signal to exist first — parseDef returns the hook result directly and records nothing about where it came from, so I would be adding one. Say which way and I will implement it.

Deferring override materialization. Same shape of question. Running the hook after reference contexts are final would make a context-sensitive override see the settled Seen, but it also moves when caller code runs relative to everything else in the conversion, and an override with side effects would observe the change. The ordering fix in the previous commit deliberately deferred only the decision, not the materialization, to avoid that. If the hook should see final contexts, that is a larger change to when the converter calls out, and worth doing on its own rather than inside this one.

60 focused tests, full suite 5,138 passed with the same 99 pre-existing failures this environment has without the change. 201 shared optional definitions still convert in 17ms. oxlint, oxfmt --check, tsc --noEmit clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5710e9208

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}

if (seenItem && !forceResolution) {
if (seenItem.referencePath === undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the first context for ordinary seen entries

When a non-preseeded schema is first reached outside a property and reused later inside one (or vice versa), its newly created Seen entry has no referencePath, so the second encounter satisfies this condition and overwrites the original propertyPath. With extract-to-root, for example, placing a shared optional first in an array branch and later in an object property incorrectly classifies the array-origin definition as property-derived and collapses its standalone encoding; reversing the occurrences changes the output. Initialize referencePath for ordinary newly seen entries, or otherwise restrict this update to entries pre-seeded from definitions.

AGENTS.md reference: AGENTS.md:L52-L57

Useful? React with 👍 / 👎.

for (const { key, collapsed, wrapperPaths } of pendingCollapses) {
const stranded =
unsafeToMove ||
wrapperPaths.some((wrapperPath) => emittedRefs.some((ref) => ref.includes(wrapperPath)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index wrapper paths instead of rescanning every ref

With N shared optional definitions, this still compares each candidate's wrapper paths against all O(N) emitted references, so the normal no-stranded-reference case remains O(N²). Fresh evidence after the earlier report is that references are now collected once, but emittedRefs.some(...) is still nested inside wrapperPaths.some(...) for every pending collapse; build a prefix index or test all wrapper paths during one reference traversal.

AGENTS.md reference: AGENTS.md:L119-L123

Useful? React with 👍 / 👎.

// standalone definition the caller asked for by name, and rewriting it
// would change a schema nothing referenced.
if (
!aliasWasReferenced(seen, definitionPath) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an emitted reference before collapsing definitions

When a caller supplies an optional schema through definitions but selects $refStrategy: 'none', using the same schema in a property does not emit a reference to that definition—the property is parsed inline. Nevertheless, the property encounter replaces the pre-seeded Seen entry, this condition classifies the supplied definition as referenced, and its standalone anyOf: [{ not: {} }, …] form is collapsed. The base revision preserves that explicitly supplied, unreferenced definition, so gate this rewrite on an actual emitted reference rather than path context alone.

AGENTS.md reference: AGENTS.md:L52-L57

Useful? React with 👍 / 👎.

…rences

Whether an extracted definition is the encoding a property asked for was
read off `refs.seen`, which answers a different question. It is keyed by
the Zod def, so two names for one schema shared a single entry; it
records the first context reached, so definition order and branch order
decided the answer; and it records reads that emitted no `$ref` at all,
so a `$refStrategy` that inlines everything still looked referenced.

The emitted document answers it directly: collect every `$ref` once,
with whether it sits under a `properties` map, and collapse a definition
only when every reference to it came from a property. A definition
reached from both a property and an array branch now keeps its wrapper
regardless of which branch is written first, `'none'` collapses nothing,
and of two aliases only the one a property points at is rewritten.

The same pass indexes the pointer prefixes the stranding check needs, so
that check is a set lookup rather than a scan of every reference per
candidate. Collapse candidacy is computed first, so a schema with no
shared optionals never walks the document at all. Converting 3200 shared
optionals: 109ms -> 65ms, with no change on schemas that collapse
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cmun2

cmun2 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Pushed ad5bffc. This replaces the refs.seen heuristics with a direct reading of the emitted document, which closes the three findings from the latest round together, and I have re-run @HAYDEN-OAI's three repros from the change request against the current revision.

What changed

Whether an extracted definition is the encoding a property asked for was read off refs.seen. That structure answers a different question: it is keyed by the Zod def, so two names for one schema share a single entry; it keeps the first context reached, so definition order and branch order decide the answer; and it records reads that emitted no $ref at all, so a strategy that inlines everything still looked referenced.

The finished document answers it directly. Every $ref is collected once, tagged with whether it sits under a properties map, and a definition collapses only when every reference to it came from a property. Definitions reached from both a property and somewhere else keep their wrapper, because neither encoding is owed to all of their references.

The three findings

Preserve the first context for ordinary seen entries — a shared optional used in an array branch and in an object property now produces the same definition regardless of which branch is written first. Pinned by a test that fails on the previous revision.

Require an emitted reference before collapsing — with $refStrategy: 'none' nothing points at the supplied definition, so nothing collapses. Also pinned by a test that fails on the previous revision.

Index wrapper paths instead of rescanning every ref — the same traversal now indexes the pointer prefixes the stranding check needs, so that check is a set lookup rather than a scan of every reference per candidate. Collapse candidacy is computed before any scan, so a schema with no shared optionals never walks the document.

shared optionals before after
400 7.90 ms 7.77 ms
3200 109.18 ms 65.35 ms

Scaling from 1600 to 3200 goes from 2.68x to 2.05x. Median of 7 samples. A 1000-property schema that collapses nothing measures 3.24 ms before and 3.05 ms after, so the reordering costs nothing on schemas the rewrite does not apply to.

The alias case falls out of the same rule rather than needing its own check: given definitions: { First: shared, Second: shared }, the property emits $ref: '#/definitions/Second', so Second collapses and First — which nothing references — keeps its standalone form. aliasWasReferenced is gone, and a test pins that behaviour so its removal is not a silent regression.

Re-verifying the change request

Each of the three repros from the change request, run on ad5bffc:

  • relative references — Maybe keeps anyOf and the reference stays 6/properties/first.
  • schema-bearing keywords — a $ref under either dependencies or contentSchema keeps the wrapper, so the target still resolves. Draft-7 property-dependency arrays are string lists rather than schemas and are skipped rather than walked.
  • array element descriptors — the accessor is not invoked and nothing throws.

A $ref supplied as an enumerable accessor also leaves the wrapper in place, so no reference is stranded when JSON.stringify later runs the getter.

Verification

  • Full suite: 5141 passed. The 18 failing files are identical to the ones on the parent commit and come from optional dependencies that are not installed here (@aws-sdk/*, @smithy/*, ws).
  • ./scripts/lint and tsc --noEmit: no findings in the changed files.
  • 3 tests added, 63 in the file. With the source change reverted and the tests kept, 2 fail — the two asserting the new behaviour. The third is a control that passes either way.
  • Net effect on the file is three helpers and one import removed.

Still open

Three findings from the previous rounds are unaddressed on purpose, with the reasoning already posted in their threads: normalizing optionals inside generated allOf branches, deferring override materialization until reference contexts are final, and preserving override result identity. The first widens what the rewrite does, and the other two change the converter's contract with the override hook in ways that seem worth a maintainer's call before I write them. Happy to take any of them in this PR or a follow-up, whichever you prefer.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad5bffc9d0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +686 to +688
const pendingCollapses = candidates.filter(
({ path }) => referencedFromProperty.has(path) && !referencedElsewhere.has(path),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore internal self-references when choosing the encoding

When a reused optional field is a recursive lazy union whose array branch points back to the same optional schema, the definition's self-$ref under items is collected with insideProperty: false. This condition consequently excludes the definition even though its external reference sites are object properties, leaving the outer anyOf: [{ not: {} }, …] intact; Zod v3 strict helpers then send the unsupported not keyword to Structured Outputs. Internal self-references should not classify the definition as having a standalone call site.

AGENTS.md reference: AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

Comment on lines +378 to +381
const ref = dataValue(current, '$ref');
if (typeof ref === 'string') {
found.push({ ref: normalizedRef(ref), insideProperty });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track recursive references before removing wrappers

When the jsonSchema2019-09 converter's override output contains a valid $recursiveRef into a candidate such as #/definitions/Maybe/anyOf/1, this collector ignores it because it recognizes only $ref; wrappersMustStay misses the same keyword. A normal property $ref can therefore make Maybe eligible for collapse, removing the recursive reference's target and emitting a dangling pointer. Index $recursiveRef alongside ordinary references, or conservatively keep wrappers when it is present.

AGENTS.md reference: AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

Comment on lines +330 to +335
const normalizedRef = (ref: string): string => {
try {
return decodeURIComponent(ref);
} catch {
return ref;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize generated refs only after escaping definition names

When a Zod v3 strict helper receives a schemaDefinitions name containing literal percent-escape text, such as A%2FB, the converter initially emits #/definitions/A%2FB while the candidate path retains that raw spelling. Decoding here changes only the emitted ref to #/definitions/A/B, so the definition is not recognized as property-referenced and retains its unsupported not; afterward escapeSchemaDefinitionRefs correctly rewrites the actual wire ref to #/definitions/A%252FB, but it is too late to reconsider the collapse. Fresh evidence beyond encoded override refs is that helper-generated refs are escaped only after this converter returns, so comparison must use the same escaped representation or distinguish generated raw refs.

AGENTS.md reference: AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

Comment on lines +378 to +381
const ref = dataValue(current, '$ref');
if (typeof ref === 'string') {
found.push({ ref: normalizedRef(ref), insideProperty });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve local refs in their declared resource scope

When override output beneath a property declares a nested $id and uses $ref: '#/definitions/Maybe' to reference that nested resource's own definition, this collector treats the raw fragment as though it targeted the document-root Maybe. An explicitly supplied but otherwise unreferenced top-level optional definition with that name is consequently misclassified as property-referenced and collapsed, even though no emitted reference names it. Track $id resource scopes while resolving local refs, or conservatively avoid this rewrite when nested resources are present.

AGENTS.md reference: AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

Comment on lines +291 to +295
const collapsedInner = collapseNeverBranch(inner, wrapperSegments, [
...prefix,
'anyOf',
String(throughNullable),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Walk nullable wrappers without recursion

When a force-resolution override for a property-derived optional definition returns a deeply nested chain of nullable wrappers ending in anyOf: [{ not: {} }, X], this recursive call consumes one JavaScript stack frame per wrapper and eventually throws RangeError before the iterative reference scan runs. Fresh evidence after the deep-scan fix is that candidate normalization itself remains recursive, so an arbitrarily deep override schema still crashes conversion whereas the base revision returned it unchanged; descend and rebuild with an explicit stack instead.

AGENTS.md reference: AGENTS.md:L98-L101

Useful? React with 👍 / 👎.

Three things came out now that the decision is read off the emitted
document rather than inferred from `refs.seen`.

`Seen.propertyPath` and `Seen.referencePath` are written and never read.
They existed to carry the reference context forward; the emitted `$ref`
carries it instead. `Refs.ts` and `parseDef.ts` go back to their original
contents, so the converter's shared dispatch is untouched by this PR.

A recursive definition whose branch points back at itself was being read
as a call site outside a property, which kept the wrapper the inline
occurrence does not have. A definition describing itself does not decide
which encoding it owes; it still counts as a pointer for the stranding
check, where removing a wrapper strands a self-reference exactly as it
would an external one.

The tuple positioning fix is a separate, pre-existing bug -- it
reproduces with no part of this change applied -- and is now openai#2512.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cmun2

cmun2 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 06c5d3d. Two changes since the last round: the newest finding is fixed, and the PR is narrowed to the one thing it is about.

The recursive self-reference

Reading the decision off the emitted document introduced this, and it is a real regression against the previous revision: a definition whose own branch points back at itself was counted as a call site outside a property, so it kept a wrapper the inline occurrence does not have.

A definition describing itself is not a second call site asking for a different encoding, so it no longer decides which encoding is owed. It still counts as a pointer for the stranding check — removing a wrapper strands a self-reference into it exactly as it would an external one. Pinned by a test asserting the definition and the inline occurrence agree for z.lazy(() => z.union([z.string(), z.array(node)])).optional().

Narrowing

Refs.ts and parseDef.ts are back to their original contents. Seen.propertyPath and Seen.referencePath were carrying the reference context forward to the collapse; the emitted $ref carries it instead, so both fields were written and never read. The converter's shared dispatch is no longer touched by this PR at all — the diff is now one source file and one test file.

The tuple positioning fix moved to #2512. It reproduces with no part of this change applied — z.tuple([z.void(), z.string()]) converts to an array requiring two entries while describing only one position — so it does not belong here.

Where the remaining findings stand

Re-running each open thread's own repro against 06c5d3d:

finding result
relative references wrapper kept, 6/properties/first intact
dependencies / contentSchema wrapper kept, target resolves
array element descriptors accessor not invoked
$ref supplied as an accessor wrapper kept, nothing stranded
OpenAPI nullable wrappers definition equals the inline occurrence
branch order / 'none' / prefix index fixed in ad5bffc, tests pin the first two

Four are outstanding and I have not implemented them, with the reasoning in their threads: $recursiveRef, percent-escaped definition names, nested $id resource scopes, and recursion depth in candidate normalization. The first three widen the rewrite to spellings the exported converter does not currently produce on its own; the fourth is a real limit, but the guard it asks for is another traversal on a path the reported input cannot reach without a hand-written override. I would rather not keep growing the defensive surface of this PR on my own judgement — happy to take any of them if you want them in, here or as a follow-up.

Verification

  • 62 tests in the file, all passing. Full suite 5140 passed; the 18 failing files match the parent commit and come from optional dependencies not installed here.
  • ./scripts/lint, ./scripts/format, tsc --noEmit: clean for both files.
  • Converting 3200 shared optionals stays at 65 ms against 109 ms before the indexing change; a schema that collapses nothing is unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 06c5d3d697

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if (!ANNOTATION_KEYWORDS.has(key) && !isOpenApiNullable) {
return null;
}
carried[key] = dataValue(schema, key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep omitted annotations from overwriting branch metadata

When an override returns a collapsible schema whose outer annotation is undefined, such as { anyOf: [{ not: {} }, { type: 'string', description: 'inner' }], description: undefined }, the outer property would be omitted during JSON serialization, leaving the branch description intact. Copying it into carried instead overwrites the surviving branch's value, so the collapsed wire schema loses that metadata. Skip annotation values that serialization omits rather than carrying them onto the branch.

AGENTS.md reference: AGENTS.md:L58-L60

Useful? React with 👍 / 👎.

Comment on lines +449 to +452
for (const key of Object.keys(definitions)) {
const ownerPath = pathOf(key);
for (const { ref, insideProperty } of collectRefs(definitions[key])) {
add(ref, insideProperty, ownerPath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate property context through referenced definitions

When a reused property schema is a container such as outer = z.array(inner) and inner is optional, the first inline outer occurrence strips inner's optional wrapper, while materializing outer later can extract inner behind an items $ref. This loop scans every definition with insideProperty: false, so that reference is classified as standalone even when every reference to outer comes from an object property; inner consequently retains anyOf: [{ not: {} }, …] and the Zod v3 strict helper sends an unsupported schema. Propagate the referring definitions' property/standalone contexts through the definition-reference graph before deciding the collapse.

AGENTS.md reference: AGENTS.md:L61-L63

Useful? React with 👍 / 👎.

`carriedSiblings` moved every annotation on the wrapper onto the surviving
branch, including one whose value is `undefined`. JSON has no such value, so
the wrapper's key would have been dropped on serialization and the branch's
own value was what reached the wire. Copying it deleted that value instead of
moving one the wrapper stated: an override supplying
`{ anyOf: [{ not: {} }, { type: 'string', description: 'inner' }],
description: undefined }` lost the branch description, which the base
revision kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cmun2

cmun2 commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Fixed in 74868d8.

carriedSiblings copied every annotation on the wrapper onto the surviving branch, including one whose value is undefined. JSON has no such value, so the wrapper's key was dropped on serialization and the branch's own value was what reached the wire; copying it deleted that value rather than moving one the wrapper stated. For an override supplying { anyOf: [{ not: {} }, { type: 'string', description: 'inner' }], description: undefined } on a property-derived optional definition, definitions.p_properties_a was { type: 'string' } at 06c5d3d and is { type: 'string', description: 'inner' } at 74868d8, matching base d497fb3a. Annotation values that serialization omits are now skipped. Regression test added; it fails with the source change reverted.

Not reproducible as a regression at 74868d8: propagating property context through referenced definitions. With inner = z.string().optional() and outer = z.array(inner), both supplied through definitions and referenced from object properties, definitions.inner is { anyOf: [{ not: {} }, { type: 'string' }] } at this head and byte-identical at base d497fb3a. Propagating context through the definition-reference graph would extend the rewrite to nested containers rather than restore prior behavior. A repro where the two revisions differ would change that reading.

Intentionally not applied at 74868d8: $recursiveRef indexing, percent-escaped definition names, and nested $id resource scopes. Each concerns a spelling the exported converter does not produce on its own, reachable only through a hand-written override or schemaDefinitions key, so they widen the rewrite rather than correct it. Available as a follow-up if wanted.

63 tests in the file pass. Full suite 5141 passed, with the 18 failing files identical to the parent commit and caused by optional dependencies absent in this environment. ./scripts/lint, ./scripts/format, and tsc --noEmit clean for both changed files.

@HAYDEN-OAI HAYDEN-OAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correctness issue in the reference-safety scan; details inline.

Static review; no tests executed.

Comment on lines +511 to +513
if (kind === 'map' && isPlainObject(child)) {
for (const name of Object.keys(child)) {
const entry = dataValue(child, name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Treat schema-map accessors as hidden references

dataValue(child, name) returns undefined for an accessor, and this traversal never checks the schema-map container itself. With a and b sharing z.string().nullable().optional() and the options name: 'p', nameStrategy: 'duplicate-ref', and $refStrategy: 'extract-to-root', an override for a third property can return:

{
  type: 'object',
  properties: {
    get x() {
      return { $ref: '#/definitions/p_properties_a/anyOf/1' };
    },
  },
}

Both collectRefs() and wrappersMustStay() miss this reference, so the new code collapses definitions.p_properties_a. JSON.stringify() then invokes the getter and emits a $ref to the removed anyOf/1 branch. The base revision preserves that branch, so this introduces a dangling reference in a previously valid serialized schema.

Please treat accessor-backed schema-map entries as hidden references before allowing the collapse. The same guard should cover map-level serialization hooks and accessor-backed schema-list entries without invoking caller code.

@HAYDEN-OAI HAYDEN-OAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes a supported API use case: sharing nullable optional schemas should not introduce unsupported not into strict Structured Outputs.

For scope, please focus the implementation on schemas generated for the Zod v3 strict helpers, preserving existing non-strict conversion and caller-supplied override behavior. Broader reference normalization, override identity changes, and override execution ordering can stay outside this PR. The current implementation extends the general converter substantially beyond the property-context change described in the PR body; please update the description to match the final approach.

The current head still has the dangling-reference regression described inline. Please resolve that within the narrower scope before merging. For caller-supplied schema forms whose rewrite cannot be established as safe, preserving the previous output is sufficient; this fix does not need to expand the converter's general schema support.

Static review of 74868d8211eb388cc170fb94e5896e2b8bba5d84; no tests run.

sylvesterkaczmarek pushed a commit to sylvesterkaczmarek/openai-node that referenced this pull request Sep 4, 2026
- [x] I understand that this repository is auto-generated and my pull
request may not be merged

## Changes being requested

`parseTupleDef` in the vendored Zod v3 converter builds `items` with a
`map` that drops any element whose parser returns `undefined`, while
`minItems` and `maxItems` are taken from `def.items.length`. The two
disagree whenever an element produces no schema.

```ts
zodToJsonSchema(z.tuple([z.void(), z.string()]));
```

```jsonc
// before
{ "type": "array", "items": [{ "type": "string" }], "minItems": 2, "maxItems": 2 }

// after
{ "type": "array", "items": [{}, { "type": "string" }], "minItems": 2, "maxItems": 2 }
```

The array-form `items` keyword is positional, so before this change the
schema requires exactly two entries and describes the *string* element
at index 0 — the position the void element occupies. A value the Zod
schema accepts is rejected by the schema generated from it, and vice
versa. The same shift happens with a rest element, where
`additionalItems` then applies one position too early.

Keeping an unconstrained `{}` for such an element preserves the
alignment and constrains nothing, which is what the element was already
saying. Zod v4 describes both positions here, so this also brings the v3
output back in line with v4.

Tuples whose elements all produce schemas are unaffected; a test pins
that.

## Additional context & links

Split out of openai#2463. It surfaced there as a regression an earlier
revision of that branch introduced, and while fixing it I found the same
drop happens without any of that change — a tuple under an object
property already hit it, because `parseObjectDef` has always set
`propertyPath`. The repro above needs no property at all and reproduces
on `main` as it stands. openai#2463 no longer touches `parseDef.ts`, so the
path that first exposed this is gone from it entirely and the two are
independent.

Scoped to one function in
`src/_vendor/zod-to-json-schema/parsers/tuple.ts`.

**Testing**

- `pnpm vitest run tests/helpers/zod-tuple-positions.test.ts` — 4
passed.
- With the source change reverted and the tests kept, 3 of the 4 fail.
The fourth is a control asserting that ordinary tuples do not change, so
it passes either way.
- Full suite: 5353 passed. The 26 failing files are identical to those
on `main` at `222f3d7` and come from optional dependencies that are not
installed in this environment (`@aws-sdk/*`, `@smithy/*`, `ws`).
- `./scripts/lint` and `./scripts/format`: clean for both files.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Hayden <hayden@openai.com>
…accessors

Two changes, both narrowing.

The rewrite now runs only when `openaiStrictMode` is set. The divergence it
fixes is that `not` reaches Structured Outputs, which is a constraint of that
path alone; every other caller of the converter keeps the output it had. Ten
non-strict shapes -- shared optionals, supplied `definitions`, relative
references, `'none'`, recursion, `openApi3`, aliases, tuples -- were dumped
before and after and are byte-identical to the base revision.

`hasHiddenReference` now also looks one level into a schema map or list. An
accessor on `properties.x` rather than on `properties` itself passed the old
check, and the walk below then read `undefined` and stopped, so the reference
the getter returns was never seen and `JSON.stringify` emitted it into a branch
this rewrite had already removed. Whether such a getter would in fact return a
reference is not knowable without running caller code, so its presence is
treated as one and the schema is left alone.

That is enough to drop the self-reference bookkeeping added earlier: a
recursive definition materializes a shape that differs from the inline
occurrence by more than the wrapper, so it now keeps what it had rather than
being reasoned about. The `openApi3` and non-strict Realtime cases go with it --
they were general converter behaviour rather than this fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8467c52c60

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +239 to +240
if (value === undefined) {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip all annotations omitted during JSON serialization

When an override supplies a collapsible wrapper with a function- or symbol-valued annotation (for example, default: () => value) and the surviving branch has its own annotation, JSON serialization would omit the wrapper value and preserve the branch value. This condition skips only undefined, so the omitted value is carried onto the branch, overwrites its annotation, and is then itself omitted from the serialized schema. Fresh evidence beyond the earlier undefined fix is that JSON also omits function and symbol values from object properties; apply the same filtering to all three types.

AGENTS.md reference: AGENTS.md:L98-L101

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0d4afe0. Correct, and it is the same defect the undefined condition was written for — JSON.stringify drops a function or a symbol from an object property exactly as it drops undefined, so the wrapper never states one either.

Reproduced with { anyOf: [{ not: {} }, { type: 'string', description: 'inner' }], description: () => 'fromWrapper' } on a property-derived optional definition: definitions.p_properties_a serialized as { type: 'string' } at 8467c52, and is { type: 'string', description: 'inner' } at 0d4afe0. The symbol case behaves identically.

The condition now skips all three, and the existing regression is parameterized over them, so it fails on two of the three with the source change reverted.

@cmun2

cmun2 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 8467c52.

The dangling reference. hasHiddenReference checked a node's own keys, so an accessor on properties.x rather than on properties itself passed it; the walk below then read undefined for that entry and stopped, and JSON.stringify emitted the getter's $ref into a branch already removed. It now looks one level into a schema map or list, and treats an accessor or a toJSON there as a reference — without reading it. Reproduced with the schema from the inline comment: definitions.p_properties_a keeps anyOf, and anyOf/1 is present in the serialized output. The array-accessor case from the earlier review also no longer executes the getter.

Scope. The rewrite now runs only when openaiStrictMode is set. Ten non-strict shapes — shared optionals, supplied definitions, $refStrategy: 'relative', 'none', recursion, openApi3, aliases, tuples — were dumped before and after and are byte-identical to the base revision.

That gate made the general-converter work removable rather than fixable. The self-reference bookkeeping is gone: a recursive definition materializes a shape differing from the inline occurrence by more than the wrapper, so it now keeps what it had. The openApi3 and non-strict Realtime cases went with it. Net effect on this head is −51 lines of source and −68 of tests.

Still declining to rewrite, and preserving previous output, for: relative references, toJSON anywhere reachable, an accessor anywhere reachable, a definition reached from outside a property as well as inside one, and recursive definitions.

The description has been rewritten to match.

62 tests in the file pass; the two new ones fail with this change reverted. Full suite 6,253 passed — the 2 failures are ecosystem-cloudflare-credential-lifecycle-security and reproduce identically with the change reverted. ./scripts/lint, ./scripts/format and tsc --noEmit clean.

The wrapper's annotation was skipped only when it held `undefined`, but
serialization drops a function or a symbol from an object property just
the same. An `override` supplying a collapsible wrapper with such a value
carried it onto the surviving branch, overwrote the branch's own
annotation, and was then itself omitted from the serialized schema, so
the branch lost a value it did state.

With `{ anyOf: [{ not: {} }, { type: 'string', description: 'inner' }],
description: () => 'fromWrapper' }`, `definitions.p_properties_a`
serialized as `{ type: 'string' }`; it is now
`{ type: 'string', description: 'inner' }`, matching the `undefined`
case. The existing regression covers all three values.
gh-actions-shared Bot pushed a commit to xf-qubit/openai-node that referenced this pull request Sep 7, 2026
## Summary

- Capture the original `parameters` schema once in `zodFunction()` and
`zodResponsesFunction()`, and use that same reference for both JSON
Schema generation and argument parsing.
- Add a focused public-entrypoint regression covering Zod v3, v4, and v4
Mini through Chat Completions and Responses, in both parsing and
streaming modes.
- Preserve callbacks, schema-object identity, serialization, and public
types. No generated files, dependencies, or parser implementations are
changed.

## Reproduction

Create a tool using a six-character string schema, then reuse its
options object by assigning a four-character schema to
`options.parameters`. On main, the existing tool still sends the
six-character JSON Schema, but its parser follows the replacement
schema: it rejects valid original arguments and accepts replacement-only
arguments. A newly constructed tool correctly uses the replacement.

The fix binds each tool's parser to the schema used when that tool was
constructed, matching the existing behavior of the Standard Schema
helpers.

## Validation

- New 72-case regression: 24 failures and 48 passing controls before the
source change; all 72 pass afterward.
- Full canonical handwritten suite: 7,872 tests passed across 206 files.
- Built-package CJS/ESM parsing and CJS streaming checks pass on exact
Node 22.0.0, Node 24.19.0, and Node 26.7.0, including retained-tool and
newly-created-tool acceptance/rejection controls.
- All 651 published declaration files are byte-identical to main.
Repository TypeScript 6, published-source TypeScript 4.9/6, CJS/ESM
build, formatting, canonical lint, and `git diff --check` pass.
- `publint` passes with the same pre-existing vendored-export warning as
main.
- Independent adversarial review checks single-read getters, parser
receiver/schema identity, callback identity, unchanged serialized tool
definitions, validation, and public parse/stream regression coverage.

The generated resource suite and remaining platform integrations are
left to CI. This is separate from openai#2463's extracted-definition
conversion work.
…al-definitions

# Conflicts:
#	src/_vendor/zod-to-json-schema/zodToJsonSchema.ts
Three rounds of review each appended a paragraph without removing the one it
replaced, so the block opened by claiming a property-derived definition has to
be parsed as though it were still in that property -- the approach this code
does not take -- and then restated the approach it does take twice.

No behaviour change.
@cmun2

cmun2 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Merged main in. The conflict was with #2695: the extracted-definition write moved to Object.defineProperty, so the materialized value this PR computes is assigned the same way and a definition named __proto__ stays an own property. Checked against the zod suites on the merge head, including the root-schema __proto__ cases — 426 passing across the eight zod files, and the failures that remain on the merge head are the same ones main has on this machine.

b7076c6 removes the superseded half of the definition-parsing comment. Three review rounds had each appended a paragraph without dropping the one it replaced, so the block opened by describing an approach this code does not take — parsing a property-derived definition as though it were still in that property — and then restated the approach it does take twice. No behaviour change.

The Sep 4 narrowing to the Zod v3 strict helpers and the annotation-serialization fix from the Codex round after it are unchanged on the current head. Ready for re-review.

@cmun2
cmun2 requested a review from HAYDEN-OAI September 15, 2026 12:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7076c6946

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +441 to +444
const segments = ref.split('/');
for (let end = 1; end <= segments.length; end++) {
referencedPrefixes.add(segments.slice(0, end).join('/'));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid materializing every prefix of long references

When an override emits a local $ref containing many JSON Pointer segments while at least one optional definition is a collapse candidate, repeatedly slicing and joining every growing prefix makes both processing time and retained string data quadratic in the reference depth. Although the earlier candidate-by-reference rescan is gone, a single sufficiently long legal reference can still exhaust CPU or memory during strict conversion; compare against indexed wrapper paths or otherwise process the reference in linear space.

AGENTS.md reference: AGENTS.md:L119-L123

Useful? React with 👍 / 👎.

@HAYDEN-OAI

Copy link
Copy Markdown
Contributor

Superseded by #2751, which fixes #2462 at optional-schema generation: omit the impossible branch in strict conversion while retaining the anyOf wrapper. The replacement avoids rewriting completed schemas and repairing references, reducing the production change to six added lines and seven removed lines. Closing this PR in favor of that smaller implementation.

@HAYDEN-OAI HAYDEN-OAI closed this Sep 16, 2026
HAYDEN-OAI added a commit to sylvesterkaczmarek/openai-node that referenced this pull request Sep 16, 2026
)

- [x] I understand that this repository is auto-generated and my pull
request may not be merged

## Changes being requested

Shared Zod v3 `.nullable().optional()` fields can generate extracted
definitions containing `anyOf: [{ not: {} }, innerSchema]`. The `not`
branch accepts no JSON values and is unsupported by strict Structured
Outputs.

For strict conversion without an override callback, generate `anyOf:
[innerSchema]` and parse the child at `anyOf/0`. Keeping the wrapper
preserves existing optional-root diagnostics and metadata behavior,
while generating references against the final layout avoids
post-processing or reference repair. Non-strict and override-enabled
conversion retain their existing behavior.

The production change is six added lines and seven removed lines in the
optional parser. Focused tests cover all four strict helpers, both
nullable/optional orders, supplied definitions and descriptions, escaped
definition names, shared nested and recursive schemas, and
non-strict/override controls.

## Additional context & links

Fixes openai#2462. Supersedes openai#2463 with a smaller fix at schema generation.
The acceptance scope is removing the generated impossible optional
branch while preserving valid references and existing diagnostics;
general JSON Schema normalization is outside this change.

Validation:

- New regression suite: 17 passed; 16 fail against the unchanged parser
and the non-strict/override control passes.
- Schema/helper suite: 1,154 passed.
- Full handwritten unit suite: 8,677 passed, four failures in
`tests/node-version-review.test.ts`. The same four failures reproduce
with the parser change removed.
- `./scripts/lint`, `pnpm exec tsc --noEmit`, and `pnpm build` passed
using Node 24.19.0.
- Built CommonJS and ESM helper regressions passed on Node 22.0.0 and
Node 24.19.0.
- Independent review found no correctness or security findings. No live
API request was used for validation.

Co-authored-by: cmun2 <dkdkdkalalal@naver.com>
@cmun2

cmun2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Makes sense — omitting the branch at generation is the smaller change, and it reaches a case mine did not. A recursive .nullable().optional() produces a definition whose shape differs from the inline occurrence by more than the wrapper, so my collapse left not in place there rather than remove something it could not prove safe; generating only the real branch has no such case to prove. Thanks for landing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Zod v3: a .nullable().optional() schema shared by two fields is encoded two different ways, and one of them uses not

2 participants