1
1
from __future__ import annotations
2
2
3
+
3
4
import json
4
5
import logging
6
+ import threading
5
7
from typing import TYPE_CHECKING , Any , Iterable , cast
6
8
from typing_extensions import TypeVar , TypeGuard , assert_never
9
+ from functools import lru_cache
10
+
7
11
8
12
import pydantic
9
13
14
+
10
15
from .._tools import PydanticFunctionTool
11
16
from ..._types import Omit , omit
12
17
from ..._utils import is_dict , is_given
30
35
from ...types .chat .completion_create_params import ResponseFormat as ResponseFormatParam
31
36
from ...types .chat .chat_completion_message_function_tool_call import Function
32
37
38
+
33
39
ResponseFormatT = TypeVar (
34
40
"ResponseFormatT" ,
35
41
# if it isn't given then we don't do any parsing
36
42
default = None ,
37
43
)
38
44
_default_response_format : None = None
39
45
46
+
40
47
log : logging .Logger = logging .getLogger ("openai.lib.parsing" )
41
48
42
49
50
+ # ============================================================================
51
+ # FIX for Issue #2672: Thread-safe bounded TypeAdapter cache
52
+ # ============================================================================
53
+
54
+ _MAX_TYPE_ADAPTER_CACHE_SIZE = 128
55
+ _type_adapter_lock = threading .Lock ()
56
+ _thread_local = threading .local ()
57
+
58
+
59
+ def _get_type_cache_key (type_ : Any ) -> str :
60
+ """
61
+ Generate a stable cache key for a type.
62
+
63
+ Uses type name and module information to create a key that
64
+ remains consistent across type recreations, preventing hash
65
+ conflicts in multi-threaded environments.
66
+
67
+ Args:
68
+ type_: The type to generate a key for
69
+
70
+ Returns:
71
+ A string key that uniquely identifies the type
72
+ """
73
+ try :
74
+ # For generic types, extract the origin and args
75
+ if hasattr (type_ , '__origin__' ):
76
+ origin = type_ .__origin__
77
+ args = getattr (type_ , '__args__' , ())
78
+
79
+ origin_key = f"{ origin .__module__ } .{ origin .__qualname__ } "
80
+ if args :
81
+ args_keys = ',' .join (_get_type_cache_key (arg ) for arg in args )
82
+ return f"{ origin_key } [{ args_keys } ]"
83
+ return origin_key
84
+ else :
85
+ # For regular types
86
+ return f"{ type_ .__module__ } .{ type_ .__qualname__ } "
87
+ except (AttributeError , TypeError ):
88
+ # Fallback to repr for complex types
89
+ return repr (type_ )
90
+
91
+
92
+ def _get_cached_type_adapter (type_ : type [ResponseFormatT ]) -> pydantic .TypeAdapter [ResponseFormatT ]:
93
+ """
94
+ Get a cached TypeAdapter for the given type.
95
+
96
+ Uses thread-local storage with bounded cache size to prevent
97
+ memory leaks in multi-threaded environments (Issue #2672).
98
+
99
+ Args:
100
+ type_: The type to create an adapter for
101
+
102
+ Returns:
103
+ A TypeAdapter instance for the given type
104
+ """
105
+ # Get or create thread-local cache
106
+ if not hasattr (_thread_local , 'adapter_cache' ):
107
+ _thread_local .adapter_cache = {}
108
+
109
+ cache = _thread_local .adapter_cache
110
+
111
+ # Use stable type name as key instead of type hash
112
+ cache_key = _get_type_cache_key (type_ )
113
+
114
+ if cache_key not in cache :
115
+ # Implement simple FIFO eviction if cache exceeds limit
116
+ if len (cache ) >= _MAX_TYPE_ADAPTER_CACHE_SIZE :
117
+ # Remove oldest entry
118
+ first_key = next (iter (cache ))
119
+ del cache [first_key ]
120
+ log .debug (
121
+ "TypeAdapter cache size limit reached (%d), evicted oldest entry" ,
122
+ _MAX_TYPE_ADAPTER_CACHE_SIZE
123
+ )
124
+
125
+ # Create new TypeAdapter
126
+ cache [cache_key ] = pydantic .TypeAdapter (type_ )
127
+ log .debug ("Created new TypeAdapter for type: %s" , cache_key )
128
+
129
+ return cache [cache_key ]
130
+
131
+
132
+ # Alternative: Global bounded cache with locking (use if thread-local has issues)
133
+ @lru_cache (maxsize = _MAX_TYPE_ADAPTER_CACHE_SIZE )
134
+ def _get_cached_type_adapter_global (cache_key : str , type_repr : str ) -> Any :
135
+ """
136
+ Global cached TypeAdapter factory with bounded LRU cache.
137
+
138
+ This is an alternative to thread-local caching. The actual TypeAdapter
139
+ must be created outside this function and cached separately.
140
+
141
+ Note: This function serves as a cache key manager only.
142
+ """
143
+ # This is used as a bounded LRU cache manager
144
+ # The actual TypeAdapter creation happens in the calling function
145
+ return None
146
+
147
+
148
+ def _get_cached_type_adapter_with_lock (type_ : type [ResponseFormatT ]) -> pydantic .TypeAdapter [ResponseFormatT ]:
149
+ """
150
+ Get a cached TypeAdapter using global cache with explicit locking.
151
+
152
+ Alternative implementation using a global cache protected by locks.
153
+ Use _get_cached_type_adapter() for better thread isolation.
154
+
155
+ Args:
156
+ type_: The type to create an adapter for
157
+
158
+ Returns:
159
+ A TypeAdapter instance for the given type
160
+ """
161
+ if not hasattr (_get_cached_type_adapter_with_lock , '_global_cache' ):
162
+ _get_cached_type_adapter_with_lock ._global_cache = {}
163
+
164
+ cache_key = _get_type_cache_key (type_ )
165
+
166
+ with _type_adapter_lock :
167
+ cache = _get_cached_type_adapter_with_lock ._global_cache
168
+
169
+ if cache_key not in cache :
170
+ if len (cache ) >= _MAX_TYPE_ADAPTER_CACHE_SIZE :
171
+ # Remove first entry (FIFO)
172
+ first_key = next (iter (cache ))
173
+ del cache [first_key ]
174
+
175
+ cache [cache_key ] = pydantic .TypeAdapter (type_ )
176
+
177
+ return cache [cache_key ]
178
+
179
+
180
+ # ============================================================================
181
+ # End of fix for Issue #2672
182
+ # ============================================================================
183
+
184
+
43
185
def is_strict_chat_completion_tool_param (
44
186
tool : ChatCompletionToolUnionParam ,
45
187
) -> TypeGuard [ChatCompletionFunctionToolParam ]:
@@ -258,14 +400,33 @@ def is_parseable_tool(input_tool: ChatCompletionToolUnionParam) -> bool:
258
400
259
401
260
402
def _parse_content (response_format : type [ResponseFormatT ], content : str ) -> ResponseFormatT :
403
+ """
404
+ Parse content string into the specified response format.
405
+
406
+ FIXED: Uses bounded thread-safe TypeAdapter cache to prevent memory leaks
407
+ in multi-threaded environments (Issue #2672).
408
+
409
+ Args:
410
+ response_format: The target type for parsing
411
+ content: The JSON string content to parse
412
+
413
+ Returns:
414
+ Parsed content of type ResponseFormatT
415
+
416
+ Raises:
417
+ TypeError: If the response format type is not supported
418
+ """
261
419
if is_basemodel_type (response_format ):
262
420
return cast (ResponseFormatT , model_parse_json (response_format , content ))
263
421
264
422
if is_dataclass_like_type (response_format ):
265
423
if PYDANTIC_V1 :
266
424
raise TypeError (f"Non BaseModel types are only supported with Pydantic v2 - { response_format } " )
267
425
268
- return pydantic .TypeAdapter (response_format ).validate_json (content )
426
+ # FIXED: Use cached TypeAdapter instead of creating new instances
427
+ # This prevents unbounded memory growth in multi-threaded scenarios
428
+ adapter = _get_cached_type_adapter (response_format )
429
+ return adapter .validate_json (content )
269
430
270
431
raise TypeError (f"Unable to automatically parse response format type { response_format } " )
271
432
@@ -291,7 +452,8 @@ def type_to_response_format_param(
291
452
json_schema_type = response_format
292
453
elif is_dataclass_like_type (response_format ):
293
454
name = response_format .__name__
294
- json_schema_type = pydantic .TypeAdapter (response_format )
455
+ # FIXED: Use cached TypeAdapter here as well
456
+ json_schema_type = _get_cached_type_adapter (response_format )
295
457
else :
296
458
raise TypeError (f"Unsupported response_format type - { response_format } " )
297
459
0 commit comments