anthropics / anthropics/anthropic-sdk-python
transform_schema empties a patternProperties map: field can only ever be {}
- Linguagem predominante
- Python
- Estrelas
- 3.9k
- Forks
- 853
- Merge médio
- 1d 11h
- PRs com merge (30d)
- 10
Descrição
### 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
```py
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:
```py
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:
```py
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`:
```py
{"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:
1. **Raise**, the way the empty-`type`-array case is now handled in #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.
Guia de contribuição
Direção de pesquisa
Read lib/_parse/_transform.py around lines 129-133 and run the offline Pydantic reproduction from the issue. Confirm the output shape for patternProperties-only, propertyNames-only, and unevaluatedProperties-only schemas, then check with maintainers whether the intended outcome is an actionable ValueError or map preservation. Add a regression test asserting the selected output shape, with completion defined by eliminating the silently unsatisfiable schema.
Escrita pelo modelo de indexação a partir do texto da issue.
Avaliação
- Stack de tecnologia
- python
- Domínio
- api
- Tipo de issue
- Bug
- Dificuldade
- 3/5
- Tempo estimado
- 1-2 dias
- Status de atividade
- Pouca atividade
- Clareza
- Razoavelmente clara
- Facilidade para iniciantes
- 52/100