diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/__init__.py b/pydantic_ai_slim/pydantic_ai/fastapi/__init__.py new file mode 100644 index 0000000000..5a6b430713 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/__init__.py @@ -0,0 +1,7 @@ +from pydantic_ai.fastapi.agent_router import create_agent_router +from pydantic_ai.fastapi.registry import AgentRegistry + +__all__ = [ + 'AgentRegistry', + 'create_agent_router', +] diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/agent_router.py b/pydantic_ai_slim/pydantic_ai/fastapi/agent_router.py new file mode 100644 index 0000000000..9229f31663 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/agent_router.py @@ -0,0 +1,76 @@ +try: + from fastapi import APIRouter, HTTPException + from fastapi.responses import StreamingResponse + from openai.types.chat.chat_completion import ChatCompletion + from openai.types.model import Model + from openai.types.responses import Response as OpenAIResponse +except ImportError as _import_error: # pragma: no cover + raise ImportError( + 'Please install the `openai` and `fastapi` packages to enable the fastapi openai compatible endpoint, ' + 'you can use the `chat-completion` optional group — `pip install "pydantic-ai-slim[chat-completion]"`' + ) from _import_error + +from pydantic_ai.fastapi.api import AgentChatCompletionsAPI, AgentModelsAPI, AgentResponsesAPI +from pydantic_ai.fastapi.data_models import ( + ChatCompletionRequest, + ModelsResponse, + ResponsesRequest, +) +from pydantic_ai.fastapi.registry import AgentRegistry + + +def create_agent_router( + agent_registry: AgentRegistry, + disable_responses_api: bool = False, + disable_completions_api: bool = False, + api_router: APIRouter | None = None, +) -> APIRouter: + """FastAPI Router factory for Pydantic Agent exposure as OpenAI endpoint.""" + if api_router is None: + api_router = APIRouter() + responses_api = AgentResponsesAPI(agent_registry) + completions_api = AgentChatCompletionsAPI(agent_registry) + models_api = AgentModelsAPI(agent_registry) + enable_responses_api = not disable_responses_api + enable_completions_api = not disable_completions_api + + if enable_completions_api: + + @api_router.post('/v1/chat/completions', response_model=ChatCompletion) + async def chat_completions( # type: ignore[reportUnusedFunction] + request: ChatCompletionRequest, + ) -> ChatCompletion | StreamingResponse: + if getattr(request, 'stream', False): + return StreamingResponse( + completions_api.create_streaming_completion(request), + media_type='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Content-Type': 'text/plain; charset=utf-8', + }, + ) + else: + return await completions_api.create_completion(request) + + if enable_responses_api: + + @api_router.post('/v1/responses', response_model=OpenAIResponse) + async def responses( # type: ignore[reportUnusedFunction] + request: ResponsesRequest, + ) -> OpenAIResponse: + if getattr(request, 'stream', False): + # TODO: add streaming support for responses api + raise HTTPException(status_code=501) + else: + return await responses_api.create_response(request) + + @api_router.get('/v1/models', response_model=ModelsResponse) + async def get_models() -> ModelsResponse: # type: ignore[reportUnusedFunction] + return await models_api.list_models() + + @api_router.get('/v1/models' + '/{model_id}', response_model=Model) + async def get_model(model_id: str) -> Model: # type: ignore[reportUnusedFunction] + return await models_api.get_model(model_id) + + return api_router diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/api/__init__.py b/pydantic_ai_slim/pydantic_ai/fastapi/api/__init__.py new file mode 100644 index 0000000000..3411b0cd43 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/api/__init__.py @@ -0,0 +1,9 @@ +from pydantic_ai.fastapi.api.completions import AgentChatCompletionsAPI +from pydantic_ai.fastapi.api.models import AgentModelsAPI +from pydantic_ai.fastapi.api.responses import AgentResponsesAPI + +__all__ = [ + 'AgentChatCompletionsAPI', + 'AgentModelsAPI', + 'AgentResponsesAPI', +] diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/api/completions.py b/pydantic_ai_slim/pydantic_ai/fastapi/api/completions.py new file mode 100644 index 0000000000..c55ca7f567 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/api/completions.py @@ -0,0 +1,121 @@ +import json +import time +from collections.abc import AsyncGenerator +from typing import Any + +try: + from fastapi import HTTPException + from openai.types import ErrorObject + from openai.types.chat.chat_completion import ChatCompletion + from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, Choice as Chunkhoice, ChoiceDelta +except ImportError as _import_error: # pragma: no cover + raise ImportError( + 'Please install the `openai` and `fastapi` packages to enable the fastapi openai compatible endpoint, ' + 'you can use the `chat-completion` optional group — `pip install "pydantic-ai-slim[chat-completion]"`' + ) from _import_error + +from pydantic import TypeAdapter + +from pydantic_ai import Agent, _utils +from pydantic_ai.fastapi.convert import ( + openai_chat_completions_2pai, + pai_result_to_openai_completions, +) +from pydantic_ai.fastapi.data_models import ChatCompletionRequest, ErrorResponse +from pydantic_ai.fastapi.registry import AgentRegistry +from pydantic_ai.settings import ModelSettings + + +class AgentChatCompletionsAPI: + """Chat completions API openai <-> pydantic-ai conversion.""" + + def __init__(self, registry: AgentRegistry) -> None: + self.registry = registry + + def get_agent(self, name: str) -> Agent: + """Retrieves agent.""" + try: + agent = self.registry.get_completions_agent(name) + except KeyError: + raise HTTPException( + status_code=404, + detail=ErrorResponse( + error=ErrorObject( + message=f'Model {name} is not available as chat completions API', + type='not_found_error', + ), + ).model_dump(), + ) + + return agent + + async def create_completion(self, request: ChatCompletionRequest) -> ChatCompletion: + """Create a non-streaming chat completion.""" + model_name = request.model + agent = self.get_agent(model_name) + + model_settings_ta = TypeAdapter(ModelSettings) + messages = openai_chat_completions_2pai(messages=request.messages) + + async with agent: + result = await agent.run( + message_history=messages, + model_settings=model_settings_ta.validate_python( + {k: v for k, v in request.model_dump().items() if v is not None}, + ), + ) + + return pai_result_to_openai_completions( + result=result, + model=model_name, + ) + + async def create_streaming_completion(self, request: ChatCompletionRequest) -> AsyncGenerator[str]: + """Create a streaming chat completion.""" + model_name = request.model + agent = self.get_agent(model_name) + messages = openai_chat_completions_2pai(messages=request.messages) + + role_sent = False + + async with ( + agent, + agent.run_stream( + message_history=messages, + ) as result, + ): + async for chunk in result.stream_text(delta=True): + delta = ChoiceDelta( + role='assistant' if not role_sent else None, + content=chunk, + ) + role_sent = True + + stream_response = ChatCompletionChunk( + id=f'chatcmpl-{_utils.now_utc().isoformat()}', + created=int(_utils.now_utc().timestamp()), + model=model_name, + object='chat.completion.chunk', + choices=[ + Chunkhoice( + index=0, + delta=delta, + ), + ], + ) + + yield f'data: {stream_response.model_dump_json()}\n\n' + + final_chunk: dict[str, Any] = { + 'id': f'chatcmpl-{int(time.time())}', + 'object': 'chat.completion.chunk', + 'model': model_name, + 'choices': [ + { + 'index': 0, + 'delta': {}, + 'finish_reason': 'stop', + }, + ], + } + yield f'data: {json.dumps(final_chunk)}\n\n' diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/api/models.py b/pydantic_ai_slim/pydantic_ai/fastapi/api/models.py new file mode 100644 index 0000000000..1626f66e54 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/api/models.py @@ -0,0 +1,54 @@ +import time + +try: + from fastapi import HTTPException + from openai.types import ErrorObject + from openai.types.model import Model +except ImportError as _import_error: # pragma: no cover + raise ImportError( + 'Please install the `openai` and `fastapi` packages to enable the fastapi openai compatible endpoint, ' + 'you can use the `chat-completion` optional group — `pip install "pydantic-ai-slim[chat-completion]"`' + ) from _import_error + +from pydantic_ai.fastapi.data_models import ( + ErrorResponse, + ModelsResponse, +) +from pydantic_ai.fastapi.registry import AgentRegistry + + +class AgentModelsAPI: + """Models API for pydantic-ai agents.""" + + def __init__(self, registry: AgentRegistry) -> None: + self.registry = registry + + async def list_models(self) -> ModelsResponse: + """List available models (OpenAI-compatible endpoint).""" + agents = self.registry.all_agents + + models = [ + Model( + id=name, + object='model', + created=int(time.time()), + owned_by='model_owner', + ) + for name in agents + ] + return ModelsResponse(data=models) + + async def get_model(self, name: str) -> Model: + """Get information about a specific model (OpenAI-compatible endpoint).""" + if name in self.registry.all_agents: + return Model(id=name, object='model', created=int(time.time()), owned_by='NDIA') + else: + raise HTTPException( + status_code=404, + detail=ErrorResponse( + error=ErrorObject( + type='not_found_error', + message=f"Model '{name}' not found", + ), + ).model_dump(), + ) diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/api/responses.py b/pydantic_ai_slim/pydantic_ai/fastapi/api/responses.py new file mode 100644 index 0000000000..c1d0727713 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/api/responses.py @@ -0,0 +1,68 @@ +from collections.abc import AsyncGenerator + +try: + from fastapi import HTTPException + from openai.types import ErrorObject + from openai.types.responses import Response +except ImportError as _import_error: # pragma: no cover + raise ImportError( + 'Please install the `openai` and `fastapi` packages to enable the fastapi openai compatible endpoint, ' + 'you can use the `chat-completion` optional group — `pip install "pydantic-ai-slim[chat-completion]"`' + ) from _import_error + +from pydantic_ai import Agent +from pydantic_ai.fastapi.convert import ( + openai_responses_input_to_pai, + pai_result_to_openai_responses, +) +from pydantic_ai.fastapi.data_models import ErrorResponse, ResponsesRequest +from pydantic_ai.fastapi.registry import AgentRegistry +from pydantic_ai.models.openai import ( + OpenAIResponsesModelSettings, +) + + +class AgentResponsesAPI: + """Responses API openai <-> pydantic-ai conversion.""" + + def __init__(self, registry: AgentRegistry) -> None: + self.registry = registry + + def get_agent(self, name: str) -> Agent: + """Retrieves agent.""" + try: + agent = self.registry.get_responses_agent(name) + except KeyError: + raise HTTPException( + status_code=404, + detail=ErrorResponse( + error=ErrorObject( + message=f'Model {name} is not available as responses API', + type='not_found_error', + ), + ).model_dump(), + ) + + return agent + + async def create_response(self, request: ResponsesRequest) -> Response: + """Create a non-streaming chat completion.""" + model_name = request.model + agent = self.get_agent(model_name) + + model_settings = OpenAIResponsesModelSettings(openai_previous_response_id='auto') + messages = openai_responses_input_to_pai(items=request.input) + + async with agent: + result = await agent.run( + message_history=messages, + model_settings=model_settings, + ) + return pai_result_to_openai_responses( + result=result, + model=model_name, + ) + + async def create_streaming_response(self, request: ResponsesRequest) -> AsyncGenerator[str]: + """Create a streaming chat completion.""" + raise NotImplementedError diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/convert/__init__.py b/pydantic_ai_slim/pydantic_ai/fastapi/convert/__init__.py new file mode 100644 index 0000000000..cabe6c5e44 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/convert/__init__.py @@ -0,0 +1,13 @@ +from pydantic_ai.fastapi.convert.convert_messages import ( + openai_chat_completions_2pai, + openai_responses_input_to_pai, + pai_result_to_openai_completions, + pai_result_to_openai_responses, +) + +__all__ = [ + 'openai_chat_completions_2pai', + 'openai_responses_input_to_pai', + 'pai_result_to_openai_completions', + 'pai_result_to_openai_responses', +] diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/convert/convert_messages.py b/pydantic_ai_slim/pydantic_ai/fastapi/convert/convert_messages.py new file mode 100644 index 0000000000..d94e2b01e8 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/convert/convert_messages.py @@ -0,0 +1,852 @@ +import base64 +import json +from collections.abc import Iterable +from typing import Any, cast + +try: + from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageParam, + ) + from openai.types.chat.chat_completion import Choice + from openai.types.completion_usage import CompletionUsage + from openai.types.responses import ( + Response, + ResponseInputParam, + ResponseOutputMessage, + ResponseOutputText, + ResponseUsage, + ) + from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails +except ImportError as _import_error: # pragma: no cover + raise ImportError( + 'Please install the `openai` and `fastapi` packages to enable the fastapi openai compatible endpoint, ' + 'you can use the `chat-completion` optional group — `pip install "pydantic-ai-slim[chat-completion]"`' + ) from _import_error + +from pydantic import TypeAdapter + +from pydantic_ai import _utils +from pydantic_ai.agent import AgentRunResult +from pydantic_ai.messages import ( + AudioUrl, + BinaryContent, + BuiltinToolCallPart, + BuiltinToolReturnPart, + DocumentUrl, + ImageUrl, + ModelMessage, + ModelRequest, + ModelRequestPart, + ModelResponse, + ModelResponsePart, + SystemPromptPart, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UserContent, + UserPromptPart, + VideoUrl, +) +from pydantic_ai.settings import ModelSettings + +model_settings_ta = TypeAdapter(ModelSettings) + + +def _flush_request_if_any( + result: list[ModelMessage], + current_request_parts: list[ModelRequestPart], +) -> None: + """Append a ModelRequest to result if there are collected request parts and clear the collector.""" + if current_request_parts: + result.append(ModelRequest(parts=current_request_parts.copy())) + current_request_parts.clear() + + +def _flush_response_if_any( + result: list[ModelMessage], + current_response_parts: list[ModelResponsePart], +) -> None: + """Append a ModelResponse to result if there are collected response parts and clear the collector.""" + if current_response_parts: + result.append(ModelResponse(parts=current_response_parts.copy())) + current_response_parts.clear() + + +def _extract_text_from_content( + content: str | list[dict[str, Any]] | Iterable[Any], +) -> str: + """Extract plain text from content (for system/developer messages). + + The Responses SDK accepts a plain string, a list of dicts, or any iterable + of content items. Accept strings, iterables of dict-like + objects, or iterables of primitive/text items. Non-dict items will be coerced + to string where reasonable. + """ + if isinstance(content, str): + return content + + text_parts: list[str] = [] + # `content` may be any iterable (list, generator, etc.) + for item in content: + # strings are direct text pieces + if isinstance(item, str): + text_parts.append(item) + continue + + # If item is a dict-like object with a type/text schema, prefer those fields + if isinstance(item, dict): + item: dict[str, Any] + itype = item.get('type') + if itype in ('input_text', 'output_text'): + text = item.get('text', '') + if text: + text_parts.append(text) + continue + # fallback to common 'text' key if present + if 'text' in item: + maybe_text = item.get('text') + if isinstance(maybe_text, str): + text_parts.append(maybe_text) + continue + # some items nest content under 'content' + nested = item.get('content') + if isinstance(nested, str): + text_parts.append(nested) + continue + # if nothing else, try to stringify the dict (best-effort) + try: + text_parts.append(str(item)) + except Exception: + continue + + # Filter out empty strings and join + return '\n'.join(p for p in text_parts if p) + + +def _convert_content_to_user_content( # noqa: C901 + content: str | Iterable[Any], +) -> str | list[Any]: # UserContent types + """Convert ResponseInputMessageContentListParam to pydantic-ai UserContent. + + Be permissive about the incoming shape: the Responses SDK may provide a + plain string or an iterable (list, generator, or SDK iterable) of content + items. Handle strings, dict-like items, and fall back to best-effort + stringification for unknown types. + """ + if isinstance(content, str): + return content + + user_content: list[Any] = [] + for item in content: + if isinstance(item, str): + user_content.append(item) + continue + + if not isinstance(item, dict): + try: + user_content.append(str(item)) + except Exception: + continue + continue + + item: dict[str, Any] + item_type = item.get('type') + + if item_type == 'input_text': + text = item.get('text') + if isinstance(text, str): + user_content.append(text) + + elif item_type == 'input_image': + if item.get('image_url'): + user_content.append(ImageUrl(url=item['image_url'])) + + elif item_type == 'input_file': + if item.get('file_data'): + file_data = base64.b64decode(item['file_data']) + media_type = _guess_media_type_from_filename(item.get('filename', '')) + user_content.append( + BinaryContent(data=file_data, media_type=media_type), + ) + elif item.get('file_url'): + media_type = _guess_media_type_from_filename(item.get('filename', '')) + if media_type.startswith('image/'): + user_content.append(ImageUrl(url=item['file_url'])) + elif media_type.startswith('audio/'): + user_content.append(AudioUrl(url=item['file_url'])) + elif media_type.startswith('video/'): + user_content.append(VideoUrl(url=item['file_url'])) + else: + user_content.append(DocumentUrl(url=item['file_url'])) + + elif item_type == 'input_audio': + input_audio = item.get('input_audio', {}) + if isinstance(input_audio, dict) and 'data' in input_audio: + input_audio: dict[str, Any] + audio_data = base64.b64decode(input_audio['data']) + media_type = f'audio/{input_audio.get("format", "wav")}' + user_content.append( + BinaryContent(data=audio_data, media_type=media_type), + ) + + else: + if 'text' in item and isinstance(item.get('text'), str): + user_content.append(item.get('text')) + elif 'content' in item and isinstance(item.get('content'), str): + user_content.append(item.get('content')) + else: + try: + user_content.append(str(item)) + except Exception: + continue + + return user_content if user_content else '' + + +def _convert_function_output_to_content( + output_list: list[dict[str, Any]], +) -> Any: + """Convert ResponseFunctionCallOutputItemListParam to content.""" + # For simplicity, extract text content or serialize as JSON + text_parts: list[Any] = [] + for item in output_list: + item: dict[str, Any] + if item.get('type') == 'output_text': + text_parts.append(item['text']) + else: + # For non-text items, serialize as JSON + text_parts.append(json.dumps(item)) + + return '\n'.join(text_parts) if text_parts else json.dumps(output_list) + + +def _guess_media_type_from_filename(filename: str) -> str: + """Guess media type from filename extension.""" + ext = filename.lower().split('.')[-1] if '.' in filename else '' + + # Image formats + if ext in ('jpg', 'jpeg'): + return 'image/jpeg' + elif ext == 'png': + return 'image/png' + elif ext == 'gif': + return 'image/gif' + elif ext == 'webp': + return 'image/webp' + + # Audio formats + elif ext in ('mp3', 'mpeg'): + return 'audio/mpeg' + elif ext == 'wav': + return 'audio/wav' + elif ext == 'ogg': + return 'audio/ogg' + + # Video formats + elif ext == 'mp4': + return 'video/mp4' + elif ext == 'webm': + return 'video/webm' + + # Document formats + elif ext == 'pdf': + return 'application/pdf' + elif ext == 'txt': + return 'text/plain' + + # Default + return 'application/octet-stream' + + +def openai_responses_input_to_pai( # noqa: C901 + items: ResponseInputParam | str, +) -> list[ModelMessage]: + """Convert OpenAI Responses API ResponseInputParam to pydantic-ai ModelMessage format.""" + result: list[ModelMessage] = [] + current_request_parts: list[ModelRequestPart] = [] + current_response_parts: list[ModelResponsePart] = [] + + # Track tool call IDs to tool names for matching outputs to calls + tool_call_map: dict[str, str] = {} + + if isinstance(items, str): + current_request_parts = [UserPromptPart(content=items)] + else: + for item in items: + item_type = item.get( + 'type', + 'message', + ) # Get item type - default to "message" if not specified (for EasyInputMessageParam) + + if item_type == 'message': + if 'role' not in item: + continue + + role = item['role'] + content = item.get('content') + + if role in ('system', 'developer'): + text_content = _extract_text_from_content(content) + current_request_parts.append(SystemPromptPart(content=text_content)) + + elif role == 'user': + user_content = _convert_content_to_user_content(content) + current_request_parts.append(UserPromptPart(content=user_content)) + + elif role == 'assistant': + _flush_request_if_any(result, current_request_parts) + if isinstance(content, str): + current_response_parts.append(TextPart(content=content)) + elif isinstance(content, list): + for content_item in content: + if content_item.get('type') == 'output_text': + current_response_parts.append( + TextPart(content=content_item['text']), # type:ignore + ) + elif content_item.get('type') == 'refusal': + current_response_parts.append( + TextPart(content=f'[REFUSAL] {content_item["refusal"]}'), # type:ignore + ) + # Flush the response immediately so the request/response ordering + _flush_response_if_any(result, current_response_parts) + + elif item_type == 'function_call': + if 'name' not in item or 'call_id' not in item or 'arguments' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + tool_name = item['name'] + call_id = item['call_id'] + tool_call_map[call_id] = tool_name + + current_response_parts.append( + ToolCallPart( + tool_name=tool_name, + args=item['arguments'], + tool_call_id=call_id, + ), + ) + # Flush response immediately so the ToolCall is emitted as a response + # before any subsequent ToolReturnParts are added (preserves ordering). + _flush_response_if_any(result, current_response_parts) + + elif item_type == 'function_call_output': + if 'call_id' not in item or 'output' not in item: + continue + + call_id = item['call_id'] + tool_name = tool_call_map.get(call_id, 'unknown_function') + output = item['output'] + + if isinstance(output, str): + content = output + else: + content = _convert_function_output_to_content(output) # type: ignore + + current_request_parts.append( + ToolReturnPart( + tool_name=tool_name, + content=content, + tool_call_id=call_id, + ), + ) + + elif item_type == 'file_search_call': + if 'id' not in item or 'queries' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + current_response_parts.append( + BuiltinToolCallPart( + tool_name='file_search', + args=json.dumps({'queries': item['queries']}), + tool_call_id=item['id'], + provider_name='openai', + ), + ) + + # If results are present, add as tool return + if item.get('results'): + _flush_response_if_any(result, current_response_parts) + + current_response_parts.append( + BuiltinToolReturnPart( + tool_name='file_search', + content=item.get('results'), + tool_call_id=item['id'], + provider_name='openai', + ), + ) + + elif item_type == 'computer_call': + if 'call_id' not in item or 'action' not in item or 'id' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + call_id = item['call_id'] + tool_call_map[call_id] = 'computer_use' + + current_response_parts.append( + BuiltinToolCallPart( + tool_name='computer_use', + args=json.dumps(item['action']), + tool_call_id=call_id, + provider_name='openai', + ), + ) + + elif item_type == 'computer_call_output': + if 'call_id' not in item or 'output' not in item: + continue + + call_id = item['call_id'] + + current_response_parts.append( + BuiltinToolReturnPart( + tool_name='computer_use', + content=item['output'], + tool_call_id=call_id, + provider_name='openai', + ), + ) + + elif item_type == 'web_search_call': + if 'id' not in item or 'action' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + current_response_parts.append( + BuiltinToolCallPart( + tool_name='web_search', + args=json.dumps(item['action']), + tool_call_id=item['id'], + provider_name='openai', + ), + ) + + elif item_type == 'reasoning': + if 'id' not in item or 'summary' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + summary = item.get('summary') or [] + content = item.get('content') or [] + summary_texts = [s['text'] for s in summary if isinstance(s, dict) and 'text' in s] + content_texts = [c['text'] for c in content if isinstance(c, dict) and 'text' in c] + thinking_text = '\n'.join(summary_texts) + if content_texts: + thinking_text = (thinking_text + '\n\n' if thinking_text else '') + '\n'.join( + content_texts, + ) + + current_response_parts.append( + ThinkingPart( + content=thinking_text, + signature=item.get('encrypted_content'), + provider_name='openai', + ), + ) + + elif item_type == 'image_generation_call': + if 'id' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + current_response_parts.append( + BuiltinToolCallPart( + tool_name='image_generation', + args=None, + tool_call_id=cast(str, item['id']), + provider_name='openai', + ), + ) + + if item.get('result'): + _flush_response_if_any(result, current_response_parts) + + current_response_parts.append( + BuiltinToolReturnPart( + tool_name='image_generation', + content=item['result'], # type: ignore + tool_call_id=cast(str, item['id']), + provider_name='openai', + ), + ) + + elif item_type == 'code_interpreter_call': + if 'id' not in item or 'container_id' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + args_dict = {'code': item.get('code'), 'container_id': item['container_id']} + current_response_parts.append( + BuiltinToolCallPart( + tool_name='code_interpreter', + args=json.dumps(args_dict), + tool_call_id=item['id'], + provider_name='openai', + ), + ) + + if item.get('outputs'): + _flush_response_if_any(result, current_response_parts) + + current_response_parts.append( + BuiltinToolReturnPart( + tool_name='code_interpreter', + content=item['outputs'], + tool_call_id=item['id'], + provider_name='openai', + ), + ) + + elif item_type == 'local_shell_call': + if 'call_id' not in item or 'action' not in item or 'id' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + call_id = item['call_id'] + tool_call_map[call_id] = 'local_shell' + + current_response_parts.append( + BuiltinToolCallPart( + tool_name='local_shell', + args=json.dumps(item['action']), + tool_call_id=call_id, + provider_name='local', + ), + ) + + elif item_type == 'local_shell_call_output': + if 'id' not in item or 'output' not in item: + continue + + current_response_parts.append( + BuiltinToolReturnPart( + tool_name='local_shell', + content=item['output'], + tool_call_id=cast(str, item['id']), + provider_name='local', + ), + ) + + elif item_type == 'mcp_list_tools': + if 'id' not in item or 'tools' not in item: + continue + + current_request_parts.append( + ToolReturnPart( + tool_name='mcp_list_tools', + content=item['tools'], + tool_call_id=item['id'], + ), + ) + + elif item_type == 'mcp_approval_request': + if 'id' not in item or 'name' not in item or 'arguments' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + current_response_parts.append( + ToolCallPart( + tool_name=item['name'], + args=item['arguments'], + tool_call_id=item['id'], + ), + ) + + elif item_type == 'mcp_approval_response': + if 'approval_request_id' not in item or 'approve' not in item: + continue + + approval_data = { + 'approve': item['approve'], + 'reason': item.get('reason'), + } + current_request_parts.append( + ToolReturnPart( + tool_name='mcp_approval', + content=json.dumps(approval_data), + tool_call_id=item['approval_request_id'], + ), + ) + + elif item_type == 'mcp_call': + if 'id' not in item or 'name' not in item or 'arguments' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + call_id = item['id'] + tool_call_map[call_id] = item['name'] + + current_response_parts.append( + ToolCallPart( + tool_name=item['name'], + args=item['arguments'], + tool_call_id=call_id, + ), + ) + + if item.get('output') or item.get('error'): + _flush_response_if_any(result, current_response_parts) + + content = item.get('output') or f'Error: {item.get("error")}' + current_request_parts.append( + ToolReturnPart( + tool_name=item['name'], + content=content, + tool_call_id=call_id, + ), + ) + + elif item_type == 'custom_tool_call': + if 'call_id' not in item or 'name' not in item or 'input' not in item: + continue + + _flush_request_if_any(result, current_request_parts) + + call_id = item['call_id'] + tool_call_map[call_id] = item['name'] + + current_response_parts.append( + ToolCallPart( + tool_name=item['name'], + args=item['input'], + tool_call_id=call_id, + ), + ) + + elif item_type == 'custom_tool_call_output': + if 'call_id' not in item or 'output' not in item: + continue + + call_id = item['call_id'] + tool_name = tool_call_map.get(call_id, 'unknown_custom_tool') + output = item['output'] + + if isinstance(output, str): + content = output + else: + content = _convert_function_output_to_content(output) # type:ignore + + current_request_parts.append( + ToolReturnPart( + tool_name=tool_name, + content=content, + tool_call_id=call_id, + ), + ) + + elif item_type == 'item_reference': + continue + + # Flush remaining messages + _flush_request_if_any(result, current_request_parts) + _flush_response_if_any(result, current_response_parts) + + return result + + +def openai_chat_completions_2pai( # noqa: C901 + messages: str | list[ChatCompletionMessageParam], +) -> list[ModelMessage]: + """Convert OpenAI ChatCompletionMessageParam list to pydantic-ai ModelMessage format.""" + result: list[ModelMessage] = [] + current_request_parts: list[ModelRequestPart] = [] + current_response_parts: list[ModelResponsePart] = [] + + if isinstance(messages, str): + current_request_parts = [UserPromptPart(content=messages)] + else: + for message in messages: + if 'role' in message: + if message.get('role') == 'system' or message.get('role') == 'developer': + content = message['content'] # type: ignore + if not isinstance(content, str): + content = '\n'.join(part['text'] for part in content) # type: ignore + current_request_parts.append(SystemPromptPart(content=content)) + + elif message.get('role') == 'user': + content = message['content'] # type: ignore + user_content: str | list[UserContent] + if isinstance(content, str): + user_content = content + else: + user_content = [] + if content is not None: + for part in content: + if part['type'] == 'text': + user_content.append(part['text']) + elif part['type'] == 'image_url': + user_content.append(ImageUrl(url=part['image_url']['url'])) + elif part['type'] == 'input_audio': + user_content.append( + BinaryContent( + data=base64.b64decode(part['input_audio']['data']), + media_type=part['input_audio']['format'], + ), + ) + elif part['type'] == 'file': + assert 'file' in part['file'] + user_content.append( + BinaryContent( + data=base64.b64decode(part['file']['file_data']), + media_type=part['file']['file']['type'], + ), + ) + else: + raise ValueError(f'Unknown content type: {part["type"]}') + current_request_parts.append(UserPromptPart(content=user_content)) + + elif message['role'] == 'assistant': + if current_request_parts: + result.append(ModelRequest(parts=current_request_parts)) + current_request_parts = [] + + current_response_parts = [] + content = message.get('content') + tool_calls = message.get('tool_calls') + + if content: + if isinstance(content, str): + current_response_parts.append(TextPart(content=content)) + else: + content_text = '\n'.join(part['text'] for part in content if part['type'] == 'text') + if content_text: + current_response_parts.append(TextPart(content=content_text)) + + if tool_calls: + for tool_call in tool_calls: + if tool_call['type'] == 'function' and 'function' in tool_call: + current_response_parts.append( + ToolCallPart( + tool_name=tool_call['function']['name'], + args=tool_call['function']['arguments'], + tool_call_id=tool_call['id'], + ), + ) + else: + raise NotImplementedError( + 'ChatCompletionMessageCustomToolCallParam translator not implemented', + ) + if current_response_parts: + result.append(ModelResponse(parts=current_response_parts)) + current_response_parts = [] + + elif message['role'] == 'tool': + tool_call_id = message['tool_call_id'] + content = message['content'] + tool_name = message.get('name', 'unknown') + + current_request_parts.append( + ToolReturnPart( + tool_name=tool_name, + content=content, + tool_call_id=tool_call_id, + ), + ) + + elif message['role'] == 'function': + name = message['name'] + content = message['content'] + + current_request_parts.append( + ToolReturnPart( + tool_name=name, + content=content, + tool_call_id=f'call_{name}', + ), + ) + + else: + raise ValueError(f'Unknown role: {message["role"]}') + else: + raise NotImplementedError('ComputerCallOutput translator not implemented.') + + if current_request_parts: + result.append(ModelRequest(parts=current_request_parts)) + if current_response_parts: + result.append(ModelResponse(parts=current_response_parts)) + + return result + + +def pai_result_to_openai_completions(result: AgentRunResult[Any], model: str) -> ChatCompletion: + """Convert a PydanticAI AgentRunResult to OpenAI ChatCompletion format.""" + content = str(result.output) + return ChatCompletion( + id=f'chatcmpl-{_utils.now_utc().isoformat()}', + object='chat.completion', + created=int(_utils.now_utc().timestamp()), + model=model, + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role='assistant', + content=content, + ), + finish_reason='stop', + ), + ], + usage=CompletionUsage( + prompt_tokens=0, + completion_tokens=0, + total_tokens=0, + ), + ) + + +def pai_result_to_openai_responses(result: AgentRunResult[Any], model: str) -> Response: + """Convert a PydanticAI AgentRunResult to OpenAI Responses format.""" + content = str(result.output) + + all_msgs = result.all_messages() + message = all_msgs[-1] + prov_id = getattr(message, 'provider_response_id', None) or f'resp_{_utils.now_utc().isoformat()}' + # message.timestamp might be None; fall back to now if needed + timestamp_obj = getattr(message, 'timestamp', None) or _utils.now_utc() + created_at = timestamp_obj.timestamp() + + msg_id = prov_id.replace('resp_', 'msg_') if isinstance(prov_id, str) else f'msg_{_utils.now_utc().isoformat()}' + + return Response( + id=prov_id, + object='response', + created_at=created_at, + model=model, + parallel_tool_calls=False, + tools=[], + tool_choice='auto', + output=[ + ResponseOutputMessage( + id=msg_id, + status='completed', + role='assistant', + type='message', + content=[ + ResponseOutputText(text=content, annotations=[], type='output_text'), + ], + ), + ], + usage=ResponseUsage( + input_tokens=0, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens=0, + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + total_tokens=0, + ), + ) diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/data_models/__init__.py b/pydantic_ai_slim/pydantic_ai/fastapi/data_models/__init__.py new file mode 100644 index 0000000000..941e90c2d4 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/data_models/__init__.py @@ -0,0 +1,15 @@ +from .models import ( + ChatCompletionRequest, + ChatMessage, + ErrorResponse, + ModelsResponse, + ResponsesRequest, +) + +__all__ = [ + 'ChatMessage', + 'ChatCompletionRequest', + 'ErrorResponse', + 'ModelsResponse', + 'ResponsesRequest', +] diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/data_models/models.py b/pydantic_ai_slim/pydantic_ai/fastapi/data_models/models.py new file mode 100644 index 0000000000..1214622e8f --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/data_models/models.py @@ -0,0 +1,322 @@ +"""OpenAI-compatible Pydantic models for request/response validation.""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +try: + from openai.types import ErrorObject, Reasoning + from openai.types.chat import ( + ChatCompletionAudioParam, + ChatCompletionMessageParam, + ChatCompletionReasoningEffort, + ChatCompletionToolParam, + ) + from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall + from openai.types.model import Model + from openai.types.responses import ( + ResponseIncludable, + ResponseInputParam, + ResponsePromptParam, + ToolParam, + ) +except ImportError as _import_error: # pragma: no cover + raise ImportError( + 'Please install the `openai` and `fastapi` packages to enable the fastapi openai compatible endpoint, ' + 'you can use the `chat-completion` optional group — `pip install "pydantic-ai-slim[chat-completion]"`' + ) from _import_error +from pydantic import BaseModel, ConfigDict, Field + +Number = Annotated[float, Field(ge=-2.0, le=2.0)] +PositiveNumber = Annotated[float, Field(ge=0.0, le=2.0)] + + +# ChatMessage example for docs +class ChatMessage(BaseModel): + """Chat message model.""" + + role: Literal['system', 'user', 'assistant', 'tool'] = Field( + ..., + description='Role of the message sender', + ) + content: str = Field(..., description='Message content') + + tool_calls: list[ChatCompletionMessageToolCall] | None = None + """The tool calls generated by the model, such as function calls.""" + + +class ChatCompletionRequest(BaseModel): + """Chat completion request model.""" + + messages: list[ChatCompletionMessageParam] = Field( + ..., + description='List of chat messages', + ) + model: str = Field( + ..., + description='Model to use for completion', + ) + audio: ChatCompletionAudioParam | None = Field( + default=None, + description='Audio configuration for transcriptions or voice', + ) + frequency_penalty: float | None = Field( + default=None, + description='Frequency penalty', + ) + logit_bias: dict[str, int] | None = Field( + default=None, + description='Modify the likelihood of specified tokens', + ) + logprobs: bool | None = Field( + default=None, + description='Whether to return log probabilities of tokens', + ) + max_completion_tokens: int | None = Field( + default=None, + description='Maximum tokens for completion', + ) + parallel_tool_calls: bool | None = Field( + default=None, + description='Allow execution of parallel tool calls', + ) + presence_penalty: Number | None = Field( + default=None, + description='Presence penalty', + ) + reasoning_effort: ChatCompletionReasoningEffort | None = Field( + default=None, + description='Reasoning level for the model', + ) + seed: int | None = Field( + default=None, + description='Seed for deterministic completions', + ) + stop: str | None = Field( + default=None, + description='Stop sequence(s) for generation', + ) + stream: bool | None = Field( + default=None, + description='Whether to stream responses', + ) + temperature: PositiveNumber | None = Field( + default=None, + description='Sampling temperature', + ) + tools: list[ChatCompletionToolParam] | None = Field( + default=None, + description='List of tool definitions', + ) + top_logprobs: Annotated[int, Field(ge=0, le=20)] | None = Field( + default=None, + description='Number of logprobs to return', + ) + top_p: PositiveNumber | None = Field( + default=None, + description='Top-p sampling parameter', + ) + user: str | None = Field( + default=None, + description='User identifier', + ) + extra_headers: dict[str, str] | None = Field( + default=None, + description='Additional request headers', + ) + extra_body: Any | None = Field( + default=None, + description='Additional request body fields', + ) + + model_config = ConfigDict( + json_schema_extra={ + 'examples': [ + { + 'model': 'model-router', + 'messages': [ + { + 'role': 'system', + 'content': 'You are a helpful assistant.', + }, + { + 'role': 'user', + 'content': 'Hi there.', + }, + ], + 'stream': False, + }, + ], + }, + ) + + +class ResponsesRequest(BaseModel): + """Responses API request model (OpenAI Responses endpoint). + + This mirrors the parameters accepted by the OpenAI Responses.create(...) method. + """ + + # Basic routing / control + model: str = Field(..., description="Model ID used to generate the response, e.g. 'gpt-4o'") + input: str | ResponseInputParam = Field( + ..., + description='Text, image, or file inputs to the model (string or SDK input param object)', + ) + instructions: str | None = Field( + default=None, + description="A system/developer instruction to insert into the model's context", + ) + prompt: ResponsePromptParam | None = Field( + default=None, + description='Reference to a prompt template and its variables (prompt param object)', + ) + prompt_cache_key: str | None = Field( + default=None, + description='Cache key used by OpenAI for prompt caching (replaces `user` in some flows)', + ) + conversation: Any | None = Field( + default=None, + description='Conversation reference whose items are prepended to `input` for this request', + ) + previous_response_id: str | None = Field( + default=None, + description='ID of a previous response to continue a multi-turn conversation', + ) + + # Sampling / output control + temperature: PositiveNumber | None = Field( + default=None, + description='Sampling temperature (0.0 - 2.0)', + ) + top_p: PositiveNumber | None = Field( + default=None, + description='Nucleus sampling top_p (0.0 - 2.0)', + ) + top_logprobs: Annotated[int, Field(ge=0, le=20)] | None = Field( + default=None, + description='Number of top logprobs to return', + ) + max_output_tokens: int | None = Field( + default=None, + description='Upper bound for generated tokens (includes visible + reasoning tokens)', + ) + truncation: Literal['auto', 'disabled'] | None = Field( + default=None, + description='Truncation strategy if input exceeds model context window', + ) + + # Tools / function-calling / reasoning + tools: list[ToolParam] | None = Field( + default=None, + description='List/definitions of tools the model may call (SDK ToolParam objects)', + ) + tool_choice: Any | None = Field( + default=None, + description='How the model should select tools (tool choice param)', + ) + max_tool_calls: int | None = Field( + default=None, + description='Max total tool calls allowed for the response', + ) + parallel_tool_calls: bool | None = Field( + default=True, + description='Allow parallel execution of tool calls', + ) + reasoning: Reasoning | None = Field( + default=None, + description='Reasoning configuration (for reasoning-capable models)', + ) + + # Misc / safety / storage + user: str | None = Field( + default=None, + description='User identifier (being replaced by `safety_identifier` / `prompt_cache_key`)', + ) + safety_identifier: str | None = Field( + default=None, + description='Stable identifier to help detect policy abuse (hash username/email)', + ) + store: bool | None = Field( + default=True, + description='Whether to store the generated model response for later retrieval', + ) + service_tier: Literal['auto', 'default', 'flex', 'scale', 'priority'] | None = Field( + default='auto', + description='Processing tier for serving the request', + ) + + # Streaming + stream: bool | None = Field( + default=False, + description='Whether to stream response data (server-sent events)', + ) + stream_options: Any | None = Field( + default=None, + description='Streaming options (only used when `stream` is True)', + ) + + # Text / structured output config + text: Any | None = Field( + default=None, + description='Configuration for text output (plain or structured JSON)', + ) + + # Metadata / logging / headers + metadata: dict[str, str] | None = Field( + default=None, + description='Optional set of up to 16 key/value pairs attached to this request', + ) + include: list[ResponseIncludable] | None = Field( + default=None, + description="Additional output data to include (e.g. 'message.output_text.logprobs')", + ) + extra_headers: Any | None = Field( + default=None, + description='Extra headers to send with the request', + ) + extra_body: Any | None = Field( + default=None, + description='Additional JSON properties to include in the request body', + ) + extra_query: Any | None = Field( + default=None, + description='Additional Query properties to include with the request ', + ) + + # Misc / legacy / fine-grained + background: bool | None = Field( + default=False, + description='Whether to run the model response in the background', + ) + + model_config = ConfigDict( + json_schema_extra={ + 'examples': [ + { + 'model': 'gpt-5-mini', + 'instructions': 'You are a helpful assistant.', + 'input': [ + {'role': 'user', 'content': 'Hello!'}, + ], + 'stream': False, + }, + ], + }, + arbitrary_types_allowed=True, + ) + + +# Model returning list of OpenAI Models +class ModelsResponse(BaseModel): + """Models list response.""" + + object: str = Field(default='list', description='Object type') + data: list[Model] = Field(..., description='List of available models') + + +# Simple Model for OpenAI ErrorObject response +class ErrorResponse(BaseModel): + """Error response model.""" + + error: ErrorObject = Field(..., description='Error details') diff --git a/pydantic_ai_slim/pydantic_ai/fastapi/registry.py b/pydantic_ai_slim/pydantic_ai/fastapi/registry.py new file mode 100644 index 0000000000..4600ecafc9 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/fastapi/registry.py @@ -0,0 +1,76 @@ +import logging +from functools import lru_cache + +from pydantic_ai import Agent + +logger = logging.getLogger(__name__) + + +class AgentRegistry: + """PydanticAI Agent registry.""" + + def __init__(self) -> None: + self.chat_completions_agents: dict[str, Agent] = {} + self.responses_agents: dict[str, Agent] = {} + + def _get_name(self, agent_name: str | None, override_name: str | None) -> str: + name = override_name or agent_name + if name is None: + raise ValueError( + 'Agent has no model name set and override_name has not been provided. Define a model in the agent or explicitly pass the name.', + ) + + return name + + def register_responses_agent( + self, + agent: Agent, + override_name: str | None = None, + ) -> 'AgentRegistry': + """Register an agent that will be exposed as /v1/responses API model.""" + name = self._get_name(agent.name, override_name) + + if name in self.responses_agents: + logger.warning('Overriding responses agent that has already been set in registry.') + + self.responses_agents[name] = agent + return self + + def register_completions_agent( + self, + agent: Agent, + override_name: str | None = None, + ) -> 'AgentRegistry': + """Register an agent that will be exposed as /v1/chat/completions API model.""" + name = self._get_name(agent.name, override_name) + + if name in self.chat_completions_agents: + logger.warning( + 'Overriding chat completions agent that has already been set in registry.', + ) + + self.chat_completions_agents[name] = agent + return self + + def get_responses_agent(self, name: str) -> Agent: + """Get responses API agent.""" + agent = self.responses_agents.get(name) + + if agent is None: + raise KeyError('Responses agent with %s has not been registered.', name) + return agent + + def get_completions_agent(self, name: str) -> Agent: + """Get chat completions API agent.""" + agent = self.chat_completions_agents.get(name) + + if agent is None: + raise KeyError('Completions agent with %s has not been registered.', name) + return agent + + @property + @lru_cache + def all_agents(self) -> list[str]: + """Retrieves all registered agents in the registry.""" + unique_agent_set = {*self.chat_completions_agents.keys(), *self.responses_agents.keys()} + return [*unique_agent_set] diff --git a/pydantic_ai_slim/pydantic_ai/settings.py b/pydantic_ai_slim/pydantic_ai/settings.py index 6941eb1ab3..b2fea9a05e 100644 --- a/pydantic_ai_slim/pydantic_ai/settings.py +++ b/pydantic_ai_slim/pydantic_ai/settings.py @@ -1,9 +1,11 @@ from __future__ import annotations from httpx import Timeout +from pydantic import ConfigDict, with_config from typing_extensions import TypedDict +@with_config(ConfigDict(arbitrary_types_allowed=True)) class ModelSettings(TypedDict, total=False): """Settings to configure an LLM. diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 54538f1deb..b12217cc78 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -111,6 +111,8 @@ temporal = ["temporalio==1.18.0"] dbos = ["dbos>=1.14.0"] # Prefect prefect = ["prefect>=3.4.21"] +# Chat completions/responses endpoint +chat-completion = ["openai>=1.107.2", "fastapi"] [tool.hatch.metadata] allow-direct-references = true diff --git a/pyproject.toml b/pyproject.toml index 3c13afdece..aab05d8bc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ requires-python = ">=3.10" [tool.hatch.metadata.hooks.uv-dynamic-versioning] dependencies = [ - "pydantic-ai-slim[openai,vertexai,google,groq,anthropic,mistral,cohere,bedrock,huggingface,cli,mcp,fastmcp,evals,ag-ui,retries,temporal,logfire,ui]=={{ version }}", + "pydantic-ai-slim[openai,vertexai,google,groq,anthropic,mistral,cohere,bedrock,huggingface,cli,mcp,fastmcp,evals,ag-ui,retries,temporal,logfire,ui,chat-completion]=={{ version }}", ] [tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies] @@ -110,6 +110,10 @@ dev = [ "pip>=25.2", "genai-prices>=0.0.28", "mcp-run-python>=0.0.20", + "pytest-asyncio>=1.2.0", + "freezegun>=1.5.5", + "aioresponses>=0.7.8", + "openai[aiohttp]", ] lint = ["mypy>=1.11.2", "pyright>=1.1.390", "ruff>=0.6.9"] docs = [ diff --git a/tests/agent_to_fastapi/__init__.py b/tests/agent_to_fastapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/agent_to_fastapi/integration_tests/__init__.py b/tests/agent_to_fastapi/integration_tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/agent_to_fastapi/integration_tests/conftest.py b/tests/agent_to_fastapi/integration_tests/conftest.py new file mode 100644 index 0000000000..e8bbfb01bc --- /dev/null +++ b/tests/agent_to_fastapi/integration_tests/conftest.py @@ -0,0 +1,123 @@ +import asyncio +from collections.abc import AsyncGenerator, Generator +from contextlib import suppress +from typing import Any, cast + +import pytest +import pytest_asyncio +from fastapi import APIRouter + +from ...conftest import try_import + +with try_import() as imports_successful: + from fastapi import FastAPI + from httpx import ASGITransport, AsyncClient + from openai import AsyncOpenAI, DefaultAioHttpClient + + from pydantic_ai import Agent + from pydantic_ai.fastapi.agent_router import create_agent_router + from pydantic_ai.fastapi.registry import AgentRegistry + from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel + from pydantic_ai.providers.openai import OpenAIProvider + + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='OpenAI client not installed or FastAPI not installed'), + pytest.mark.anyio, +] + + +@pytest.fixture(scope='session') +def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: + """Provide an asyncio event loop for pytest-asyncio.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def app() -> FastAPI: + """Create a FastAPI app configured with an AgentAPIRouter backed by a small test + AgentRegistry. This fixture also disables the `response_model` on the /v1/responses + route so tests can return simple dicts without matching the full complex Pydantic + Responses model. + """ + registry = AgentRegistry() + registry.chat_completions_agents['test-model'] = cast(Any, object()) + registry.responses_agents['test-model'] = cast(Any, object()) + + router = create_agent_router(agent_registry=registry) + + for route in list(getattr(router, 'routes', [])): + if getattr(route, 'path', None) == '/v1/responses': + with suppress(Exception): + route.response_model = None + + app = FastAPI() + app.include_router(router) + + app.state.agent_router = router + + return app + + +@pytest.fixture +def agent_router(app: FastAPI) -> APIRouter: + """Return the AgentAPIRouter instance attached to the app by the `app` fixture. + Tests can use this to stub `completions_api` and `responses_api` coroutine methods. + """ + return app.state.agent_router + + +@pytest_asyncio.fixture +async def async_client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]: + """Provide an httpx.AsyncClient configured to talk to the FastAPI app in-process. + Use this in async tests to make HTTP requests against the test app. + """ + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + yield client + + +@pytest_asyncio.fixture +async def registry_with_openai_clients() -> AsyncGenerator[AgentRegistry, None]: + """Build an AgentRegistry wired to real pydantic-ai Agents backed by AsyncOpenAI + clients pointed at a test base URL. + """ + fake_openai_base = 'https://api.openai.test/v1' + + openai_client_for_chat = AsyncOpenAI( + base_url=fake_openai_base, + api_key='test-key', + http_client=DefaultAioHttpClient(), + ) + openai_client_for_responses = AsyncOpenAI( + base_url=fake_openai_base, + api_key='test-key', + http_client=DefaultAioHttpClient(), + ) + + chat_model = OpenAIChatModel( + model_name='test-model', + provider=OpenAIProvider(openai_client=openai_client_for_chat), + ) + responses_model = OpenAIResponsesModel( + model_name='test-model', + provider=OpenAIProvider(openai_client=openai_client_for_responses), + ) + + agent_chat = Agent(model=chat_model, system_prompt='You are a helpful assistant') + agent_responses = Agent(model=responses_model, system_prompt='You are a helpful assistant') + + registry = AgentRegistry() + registry.chat_completions_agents['test-model'] = agent_chat + registry.responses_agents['test-model'] = agent_responses + + registry.chat_completions_agents['test-model-only-completions'] = agent_chat + registry.responses_agents['test-model-only-responses'] = agent_responses + + try: + yield registry + finally: + await openai_client_for_chat.close() + await openai_client_for_responses.close() diff --git a/tests/agent_to_fastapi/integration_tests/test_api_integration.py b/tests/agent_to_fastapi/integration_tests/test_api_integration.py new file mode 100644 index 0000000000..62eb830748 --- /dev/null +++ b/tests/agent_to_fastapi/integration_tests/test_api_integration.py @@ -0,0 +1,247 @@ +import contextlib +import time + +import pytest +from aioresponses import aioresponses + +from ...conftest import try_import + +with try_import() as imports_successful: + from fastapi import FastAPI + from fastapi.routing import APIRoute + from httpx import ASGITransport, AsyncClient + + from pydantic_ai.fastapi.agent_router import create_agent_router + from pydantic_ai.fastapi.registry import AgentRegistry + + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='OpenAI client not installed or FastAPI not installed'), + pytest.mark.anyio, +] + + +@pytest.mark.asyncio +async def test_models_list_and_get( + registry_with_openai_clients: AgentRegistry, +) -> None: + """Verify model listing and retrieval endpoints behave as expected when real Agents are registered.""" + registry = registry_with_openai_clients + + router = create_agent_router(agent_registry=registry) + + app = FastAPI() + app.include_router(router) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + resp = await client.get('/v1/models') + assert resp.status_code == 200 + + body = resp.json() + assert 'data' in body + + ids = [m['id'] for m in body['data']] + assert set(ids) == { + 'test-model', + 'test-model-only-completions', + 'test-model-only-responses', + } + + resp2 = await client.get('/v1/models/test-model') + assert resp2.status_code == 200 + + model_body = resp2.json() + assert model_body['id'] == 'test-model' + + resp3 = await client.get('/v1/models/nonexistent-model') + assert resp3.status_code == 404 + + detail = resp3.json().get('detail') + assert isinstance(detail, dict) + assert 'error' in detail + assert detail['error']['type'] == 'not_found_error' + + +@pytest.mark.asyncio +async def test_routers_disabled( + registry_with_openai_clients: AgentRegistry, +) -> None: + """Verify whether disabling apis actually effectively not adds APIRoutes to the app.""" + registry = registry_with_openai_clients + + router = create_agent_router(agent_registry=registry, disable_completions_api=True, disable_responses_api=True) + + app = FastAPI() + app.include_router(router) + + transport = ASGITransport(app=app) + + api_routes: list[APIRoute] = list(filter(lambda x: isinstance(x, APIRoute), app.routes)) # type: ignore + assert {item.path for item in api_routes} == {'/v1/models', '/v1/models/{model_id}'} + + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + payload = { + 'model': 'test-model', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + + response = await client.post('/v1/chat/completions', json=payload) + assert response.is_error + assert response.status_code == 404 + + response = await client.post('/v1/responses', json=payload) + assert response.is_error + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_route_not_implemented(registry_with_openai_clients: AgentRegistry) -> None: + """Isolated test to assert registry raises KeyError for models that only implement the other route.""" + registry = registry_with_openai_clients + + with pytest.raises(KeyError) as excinfo: + registry.get_completions_agent('test-model-only-responses') + assert excinfo.value.args == ( + 'Completions agent with %s has not been registered.', + 'test-model-only-responses', + ) + + with pytest.raises(KeyError) as excinfo: + registry.get_responses_agent('test-model-only-completions') + assert excinfo.value.args == ( + 'Responses agent with %s has not been registered.', + 'test-model-only-completions', + ) + + +@pytest.mark.asyncio +async def test_chat_completions_e2e_with_mocked_openai( + registry_with_openai_clients: AgentRegistry, + allow_model_requests: None, +) -> None: + """End-to-end-ish test for /v1/chat/completions: + - Registers a real pydantic-ai Agent backed by AsyncOpenAI pointed at a fake base URL. + - Uses aioresponses to intercept the outbound HTTP POST to the OpenAI chat completions endpoint. + - Asserts that the final FastAPI response is the expected OpenAI-style chat completion. + """ + fake_openai_base = 'https://api.openai.test/v1' + registry = registry_with_openai_clients + + router = create_agent_router(agent_registry=registry) + + app = FastAPI() + app.include_router(router) + transport = ASGITransport(app=app) + + fake_openai_resp = { + 'id': 'chatcmpl-test', + 'object': 'chat.completion', + 'created': int(time.time()), + 'model': 'test-model', + 'choices': [ + { + 'index': 0, + 'finish_reason': 'stop', + 'message': {'role': 'assistant', 'content': 'hello from mocked openai'}, + }, + ], + 'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}, + } + + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + # Intercept outbound AsyncOpenAI request and return our canned response + with aioresponses() as mocked: + mocked.post( # type: ignore + fake_openai_base + '/chat/completions', + payload=fake_openai_resp, + status=200, + ) + + payload = { + 'model': 'test-model', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + + r2 = await client.post('/v1/chat/completions', json=payload) + assert r2.status_code == 200 + + body = r2.json() + assert body['model'] == 'test-model' + assert body['choices'][0]['message']['content'] == 'hello from mocked openai' + + # Intercept outbound AsyncOpenAI request and return our canned response + with aioresponses() as mocked: + mocked.post( # type: ignore + fake_openai_base + '/chat/completions', + payload=fake_openai_resp, + status=200, + ) + payload_missing = { + 'model': 'test-model-only-responses', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + + r_missing = await client.post('/v1/chat/completions', json=payload_missing) + assert r_missing.status_code == 404 + + +@pytest.mark.asyncio +async def test_responses_e2e_with_mocked_openai( + registry_with_openai_clients: AgentRegistry, + allow_model_requests: None, +) -> None: + """End-to-end-ish test for /v1/responses: + - Registers a real pydantic-ai Agent backed by AsyncOpenAI pointed at a fake base URL. + - Uses aioresponses to intercept the outbound HTTP POST to the OpenAI Responses endpoint. + - Asserts that the final FastAPI response contains the expected output text. + """ + fake_openai_base = 'https://api.openai.test/v1' + registry = registry_with_openai_clients + + router = create_agent_router(agent_registry=registry) + + # Disable response_model on the /v1/responses route so tests can return simple dicts if needed + for route in list(getattr(router, 'routes', [])): + if getattr(route, 'path', None) == '/v1/responses': + with contextlib.suppress(Exception): + route.response_model = None + + app = FastAPI() + app.include_router(router) + transport = ASGITransport(app=app) + + fake_openai_resp = { + 'id': 'resp-test', + 'object': 'response', + 'model': 'test-model', + 'created_at': int(time.time()), + 'output': [ + { + 'id': 'msg-1', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': 'response from mocked openai'}], + }, + ], + } + + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + with aioresponses() as mocked: + mocked.post(fake_openai_base + '/responses', payload=fake_openai_resp, status=200) # type: ignore + + payload_missing = {'model': 'test-model-only-completions', 'input': 'Say hi'} + r_missing = await client.post('/v1/responses', json=payload_missing) + assert r_missing.status_code == 404 + + payload = {'model': 'test-model', 'input': 'Say hi'} + r2 = await client.post('/v1/responses', json=payload) + assert r2.status_code == 200 + + body = r2.json() + assert body['model'] == 'test-model' + + assert any( + isinstance(part, dict) and part.get('text') == 'response from mocked openai' # type: ignore + for out in body.get('output', []) + for part in out.get('content', []) + ), 'Expected to find output text from mocked openai' diff --git a/tests/agent_to_fastapi/unit_tests/__init__.py b/tests/agent_to_fastapi/unit_tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/agent_to_fastapi/unit_tests/test_convert_messages.py b/tests/agent_to_fastapi/unit_tests/test_convert_messages.py new file mode 100644 index 0000000000..994d28db80 --- /dev/null +++ b/tests/agent_to_fastapi/unit_tests/test_convert_messages.py @@ -0,0 +1,396 @@ +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from freezegun import freeze_time + +from ...conftest import try_import + +with try_import() as imports_successful: + from openai.types.chat import ( + ChatCompletionMessageParam, + ) + from openai.types.responses import ( + ResponseInputParam, + ) + + from pydantic_ai.fastapi.convert import ( + openai_chat_completions_2pai, + openai_responses_input_to_pai, + pai_result_to_openai_completions, + pai_result_to_openai_responses, + ) + + +from pydantic_ai.agent import AgentRunResult +from pydantic_ai.messages import ( + BuiltinToolCallPart, + BuiltinToolReturnPart, + ImageUrl, + ModelMessage, + ModelRequest, + ModelRequestPart, + ModelResponse, + ModelResponsePart, + SystemPromptPart, + TextPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='OpenAI client not installed or FastAPI not installed'), + pytest.mark.anyio, +] + + +@pytest.fixture +def chat_user_with_image() -> list[dict[str, Any]]: + """Chat-style user message with a text and an image_url part.""" + return [ + { + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'see this image'}, + {'type': 'image_url', 'image_url': {'url': 'https://example.com/img.png'}}, + ], + }, + ] + + +@pytest.fixture +def chat_messages_with_tool_calls() -> list[dict[str, Any]]: + """Messages exercising an assistant function/tool call flow.""" + return [ + {'role': 'system', 'content': 'system prompt'}, + {'role': 'user', 'content': 'please call tool'}, + { + 'role': 'assistant', + 'content': 'assistant invoking tool', + 'tool_calls': [ + { + 'type': 'function', + 'id': 'call_1', + 'function': {'name': 'myfunc', 'arguments': {'x': 1}}, + }, + ], + }, + {'role': 'tool', 'tool_call_id': 'call_1', 'content': 'tool output', 'name': 'myfunc'}, + {'role': 'function', 'name': 'myfunc', 'content': 'func return content'}, + ] + + +@pytest.fixture +def responses_assistant_content() -> list[dict[str, Any]]: + """Responses-style assistant content with output_text and a refusal.""" + return [ + {'type': 'message', 'role': 'user', 'content': 'user asks'}, + { + 'type': 'message', + 'role': 'assistant', + 'content': [ + {'type': 'output_text', 'text': 'hello world'}, + {'type': 'refusal', 'refusal': 'I refuse'}, + ], + }, + ] + + +@pytest.fixture +def responses_function_call_and_output() -> list[dict[str, Any]]: + """Function_call followed by its function_call_output (Responses format).""" + return [ + {'type': 'message', 'role': 'user', 'content': 'start'}, + {'type': 'function_call', 'name': 'toolA', 'call_id': 'c1', 'arguments': {'a': 1}}, + { + 'type': 'function_call_output', + 'call_id': 'c1', + 'output': [{'type': 'output_text', 'text': 'resultA'}], + }, + ] + + +@pytest.fixture +def responses_builtin_calls() -> list[dict[str, Any]]: + """Examples of built-in Responses API tool calls.""" + return [ + {'type': 'message', 'role': 'user', 'content': 'begin'}, + {'type': 'file_search_call', 'id': 'f1', 'queries': ['a', 'b'], 'results': [{'name': 'x'}]}, + {'type': 'image_generation_call', 'id': 'img1', 'result': [{'url': 'https://img'}]}, + { + 'type': 'code_interpreter_call', + 'id': 'ci1', + 'container_id': 'cont', + 'outputs': [{'path': '/out'}], + 'code': 'print(1)', + }, + ] + + +class MinimalAgentRunResult(AgentRunResult[str]): + """Minimal AgentRunResult for tests: only `output` and `all_messages` are required.""" + + def __init__(self, output: str, messages: list[Any]) -> None: + self.output = output + self._messages = messages + + def all_messages( + self, + *, + output_tool_return_content: str | None = None, + ) -> list[Any]: + """Return the message history for the run.""" + return self._messages + + +@pytest.fixture +def fake_agent_result() -> AgentRunResult[str]: + """Minimal AgentRunResult instance with `output` and `all_messages()`.""" + fake_msg = SimpleNamespace(provider_response_id=None, timestamp=None) + return MinimalAgentRunResult(output='agent output text', messages=[fake_msg]) + + +# --- Helpers ------------------------------------------------------------------ + + +def collect_parts_of_type( + messages: list[ModelMessage], + part_type: type[ModelRequestPart | ModelResponsePart], +) -> list[ModelRequestPart | ModelResponsePart]: + """Collect all parts of the given type from messages.""" + found: list[ModelRequestPart | ModelResponsePart] = [] + for message in messages: + for part in getattr(message, 'parts', []): + if isinstance(part, part_type): + found.append(part) + return found + + +# --- Tests ------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ('input_value', 'expected_content'), + [ + ('plain text input', 'plain text input'), + ([{'role': 'user', 'content': 'plain user list'}], 'plain user list'), + ], +) +@freeze_time('1970-01-01') +def test_openai_chat_completions_2pai_basic_string_and_list( + input_value: list[ChatCompletionMessageParam] | str, + expected_content: str, +) -> None: + """ChatCompletions converter produces a ModelRequest with a UserPromptPart.""" + parsed = openai_chat_completions_2pai(input_value) + assert parsed, 'Expected non-empty result' + + expected = [ModelRequest(parts=[UserPromptPart(content=expected_content)])] + assert parsed == expected + + +@freeze_time('1970-01-01') +def test_openai_chat_completions_2pai_user_with_image( + chat_user_with_image: list[ChatCompletionMessageParam] | str, +) -> None: + """image_url parts become ImageUrl instances and preserve the URL.""" + parsed = openai_chat_completions_2pai(chat_user_with_image) + assert parsed, 'Expected parsed messages' + + expected = [ + ModelRequest( + parts=[ + UserPromptPart( + content=[ + 'see this image', + ImageUrl(url='https://example.com/img.png'), + ], + ), + ], + ), + ] + assert parsed == expected + + +@freeze_time('1970-01-01') +def test_openai_chat_completions_2pai_assistant_tool_flow( + chat_messages_with_tool_calls: list[ChatCompletionMessageParam] | str, +) -> None: + """Tool calls and returns are converted into ToolCallPart and ToolReturnPart entries.""" + parsed = openai_chat_completions_2pai(chat_messages_with_tool_calls) + expected = [ + ModelRequest( + parts=[ + SystemPromptPart(content='system prompt'), + UserPromptPart(content='please call tool'), + ], + ), + ModelResponse( + parts=[ + TextPart(content='assistant invoking tool'), + ToolCallPart(tool_name='myfunc', args={'x': 1}, tool_call_id='call_1'), + ], + ), + ModelRequest( + parts=[ + ToolReturnPart(tool_name='myfunc', content='tool output', tool_call_id='call_1'), + ToolReturnPart( + tool_name='myfunc', + content='func return content', + tool_call_id='call_myfunc', + ), + ], + ), + ] + assert parsed == expected + + +@pytest.mark.parametrize( + ('items', 'expected_text'), + [ + ('just a string', 'just a string'), + ([{'type': 'message', 'role': 'user', 'content': 'some iterable'}], 'some iterable'), + ], +) +@freeze_time('1970-01-01') +def test_openai_responses_input_to_pai_string_variants( + items: ResponseInputParam | str, + expected_text: str, +) -> None: + """Responses-style strings and message lists produce a UserPromptPart.""" + parsed = openai_responses_input_to_pai(items) + assert parsed, 'Expected parsed messages' + + expected = [ModelRequest(parts=[UserPromptPart(content=expected_text)])] + assert parsed == expected + + +@freeze_time('1970-01-01') +def test_openai_responses_input_to_pai_assistant_content( + responses_assistant_content: ResponseInputParam | str, +) -> None: + """output_text -> TextPart; refusal -> prefixed text.""" + parsed = openai_responses_input_to_pai(responses_assistant_content) + expected = [ + ModelRequest(parts=[UserPromptPart(content='user asks')]), + ModelResponse( + parts=[ + TextPart(content='hello world'), + TextPart(content='[REFUSAL] I refuse'), + ], + ), + ] + assert parsed == expected + + +@freeze_time('1970-01-01') +def test_openai_responses_input_to_pai_function_call_and_output( + responses_function_call_and_output: ResponseInputParam | str, +) -> None: + """function_call + function_call_output -> ToolCallPart and ToolReturnPart.""" + parsed = openai_responses_input_to_pai(responses_function_call_and_output) + + expected = [ + ModelRequest(parts=[UserPromptPart(content='start')]), + ModelResponse( + parts=[ + ToolCallPart(tool_name='toolA', args={'a': 1}, tool_call_id='c1'), + ], + ), + ModelRequest( + parts=[ + ToolReturnPart(tool_name='toolA', content='resultA', tool_call_id='c1'), + ], + ), + ] + + assert parsed == expected + + +@freeze_time('1970-01-01') +def test_openai_responses_input_to_pai_builtin_calls( + responses_builtin_calls: ResponseInputParam, +) -> None: + """Built-in calls (file_search, image_generation, code_interpreter) produce BuiltinToolCall/Return parts.""" + parsed = openai_responses_input_to_pai(responses_builtin_calls) + + expected = [ + ModelRequest(parts=[UserPromptPart(content='begin')]), + ModelResponse( + parts=[ + BuiltinToolCallPart( + tool_name='file_search', + args=json.dumps({'queries': ['a', 'b']}), + tool_call_id='f1', + provider_name='openai', + ), + ], + ), + ModelResponse( + parts=[ + BuiltinToolReturnPart( + tool_name='file_search', + content=[{'name': 'x'}], + tool_call_id='f1', + provider_name='openai', + ), + BuiltinToolCallPart( + tool_name='image_generation', + args=None, + tool_call_id='img1', + provider_name='openai', + ), + ], + ), + ModelResponse( + parts=[ + BuiltinToolReturnPart( + tool_name='image_generation', + content=[{'url': 'https://img'}], + tool_call_id='img1', + provider_name='openai', + ), + BuiltinToolCallPart( + tool_name='code_interpreter', + args=json.dumps({'code': 'print(1)', 'container_id': 'cont'}), + tool_call_id='ci1', + provider_name='openai', + ), + ], + ), + ModelResponse( + parts=[ + BuiltinToolReturnPart( + tool_name='code_interpreter', + content=[{'path': '/out'}], + tool_call_id='ci1', + provider_name='openai', + ), + ], + ), + ] + + assert parsed == expected + + +@freeze_time('1970-01-01') +def test_pai_result_to_openai_completions_and_responses(fake_agent_result: AgentRunResult) -> None: + """Convert AgentRunResult-like object into OpenAI ChatCompletion and Responses outputs.""" + expected_text = 'agent output text' + + chat = pai_result_to_openai_completions(fake_agent_result, model='unit-test-model') + assert chat.model == 'unit-test-model' + assert getattr(chat, 'choices', None), 'Expected at least one choice in ChatCompletion response' + assert chat.choices[0].message.content == expected_text + + resp = pai_result_to_openai_responses(fake_agent_result, model='unit-test-model') + assert resp.model == 'unit-test-model' + assert getattr(resp, 'output', None), 'Expected Responses output to be non-empty' + + output_msg = resp.output[0] + assert getattr(output_msg, 'role', None) == 'assistant' + + content_texts = [getattr(part, 'text', None) for part in output_msg.content] # type:ignore + assert expected_text in content_texts diff --git a/tests/conftest.py b/tests/conftest.py index ef4e442dbb..1d3941e61b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ import httpx import pytest +import pytest_asyncio from _pytest.assertion.rewrite import AssertionRewritingHook from pytest_mock import MockerFixture from vcr import VCR, request as vcr_request @@ -303,7 +304,7 @@ def vcr_config(): } -@pytest.fixture(autouse=True) +@pytest_asyncio.fixture(autouse=True) async def close_cached_httpx_client(anyio_backend: str) -> AsyncIterator[None]: yield for provider in [ diff --git a/uv.lock b/uv.lock index d311853566..7c91e7f0c2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -154,6 +154,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/fd/677def96a75057b0a26446b62f8fbb084435b20a7d270c99539c26573bfd/aiohttp-3.11.12-cp313-cp313-win_amd64.whl", hash = "sha256:f7914ab70d2ee8ab91c13e5402122edbc77821c66d2758abb53aabe87f013287", size = 436234, upload-time = "2025-02-06T00:28:01.693Z" }, ] +[[package]] +name = "aioresponses" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" }, +] + [[package]] name = "aiosignal" version = "1.3.2" @@ -448,6 +461,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.13.3" @@ -1829,6 +1851,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164, upload-time = "2025-01-21T20:04:47.734Z" }, ] +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + [[package]] name = "frozendict" version = "2.4.6" @@ -2315,6 +2349,19 @@ http2 = [ { name = "h2" }, ] +[[package]] +name = "httpx-aiohttp" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/f2/9a86ce9bc48cf57dabb3a3160dfed26d8bbe5a2478a51f9d1dbf89f2f1fc/httpx_aiohttp-0.1.9.tar.gz", hash = "sha256:4ee8b22e6f2e7c80cd03be29eff98bfe7d89bd77f021ce0b578ee76b73b4bfe6", size = 206023, upload-time = "2025-10-15T08:52:57.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/db/5cfa8254a86c34a1ab7fe0dbec9f81bb5ebd831cbdd65aa4be4f37027804/httpx_aiohttp-0.1.9-py3-none-any.whl", hash = "sha256:3dc2845568b07742588710fcf3d72db2cbcdf2acc93376edf85f789c4d8e5fda", size = 6180, upload-time = "2025-10-15T08:52:56.521Z" }, +] + [[package]] name = "httpx-sse" version = "0.4.0" @@ -4026,6 +4073,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/65/e51a77a368eed7b9cc22ce394087ab43f13fa2884724729b716adf2da389/openai-1.107.2-py3-none-any.whl", hash = "sha256:d159d4f3ee3d9c717b248c5d69fe93d7773a80563c8b1ca8e9cad789d3cf0260", size = 946937, upload-time = "2025-09-12T19:52:19.355Z" }, ] +[package.optional-dependencies] +aiohttp = [ + { name = "aiohttp" }, + { name = "httpx-aiohttp" }, +] + [[package]] name = "openai-harmony" version = "0.0.4" @@ -5321,7 +5374,7 @@ email = [ name = "pydantic-ai" source = { editable = "." } dependencies = [ - { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "chat-completion", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai"] }, ] [package.optional-dependencies] @@ -5355,6 +5408,7 @@ prefect = [ [package.dev-dependencies] dev = [ + { name = "aioresponses" }, { name = "anyio" }, { name = "asgi-lifespan" }, { name = "boto3-stubs", extra = ["bedrock-runtime"] }, @@ -5363,11 +5417,14 @@ dev = [ { name = "diff-cover" }, { name = "dirty-equals" }, { name = "duckduckgo-search" }, + { name = "freezegun" }, { name = "genai-prices" }, { name = "inline-snapshot" }, { name = "mcp-run-python" }, + { name = "openai", extra = ["aiohttp"] }, { name = "pip" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-examples" }, { name = "pytest-mock" }, { name = "pytest-pretty" }, @@ -5400,7 +5457,7 @@ lint = [ requires-dist = [ { name = "fasta2a", marker = "extra == 'a2a'", specifier = ">=0.4.1" }, { name = "pydantic-ai-examples", marker = "extra == 'examples'", editable = "examples" }, - { name = "pydantic-ai-slim", extras = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai"], editable = "pydantic_ai_slim" }, + { name = "pydantic-ai-slim", extras = ["ag-ui", "anthropic", "bedrock", "chat-completion", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai"], editable = "pydantic_ai_slim" }, { name = "pydantic-ai-slim", extras = ["dbos"], marker = "extra == 'dbos'", editable = "pydantic_ai_slim" }, { name = "pydantic-ai-slim", extras = ["outlines-llamacpp"], marker = "extra == 'outlines-llamacpp'", editable = "pydantic_ai_slim" }, { name = "pydantic-ai-slim", extras = ["outlines-mlxlm"], marker = "extra == 'outlines-mlxlm'", editable = "pydantic_ai_slim" }, @@ -5413,6 +5470,7 @@ provides-extras = ["a2a", "dbos", "examples", "outlines-llamacpp", "outlines-mlx [package.metadata.requires-dev] dev = [ + { name = "aioresponses", specifier = ">=0.7.8" }, { name = "anyio", specifier = ">=4.5.0" }, { name = "asgi-lifespan", specifier = ">=2.1.0" }, { name = "boto3-stubs", extras = ["bedrock-runtime"] }, @@ -5421,11 +5479,14 @@ dev = [ { name = "diff-cover", specifier = ">=9.2.0" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "duckduckgo-search", specifier = ">=7.0.0" }, + { name = "freezegun", specifier = ">=1.5.5" }, { name = "genai-prices", specifier = ">=0.0.28" }, { name = "inline-snapshot", specifier = ">=0.19.3" }, { name = "mcp-run-python", specifier = ">=0.0.20" }, + { name = "openai", extras = ["aiohttp"] }, { name = "pip", specifier = ">=25.2" }, { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-examples", specifier = ">=0.0.18" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-pretty", specifier = ">=1.3.0" }, @@ -5522,6 +5583,10 @@ anthropic = [ bedrock = [ { name = "boto3" }, ] +chat-completion = [ + { name = "fastapi" }, + { name = "openai" }, +] cli = [ { name = "argcomplete" }, { name = "prompt-toolkit" }, @@ -5616,6 +5681,7 @@ requires-dist = [ { name = "ddgs", marker = "extra == 'duckduckgo'", specifier = ">=9.0.0" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "fasta2a", marker = "extra == 'a2a'", specifier = ">=0.4.1" }, + { name = "fastapi", marker = "extra == 'chat-completion'" }, { name = "fastmcp", marker = "extra == 'fastmcp'", specifier = ">=2.12.0" }, { name = "genai-prices", specifier = ">=0.0.35" }, { name = "google-auth", marker = "extra == 'vertexai'", specifier = ">=2.36.0" }, @@ -5627,6 +5693,7 @@ requires-dist = [ { name = "logfire", extras = ["httpx"], marker = "extra == 'logfire'", specifier = ">=3.14.1" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.12.3" }, { name = "mistralai", marker = "extra == 'mistral'", specifier = ">=1.9.10" }, + { name = "openai", marker = "extra == 'chat-completion'", specifier = ">=1.107.2" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.107.2" }, { name = "opentelemetry-api", specifier = ">=1.28.0" }, { name = "outlines", marker = "extra == 'outlines-vllm-offline'", specifier = ">=1.0.0,<1.3.0" }, @@ -5653,7 +5720,7 @@ requires-dist = [ { name = "typing-inspection", specifier = ">=0.4.0" }, { name = "vllm", marker = "python_full_version < '3.12' and extra == 'outlines-vllm-offline'" }, ] -provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "cli", "cohere", "dbos", "duckduckgo", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "outlines-llamacpp", "outlines-mlxlm", "outlines-sglang", "outlines-transformers", "outlines-vllm-offline", "prefect", "retries", "tavily", "temporal", "ui", "vertexai"] +provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "chat-completion", "cli", "cohere", "dbos", "duckduckgo", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "outlines-llamacpp", "outlines-mlxlm", "outlines-sglang", "outlines-transformers", "outlines-vllm-offline", "prefect", "retries", "tavily", "temporal", "ui", "vertexai"] [[package]] name = "pydantic-core" @@ -5887,6 +5954,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + [[package]] name = "pytest-examples" version = "0.0.18"