You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
transform_schema turns a dict-shaped field whose keys are described by patternProperties (or propertyNames / unevaluatedProperties) into an object whose only legal value is {}. The request builds, the API returns 200, and the field can never be populated. Nothing raises and nothing warns.
This is not the same thing as the demote-to-prose policy working as intended — see "why this isn't just a demotion" below. It reproduces from ordinary Pydantic output, on anthropic==0.121.0.
properties: {} plus additionalProperties: False admits exactly one instance: {}. The model is being asked for a dictionary it is structurally forbidden from filling.
Same result for the Optional[...] spelling, which Pydantic renders as an anyOf with the map in one branch:
patternProperties is not consulted here, so it falls through to the leftover-stringify pass and lands in description. Both steps are individually defensible; the node is emptied by their combination.
Why this isn't just a demotion
Demotion normally widens: the constraint stops being enforced and the field keeps working. That is exactly what happens when the node also declares properties:
{"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"],
"patternProperties": {"^S_": {"type": "string"}}}
# -> properties preserved, patternProperties demoted to description. Field still works.
When one of these keywords is the node's only way of admitting a key, the same demotion plus the forced close narrows the field to nothing. So the honest description of the outcome flips depending on whether properties is present — and the failing case is the one that produces no signal at all.
Scope (measured 2026-08-10, with controls)
patternProperties, propertyNames and unevaluatedProperties all reach the emptying; an ordinary closed object and a node carrying properties are unaffected, which is the control that the harness discriminates.
Worth knowing because it suggests a shared design rather than a Python-only slip: @anthropic-ai/sdk@0.116.0 and anthropic-sdk-go@v1.62.0 produce the identical emptied node for these keywords. That is a different split from a plain additionalProperties map, which the Go SDK preserves — transformSchema has an explicit dictionary clause, but it keys on additionalProperties, so it does nothing for these three spellings. If the dictionary clause is considered the intended behaviour for maps, these keywords are simply outside it.
Reachability, stated precisely: only patternProperties is emitted by Pydantic (the pattern-keyed dict above). propertyNames alone is a hand-authored / OpenAPI-3.1 shape — zod's z.record() emits it beside additionalProperties, which takes the ordinary map path.
Suggestion
I don't think there is a lossless repair — structured outputs has no way to express "keys matching a pattern", so any fix is a choice about which failure the caller gets. Two options that both seem better than the current one:
Raise, the way the empty-type-array case is now handled in fix: support list-form JSON schema types #1813. A ValueError naming the keyword is actionable; a silently unsatisfiable field is not.
Preserve the map by treating a patternProperties-only node the way the Go SDK treats an additionalProperties-only node, if the API tolerates it. That needs an API-side answer I can't get offline.
A regression test for either should assert on the output shape, not on accept/reject: the current behaviour is accepted by everything, so acceptance can't see this bug — being accepted is precisely what goes wrong.
All of the above is checkable offline; transform_schema needs no API key.
Independent of it — the emptying reproduces identically before and after that PR for every Pydantic-emitted shape, so it is pre-existing and not caused by it. One small interaction worth noting for whoever merges: the list-form spelling {"type": ["object", "null"], "patternProperties": {...}} currently raises AssertionError and after #1813 it builds successfully as an emptied field, i.e. it joins this bug. That spelling isn't Pydantic-emitted (Pydantic uses anyOf for Optional), so it's hand-authored input only — not a reason to hold the PR, which fixes what it says it fixes.
Summary
transform_schematurns a dict-shaped field whose keys are described bypatternProperties(orpropertyNames/unevaluatedProperties) into an object whose only legal value is{}. The request builds, the API returns 200, and the field can never be populated. Nothing raises and nothing warns.This is not the same thing as the demote-to-prose policy working as intended — see "why this isn't just a demotion" below. It reproduces from ordinary Pydantic output, on
anthropic==0.121.0.Reproduction
properties: {}plusadditionalProperties: Falseadmits exactly one instance:{}. The model is being asked for a dictionary it is structurally forbidden from filling.Same result for the
Optional[...]spelling, which Pydantic renders as ananyOfwith the map in one branch:Mechanism
lib/_parse/_transform.py:129-133(0.121.0) — the object branch:patternPropertiesis not consulted here, so it falls through to the leftover-stringify pass and lands indescription. Both steps are individually defensible; the node is emptied by their combination.Why this isn't just a demotion
Demotion normally widens: the constraint stops being enforced and the field keeps working. That is exactly what happens when the node also declares
properties:{"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"], "patternProperties": {"^S_": {"type": "string"}}} # -> properties preserved, patternProperties demoted to description. Field still works.When one of these keywords is the node's only way of admitting a key, the same demotion plus the forced close narrows the field to nothing. So the honest description of the outcome flips depending on whether
propertiesis present — and the failing case is the one that produces no signal at all.Scope (measured 2026-08-10, with controls)
patternProperties,propertyNamesandunevaluatedPropertiesall reach the emptying; an ordinary closed object and a node carryingpropertiesare unaffected, which is the control that the harness discriminates.Worth knowing because it suggests a shared design rather than a Python-only slip:
@anthropic-ai/sdk@0.116.0andanthropic-sdk-go@v1.62.0produce the identical emptied node for these keywords. That is a different split from a plainadditionalPropertiesmap, which the Go SDK preserves —transformSchemahas an explicit dictionary clause, but it keys onadditionalProperties, so it does nothing for these three spellings. If the dictionary clause is considered the intended behaviour for maps, these keywords are simply outside it.Reachability, stated precisely: only
patternPropertiesis emitted by Pydantic (the pattern-keyed dict above).propertyNamesalone is a hand-authored / OpenAPI-3.1 shape —zod'sz.record()emits it besideadditionalProperties, which takes the ordinary map path.Suggestion
I don't think there is a lossless repair — structured outputs has no way to express "keys matching a pattern", so any fix is a choice about which failure the caller gets. Two options that both seem better than the current one:
type-array case is now handled in fix: support list-form JSON schema types #1813. AValueErrornaming the keyword is actionable; a silently unsatisfiable field is not.patternProperties-only node the way the Go SDK treats anadditionalProperties-only node, if the API tolerates it. That needs an API-side answer I can't get offline.A regression test for either should assert on the output shape, not on accept/reject: the current behaviour is accepted by everything, so acceptance can't see this bug — being accepted is precisely what goes wrong.
All of the above is checkable offline;
transform_schemaneeds no API key.Relationship to #1813
Independent of it — the emptying reproduces identically before and after that PR for every Pydantic-emitted shape, so it is pre-existing and not caused by it. One small interaction worth noting for whoever merges: the list-form spelling
{"type": ["object", "null"], "patternProperties": {...}}currently raisesAssertionErrorand after #1813 it builds successfully as an emptied field, i.e. it joins this bug. That spelling isn't Pydantic-emitted (Pydantic usesanyOfforOptional), so it's hand-authored input only — not a reason to hold the PR, which fixes what it says it fixes.