Repository navigation
fix(pydantic): handle annotated recursive references - #3932
LittleYier wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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
anyOfwhile 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.
|
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 Compared main 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.'})) |
Fix
RecursionErrorinpydantic_function_tool()when recursive model references include descriptions (e.g.Annotated[Node, Field(description=...)]).Track expanding schemas and wrap recursive references in
anyOfto preserve field metadata without infinite recursion. Added regression tests.