Skip to content

transform_schema empties a patternProperties map: field can only ever be {} #1817

Description

@percymcn

Summary

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.

Reproduction

from typing import Dict
from typing_extensions import Annotated
from pydantic import BaseModel, StringConstraints
from anthropic.lib._parse._transform import transform_schema

class M(BaseModel):
    m: Dict[Annotated[str, StringConstraints(pattern=r'^S_')], str]

field = M.model_json_schema()["properties"]["m"]
# {'patternProperties': {'^S_': {'type': 'string'}}, 'title': 'M', 'type': 'object'}

print(transform_schema(field))
# {'type': 'object', 'title': 'M', 'properties': {}, 'additionalProperties': False,
#  'description': "{patternProperties: {'^S_': {'type': 'string'}}}"}

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:

m: Optional[Dict[Annotated[str, StringConstraints(pattern=r'^S_')], str]] = None
# -> {'anyOf': [{'type': 'object', 'properties': {}, 'additionalProperties': False,
#                'description': "{patternProperties: ...}"}, {'type': 'null'}], ...}

Mechanism

lib/_parse/_transform.py:129-133 (0.121.0) — the object branch:

strict_schema["properties"] = {                       # absent `properties` -> {} via pop's default
    key: transform_schema(p) for key, p in json_schema.pop("properties", {}).items()
}
json_schema.pop("additionalProperties", None)
strict_schema["additionalProperties"] = False

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 preservestransformSchema 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:

  1. 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.
  2. 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions