anthropics / anthropics/anthropic-sdk-python

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

Abierto
#1,817 0 comentarios 0 reacciones 0 asignados Ver en GitHub
Lenguaje dominante
Python
Estrellas
3.9k
Forks
853
Merge medio
1 d 11 h
PR fusionados (30 d)
10

Descripción

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

Guía de contribución

Abrir la guía de contribución

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
python
Área
api
Tipo de issue
Error
Dificultad
3/5
Tiempo estimado
1-2 días
Estado de actividad
Tranquilo
Claridad
Bastante claro
Aptitud para principiantes
52/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.