Skip to content

fix(pydantic): handle annotated recursive references - #3932

Open
LittleYier wants to merge 1 commit into
openai:mainfrom
LittleYier:fix/pydantic-recursive-refs
Open

LittleYier wants to merge 1 commit into
openai:mainfrom
LittleYier:fix/pydantic-recursive-refs

Conversation

@LittleYier

Copy link
Copy Markdown
  • I understand that this repository is auto-generated and my pull request may not be merged

Fix RecursionError in pydantic_function_tool() when recursive model references include descriptions (e.g. Annotated[Node, Field(description=...)]).

Track expanding schemas and wrap recursive references in anyOf to preserve field metadata without infinite recursion. Added regression tests.

@LittleYier
LittleYier requested a review from a team as a code owner September 22, 2026 03:22
Copilot AI lite review requested due to automatic review settings September 22, 2026 03:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved review issues were identified.

Review effort: Lite
Findings: None

What changed in this PR

Fixes recursion errors in pydantic_function_tool() for annotated recursive Pydantic references.

Changes:

  • Tracks active schemas to prevent infinite expansion.
  • Wraps recursive references in anyOf while preserving metadata.
  • Adds recursive-reference regression tests.
File Summary
tests/​lib/​test_pydantic.py Adds recursive and mutually recursive regression coverage.
src/​openai/​lib/​_pydantic.py Adds recursion tracking during schema normalization.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@jnohclee-rgb

Copy link
Copy Markdown

Independent offline verification (AI-assisted): I independently checked described self-list references, described optional self-references, mutual references, plain recursion, two differently described shared leaves, and a nonrecursive leaf. Main passed 3/6, with RecursionError in the three annotated recursive shapes; this head passed 6/6. Beyond termination, every resulting schema was JSON-serializable, passed local Draft 2020-12 schema checking, validated a finite sample tree, and retained the expected description strings. This does not establish OpenAI strict-subset acceptance; a local JSON Schema validator is not an API oracle.

Compared main 919b6236382f3f9f76d8453efb9f9ad03cff1126 with this PR head 9f726a165dc5a1a789452d10763341ca0a857c37, isolated source trees, Python 3.14.7, Pydantic 2.13.5 and shared dependencies plus isolated legacy HTTPX compatibility dependencies. Network-denied and fictional values only. Not a historical lockfile matrix, full SDK or merge approval.

Runnable separately against each source tree:

from __future__ import annotations
import importlib,json
from typing import Annotated
from pydantic import BaseModel,Field
from jsonschema import Draft202012Validator
from openai import pydantic_function_tool
source=importlib.import_module('openai.lib._pydantic')
class SelfList(BaseModel):
 label:str
 children:list[Annotated['SelfList',Field(description='list child')]]
class SelfOptional(BaseModel):
 label:str
 child:Annotated['SelfOptional',Field(description='optional child')]|None=None
class A(BaseModel):
 branches:list[Annotated['B',Field(description='A to B')]]
class B(BaseModel):
 branches:list[Annotated[A,Field(description='B to A')]]
A.model_rebuild()
class Plain(BaseModel):
 label:str
 children:list['Plain']
class Leaf(BaseModel):label:str
class Shared(BaseModel):
 left:Annotated[Leaf,Field(description='left leaf')]
 right:Annotated[Leaf,Field(description='right leaf')]
cases=[('self_list',SelfList,{'label':'root','children':[{'label':'child','children':[]}]},['list child']),('self_optional',SelfOptional,{'label':'root','child':{'label':'child','child':None}},['optional child']),('mutual',A,{'branches':[{'branches':[{'branches':[]}]}]},['A to B','B to A']),('plain_recursive',Plain,{'label':'root','children':[{'label':'child','children':[]}]},[]),('shared_leaf',Shared,{'left':{'label':'a'},'right':{'label':'b'}},['left leaf','right leaf']),('leaf',Leaf,{'label':'a'},[])]
rows=[]
for name,model,value,descriptions in cases:
 try:
  schema=pydantic_function_tool(model)['function']['parameters'];serialized=json.dumps(schema);Draft202012Validator.check_schema(schema);Draft202012Validator(schema).validate(value)
  missing=[d for d in descriptions if d not in serialized];too_long=len(serialized)>100000
  rows.append({'case':name,'error':None,'schema_bytes':len(serialized),'missing_descriptions':missing,'pass':not missing and not too_long})
 except Exception as exc:rows.append({'case':name,'error':type(exc).__name__,'pass':False})
print(json.dumps({'source_module':source.__file__,'cases':rows,'passed':sum(r['pass'] for r in rows),'failed':sum(not r['pass'] for r in rows),'scope':'Actual tool schema generation plus local JSON Schema syntax/finite sample validation; not strict API subset acceptance or live tool execution. Size check bounds this test artifact only, not SDK payloads.'}))

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants