Skip to content

fix: align structured output readers with Sorbet types - #385

Closed
jbeckwith-oai wants to merge 9 commits into
mainfrom
codex/typed-structured-output-readers
Closed

fix: align structured output readers with Sorbet types#385
jbeckwith-oai wants to merge 9 commits into
mainfrom
codex/typed-structured-output-readers

Conversation

@jbeckwith-oai

@jbeckwith-oai jbeckwith-oai commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix the contract mismatch between the Tapioca structured-output compiler introduced in #364 and the raw-value compatibility rollback in #375.

  • Materialize typed readers only for application-defined OpenAI::BaseModel structured-output classes.
  • Keep caller-owned hashes and arrays untouched in [] and to_h, including on directly constructed and reassigned structured-output models.
  • Preserve already-materialized parsed-response values, including nullable array identity, and retain precise nested model, array, union, enum, boolean, and nullable Sorbet signatures.
  • Fix the root cause of recursive Symbol failures once in the SDK-owned primitive converter; String → Symbol now works naturally through arbitrary arrays, unions, and nested models without structured-output-specific normalization branches.
  • Discard stale errors from viable structured-output union branches, honor explicitly assigned nullable values before inherited converter-error checks, and reject missing/non-nullable nil, non-viable boolean, enum, and mutated-container values with OpenAI::Errors::ConversionError instead of violating generated reader types.
  • Verify the original customer-facing message.parsed workflow from Beta Feedback and Feature Requests! #126/Provide sound Sorbet typing for application-owned structured-output models #309 and the Responses content.parsed workflow through real public client calls.
  • Add a cross-boundary contract matrix that checks generated RBIs against actual construction, assignment, and parsed-response behavior.

Why existing tests did not catch this

  1. fix: coerce nested BaseModel fields #295 had already enabled global nested coercion when feat: add Tapioca typing for structured outputs #364 introduced the Tapioca compiler, so concrete nested signatures matched the then-current behavior.
  2. The compiler regression generated RBI text and ran Sorbet against a synthetic parsed value, but never instantiated the models or compared runtime reader values to those signatures.
  3. revert: nested BaseModel coercion (#295) #375 reverted global coercion and added strong raw-value compatibility tests for generated/internal request models and parsed API models, but did not exercise application-defined structured-output models or the Tapioca compiler contract.
  4. Existing structured-output streaming tests used scalar-only models, so neither nested readers nor nested array elements exposed the mismatch.
  5. The primitive converter matrix tested literal Symbol constants but never tested Symbol as a class target, even though structured-output schemas explicitly advertise Symbol; successive container-specific fixes therefore masked the missing canonical scalar behavior.

The new compiler test closes the gap by checking every advertised field shape against runtime readers for direct construction, explicit assignment, and parsed responses in the same subprocess that generates and typechecks the RBI. It additionally checks raw identity through both accessors, nullable array elements and parsed array identity, direct/nested Symbol arrays and unions, including Symbol-bearing models inside arrays and unions, enum conversion, both union branches, nested model inheritance, caller-owned mutations, and conversion errors for invalid booleans, enums, and nested container values.

A 108-assertion scalar/container matrix systematically combines every supported scalar (String, Symbol, Integer, Float, boolean, enum) with nested model, array, and union boundaries across construction, assignment, and parsed conversion. Public-client integration tests exercise both chat.completions.create(response_format: Model) and responses.create(text: Model) using Minitest's stub on the SDK's actual HTTP-client protocol. Primitive-converter regressions separately verify valid Symbols, JSON strings, invalid input, and exactness/error accounting. A separate nine-shape nullable matrix verifies explicitly constructed, assigned, and parsed nil across String, Symbol, Integer, Float, Boolean, model, array, enum, and union readers; the generated-RBI subprocess independently checks nullable constructor and assignment behavior.

Design tradeoff

Globally restoring nested coercion would regress the published generated-request model contract. Widening generated readers to model/hash unions would make signatures truthful but would also require narrowing every nested field in normal parsed-response code. Documenting the signatures as parsed-only would leave direct construction and assignment unsound.

Instead, the structured-output subclass prepends one small reader module per application-defined model. Each wrapper delegates to the original generated reader for existing validation, returns already-materialized values unchanged, and uses the existing canonical converter only when the raw value does not already match its declared target. Raw storage remains unchanged, and generated SDK request models never enter this path.

Symbol conversion belongs alongside the converter's existing String, Integer, and Float primitive branches, not in separate model/array/union pre-processing hooks. Fixing that canonical scalar branch deletes 41 lines of workaround logic and automatically handles every recursive composition. An audit of all 2,308 generated/internal model classes found zero direct Symbol declarations, and the generated-request raw-value compatibility suite remains unchanged and green. This changes scalar parsing only; it does not restore global nested-model coercion.

Setters are intentionally not added to generated RBIs: the documented compiler contract promises readers.

Castiron ownership

No upstream Castiron compiler or renderer change is required.

The SDK contributor guide explicitly says the generator never modifies lib/openai/helpers/. Castiron's authoritative generated-file-excludes.yaml independently excludes README.md globally, both lib/openai/helpers/ and lib/openai/internal/ for Ruby, and the handwritten test/ tree except generated resource tests and namespace scaffolding. I evaluated that policy against every changed PR path:

EXCLUDED README.md
EXCLUDED lib/openai/helpers/structured_output/base_model.rb
EXCLUDED lib/openai/helpers/structured_output/array_of.rb
EXCLUDED lib/openai/helpers/structured_output/union_of.rb
EXCLUDED lib/openai/internal/type/converter.rb
EXCLUDED test/openai/helpers/structured_output_test.rb
EXCLUDED test/openai/internal/type/base_model_test.rb
EXCLUDED test/openai/tapioca/base_model_compiler_test.rb

Castiron's Ruby renderer generates package/client/model/resource/signature files and resource tests; it does not emit the handwritten structured-output helper or these tests. The existing Tapioca compiler file is unchanged.

Verification

  • mise exec ruby@4.0.6 -- env TEST_API_BASE_URL=http://127.0.0.1:4520 ./scripts/test — 648 runs, 2,813 assertions.
  • mise exec ruby@3.4.10 -- env TEST_API_BASE_URL=http://127.0.0.1:4520 ./scripts/test — 648 runs, 2,813 assertions.
  • mise exec ruby@3.3.12 -- env TEST_API_BASE_URL=http://127.0.0.1:4520 ./scripts/test — 648 runs, 2,813 assertions.
  • mise exec ruby@4.0.6 -- bundle exec rake lint on current main — 1,389 RuboCop files clean, Sorbet clean, and 1,212 RBS files valid.
  • Expanded Tapioca compiler/runtime contract regression passes on Ruby 3.3, 3.4, and 4.0.
  • Existing raw-value compatibility suite remains green.
  • mise exec ruby@4.0.6 -- bundle exec rake build:gem; package contains the handwritten structured-output runtime and compiler with no sorbet-runtime runtime dependency.
  • Required thermo-nuclear code-quality review repeated with no findings.

@jbeckwith-oai
jbeckwith-oai requested a review from a team as a code owner August 13, 2026 17:38
@openai-sdks

openai-sdks Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

OkTest Summary

237/237 SDK tests passed in 9.728s for Ruby SDK PR #385.

Test results — 42 files
Test Result Time
tests/chat-completions-complex-body.test.ts ✅ Passed 227ms
tests/chat-completions-create.test.ts ✅ Passed 309ms
tests/chat-completions-stream.test.ts ✅ Passed 125ms
tests/files-content-binary.test.ts ✅ Passed 308ms
tests/files-create-multipart.test.ts ✅ Passed 218ms
tests/files-list-pagination.test.ts ✅ Passed 171ms
tests/initialize-config.test.ts ✅ Passed 146ms
tests/instance-isolation.test.ts ✅ Passed 191ms
tests/models-list.test.ts ✅ Passed 162ms
tests/responses-background-lifecycle.test.ts ✅ Passed 178ms
tests/responses-body-method-errors.test.ts ✅ Passed 546ms
tests/responses-cancel-timeout.test.ts ✅ Passed 334ms
tests/responses-cancel.test.ts ✅ Passed 304ms
tests/responses-compact-retries.test.ts ✅ Passed 324ms
tests/responses-compact.test.ts ✅ Passed 417ms
tests/responses-create-advanced-stream.test.ts ✅ Passed 155ms
tests/responses-create-advanced.test.ts ✅ Passed 240ms
tests/responses-create-disconnect.test.ts ✅ Passed 151ms
tests/responses-create-errors.test.ts ✅ Passed 545ms
tests/responses-create-malformed-api-responses.test.ts ✅ Passed 128ms
tests/responses-create-retries.test.ts ✅ Passed 326ms
tests/responses-create-stream-failures.test.ts ✅ Passed 132ms
tests/responses-create-stream-timeout.test.ts ✅ Passed 216ms
tests/responses-create-stream-wire.test.ts ✅ Passed 2.029s
tests/responses-create-stream.test.ts ✅ Passed 157ms
tests/responses-create-terminal-states.test.ts ✅ Passed 365ms
tests/responses-create-timeout.test.ts ✅ Passed 302ms
tests/responses-create.test.ts ✅ Passed 362ms
tests/responses-delete.test.ts ✅ Passed 262ms
tests/responses-input-items-errors.test.ts ✅ Passed 229ms
tests/responses-input-items-list.test.ts ✅ Passed 231ms
tests/responses-input-items-options.test.ts ✅ Passed 239ms
tests/responses-input-tokens-count-timeout.test.ts ✅ Passed 366ms
tests/responses-input-tokens-count.test.ts ✅ Passed 220ms
tests/responses-malformed-inputs.test.ts ✅ Passed 2.074s
tests/responses-not-found-errors.test.ts ✅ Passed 415ms
tests/responses-parse.test.ts ✅ Passed 270ms
tests/responses-retrieve-retries.test.ts ✅ Passed 236ms
tests/responses-retrieve.test.ts ✅ Passed 272ms
tests/responses-stored-method-errors.test.ts ✅ Passed 1.039s
tests/retry-behavior.test.ts ✅ Passed 1.896s
tests/sdk-error-shape.test.ts ✅ Passed 551ms

View OkTest run #31736547160

SDK merge (c97645e330d6) · head (d52773283fdc) · base (0531498c5498) · OkTest (a845fa206fa4)

@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: 1290cf5e9d

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/openai/helpers/structured_output/base_model.rb
@jbeckwith-oai jbeckwith-oai added the generator Touches generated SDK files label Aug 13, 2026

@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.

Reviewed e01f430b4572b8eeebf5d64ac3625dd5f0a0b9c0. I found two remaining reader-contract gaps in the new wrapper; details are inline. I also agree with the existing unresolved @api private documentation note.

Comment thread lib/openai/helpers/structured_output/base_model.rb Outdated
Comment thread lib/openai/helpers/structured_output/base_model.rb Outdated
@jbeckwith-oai
jbeckwith-oai force-pushed the codex/typed-structured-output-readers branch from f658366 to 2bebdfd Compare August 13, 2026 18:46

@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.

Re-reviewed the new head 2bebdfda62cdece88e476bb831f1028bd402b264. The previous conversion-viability, nullable-array identity, and private-API findings are addressed. One remaining non-nil reader-contract gap is inline.

Comment thread lib/openai/helpers/structured_output/base_model.rb Outdated

@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: 2bebdfda62

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/openai/helpers/structured_output/base_model.rb Outdated
Comment thread lib/openai/helpers/structured_output/base_model.rb Outdated

@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: bc0c6a1647

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/openai/helpers/structured_output/base_model.rb Outdated

@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.

Re-reviewed current head bc0c6a1. The required-nil and stale-union-error fixes address their reported cases. One remaining recursive Symbol-normalization gap is inline.

Comment thread lib/openai/helpers/structured_output/base_model.rb Outdated

@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: 9ad3490c5b

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/openai/helpers/structured_output/array_of.rb Outdated

@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.

Re-reviewed current head 9ad3490. Direct Symbol arrays and unions are fixed, but one remaining model-boundary recursion gap is inline.

Comment thread lib/openai/helpers/structured_output/array_of.rb Outdated
@jbeckwith-oai
jbeckwith-oai force-pushed the codex/typed-structured-output-readers branch from ec0f2be to b592d72 Compare August 13, 2026 19:28

@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: b592d72ccc

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/openai/helpers/structured_output/base_model.rb
@jbeckwith-oai
jbeckwith-oai requested review from a team and apcha-oai August 13, 2026 19:38
@jbeckwith-oai

Copy link
Copy Markdown
Contributor Author

Closing after architecture review. OpenAI::BaseModel is an SDK transport model with permissive construction and raw-storage compatibility; retrofitting it into a strongly typed modeling framework creates conflicting reader/storage semantics and unnecessary coercion complexity. The customer need is useful, but the proposed abstraction is not. A future design should evaluate purpose-built Ruby typed models such as Sorbet T::Struct and derive schemas/hydration at the integration boundary rather than extending this PR.

virelai2604 pushed a commit to virelai2604/openai-ruby that referenced this pull request Aug 15, 2026
## Summary

- Generate structured-output JSON Schema properties, required names, and
reused-definition paths from each field's canonical `api_name`.
- Reject duplicate API aliases explicitly instead of silently collapsing
schema properties.
- Encode each definition path segment separately to prevent
direct-alias/nested-path collisions, escape complete `$defs` names as
RFC 6901 JSON Pointer tokens, and percent-encode URI fragments
correctly.
- Support aliases containing `/`, `~`, `#`, spaces, `%`, reserved URI
characters, and Unicode while preserving valid `$defs` references.
- Keep internal `api_name` metadata out of emitted schemas while
preserving descriptions, nullable fields, Ruby readers, API parsing, and
request serialization.
- Remove the redundant converter dispatch branch and reuse the existing
canonical JSON Schema converter.
- Add isolated regression coverage for aliases, duplicate aliases,
pointer/URI escaping, deep nested/reused definitions, colliding
direct/nested paths, Ruby/API-name collisions, converter round trips,
and real WebMock-backed Chat Completions and Responses request/response
round trips.

## Castiron / generation ownership

**No upstream Castiron generator, compiler, renderer, template, OpenAPI
schema, or companion regeneration change is required.**

Verified against the actual Castiron generation workflow, not just file
appearance:

1.
`docs/stainless-sdk-json-fixtures/release-primary/generated-file-excludes.yaml`
explicitly excludes Ruby `lib/openai/helpers/` and broad `test/`, while
explicitly re-including only generated resource tests and namespace
scaffolding.
2. Castiron's `crates/castiron-verify/src/generated_files.rs` implements
the exact prefix/include-prefix precedence used by generated-tree
verification.
3. Evaluating that real policy against every changed path and a
generated control path gives:

```
EXCLUDED lib/openai/helpers/structured_output/base_model.rb
EXCLUDED lib/openai/helpers/structured_output/json_schema_converter.rb
EXCLUDED test/openai/helpers/structured_output_api_names_test.rb
GENERATED test/openai/resources/chat/completions_test.rb
```

4. Castiron's Ruby renderer (`crates/castiron-render-ruby/src/output.rs`
and `src/lib/render_views.rs`) emits
package/client/model/resource/signature files and resource tests, not
structured-output helper code or handwritten helper tests.
5. The SDK's `CONTRIBUTING.md` independently states that the generator
never modifies `lib/openai/helpers/`.

The overlapping openai#385 changes unrelated Tapioca/reader behavior; this PR
does not incorporate or modify any of that work.

## Regression evidence

The initial suite failed against unmodified `origin/main` with **5
failures in 6 tests**: invalid property names, leaked `api_name`, lost
nested `$defs`, and incorrect Chat Completions/Responses request
schemas. Subsequent reviewer regressions independently failed before
their fixes for duplicate aliases, JSON Pointer escaping, URI-fragment
escaping, deeply nested definitions, and direct-alias/nested-path
collisions.

## Verification

- `mise exec ruby@4.0.6 -- env TEST_API_BASE_URL=http://127.0.0.1:4510
./scripts/test` — **637 runs, 2,577 assertions, 0 failures**.
- `mise exec ruby@4.0.6 -- bundle exec rake lint` — **1,390 RuboCop
files clean, Sorbet clean, and 1,212 RBS files valid**.
- `mise exec ruby@4.0.6 -- bundle exec rake build:gem` — gem builds
successfully.
- New alias/pointer/URI/collision regressions pass on Ruby **3.3.12**,
**3.4.10**, and **4.0.6**.
- Existing structured-output suite remains green, with no new Ruby 4 URI
deprecation warnings.
- Explicit thermo-nuclear code-quality review completed before every
push; schema dispatch and complete reference encoding stay in their
canonical owning layers.
virelai2604 pushed a commit to virelai2604/openai-ruby that referenced this pull request Aug 15, 2026
## Summary

- Remove the unreleased `OpenAI::BaseModel` Tapioca DSL compiler, its
compiler-specific README section, and the compiler-only tests.
- Preserve the independently useful Rails/Tapioca load-order handling
and nullable-array Sorbet conversion fixes from openai#364.
- Add focused compatibility regressions proving application-defined
structured-output models preserve raw nested hashes and arrays on
construction/assignment, while Chat Completions and Responses both
hydrate nested models in parsed API responses.
- Keep the nullable-array Sorbet regression in an isolated subprocess so
`sorbet-runtime` remains optional for normal SDK usage.

## Why this is the correct architectural rollback

openai#364 addressed the customer report in openai#309 by adding a compiler that
generated readers such as `Participant` and `T::Array[Participant]`.
Those signatures are unsound for the SDK's supported transport-model
contract:

```ruby
event = Event.new(participant: {name: "Ada"}, participants: [{name: "Grace"}])
event.participant       # => Hash
event.participants.first # => Hash
```

Parsed API responses correctly hydrate those same fields into
application-defined model objects. The original compiler test only
type-checked synthetic RBI usage; it never compared generated reader
signatures with direct model construction, assignment, or both public
parsed-response boundaries. The proposed runtime workaround in openai#385
introduced additional coercion and conflicting accessor/storage
semantics instead of addressing the incorrect abstraction.

Architecture review concluded that SDK transport models should remain
permissive transport models. Sound application-owned typed structured
outputs should instead be designed at the structured-output integration
boundary. openai#309 has therefore been reopened and rewritten around the
original customer outcome rather than the rejected implementation.

The removed compiler has not appeared in a released gem, so removing it
now avoids publishing an unsound API contract. The actual live Castiron
generator also emitted this compiler; its coordinated upstream removal
is openai/openai#1291001.

## Why retain the other openai#364 changes

- `lib/openai.rb` loader detection independently fixes Rails/Tapioca
load order and retains its existing regression coverage.
- `ArrayOf#to_sorbet_type` correctly preserves nullable array elements
independently of the deleted compiler; the new isolated subprocess test
preserves coverage without making Sorbet a runtime dependency.
- Existing SDK request-model raw-value contract tests remain unchanged,
and new public `OpenAI::BaseModel` tests cover both individual nested
fields and nested arrays.

## Validation

- Ruby 4.0.6: **695 tests, 3,134 assertions, zero
failures/errors/skips**.
- Ruby 3.4.10: **695 tests, 3,134 assertions, zero
failures/errors/skips**.
- Ruby 3.3.12: **695 tests, 3,134 assertions, zero
failures/errors/skips**.
- `bundle exec rake lint`: **2,618 RuboCop files clean**, Sorbet clean,
**1,212 RBS files valid**.
- `bundle exec rake build:gem`; inspected the built gem to verify it
contains no Tapioca compiler and does not add `sorbet-runtime` as a
runtime dependency.
- Castiron companion: **574 Ruby renderer tests passed**, including
assertions that both generation profiles omit the compiler while
preserving their intended loader behavior.

Related customer issue: openai#309. Do not auto-close it: the underlying typed
structured-output design remains open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

generator Touches generated SDK files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants