Skip to content

Generate enums with associated values as Foundation Models does - #322

Merged
mattt merged 11 commits into
mainfrom
mattt/pr-301-fixes
Oct 13, 2026
Merged

mattt merged 11 commits into
mainfrom
mattt/pr-301-fixes

Conversation

@mattt

@mattt mattt commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

This PR lands #301 by @akbashev, with fixes on top. @Generable now describes an enum whose cases carry values the way Foundation Models does: one object per case, with a type that names the case and a typed property for each value. The generated content and decoding match Foundation Models too.

On top of #301, this PR:

  • Gives each case's schema a unique name, so cases like foo and Foo don't collide
  • Writes a nil value as null and reads null back as nil, as Foundation Models does
  • Describes each case's type as a const
  • Wraps a response schema whose root isn't an object for OpenAI and Open Responses, and a union root for Gemini, then unwraps the response, because OpenAI's structured outputs require an object at the root
  • Starts each enum case's object with its type when MLX, llama.cpp, and Core ML models generate it, so the model chooses the case first, as Foundation Models does

This changes the schema and content for these enums, so content generated by an earlier version won't decode.

akbashev and others added 11 commits October 6, 2026 10:50
Cases whose names differ only in their first letter, like foo and Foo, shared one Discriminated definition, so one case lost its schema. A case could also take the name of a value's nested type. A name that's taken now gets the first free numeric suffix.
Foundation Models reads a null property as nil when the property is optional, and its @generable writes a nil enum value as null. AnyLanguageModel threw a type mismatch, so such content didn't decode.
The macro left out a nil value only when its type was spelled T? or Optional<T>, so Swift.Optional<T> and a type alias like String? wrote null instead. Foundation Models' @generable writes null for every nil value, whatever its spelling, so the macro now does the same and no longer checks the spelling. The schema and decoding already use overload resolution.
Foundation Models' @generable guides an enum case's type with .constant, which encodes as const. Since #304, providers whose schemas don't support const already get a one-choice enum, so the schema can use a constant everywhere and still work with OpenAI, Open Responses, Gemini, and Ollama.
An enum whose cases have associated values now has an anyOf at the root of its schema, as in Foundation Models. OpenAI's structured outputs reject a root that isn't an object or that uses anyOf, and Open Responses follows them, so OpenAI and Open Responses get any other root as the response property of an object. Gemini's response schemas can be objects, primitives, or arrays, so Gemini gets a union root wrapped the same way. Each provider unwraps the content it returns, streamed or not. Content without the wrapper, from a provider that doesn't apply the schema, is used as sent. GenerationSchema itself keeps the Foundation Models form.
The schema for such an enum has a union at the root, which the system model gets without a wrapper.
Constrained generation for MLX, llama.cpp, and Core ML always generated the first choice of an anyOf. Now that such an enum's schema is a union of objects, local models could only produce its first case. When every choice is an object with a different constant for the same required property, like each case's type, the model now chooses that constant first and then generates that object's other properties. Other unions still use the first choice.
Resolve the conflict in StructuredGeneration.swift by keeping #320's variant selection, where the keys the model writes choose among the objects of a union. When those objects are the cases of an enumeration with associated values, each with a different constant type, the object now starts with type, and #320's string choice narrowing picks the case.
Other unions follow the keys the model writes, so a payload key can come first. The object of an enumeration case can't start with anything but its type.
@mattt
mattt merged commit f527aff into main Oct 13, 2026
11 checks passed
@mattt
mattt deleted the mattt/pr-301-fixes branch October 13, 2026 13:05
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.

2 participants