Skip to content

馃挜 Make generated enums and tagged unions open - #24

Merged
cretz merged 1 commit into
mainfrom
cretz/open-generated-enums
Oct 7, 2026
Merged

cretz merged 1 commit into
mainfrom
cretz/open-generated-enums

Conversation

@cretz

@cretz cretz commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

馃殌 What

  • 馃挜 Generated enums are now Literal[...] | str instead of StrEnum classes, so values the server adds later are accepted instead of failing validation. Enum classes such as VolumeTokenScope are removed; pass the string ("PULL")
  • 馃挜 Root models used as a field's whole type are inlined, so those fields hold the plain value rather than a wrapper read through .root
  • Tagged unions fall back to a plain dict for a variant the server adds later (e.g. a new audit-log event type) instead of failing the whole response

馃挜 This is a BACKWARDS INCOMPATIBLE change

馃捇 How

  • --enum-field-as-literal all and --collapse-root-models for datamodel-codegen, which has no open-enum option
  • Postprocess adds | str to every Literal, except the discriminator fields that tagged unions need closed
  • Postprocess wraps each tagged union as Annotated[<union> | dict[str, Any], Field(union_mode="left_to_right")]. A known tag with a malformed payload also falls back to the dict (Go and JS don't detect that either)

馃敩 Testing

  • Unit tests for an unknown enum value and an unknown tagged-union variant, plus the existing tests updated for the removed .root and enum classes
  • Lint, ty, and unit tests pass

@cretz
cretz requested review from marius-baseten and a balanced review from Copilot October 6, 2026 14:47

Copilot AI 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.

Copilot review overview

馃煛 Changes recommended

The AST postprocessor mixes UTF-8 byte offsets with character offsets and can corrupt generated source containing non-ASCII metadata.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

This breaking change makes generated enums and tagged unions forward-compatible while flattening field-level root models.

Changes:

  • Generates open string literals instead of StrEnum classes.
  • Adds dictionary fallbacks for unknown tagged-union variants.
  • Collapses root-model fields and updates tests accordingly.
File Description
scripts/鈥媋pigen/鈥媉_main__.py Enables literal enums and root-model collapsing.
scripts/鈥媋pigen/鈥媝ostprocess.py Opens literals and tagged unions.
baseten/鈥媍lient/鈥媘anagementapi/鈥媉models.py Regenerates management models.
baseten/鈥媍lient/鈥媘anagementapi/鈥媉_init__.py Removes obsolete model exports.
baseten/鈥媍lient/鈥媘odelconfig/鈥媉models.py Regenerates configuration models.
baseten/鈥媍lient/鈥媘odelconfig/鈥媉_init__.py Updates configuration exports.
baseten/鈥媍lient/鈥媔nferenceapi/鈥媉models.py Regenerates inference models.
baseten/鈥媍lient/鈥媔nferenceapi/鈥媉_init__.py Removes enum exports.
baseten/鈥媍lient/鈥媠andboxapi/鈥媉models.py Regenerates sandbox models.
baseten/鈥媍lient/鈥媠andboxapi/鈥媉_init__.py Removes enum exports.
tests/鈥媍lient/鈥媡est_management.py Tests open enums and unions.
tests/鈥媍lient/鈥媡est_modelconfig.py Tests flattened root-model fields.
tests/鈥媡est_e2e.py Uses the new string enum API.

馃挕 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +68 to +69
line_starts[node.lineno - 1] + node.col_offset,
line_starts[node.end_lineno - 1] + node.end_col_offset,
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"


class AsyncRequestError(BaseModel):

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.

I've been wondering if it would be better to create a custom OpenStrEnum class that handles unknown values well, but would allow using it's members for a call sites instead of now having str literals everywhere.

But it turns out in terms of assert_never(status)-exhaustive handling - forcing to add a branch of the unknown values that are the plain str case, your Literal approach is better.

But there is a way to combine them, by still defining constants that match the literals, e.g.

Status = Literal["ACTIVE", "PAUSED"] | str

class Statuses:
    ACTIVE: Final = "ACTIVE"
    PAUSED: Final = "PAUSED"

and then you can reference at call sites Statuses.ACTIVE instead of literals. The issue with the str base case is that it would not check typos at call sites, you could write "ACTVIE" without noticing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Makes sense, just not sure it's worth doubling the generated surface area for enumerates that aren't even used in our code. But we can add later if needed.

We have this same problem in JS and Go SDK, such is the nature of allowing any string. Same for Stainless-generated SDKs (like OpenAI, Anthropic, etc).

Another option is that we could try to limit this fix only to enumerates that are returned and leave input types closed, but we can't guarantee they will stay as only returned and it seemed like overkill. We will have this same pattern in the high-level SDK wrt enumerates, so this is also a concern there.

@marius-baseten

Copy link
Copy Markdown
Contributor

The known-tag + malformed-payload -> silent dict fallback is my one gripe here. An unknown tag must degrade, sure - but a known variant whose body drifted (new required field server-side, schema regression) should fail loud, not parse as a dict with zero signal. That one will bite as "why am I suddenly getting dicts", far from the cause.

left_to_right cannot tell the two cases apart. A small BeforeValidator that peeks at the tag can: unknown tag -> dict, known tag -> propagate the ValidationError. Costs a custom validator per union instead of the mechanical wrap - worth it IMO.

Won't block if Go/JS parity wins, but then note it as a conscious tradeoff in the PR.

btw, one cheap guard for postprocess.py: assert count("discriminator=") == count(union_mode wraps) and no discriminator field ends up | str. The AST matchers silently miss on shapes like string forward refs, and then this exact bug comes back unnoticed.

@cretz

cretz commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

An unknown tag must degrade, sure - but a known variant whose body drifted (new required field server-side, schema regression) should fail loud, not parse as a dict with zero signal

This is being addressed server-side by ensuring our REST API no longer has "additionalProperties": false in any type that can be returned, which means no case of new-field-is-fall-through. A backwards incompatible change such as a removed field I think is ok to fall through, same as Go/JS (but ideally should never happen).

btw, one cheap guard for postprocess.py

Agreed, will make generation fail unless every discriminator= ends up wrapped, and no discriminator field gets widened to | str.

@cretz cretz mentioned this pull request Oct 7, 2026
@cretz
cretz merged commit a6dabe5 into main Oct 7, 2026
6 of 14 checks passed
@cretz
cretz deleted the cretz/open-generated-enums branch October 7, 2026 22:20
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.

3 participants