1+ # Copyright 2025 Google LLC
2+ #
3+ # Licensed under the Apache License, Version 2.0 (the "License");
4+ # you may not use this file except in compliance with the License.
5+ # You may obtain a copy of the License at
6+ #
7+ # http://www.apache.org/licenses/LICENSE-2.0
8+ #
9+ # Unless required by applicable law or agreed to in writing, software
10+ # distributed under the License is distributed on an "AS IS" BASIS,
11+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ # See the License for the specific language governing permissions and
13+ # limitations under the License.
14+
15+ """Pydantic v2 compatibility patches for Google ADK.
16+
17+ This module provides patches for various types that are not compatible with
18+ Pydantic v2 schema generation, which is required for OpenAPI/Swagger UI
19+ functionality in FastAPI applications.
20+ """
21+
22+ from __future__ import annotations
23+
24+ import logging
25+ from typing import Any , Dict
26+
27+ logger = logging .getLogger ("google_adk." + __name__ )
28+
29+
30+ def patch_types_for_pydantic_v2 () -> bool :
31+ """Patch various types to be Pydantic v2 compatible for OpenAPI generation.
32+
33+ This function applies compatibility patches for:
34+ 1. MCP ClientSession - removes deprecated __modify_schema__ method
35+ 2. types.GenericAlias - adds support for modern generic syntax (list[str], etc.)
36+ 3. httpx.Client/AsyncClient - adds schema generation support
37+
38+ Returns:
39+ bool: True if any patches were applied successfully, False otherwise.
40+ """
41+ success_count = 0
42+
43+ # Patch MCP ClientSession
44+ try :
45+ from mcp .client .session import ClientSession
46+
47+ # Add Pydantic v2 schema method only (v2 rejects __modify_schema__)
48+ def __get_pydantic_core_schema__ (cls , source_type , handler ):
49+ from pydantic_core import core_schema
50+ return core_schema .any_schema ()
51+
52+ # Only set the Pydantic v2 method - remove v1 method to avoid conflicts
53+ setattr (ClientSession , "__get_pydantic_core_schema__" , classmethod (__get_pydantic_core_schema__ ))
54+
55+ # Remove __modify_schema__ if it exists to prevent Pydantic v2 conflicts
56+ if hasattr (ClientSession , "__modify_schema__" ):
57+ delattr (ClientSession , "__modify_schema__" )
58+
59+ logger .info ("MCP ClientSession patched for Pydantic v2 compatibility" )
60+ success_count += 1
61+
62+ except ImportError :
63+ logger .debug ("MCP not available for patching (expected in some environments)" )
64+ except Exception as e :
65+ logger .warning (f"Failed to patch MCP ClientSession: { e } " )
66+
67+ # Patch types.GenericAlias for modern generic syntax (list[str], dict[str, int], etc.)
68+ try :
69+ import types
70+
71+ def generic_alias_get_pydantic_core_schema (cls , source_type , handler ):
72+ """Handle modern generic types like list[str], dict[str, int]."""
73+ from pydantic_core import core_schema
74+
75+ # For GenericAlias, try to use the handler to generate schema for the origin type
76+ if hasattr (source_type , "__origin__" ) and hasattr (source_type , "__args__" ):
77+ try :
78+ # Let pydantic handle the origin type (list, dict, etc.)
79+ return handler .generate_schema (source_type .__origin__ )
80+ except Exception :
81+ # Fallback to any schema if we can't handle the specific type
82+ return core_schema .any_schema ()
83+
84+ # Default fallback
85+ return core_schema .any_schema ()
86+
87+ # Patch types.GenericAlias
88+ setattr (types .GenericAlias , "__get_pydantic_core_schema__" , classmethod (generic_alias_get_pydantic_core_schema ))
89+
90+ logger .info ("types.GenericAlias patched for Pydantic v2 compatibility" )
91+ success_count += 1
92+
93+ except Exception as e :
94+ logger .warning (f"Failed to patch types.GenericAlias: { e } " )
95+
96+ # Patch httpx.Client and httpx.AsyncClient for Pydantic v2 compatibility
97+ try :
98+ import httpx
99+
100+ def httpx_client_get_pydantic_core_schema (cls , source_type , handler ):
101+ """Handle httpx.Client and httpx.AsyncClient."""
102+ from pydantic_core import core_schema
103+ # These are not serializable to JSON, so we provide a generic schema
104+ return core_schema .any_schema ()
105+
106+ # Patch both Client and AsyncClient
107+ for client_class in [httpx .Client , httpx .AsyncClient ]:
108+ setattr (client_class , "__get_pydantic_core_schema__" , classmethod (httpx_client_get_pydantic_core_schema ))
109+
110+ logger .info ("httpx.Client and httpx.AsyncClient patched for Pydantic v2 compatibility" )
111+ success_count += 1
112+
113+ except Exception as e :
114+ logger .warning (f"Failed to patch httpx clients: { e } " )
115+
116+ if success_count > 0 :
117+ logger .info (f"Successfully applied { success_count } Pydantic v2 compatibility patches" )
118+ return True
119+ else :
120+ logger .warning ("No Pydantic v2 compatibility patches were applied" )
121+ return False
122+
123+
124+ def create_robust_openapi_function (app ):
125+ """Create a robust OpenAPI function that handles Pydantic v2 compatibility issues.
126+
127+ This function provides a fallback mechanism for OpenAPI generation when
128+ Pydantic v2 compatibility issues prevent normal schema generation.
129+
130+ Args:
131+ app: The FastAPI application instance
132+
133+ Returns:
134+ Callable that generates OpenAPI schema with error handling
135+ """
136+ def robust_openapi () -> Dict [str , Any ]:
137+ """Generate OpenAPI schema with comprehensive error handling."""
138+ if app .openapi_schema :
139+ return app .openapi_schema
140+
141+ # First attempt: Try normal OpenAPI generation with recursion limits
142+ try :
143+ import sys
144+ from fastapi .openapi .utils import get_openapi
145+
146+ # Set a lower recursion limit to catch infinite loops early
147+ original_limit = sys .getrecursionlimit ()
148+ try :
149+ sys .setrecursionlimit (min (500 , original_limit ))
150+
151+ # Attempt normal OpenAPI generation
152+ openapi_schema = get_openapi (
153+ title = app .title ,
154+ version = app .version ,
155+ description = app .description ,
156+ routes = app .routes ,
157+ )
158+ app .openapi_schema = openapi_schema
159+ logger .info ("OpenAPI schema generated successfully with all routes" )
160+ return app .openapi_schema
161+
162+ finally :
163+ sys .setrecursionlimit (original_limit )
164+
165+ except RecursionError as re :
166+ logger .warning ("🔄 RecursionError detected in OpenAPI generation - likely model circular reference" )
167+ except Exception as e :
168+ error_str = str (e )
169+
170+ # Check if this is a known Pydantic v2 compatibility issue
171+ is_pydantic_error = any (pattern in error_str for pattern in [
172+ "PydanticSchemaGenerationError" ,
173+ "PydanticInvalidForJsonSchema" ,
174+ "PydanticUserError" ,
175+ "__modify_schema__" ,
176+ "Unable to generate pydantic-core schema" ,
177+ "schema-for-unknown-type" ,
178+ "invalid-for-json-schema" ,
179+ "mcp.client.session.ClientSession" ,
180+ "httpx.Client" ,
181+ "types.GenericAlias" ,
182+ "generate_inner" ,
183+ "handler" ,
184+ "core_schema"
185+ ])
186+
187+ if not is_pydantic_error :
188+ # Re-raise non-Pydantic/non-recursion related errors
189+ logger .error (f"Unexpected error during OpenAPI generation: { e } " )
190+ raise e
191+
192+ logger .warning (f"OpenAPI schema generation failed due to Pydantic v2 compatibility issues: { str (e )[:200 ]} ..." )
193+
194+ # Fallback: Provide comprehensive minimal OpenAPI schema
195+ logger .info ("🔄 Providing robust fallback OpenAPI schema for ADK service" )
196+
197+ fallback_schema = {
198+ "openapi" : "3.1.0" ,
199+ "info" : {
200+ "title" : getattr (app , 'title' , 'Google ADK API Server' ),
201+ "version" : getattr (app , 'version' , '1.0.0' ),
202+ "description" : (
203+ "Google Agent Development Kit (ADK) API Server\n \n "
204+ "This is a robust fallback OpenAPI schema generated due to Pydantic v2 "
205+ "compatibility issues (likely circular model references or unsupported types). "
206+ "All API endpoints remain fully functional, but detailed request/response "
207+ "schemas are simplified for compatibility.\n \n "
208+ "For full schema support, see: https://github.com/googleapis/genai-adk/issues"
209+ ),
210+ },
211+ "paths" : {},
212+ "components" : {
213+ "schemas" : {
214+ "HTTPValidationError" : {
215+ "title" : "HTTPValidationError" ,
216+ "type" : "object" ,
217+ "properties" : {
218+ "detail" : {
219+ "title" : "Detail" ,
220+ "type" : "array" ,
221+ "items" : {"$ref" : "#/components/schemas/ValidationError" }
222+ }
223+ }
224+ },
225+ "ValidationError" : {
226+ "title" : "ValidationError" ,
227+ "required" : ["loc" , "msg" , "type" ],
228+ "type" : "object" ,
229+ "properties" : {
230+ "loc" : {
231+ "title" : "Location" ,
232+ "type" : "array" ,
233+ "items" : {"anyOf" : [{"type" : "string" }, {"type" : "integer" }]}
234+ },
235+ "msg" : {"title" : "Message" , "type" : "string" },
236+ "type" : {"title" : "Error Type" , "type" : "string" }
237+ }
238+ },
239+ "GenericResponse" : {
240+ "title" : "Generic Response" ,
241+ "type" : "object" ,
242+ "properties" : {
243+ "success" : {"type" : "boolean" , "description" : "Operation success status" },
244+ "message" : {"type" : "string" , "description" : "Response message" },
245+ "data" : {"type" : "object" , "description" : "Response data" , "additionalProperties" : True }
246+ }
247+ },
248+ "AgentInfo" : {
249+ "title" : "Agent Information" ,
250+ "type" : "object" ,
251+ "properties" : {
252+ "name" : {"type" : "string" , "description" : "Agent name" },
253+ "description" : {"type" : "string" , "description" : "Agent description" },
254+ "status" : {"type" : "string" , "description" : "Agent status" }
255+ }
256+ }
257+ }
258+ },
259+ "tags" : [
260+ {"name" : "agents" , "description" : "Agent management operations" },
261+ {"name" : "auth" , "description" : "Authentication operations" },
262+ {"name" : "health" , "description" : "Health and status operations" }
263+ ]
264+ }
265+
266+ # Safely extract route information without triggering schema generation
267+ try :
268+ for route in getattr (app , 'routes' , []):
269+ if not hasattr (route , 'path' ) or not hasattr (route , 'methods' ):
270+ continue
271+
272+ path = route .path
273+
274+ # Skip internal routes
275+ if path .startswith (('/docs' , '/redoc' , '/openapi.json' )):
276+ continue
277+
278+ path_item = {}
279+ methods = getattr (route , 'methods' , set ())
280+
281+ for method in methods :
282+ method_lower = method .lower ()
283+ if method_lower not in ['get' , 'post' , 'put' , 'delete' , 'patch' , 'head' , 'options' ]:
284+ continue
285+
286+ if method_lower == 'head' :
287+ continue # Skip HEAD methods in OpenAPI
288+
289+ # Create basic operation spec
290+ operation = {
291+ "summary" : f"{ method .upper ()} { path } " ,
292+ "description" : f"Endpoint for { path } " ,
293+ "responses" : {
294+ "200" : {
295+ "description" : "Successful Response" ,
296+ "content" : {
297+ "application/json" : {
298+ "schema" : {"$ref" : "#/components/schemas/GenericResponse" }
299+ }
300+ }
301+ }
302+ }
303+ }
304+
305+ # Add validation error response for POST/PUT/PATCH
306+ if method_lower in ['post' , 'put' , 'patch' ]:
307+ operation ["responses" ]["422" ] = {
308+ "description" : "Validation Error" ,
309+ "content" : {
310+ "application/json" : {
311+ "schema" : {"$ref" : "#/components/schemas/HTTPValidationError" }
312+ }
313+ }
314+ }
315+
316+ # Add appropriate tags based on path
317+ if any (keyword in path .lower () for keyword in ['agent' , 'app' ]):
318+ operation ["tags" ] = ["agents" ]
319+ elif 'auth' in path .lower ():
320+ operation ["tags" ] = ["auth" ]
321+ elif any (keyword in path .lower () for keyword in ['health' , 'status' , 'ping' ]):
322+ operation ["tags" ] = ["health" ]
323+
324+ # Special handling for known ADK endpoints
325+ if path == "/" and method_lower == "get" :
326+ operation ["summary" ] = "API Root"
327+ operation ["description" ] = "Get API server information and status"
328+ elif path == "/list-apps" and method_lower == "get" :
329+ operation ["summary" ] = "List Available Agents"
330+ operation ["description" ] = "Get list of available agent applications"
331+ operation ["responses" ]["200" ]["content" ]["application/json" ]["schema" ] = {
332+ "type" : "array" ,
333+ "items" : {"type" : "string" },
334+ "description" : "List of available agent names"
335+ }
336+ elif "health" in path .lower ():
337+ operation ["summary" ] = "Health Check"
338+ operation ["description" ] = "Check service health and status"
339+
340+ path_item [method_lower ] = operation
341+
342+ if path_item :
343+ fallback_schema ["paths" ][path ] = path_item
344+
345+ except Exception as route_error :
346+ logger .warning (f"Could not extract route information safely: { route_error } " )
347+
348+ # Add minimal essential endpoints manually if route extraction fails
349+ fallback_schema ["paths" ].update ({
350+ "/" : {
351+ "get" : {
352+ "summary" : "API Root" ,
353+ "description" : "Get API server information and status" ,
354+ "tags" : ["health" ],
355+ "responses" : {
356+ "200" : {
357+ "description" : "API server information" ,
358+ "content" : {
359+ "application/json" : {
360+ "schema" : {"$ref" : "#/components/schemas/GenericResponse" }
361+ }
362+ }
363+ }
364+ }
365+ }
366+ },
367+ "/health" : {
368+ "get" : {
369+ "summary" : "Health Check" ,
370+ "description" : "Check service health and status" ,
371+ "tags" : ["health" ],
372+ "responses" : {
373+ "200" : {
374+ "description" : "Service health status" ,
375+ "content" : {
376+ "application/json" : {
377+ "schema" : {"$ref" : "#/components/schemas/GenericResponse" }
378+ }
379+ }
380+ }
381+ }
382+ }
383+ }
384+ })
385+
386+ app .openapi_schema = fallback_schema
387+ logger .info ("Using robust fallback OpenAPI schema with enhanced error handling" )
388+ return app .openapi_schema
389+
390+ return robust_openapi
0 commit comments