OpenAI strict mode: `prefixItems` (tuple fields) is marked strict-compatible but is rejected; `minLength`/`maxLength` are marked incompatible but are accepted
Summary
OpenAIJsonSchemaTransformer treats prefixItems as a valid way to type an array's elements, so any model with a tuple field is auto-marked strict-compatible and sent with strict: true. prefixItems is one of the keywords OpenAI's own SDK lists as unsupported in strict mode, so this is a request the API cannot accept.
Separately, minLength/maxLength are listed as strict-incompatible when OpenAI actually preserves them, which silently downgrades models that use Field(min_length=...) to non-strict.
The second issue masks the first, which is probably why this has survived several passes over this code (#4474, #4475, #4478, #4479, #4756, #6547): a model that has both a tuple and a min_length field lands on strict=False and never fails. Only a model with a tuple and no length-constrained string reaches the bad request.
Measured against pydantic-ai==2.27.0 and openai==7.4.0.
1. prefixItems → strict: true → rejected by OpenAI
profiles/openai.py:570:
has_typed_items = isinstance(items, dict) and any(key in items for key in _TYPE_BEARING_KEYS)
if not has_typed_items and not schema.get('prefixItems'):and the comment above it:
OpenAI strict mode requires an array to describe its elements' type, via either
items(list types) orprefixItems(tuple types).
prefixItems does not satisfy strict mode — it is rejected outright. In [email protected], lib/transform.js:52 defines:
const JSON_SCHEMA_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
..., 'not', 'patternProperties', 'prefixItems', 'propertyNames', ...
]);Repro:
from pydantic import BaseModel
from pydantic_ai import Agent
class WithTuple(BaseModel):
title: str
bbox: tuple[int, int, int, int]
Agent('openai:gpt-4o', output_type=WithTuple).run_sync('go')Captured request (patched httpx.Client.send, dummy key):
{"name": "final_result", "strict": true, "parameters": {
"properties": {"title": {"type": "string"},
"bbox": {"type": "array", "maxItems": 4, "minItems": 4,
"prefixItems": [{"type":"integer"},{"type":"integer"},{"type":"integer"},{"type":"integer"}]}},
"required": ["title","bbox"], "type": "object", "additionalProperties": false}}Feeding exactly those parameters to the vendor's own transformer:
toStrictJsonSchema(params)
-> Error: Schema at `properties/bbox` uses unsupported keyword `prefixItems`
and cannot be represented in strict Structured OutputsSuggested fix. A homogeneous tuple converts losslessly — prefixItems: [T, T, T, T] with minItems == maxItems == 4 is exactly items: T plus minItems/maxItems, both of which strict mode supports. That keeps tuple[int,int,int,int] working and strict. A heterogeneous tuple has no strict representation, so it should set is_strict_compatible = False rather than be rewritten (dropping prefixItems for a bare items would silently widen the schema). Note that popping prefixItems is required either way — leaving it beside items trades one rejection for another.
2. minLength / maxLength are not strict-incompatible
profiles/openai.py:397 lists both in _STRICT_INCOMPATIBLE_KEYS. Running each of those twelve keys through toStrictJsonSchema:
| key | OpenAI's own transformer |
|---|---|
minLength |
preserved byte-identical |
maxLength |
preserved byte-identical |
| the other 10 | throws — correctly listed |
So class M(BaseModel): level: str = Field(min_length=2) is sent with strict: false even though OpenAI accepts it unchanged. The cost is silent: the user still gets a 200, but the response is no longer grammar-constrained, so the schema becomes a suggestion rather than a guarantee — and nothing in the API response says so.
(pattern is already handled correctly — only lookaround patterns are rejected.)
3. Smaller: keys in OpenAI's unsupported set that aren't in _STRICT_INCOMPATIBLE_KEYS
Comparing the two lists, 17 of OpenAI's unsupported keywords are absent from yours; prefixItems above is the only one an ordinary Pydantic model emits. The rest — allOf, not, if/then/else, dependentRequired, dependentSchemas, dependencies, contentEncoding, contentMediaType, contentSchema, $anchor, $dynamicAnchor, $dynamicRef, $recursiveAnchor, $recursiveRef — are reachable when a caller supplies a raw JSON Schema for a tool rather than a Pydantic model.
I checked the obvious Pydantic routes to these and they're all handled correctly today, so this is low priority rather than a live bug: a nested model with Field(description=...) emits $ref + sibling (moved into anyOf correctly), and Base64Bytes emits format: "base64" (correctly flagged via _STRICT_COMPATIBLE_STRING_FORMATS).
Testing without an API key
toStrictJsonSchema from openai/lib/transform is the function the OpenAI SDK itself uses to build strict payloads, and it throws on exactly the unsupported set — so it works as an offline oracle. Asserting that every schema pydantic-ai marks is_strict_compatible = True survives it would catch all three of the above in CI, with no network and no key.
Source: pydantic/pydantic-ai