Pydantic model to Sume output_schema: extra forbid, no defaults

Turn a Pydantic v2 model into a valid Sume Format output_schema: extra=forbid, nullable instead of defaults, and the SumeMediaFile reference. Tested.

5 min readSume
All posts

A Pydantic model's model_json_schema() is very close to what a Sume Format run accepts as output_schema, but two defaults break it: Pydantic leaves additionalProperties out unless you set extra="forbid", and a field with a default drops out of required. Fix both with a strict base class and no defaults, make optional values X | None, and swap your media field for Sume's built-in SumeMediaFile# reference.

I ran the sample below with Pydantic and through Sume's own strict-subset validator from the repository, and it passed with zero violations. The same model with a default (subtitle: str | None = None) and no extra setting failed with additional_properties_false and required_completeness.

What does Sume require that Pydantic does not emit by default?

Sume's structured-output docs describe a strict subset in the OpenAI style: the root is an object, every object sets additionalProperties: false, and every declared property is listed in required, with null as the way to say a value is optional. Pydantic's documentation says fields with defaults do not appear in required, and that sub-models are placed in $defs and referenced, which Sume accepts as long as the $defs sit at the root of the document.

Pydantic's extra setting accepts ignore, allow or forbid, and the config docs describe forbid as raising a ValidationError for extra data. The mapping from forbid to additionalProperties: false is not spelled out on those pages, so the sample prints the schema and I checked the output directly rather than trusting the docs.

Pydantic default versus the Sume output_schema rule, checked locally and against the docs, read 2026-10-03
ConcernPydantic defaultWhat to do for Sume
Extra keys on objectsNo additionalProperties emittedSet extra="forbid" on a shared base class
Optional fieldx: str = None leaves it out of requiredWrite x: str | None with no default
Nullable typeanyOf of the type and nullAccepted as is
Nested models$ref to #/$defs/NameAccepted; $defs must be at the root
Media outputYour own model shapeSwap the reference for SumeMediaFile#
Extra keywords such as titleAdded to every propertyAccepted by the validator in my run

What does the working conversion look like?

The MediaFile class is only a stand-in so Pydantic has something to reference. swap_media rewrites every reference to it into Sume's built-in shape, and the stand-in definition is then removed, because an unused $defs entry would only add noise. The printed body is exactly the output_schema object that creating a run takes, with strict set to true.

import json
from pydantic import BaseModel, ConfigDict
class Strict(BaseModel):
    model_config = ConfigDict(extra="forbid")  # emits additionalProperties: false

class MediaFile(Strict):  # stand-in, replaced by Sume's built-in media shape below
    url: str
class Scene(Strict):
    caption: str
    clip: MediaFile

class Promo(Strict):
    headline: str
    subtitle: str | None  # no default: optional means nullable and required
    scenes: list[Scene]

def swap_media(node):
    if isinstance(node, dict):
        if node.get("$ref") == "#/$defs/MediaFile":
            return {"$ref": "SumeMediaFile#"}
        return {k: swap_media(v) for k, v in node.items()}
    if isinstance(node, list):
        return [swap_media(v) for v in node]
    return node

schema = swap_media(Promo.model_json_schema())
schema["$defs"].pop("MediaFile")
body = {"output_schema": {"name": "acme/promo/v1", "strict": True, "schema": schema}}
print(json.dumps(body, indent=2))

Which Pydantic features will Sume reject?

Anything that leaves the allowlist is rejected with a 400 that lists every violation, each as path, rule and message, and the rule token is stable enough to switch on. Defaults are the common one, but a few others are worth knowing before you port a big model.

oneOf and allOf are not accepted, only anyOf. I tested this: a plain A | B union passes, and a Literal with several values passes, but Field(discriminator="kind") fails with unsupported_keyword for both discriminator and oneOf. So drop the discriminator and keep the plain union, then tell the branches apart in your own code by the kind field. A self-reference through a named definition is fine, but a root recursion written as $ref: "#" is rejected. Limits also apply: ten levels of nesting, 5000 properties and 120,000 characters of strings across the whole document, which long description text can exhaust.

  • Run Promo.model_json_schema() in CI and diff it against a stored copy, so a field change is visible in review.
  • Never set strict to false to get past a failure: the docs say it changes nothing about the subset.
  • Keep Field(description=...) short, since strings count toward the document-wide budget.
  • Validate the finished run output with the same model, using model_validate, so a drift shows up in your code too.

Should the model be the source of truth or the schema?

Keep the model. It gives you a parsed object on the receiving side, and the schema is generated, so the two cannot drift. The cost is that you must remember the Sume-specific parts, which is why they live in one base class and one swap function rather than in each model.

One boundary is worth stating. The schema describes the shape of the run's result, and Sume does not promise the content of a field beyond that shape, so keep business checks, such as a caption length or a banned word list, in your own validators after the run completes, and treat a failed check as a reason to re-run with a new idempotency key rather than editing the result by hand. The structured output docs cover the full keyword list and the media shape.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume