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.

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.
| Concern | Pydantic default | What to do for Sume |
|---|---|---|
| Extra keys on objects | No additionalProperties emitted | Set extra="forbid" on a shared base class |
| Optional field | x: str = None leaves it out of required | Write x: str | None with no default |
| Nullable type | anyOf of the type and null | Accepted as is |
| Nested models | $ref to #/$defs/Name | Accepted; $defs must be at the root |
| Media output | Your own model shape | Swap the reference for SumeMediaFile# |
Extra keywords such as title | Added to every property | Accepted 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
strictto 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
- Unit test Sume webhook signature checks in pytest (Python)
A pytest file for HMAC webhook verification: tamper, rotation header, stale timestamp and empty secret, written against Sume's sume-v1 scheme.
- Log x-sume-request-id and Idempotency-Key on every call (Python)
A requests response hook that writes one JSON log line per Sume call: x-sume-request-id, idempotency key, error code and rate-limit headers. Tested.
- How to test a webhook URL before a Sume Format run uses it
POST /v1/webhooks/test-deliveries sends a signed webhook.test event to your URL. See the scope, the response fields, and the secret check, with no paid run.
- Transactional outbox for paid API calls in Python (Sume)
Write the Sume request and its Idempotency-Key in the order's transaction, drain later: a tested Python outbox that survives crashes, 429s and 409s.
Written by Sume