From 1cfa48ed76180edafcc17a2d40b4306ef10b6b4e Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:19:04 -0500 Subject: [PATCH 01/15] add Agent.to_web() method and web chat UI module #3295 --- .../pydantic_ai/agent/__init__.py | 29 +++++ .../pydantic_ai/ui/web/__init__.py | 16 +++ .../pydantic_ai/ui/web/agent_options.py | 75 +++++++++++++ pydantic_ai_slim/pydantic_ai/ui/web/api.py | 80 ++++++++++++++ pydantic_ai_slim/pydantic_ai/ui/web/app.py | 47 ++++++++ tests/test_ui_web.py | 101 ++++++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 pydantic_ai_slim/pydantic_ai/ui/web/__init__.py create mode 100644 pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py create mode 100644 pydantic_ai_slim/pydantic_ai/ui/web/api.py create mode 100644 pydantic_ai_slim/pydantic_ai/ui/web/app.py create mode 100644 tests/test_ui_web.py diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 4cd353b44a..36a1c659a8 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -1470,6 +1470,35 @@ def _set_sampling_model(toolset: AbstractToolset[AgentDepsT]) -> None: self._get_toolset().apply(_set_sampling_model) + def to_web(self) -> Any: + """Create a FastAPI app that serves a web chat UI for this agent. + + This method returns a pre-configured FastAPI application that provides a web-based + chat interface for interacting with the agent. The UI is served from a CDN and + includes support for model selection and builtin tool configuration. + + Returns: + A configured FastAPI application ready to be served (e.g., with uvicorn) + + Example: + ```python + from pydantic_ai import Agent + + agent = Agent('openai:gpt-5') + + @agent.tool + def get_weather(city: str) -> str: + return f"The weather in {city} is sunny" + + app = agent.to_web() + + # Then run with: uvicorn app:app --reload + ``` + """ + from ..ui.web import create_chat_app + + return create_chat_app(self) + @asynccontextmanager @deprecated( '`run_mcp_servers` is deprecated, use `async with agent:` instead. If you need to set a sampling model on all MCP servers, use `agent.set_mcp_sampling_model()`.' diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py new file mode 100644 index 0000000000..20d29f2b2f --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py @@ -0,0 +1,16 @@ +"""Web-based chat UI for Pydantic AI agents.""" + +from .agent_options import AI_MODELS, BUILTIN_TOOLS, AIModel, AIModelID, BuiltinTool, BuiltinToolID +from .api import create_api_router +from .app import create_chat_app + +__all__ = [ + 'create_chat_app', + 'create_api_router', + 'AI_MODELS', + 'BUILTIN_TOOLS', + 'AIModel', + 'AIModelID', + 'BuiltinTool', + 'BuiltinToolID', +] diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py new file mode 100644 index 0000000000..456a273cfb --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py @@ -0,0 +1,75 @@ +"""Model and builtin tool configurations for the web chat UI.""" + +from typing import Literal + +from pydantic import BaseModel + +from pydantic_ai.builtin_tools import ( + AbstractBuiltinTool, + CodeExecutionTool, + ImageGenerationTool, + WebSearchTool, +) + +AIModelID = Literal[ + 'anthropic:claude-sonnet-4-5', + 'openai-responses:gpt-5', + 'google-gla:gemini-2.5-pro', +] +BuiltinToolID = Literal['web_search', 'image_generation', 'code_execution'] + + +class AIModel(BaseModel): + """Defines an AI model with its associated built-in tools.""" + + id: AIModelID + name: str + builtin_tools: list[BuiltinToolID] + + +class BuiltinTool(BaseModel): + """Defines a built-in tool.""" + + id: BuiltinToolID + name: str + + +BUILTIN_TOOL_DEFS: list[BuiltinTool] = [ + BuiltinTool(id='web_search', name='Web Search'), + BuiltinTool(id='code_execution', name='Code Execution'), + BuiltinTool(id='image_generation', name='Image Generation'), +] + +BUILTIN_TOOLS: dict[BuiltinToolID, AbstractBuiltinTool] = { + 'web_search': WebSearchTool(), + 'code_execution': CodeExecutionTool(), + 'image_generation': ImageGenerationTool(), +} + +AI_MODELS: list[AIModel] = [ + AIModel( + id='anthropic:claude-sonnet-4-5', + name='Claude Sonnet 4.5', + builtin_tools=[ + 'web_search', + 'code_execution', + ], + ), + AIModel( + id='openai-responses:gpt-5', + name='GPT 5', + builtin_tools=[ + 'web_search', + 'code_execution', + 'image_generation', + ], + ), + AIModel( + id='google-gla:gemini-2.5-pro', + name='Gemini 2.5 Pro', + builtin_tools=[ + 'web_search', + 'code_execution', + ], + ), +] diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/api.py b/pydantic_ai_slim/pydantic_ai/ui/web/api.py new file mode 100644 index 0000000000..b52ccac577 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/api.py @@ -0,0 +1,80 @@ +"""API router for the web chat UI.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel +from pydantic.alias_generators import to_camel + +from pydantic_ai import Agent +from pydantic_ai.ui.vercel_ai._adapter import VercelAIAdapter + +from .agent_options import ( + AI_MODELS, + BUILTIN_TOOL_DEFS, + BUILTIN_TOOLS, + AIModel, + AIModelID, + BuiltinTool, + BuiltinToolID, +) + + +def get_agent(request: Request) -> Agent: + """Get the agent from app state.""" + agent = getattr(request.app.state, 'agent', None) + if agent is None: + raise RuntimeError('No agent configured. Server must be started with a valid agent.') + return agent + + +def create_api_router() -> APIRouter: + """Create the API router for chat endpoints.""" + router = APIRouter() + + @router.options('/api/chat') + def options_chat(): # pyright: ignore[reportUnusedFunction] + """Handle CORS preflight requests.""" + pass + + class ConfigureFrontend(BaseModel, alias_generator=to_camel, populate_by_name=True): + """Response model for frontend configuration.""" + + models: list[AIModel] + builtin_tools: list[BuiltinTool] + + @router.get('/api/configure') + async def configure_frontend() -> ConfigureFrontend: # pyright: ignore[reportUnusedFunction] + """Endpoint to configure the frontend with available models and tools.""" + return ConfigureFrontend( + models=AI_MODELS, + builtin_tools=BUILTIN_TOOL_DEFS, + ) + + @router.get('/api/health') + async def health() -> dict[str, bool]: # pyright: ignore[reportUnusedFunction] + """Health check endpoint.""" + return {'ok': True} + + class ChatRequestExtra(BaseModel, extra='ignore', alias_generator=to_camel): + """Extra data extracted from chat request.""" + + model: AIModelID | None = None + builtin_tools: list[BuiltinToolID] = [] + + @router.post('/api/chat') + async def post_chat( # pyright: ignore[reportUnusedFunction] + request: Request, agent: Annotated[Agent, Depends(get_agent)] + ) -> Response: + """Handle chat requests via Vercel AI Adapter.""" + adapter = await VercelAIAdapter.from_request(request, agent=agent) + extra_data = ChatRequestExtra.model_validate(adapter.run_input.__pydantic_extra__) + streaming_response = await VercelAIAdapter.dispatch_request( + request, + agent=agent, + model=extra_data.model, + builtin_tools=[BUILTIN_TOOLS[tool_id] for tool_id in extra_data.builtin_tools], + ) + return streaming_response + + return router diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/app.py b/pydantic_ai_slim/pydantic_ai/ui/web/app.py new file mode 100644 index 0000000000..1db5a92644 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/app.py @@ -0,0 +1,47 @@ +"""Factory function for creating a web chat app for a Pydantic AI agent.""" + +from __future__ import annotations + +from typing import TypeVar + +import fastapi +import httpx +from fastapi import Request +from fastapi.responses import HTMLResponse + +from pydantic_ai import Agent + +from .api import create_api_router + +CDN_URL = 'https://cdn.jsdelivr.net/npm/@pydantic/ai-chat-ui@0.0.2/dist/index.html' + +AgentDepsT = TypeVar('AgentDepsT') +OutputDataT = TypeVar('OutputDataT') + + +def create_chat_app( + agent: Agent[AgentDepsT, OutputDataT], +) -> fastapi.FastAPI: + """Create a FastAPI app that serves a web chat UI for the given agent. + + Args: + agent: The Pydantic AI agent to serve + + Returns: + A configured FastAPI application ready to be served + """ + app = fastapi.FastAPI() + + app.state.agent = agent + + app.include_router(create_api_router()) + + @app.get('/') + @app.get('/{id}') + async def index(request: Request): # pyright: ignore[reportUnusedFunction] + """Serve the chat UI from CDN.""" + async with httpx.AsyncClient() as client: + response = await client.get(CDN_URL) + return HTMLResponse(content=response.content, status_code=response.status_code) + + return app diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py new file mode 100644 index 0000000000..23ba702547 --- /dev/null +++ b/tests/test_ui_web.py @@ -0,0 +1,101 @@ +"""Tests for the web chat UI module.""" + +from __future__ import annotations + +import pytest + +from pydantic_ai import Agent +from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOLS, create_chat_app + +from .conftest import try_import + +with try_import() as fastapi_import_successful: + from fastapi import FastAPI + from fastapi.testclient import TestClient + +pytestmark = [ + pytest.mark.skipif(not fastapi_import_successful, reason='fastapi not installed'), +] + + +def test_create_chat_app_basic(): + """Test creating a basic chat app.""" + agent = Agent('test') + app = create_chat_app(agent) + + assert isinstance(app, FastAPI) + assert app.state.agent is agent + + +def test_agent_to_web(): + """Test the Agent.to_web() method.""" + agent = Agent('test') + app = agent.to_web() + + assert isinstance(app, FastAPI) + assert app.state.agent is agent + + +def test_chat_app_health_endpoint(): + """Test the /api/health endpoint.""" + agent = Agent('test') + app = create_chat_app(agent) + + with TestClient(app) as client: + response = client.get('/api/health') + assert response.status_code == 200 + assert response.json() == {'ok': True} + + +def test_chat_app_configure_endpoint(): + """Test the /api/configure endpoint.""" + agent = Agent('test') + app = create_chat_app(agent) + + with TestClient(app) as client: + response = client.get('/api/configure') + assert response.status_code == 200 + data = response.json() + assert 'models' in data + assert 'builtinTools' in data # camelCase due to alias generator + + # no snapshot bc we're checking against the actual model/tool definitions + assert len(data['models']) == len(AI_MODELS) + assert len(data['builtinTools']) == len(BUILTIN_TOOLS) + + +def test_chat_app_index_endpoint(): + """Test that the index endpoint serves the UI from CDN.""" + agent = Agent('test') + app = create_chat_app(agent) + + with TestClient(app) as client: + response = client.get('/') + assert response.status_code == 200 + assert response.headers['content-type'] == 'text/html; charset=utf-8' + assert len(response.content) > 0 + + +def test_ai_models_configuration(): + """Test that AI models are configured correctly.""" + assert len(AI_MODELS) == 3 + + model_ids = {model.id for model in AI_MODELS} + assert 'anthropic:claude-sonnet-4-5' in model_ids + assert 'openai-responses:gpt-5' in model_ids + assert 'google-gla:gemini-2.5-pro' in model_ids + + +def test_builtin_tools_configuration(): + """Test that builtin tools are configured correctly.""" + assert len(BUILTIN_TOOLS) == 3 + + assert 'web_search' in BUILTIN_TOOLS + assert 'code_execution' in BUILTIN_TOOLS + assert 'image_generation' in BUILTIN_TOOLS + + from pydantic_ai.builtin_tools import CodeExecutionTool, ImageGenerationTool, WebSearchTool + + assert isinstance(BUILTIN_TOOLS['web_search'], WebSearchTool) + assert isinstance(BUILTIN_TOOLS['code_execution'], CodeExecutionTool) + assert isinstance(BUILTIN_TOOLS['image_generation'], ImageGenerationTool) From d83caa5bf704b63684f7d133e1be2c48c9d994c5 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:28:24 -0500 Subject: [PATCH 02/15] add "web" group --- pydantic_ai_slim/pydantic_ai/agent/__init__.py | 2 +- pydantic_ai_slim/pyproject.toml | 2 ++ uv.lock | 16 ++++++++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 36a1c659a8..d18a540da6 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -1488,7 +1488,7 @@ def to_web(self) -> Any: @agent.tool def get_weather(city: str) -> str: - return f"The weather in {city} is sunny" + return f'The weather in {city} is sunny' app = agent.to_web() diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 1b5909140d..5af886769a 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -103,6 +103,8 @@ ui = ["starlette>=0.45.3"] a2a = ["fasta2a>=0.4.1"] # AG-UI ag-ui = ["ag-ui-protocol>=0.1.8", "starlette>=0.45.3"] +# Web +web = ["fastapi>=0.115.0", "httpx>=0.27.0"] # Retries retries = ["tenacity>=8.2.3"] # Temporal diff --git a/uv.lock b/uv.lock index 62d3f22778..8f95a1dd50 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'", @@ -2763,6 +2763,7 @@ version = "0.7.30" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/38/d1ef3ae08d8d857e5e0690c5b1e07bf7eb4a1cae5881d87215826dc6cadb/llguidance-0.7.30.tar.gz", hash = "sha256:e93bf75f2b6e48afb86a5cee23038746975e1654672bf5ba0ae75f7d4d4a2248", size = 1055528, upload-time = "2025-06-23T00:23:49.247Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/e1/694c89986fcae7777184fc8b22baa0976eba15a6847221763f6ad211fc1f/llguidance-0.7.30-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c80af02c118d2b0526bcecaab389af2ed094537a069b0fc724cd2a2f2ba3990f", size = 3327974, upload-time = "2025-06-23T00:23:47.556Z" }, { url = "https://files.pythonhosted.org/packages/fd/77/ab7a548ae189dc23900fdd37803c115c2339b1223af9e8eb1f4329b5935a/llguidance-0.7.30-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:00a256d532911d2cf5ba4ef63e182944e767dd2402f38d63002016bc37755958", size = 3210709, upload-time = "2025-06-23T00:23:45.872Z" }, { url = "https://files.pythonhosted.org/packages/9c/5b/6a166564b14f9f805f0ea01ec233a84f55789cb7eeffe1d6224ccd0e6cdd/llguidance-0.7.30-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af8741c867e4bc7e42f7cdc68350c076b4edd0ca10ecefbde75f15a9f6bc25d0", size = 14867038, upload-time = "2025-06-23T00:23:39.571Z" }, { url = "https://files.pythonhosted.org/packages/af/80/5a40b9689f17612434b820854cba9b8cabd5142072c491b5280fe5f7a35e/llguidance-0.7.30-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9edc409b9decd6cffba5f5bf3b4fbd7541f95daa8cbc9510cbf96c6ab1ffc153", size = 15004926, upload-time = "2025-06-23T00:23:43.965Z" }, @@ -5655,6 +5656,10 @@ vertexai = [ { name = "google-auth" }, { name = "requests" }, ] +web = [ + { name = "fastapi" }, + { name = "httpx" }, +] [package.metadata] requires-dist = [ @@ -5667,6 +5672,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 == 'web'", specifier = ">=0.115.0" }, { 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" }, @@ -5674,6 +5680,7 @@ requires-dist = [ { name = "griffe", specifier = ">=1.3.2" }, { name = "groq", marker = "extra == 'groq'", specifier = ">=0.25.0" }, { name = "httpx", specifier = ">=0.27" }, + { name = "httpx", marker = "extra == 'web'", specifier = ">=0.27.0" }, { name = "huggingface-hub", extras = ["inference"], marker = "extra == 'huggingface'", specifier = ">=0.33.5" }, { name = "logfire", extras = ["httpx"], marker = "extra == 'logfire'", specifier = ">=3.14.1" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.12.3" }, @@ -5706,7 +5713,7 @@ requires-dist = [ { name = "typing-inspection", specifier = ">=0.4.0" }, { name = "vllm", marker = "(python_full_version < '3.12' and platform_machine != 'x86_64' and extra == 'outlines-vllm-offline') or (python_full_version < '3.12' and sys_platform != 'darwin' 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", "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", "web"] [[package]] name = "pydantic-core" @@ -6742,6 +6749,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/fa/3234f913fe9a6525a7b97c6dad1f51e72b917e6872e051a5e2ffd8b16fbb/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:70eda7703b8126f5e52fcf276e6c0f40b0d314674f896fc58c47b0aef2b9ae83", size = 137970, upload-time = "2025-09-22T19:51:09.472Z" }, { url = "https://files.pythonhosted.org/packages/ef/ec/4edbf17ac2c87fa0845dd366ef8d5852b96eb58fcd65fc1ecf5fe27b4641/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a0cb71ccc6ef9ce36eecb6272c81afdc2f565950cdcec33ae8e6cd8f7fc86f27", size = 739639, upload-time = "2025-09-22T19:51:10.566Z" }, { url = "https://files.pythonhosted.org/packages/15/18/b0e1fafe59051de9e79cdd431863b03593ecfa8341c110affad7c8121efc/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7cb9ad1d525d40f7d87b6df7c0ff916a66bc52cb61b66ac1b2a16d0c1b07640", size = 764456, upload-time = "2025-09-22T19:51:11.736Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cd/150fdb96b8fab27fe08d8a59fe67554568727981806e6bc2677a16081ec7/ruamel_yaml_clib-0.2.14-cp314-cp314-win32.whl", hash = "sha256:9b4104bf43ca0cd4e6f738cb86326a3b2f6eef00f417bd1e7efb7bdffe74c539", size = 102394, upload-time = "2025-11-14T21:57:36.703Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/a3fa40084558c7e1dc9546385f22a93949c890a8b2e445b2ba43935f51da/ruamel_yaml_clib-0.2.14-cp314-cp314-win_amd64.whl", hash = "sha256:13997d7d354a9890ea1ec5937a219817464e5cc344805b37671562a401ca3008", size = 122673, upload-time = "2025-11-14T21:57:38.177Z" }, ] [[package]] @@ -8589,14 +8598,17 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/f2/a9/dc3c63cf7f082d183711e46ef34d10d8a135c2319dc581905d79449f52ea/xgrammar-0.1.25.tar.gz", hash = "sha256:70ce16b27e8082f20808ed759b0733304316facc421656f0f30cfce514b5b77a", size = 2297187, upload-time = "2025-09-21T05:58:58.942Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/b4/8f78b56ebf64f161258f339cc5898bf761b4fb6c6805d0bca1bcaaaef4a1/xgrammar-0.1.25-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:d12d1078ee2b5c1531610489b433b77694a7786210ceb2c0c1c1eb058e9053c7", size = 679074, upload-time = "2025-09-21T05:58:20.344Z" }, { url = "https://files.pythonhosted.org/packages/52/38/b57120b73adcd342ef974bff14b2b584e7c47edf28d91419cb9325fd5ef2/xgrammar-0.1.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c2e940541b7cddf3ef55a70f20d4c872af7f0d900bc0ed36f434bf7212e2e729", size = 622668, upload-time = "2025-09-21T05:58:22.269Z" }, { url = "https://files.pythonhosted.org/packages/19/8d/64430d01c21ca2b1d8c5a1ed47c90f8ac43717beafc9440d01d81acd5cfc/xgrammar-0.1.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2063e1c72f0c00f47ac8ce7ce0fcbff6fa77f79012e063369683844e2570c266", size = 8517569, upload-time = "2025-09-21T05:58:23.77Z" }, { url = "https://files.pythonhosted.org/packages/b1/c4/137d0e9cd038ff4141752c509dbeea0ec5093eb80815620c01b1f1c26d0a/xgrammar-0.1.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9785eafa251c996ebaa441f3b8a6c037538930104e265a64a013da0e6fd2ad86", size = 8709188, upload-time = "2025-09-21T05:58:26.246Z" }, { url = "https://files.pythonhosted.org/packages/6c/3d/c228c470d50865c9db3fb1e75a95449d0183a8248519b89e86dc481d6078/xgrammar-0.1.25-cp310-cp310-win_amd64.whl", hash = "sha256:42ecefd020038b3919a473fe5b9bb9d8d809717b8689a736b81617dec4acc59b", size = 698919, upload-time = "2025-09-21T05:58:28.368Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b7/ca0ff7c91f24b2302e94b0e6c2a234cc5752b10da51eb937e7f2aa257fde/xgrammar-0.1.25-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:27d7ac4be05cf9aa258c109a8647092ae47cb1e28df7d27caced6ab44b72b799", size = 678801, upload-time = "2025-09-21T05:58:29.936Z" }, { url = "https://files.pythonhosted.org/packages/43/cd/fdf4fb1b5f9c301d381656a600ad95255a76fa68132978af6f06e50a46e1/xgrammar-0.1.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:151c1636188bc8c5cdf318cefc5ba23221c9c8cc07cb392317fb3f7635428150", size = 622565, upload-time = "2025-09-21T05:58:31.185Z" }, { url = "https://files.pythonhosted.org/packages/55/04/55a87e814bcab771d3e4159281fa382b3d5f14a36114f2f9e572728da831/xgrammar-0.1.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35fc135650aa204bf84db7fe9c0c0f480b6b11419fe47d89f4bd21602ac33be9", size = 8517238, upload-time = "2025-09-21T05:58:32.835Z" }, { url = "https://files.pythonhosted.org/packages/31/f6/3c5210bc41b61fb32b66bf5c9fd8ec5edacfeddf9860e95baa9caa9a2c82/xgrammar-0.1.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc19d6d7e8e51b6c9a266e949ac7fb3d2992447efeec7df32cca109149afac18", size = 8709514, upload-time = "2025-09-21T05:58:34.727Z" }, { url = "https://files.pythonhosted.org/packages/21/de/85714f307536b328cc16cc6755151865e8875378c8557c15447ca07dff98/xgrammar-0.1.25-cp311-cp311-win_amd64.whl", hash = "sha256:8fcb24f5a7acd5876165c50bd51ce4bf8e6ff897344a5086be92d1fe6695f7fe", size = 698722, upload-time = "2025-09-21T05:58:36.411Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d7/a7bdb158afa88af7e6e0d312e9677ba5fb5e423932008c9aa2c45af75d5d/xgrammar-0.1.25-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:96500d7578c46e8551253b9211b02e02f54e147bc290479a64717d80dcf4f7e3", size = 678250, upload-time = "2025-09-21T05:58:37.936Z" }, { url = "https://files.pythonhosted.org/packages/10/9d/b20588a3209d544a3432ebfcf2e3b1a455833ee658149b08c18eef0c6f59/xgrammar-0.1.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ba9031e359447af53ce89dfb0775e7b9f4b358d513bcc28a6b4deace661dd5", size = 621550, upload-time = "2025-09-21T05:58:39.464Z" }, { url = "https://files.pythonhosted.org/packages/99/9c/39bb38680be3b6d6aa11b8a46a69fb43e2537d6728710b299fa9fc231ff0/xgrammar-0.1.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c519518ebc65f75053123baaf23776a21bda58f64101a64c2fc4aa467c9cd480", size = 8519097, upload-time = "2025-09-21T05:58:40.831Z" }, { url = "https://files.pythonhosted.org/packages/c6/c2/695797afa9922c30c45aa94e087ad33a9d87843f269461b622a65a39022a/xgrammar-0.1.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47fdbfc6007df47de2142613220292023e88e4a570546b39591f053e4d9ec33f", size = 8712184, upload-time = "2025-09-21T05:58:43.142Z" }, From bfffd4b0c94ff09a9da46d8d54c859729593b25c Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:44:11 -0500 Subject: [PATCH 03/15] try import create_chat_app --- tests/test_ui_web.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index 23ba702547..fff873cdb6 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -5,7 +5,6 @@ import pytest from pydantic_ai import Agent -from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOLS, create_chat_app from .conftest import try_import @@ -13,6 +12,8 @@ from fastapi import FastAPI from fastapi.testclient import TestClient + from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOLS, create_chat_app + pytestmark = [ pytest.mark.skipif(not fastapi_import_successful, reason='fastapi not installed'), ] From 055e1200ad6b4cf87a51789ecc0f0aa0c6dabc0c Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:58:08 -0500 Subject: [PATCH 04/15] fix tests to run on CI --- tests/test_ui_web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index fff873cdb6..3a5b9ea116 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -15,7 +15,7 @@ from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOLS, create_chat_app pytestmark = [ - pytest.mark.skipif(not fastapi_import_successful, reason='fastapi not installed'), + pytest.mark.skipif(not fastapi_import_successful(), reason='fastapi not installed'), ] From 6bc8b16ecfe379e76df7af04a1122bf1c70854f9 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:09:37 -0500 Subject: [PATCH 05/15] fix example to use tool_plain --- pydantic_ai_slim/pydantic_ai/agent/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index d18a540da6..2909bf905a 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -1486,7 +1486,7 @@ def to_web(self) -> Any: agent = Agent('openai:gpt-5') - @agent.tool + @agent.tool_plain def get_weather(city: str) -> str: return f'The weather in {city} is sunny' From 32f7e1d3c1c78953bb0b621ff54edb4cf658085b Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:53:52 -0500 Subject: [PATCH 06/15] add clai web - tested with uv run clai web --- clai/README.md | 4 +- clai/clai/chat/__init__.py | 6 + clai/clai/chat/agent_discovery.py | 186 ++++++++++++++++++ clai/clai/chat/cli.py | 166 ++++++++++++++++ pydantic_ai_slim/pydantic_ai/_cli.py | 33 ++++ .../pydantic_ai/ui/web/__init__.py | 11 +- .../pydantic_ai/ui/web/agent_options.py | 8 +- pydantic_ai_slim/pydantic_ai/ui/web/api.py | 39 ++-- pydantic_ai_slim/pydantic_ai/ui/web/app.py | 14 +- tests/test_agent_discovery.py | 172 ++++++++++++++++ tests/test_ui_web.py | 4 +- 11 files changed, 617 insertions(+), 26 deletions(-) create mode 100644 clai/clai/chat/__init__.py create mode 100644 clai/clai/chat/agent_discovery.py create mode 100644 clai/clai/chat/cli.py create mode 100644 tests/test_agent_discovery.py diff --git a/clai/README.md b/clai/README.md index da0ed9bc27..f36fc65f6c 100644 --- a/clai/README.md +++ b/clai/README.md @@ -54,7 +54,7 @@ Either way, running `clai` will start an interactive session where you can chat ## Help ``` -usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [--no-stream] [--version] [prompt] +usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [--no-stream] [--version] {web} ... [prompt] Pydantic AI CLI v... @@ -65,6 +65,8 @@ Special prompts: * `/cp` - copy the last response to clipboard positional arguments: + {web} Available commands + web Launch web chat UI for discovered agents prompt AI Prompt, if omitted fall into interactive mode options: diff --git a/clai/clai/chat/__init__.py b/clai/clai/chat/__init__.py new file mode 100644 index 0000000000..d6a65e38a1 --- /dev/null +++ b/clai/clai/chat/__init__.py @@ -0,0 +1,6 @@ +"""Chat UI module for clai.""" + +from .agent_discovery import find_agents +from .cli import run_chat_command + +__all__ = ['find_agents', 'run_chat_command'] diff --git a/clai/clai/chat/agent_discovery.py b/clai/clai/chat/agent_discovery.py new file mode 100644 index 0000000000..e2397db86f --- /dev/null +++ b/clai/clai/chat/agent_discovery.py @@ -0,0 +1,186 @@ +"""Agent discovery using AST parsing to find pydantic_ai.Agent objects.""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import NamedTuple + + +class AgentInfo(NamedTuple): + """Information about a discovered agent.""" + + module_path: str # e.g., "chat.golden_gate_bridge:agent" + file_path: Path + agent_name: str + + +# Directories to exclude from search +EXCLUDE_DIRS = { + '.venv', + 'venv', + 'env', + 'node_modules', + '__pycache__', + '.pytest_cache', + '.git', + '.hg', + '.svn', + 'dist', + 'build', + '.eggs', + '.tox', + '.nox', + '.mypy_cache', + '.ruff_cache', + '.pydantic-work', +} + + +def _should_exclude_dir(dir_path: Path) -> bool: + return dir_path.name in EXCLUDE_DIRS or dir_path.name.startswith('.') + + +def _file_to_module_path(file_path: Path, root_dir: Path) -> str | None: + """Convert a file path to a Python module path. + + Args: + file_path: The file path to convert. + root_dir: The root directory of the project. + + Returns: + The module path (e.g., "chat.my_agent") or None if conversion fails. + """ + try: + rel_path = file_path.relative_to(root_dir) + + if rel_path.suffix != '.py': + return None + + parts = list(rel_path.parts[:-1]) + [rel_path.stem] + module_path = '.'.join(parts) + + return module_path + except (ValueError, AttributeError): + return None + + +def _parse_file_for_agents(file_path: Path, root_dir: Path) -> list[AgentInfo]: # noqa: C901 + """Parse a Python file for pydantic_ai.Agent instances using AST. + + Args: + file_path: The Python file to parse. + root_dir: The root directory of the project. + + Returns: + List of AgentInfo objects for agents found in the file. + """ + try: + content = file_path.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + # Skip files we can't read + return [] + + try: + tree = ast.parse(content, filename=str(file_path)) + except SyntaxError: + # Skip files with syntax errors + return [] + + # Track imports of Agent class + agent_names: set[str] = set() + + for node in ast.walk(tree): + # Handle: from pydantic_ai import Agent + if isinstance(node, ast.ImportFrom): + if node.module and 'pydantic_ai' in node.module: + for alias in node.names: + if alias.name == 'Agent': + agent_names.add(alias.asname or 'Agent') + + # Handle: import pydantic_ai (then pydantic_ai.Agent) + elif isinstance(node, ast.Import): + for alias in node.names: + if 'pydantic_ai' in alias.name: + # Track as the alias or original name + agent_names.add(alias.asname or alias.name) + + if not agent_names: + # No pydantic_ai imports, skip this file + return [] + + # Find agent instances + agents: list[AgentInfo] = [] + module_path = _file_to_module_path(file_path, root_dir) + + if not module_path: + return [] + + for node in ast.walk(tree): + # Look for assignments like: agent = Agent(...) + if isinstance(node, ast.Assign): + # Check if the value is a Call to Agent + if isinstance(node.value, ast.Call): + call_name = None + + # Handle direct call: Agent(...) + if isinstance(node.value.func, ast.Name): + if node.value.func.id in agent_names: + call_name = node.value.func.id + + # Handle attribute call: pydantic_ai.Agent(...) + elif isinstance(node.value.func, ast.Attribute): + if ( + node.value.func.attr == 'Agent' + and isinstance(node.value.func.value, ast.Name) + and node.value.func.value.id in agent_names + ): + call_name = 'Agent' + + if call_name: + # Extract variable names being assigned + for target in node.targets: + if isinstance(target, ast.Name): + agent_name = target.id + agents.append( + AgentInfo( + module_path=f'{module_path}:{agent_name}', + file_path=file_path, + agent_name=agent_name, + ) + ) + + return agents + + +def find_agents(root_dir: Path | None = None) -> list[AgentInfo]: + """Find all pydantic_ai.Agent instances in the current directory. + + Args: + root_dir: The root directory to search. Defaults to current working directory. + + Returns: + List of AgentInfo objects for all discovered agents. + """ + if root_dir is None: + root_dir = Path.cwd() + + agents: list[AgentInfo] = [] + visited_files: set[Path] = set() + + for path in root_dir.rglob('*.py'): + if path in visited_files: + continue + visited_files.add(path) + + try: + # Check if any parent directory should be excluded + if any(_should_exclude_dir(parent) for parent in path.parents): + continue + except (OSError, RuntimeError): + continue + + file_agents = _parse_file_for_agents(path, root_dir) + agents.extend(file_agents) + + return agents diff --git a/clai/clai/chat/cli.py b/clai/clai/chat/cli.py new file mode 100644 index 0000000000..961e3011b1 --- /dev/null +++ b/clai/clai/chat/cli.py @@ -0,0 +1,166 @@ +"""CLI command for launching a web chat UI for discovered agents.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from pydantic_ai import Agent +from pydantic_ai.builtin_tools import AbstractBuiltinTool +from pydantic_ai.ui.web import AIModel, BuiltinTool, create_chat_app + +from .agent_discovery import AgentInfo, find_agents + + +def load_agent_options( + config_path: Path, +) -> tuple[list[AIModel] | None, dict[str, AbstractBuiltinTool] | None, list[BuiltinTool] | None]: + """Load agent options from a config file. + + Args: + config_path: Path to the config file (e.g., agent_options.py) + + Returns: + Tuple of (models, builtin_tools, builtin_tool_defs) or (None, None, None) if not found + """ + if not config_path.exists(): + return None, None, None + + try: + spec = importlib.util.spec_from_file_location('agent_options_config', config_path) + if spec is None or spec.loader is None: + print(f'Warning: Could not load config from {config_path}') + return None, None, None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + models = getattr(module, 'AI_MODELS', None) + builtin_tools = getattr(module, 'BUILTIN_TOOLS', None) + builtin_tool_defs = getattr(module, 'BUILTIN_TOOL_DEFS', None) + + return models, builtin_tools, builtin_tool_defs + + except Exception as e: + print(f'Warning: Error loading config from {config_path}: {e}') + return None, None, None + + +def select_agent(agents: list[AgentInfo]) -> AgentInfo | None: + """Prompt user to select an agent from the list.""" + if not agents: + print('No agents found in the current directory.') + return None + + if len(agents) == 1: + print(f'Found agent: {agents[0].agent_name} in {agents[0].file_path}') + return agents[0] + + print('Multiple agents found:') + for i, agent_info in enumerate(agents, 1): + print(f' {i}. {agent_info.agent_name} ({agent_info.file_path})') + + while True: + try: + choice = input('\nSelect an agent (enter number): ').strip() + index = int(choice) - 1 + if 0 <= index < len(agents): + return agents[index] + else: + print(f'Please enter a number between 1 and {len(agents)}') + except (ValueError, KeyboardInterrupt): + print('\nSelection cancelled.') + return None + + +def load_agent(agent_info: AgentInfo) -> Agent | None: + """Load an agent from the given agent info.""" + sys.path.insert(0, str(Path.cwd())) + + try: + spec = importlib.util.spec_from_file_location(agent_info.module_path, agent_info.file_path) + if spec is None or spec.loader is None: + print(f'Error: Could not load module from {agent_info.file_path}') + return None + + module = importlib.util.module_from_spec(spec) + sys.modules[agent_info.module_path] = module + spec.loader.exec_module(module) + + agent = getattr(module, agent_info.agent_name, None) + if agent is None: + print(f'Error: Agent {agent_info.agent_name} not found in module') + return None + + if not isinstance(agent, Agent): + print(f'Error: {agent_info.agent_name} is not an Agent instance') + return None + + return agent # pyright: ignore[reportUnknownVariableType] + + except Exception as e: + print(f'Error loading agent: {e}') + return None + + +def run_chat_command( + root_dir: Path | None = None, + host: str = '127.0.0.1', + port: int = 8000, + config_path: Path | None = None, + auto_config: bool = True, +) -> int: + """Run the chat command to discover and serve an agent. + + Args: + root_dir: Directory to search for agents (defaults to current directory) + host: Host to bind the server to + port: Port to bind the server to + config_path: Path to agent_options.py config file + auto_config: Auto-discover agent_options.py in current directory + """ + search_dir = root_dir or Path.cwd() + + print(f'Searching for agents in {search_dir}...') + agents = find_agents(search_dir) + + selected = select_agent(agents) + if selected is None: + return 1 + + agent = load_agent(selected) + if agent is None: + return 1 + + models, builtin_tools, builtin_tool_defs = None, None, None + if config_path: + print(f'Loading config from {config_path}...') + models, builtin_tools, builtin_tool_defs = load_agent_options(config_path) + elif auto_config: + default_config = Path.cwd() / 'agent_options.py' + if default_config.exists(): + print(f'Found config file: {default_config}') + models, builtin_tools, builtin_tool_defs = load_agent_options(default_config) + + app = create_chat_app(agent, models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs) + + print(f'\nStarting chat UI for {selected.agent_name}...') + print(f'Open your browser at: http://{host}:{port}') + print('Press Ctrl+C to stop the server\n') + + try: + import uvicorn + + uvicorn.run(app, host=host, port=port) + return 0 + except KeyboardInterrupt: + print('\nServer stopped.') + return 0 + except ImportError: + print('Error: uvicorn is required to run the chat UI') + print('Install it with: pip install uvicorn') + return 1 + except Exception as e: + print(f'Error starting server: {e}') + return 1 diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 4e4889bdfc..91f030ed76 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -119,6 +119,24 @@ def cli( # noqa: C901 """, formatter_class=argparse.RawTextHelpFormatter, ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Web subcommand (only available for clai) + if prog_name == 'clai': + web_parser = subparsers.add_parser('web', help='Launch web chat UI for discovered agents') + web_parser.add_argument('--host', default='127.0.0.1', help='Host to bind the server to (default: 127.0.0.1)') + web_parser.add_argument('--port', type=int, default=8000, help='Port to bind the server to (default: 8000)') + web_parser.add_argument('--dir', type=Path, help='Directory to search for agents (default: current directory)') + web_parser.add_argument( + '--config', type=Path, help='Path to agent_options.py config file (overrides auto-discovery)' + ) + web_parser.add_argument( + '--no-auto-config', + action='store_true', + help='Disable auto-discovery of agent_options.py in current directory', + ) + parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode') arg = parser.add_argument( '-m', @@ -154,6 +172,21 @@ def cli( # noqa: C901 argcomplete.autocomplete(parser) args = parser.parse_args(args_list) + # Handle web subcommand + if args.command == 'web': + try: + from clai.chat.cli import run_chat_command + except ImportError: + print('Error: clai web command is only available when clai is installed.') + return 1 + return run_chat_command( + root_dir=args.dir, + host=args.host, + port=args.port, + config_path=args.config, + auto_config=not args.no_auto_config, + ) + console = Console() name_version = f'[green]{prog_name} - Pydantic AI CLI v{__version__}[/green]' if args.version: diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py index 20d29f2b2f..c5fe89e785 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py @@ -1,6 +1,14 @@ """Web-based chat UI for Pydantic AI agents.""" -from .agent_options import AI_MODELS, BUILTIN_TOOLS, AIModel, AIModelID, BuiltinTool, BuiltinToolID +from .agent_options import ( + AI_MODELS, + BUILTIN_TOOL_DEFS, + BUILTIN_TOOLS, + AIModel, + AIModelID, + BuiltinTool, + BuiltinToolID, +) from .api import create_api_router from .app import create_chat_app @@ -8,6 +16,7 @@ 'create_chat_app', 'create_api_router', 'AI_MODELS', + 'BUILTIN_TOOL_DEFS', 'BUILTIN_TOOLS', 'AIModel', 'AIModelID', diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py index 456a273cfb..60f1daa859 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py @@ -22,15 +22,15 @@ class AIModel(BaseModel): """Defines an AI model with its associated built-in tools.""" - id: AIModelID + id: str name: str - builtin_tools: list[BuiltinToolID] + builtin_tools: list[str] class BuiltinTool(BaseModel): """Defines a built-in tool.""" - id: BuiltinToolID + id: str name: str @@ -40,7 +40,7 @@ class BuiltinTool(BaseModel): BuiltinTool(id='image_generation', name='Image Generation'), ] -BUILTIN_TOOLS: dict[BuiltinToolID, AbstractBuiltinTool] = { +BUILTIN_TOOLS: dict[str, AbstractBuiltinTool] = { 'web_search': WebSearchTool(), 'code_execution': CodeExecutionTool(), 'image_generation': ImageGenerationTool(), diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/api.py b/pydantic_ai_slim/pydantic_ai/ui/web/api.py index b52ccac577..d863e43192 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/api.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/api.py @@ -7,17 +7,10 @@ from pydantic.alias_generators import to_camel from pydantic_ai import Agent +from pydantic_ai.builtin_tools import AbstractBuiltinTool from pydantic_ai.ui.vercel_ai._adapter import VercelAIAdapter -from .agent_options import ( - AI_MODELS, - BUILTIN_TOOL_DEFS, - BUILTIN_TOOLS, - AIModel, - AIModelID, - BuiltinTool, - BuiltinToolID, -) +from .agent_options import AI_MODELS, BUILTIN_TOOL_DEFS, BUILTIN_TOOLS, AIModel, BuiltinTool def get_agent(request: Request) -> Agent: @@ -28,8 +21,22 @@ def get_agent(request: Request) -> Agent: return agent -def create_api_router() -> APIRouter: - """Create the API router for chat endpoints.""" +def create_api_router( + models: list[AIModel] | None = None, + builtin_tools: dict[str, AbstractBuiltinTool] | None = None, + builtin_tool_defs: list[BuiltinTool] | None = None, +) -> APIRouter: + """Create the API router for chat endpoints. + + Args: + models: Optional list of AI models (defaults to AI_MODELS) + builtin_tools: Optional dict of builtin tool instances (defaults to BUILTIN_TOOLS) + builtin_tool_defs: Optional list of builtin tool definitions (defaults to BUILTIN_TOOL_DEFS) + """ + _models = models or AI_MODELS + _builtin_tools: dict[str, AbstractBuiltinTool] = builtin_tools or BUILTIN_TOOLS + _builtin_tool_defs = builtin_tool_defs or BUILTIN_TOOL_DEFS + router = APIRouter() @router.options('/api/chat') @@ -47,8 +54,8 @@ class ConfigureFrontend(BaseModel, alias_generator=to_camel, populate_by_name=Tr async def configure_frontend() -> ConfigureFrontend: # pyright: ignore[reportUnusedFunction] """Endpoint to configure the frontend with available models and tools.""" return ConfigureFrontend( - models=AI_MODELS, - builtin_tools=BUILTIN_TOOL_DEFS, + models=_models, + builtin_tools=_builtin_tool_defs, ) @router.get('/api/health') @@ -59,8 +66,8 @@ async def health() -> dict[str, bool]: # pyright: ignore[reportUnusedFunction] class ChatRequestExtra(BaseModel, extra='ignore', alias_generator=to_camel): """Extra data extracted from chat request.""" - model: AIModelID | None = None - builtin_tools: list[BuiltinToolID] = [] + model: str | None = None + builtin_tools: list[str] = [] @router.post('/api/chat') async def post_chat( # pyright: ignore[reportUnusedFunction] @@ -73,7 +80,7 @@ async def post_chat( # pyright: ignore[reportUnusedFunction] request, agent=agent, model=extra_data.model, - builtin_tools=[BUILTIN_TOOLS[tool_id] for tool_id in extra_data.builtin_tools], + builtin_tools=[_builtin_tools[tool_id] for tool_id in extra_data.builtin_tools], ) return streaming_response diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/app.py b/pydantic_ai_slim/pydantic_ai/ui/web/app.py index 1db5a92644..ba040dae5e 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/app.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/app.py @@ -10,10 +10,12 @@ from fastapi.responses import HTMLResponse from pydantic_ai import Agent +from pydantic_ai.builtin_tools import AbstractBuiltinTool +from .agent_options import AIModel, BuiltinTool from .api import create_api_router -CDN_URL = 'https://cdn.jsdelivr.net/npm/@pydantic/ai-chat-ui@0.0.2/dist/index.html' +CDN_URL = 'https://cdn.jsdelivr.net/npm/@pydantic/ai-chat-ui/dist/index.html' AgentDepsT = TypeVar('AgentDepsT') OutputDataT = TypeVar('OutputDataT') @@ -21,11 +23,17 @@ def create_chat_app( agent: Agent[AgentDepsT, OutputDataT], + models: list[AIModel] | None = None, + builtin_tools: dict[str, AbstractBuiltinTool] | None = None, + builtin_tool_defs: list[BuiltinTool] | None = None, ) -> fastapi.FastAPI: """Create a FastAPI app that serves a web chat UI for the given agent. Args: agent: The Pydantic AI agent to serve + models: Optional list of AI models (defaults to AI_MODELS) + builtin_tools: Optional dict of builtin tool instances (defaults to BUILTIN_TOOLS) + builtin_tool_defs: Optional list of builtin tool definitions (defaults to BUILTIN_TOOL_DEFS) Returns: A configured FastAPI application ready to be served @@ -34,7 +42,9 @@ def create_chat_app( app.state.agent = agent - app.include_router(create_api_router()) + app.include_router( + create_api_router(models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs) + ) @app.get('/') @app.get('/{id}') diff --git a/tests/test_agent_discovery.py b/tests/test_agent_discovery.py new file mode 100644 index 0000000000..dd8c7791b0 --- /dev/null +++ b/tests/test_agent_discovery.py @@ -0,0 +1,172 @@ +"""Tests for agent discovery functionality.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest +from inline_snapshot import snapshot + +from .conftest import try_import + +with try_import() as clai_import_successful: + from clai.chat.agent_discovery import AgentInfo, find_agents + +pytestmark = [ + pytest.mark.skipif(not clai_import_successful(), reason='clai not installed'), +] + + +def test_find_agents_empty_directory(): + """Test finding agents in an empty directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + agents = find_agents(Path(tmpdir)) + assert agents == [] + + +def test_find_agents_no_agents(): + """Test finding agents in a directory with Python files but no agents.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create a Python file without any agents + (tmpdir_path / 'test.py').write_text('print("hello")') + + agents = find_agents(tmpdir_path) + assert agents == [] + + +def test_find_agents_single_agent(): + """Test finding a single agent.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create a Python file with an agent + (tmpdir_path / 'my_agent.py').write_text( + """ +from pydantic_ai import Agent + +my_agent = Agent('openai:gpt-5') +""" + ) + + agents = find_agents(tmpdir_path) + assert len(agents) == snapshot() + + +def test_find_agents_multiple_agents(): + """Test finding multiple agents in different files.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create multiple Python files with agents + (tmpdir_path / 'agent1.py').write_text( + """ +from pydantic_ai import Agent + +agent1 = Agent('openai:gpt-5') +""" + ) + (tmpdir_path / 'agent2.py').write_text( + """ +from pydantic_ai import Agent + +agent2 = Agent('anthropic:claude-sonnet-4-5') +""" + ) + + agents = find_agents(tmpdir_path) + assert len(agents) == 2 + + agent_names = {agent.agent_name for agent in agents} + assert 'agent1' in agent_names + assert 'agent2' in agent_names + + +def test_find_agents_multiple_in_same_file(): + """Test finding multiple agents in the same file.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create a Python file with multiple agents + (tmpdir_path / 'agents.py').write_text( + """ +from pydantic_ai import Agent + +agent_a = Agent('openai:gpt-5') +agent_b = Agent('anthropic:claude-sonnet-4-5') +""" + ) + + agents = find_agents(tmpdir_path) + assert len(agents) == 2 + + agent_names = {agent.agent_name for agent in agents} + assert 'agent_a' in agent_names + assert 'agent_b' in agent_names + + +def test_find_agents_in_subdirectory(): + """Test finding agents in subdirectories.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create a subdirectory with an agent + subdir = tmpdir_path / 'subdir' + subdir.mkdir() + (subdir / 'agent.py').write_text( + """ +from pydantic_ai import Agent + +sub_agent = Agent('openai:gpt-5') +""" + ) + + agents = find_agents(tmpdir_path) + assert len(agents) == 1 + assert agents[0].agent_name == 'sub_agent' + assert agents[0].file_path == subdir / 'agent.py' + + +def test_find_agents_excludes_venv(): + """Test that .venv directories are excluded.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create a .venv directory with an agent + venv_dir = tmpdir_path / '.venv' / 'lib' + venv_dir.mkdir(parents=True) + (venv_dir / 'agent.py').write_text( + """ +from pydantic_ai import Agent + +venv_agent = Agent('openai:gpt-5') +""" + ) + + agents = find_agents(tmpdir_path) + assert len(agents) == 0 + + +def test_agent_info_structure(): + """Test the AgentInfo structure.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + (tmpdir_path / 'test_agent.py').write_text( + """ +from pydantic_ai import Agent + +test_agent = Agent('openai:gpt-5') +""" + ) + + agents = find_agents(tmpdir_path) + assert len(agents) == 1 + + agent_info = agents[0] + assert isinstance(agent_info, AgentInfo) + assert agent_info.agent_name == 'test_agent' + assert agent_info.file_path == tmpdir_path / 'test_agent.py' + assert isinstance(agent_info.module_path, str) diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index 3a5b9ea116..0ac200a0dc 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient - from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOLS, create_chat_app + from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOL_DEFS, BUILTIN_TOOLS, create_chat_app pytestmark = [ pytest.mark.skipif(not fastapi_import_successful(), reason='fastapi not installed'), @@ -62,7 +62,7 @@ def test_chat_app_configure_endpoint(): # no snapshot bc we're checking against the actual model/tool definitions assert len(data['models']) == len(AI_MODELS) - assert len(data['builtinTools']) == len(BUILTIN_TOOLS) + assert len(data['builtinTools']) == len(BUILTIN_TOOL_DEFS) def test_chat_app_index_endpoint(): From cf0e17772c2c190e3fce1563df8590e6632a67e9 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:03:11 -0500 Subject: [PATCH 07/15] wip: remove agent discovery and fix tests --- clai/README.md | 44 ++++++- clai/clai/chat/__init__.py | 6 - clai/clai/chat/agent_discovery.py | 186 --------------------------- clai/clai/web/__init__.py | 5 + clai/clai/{chat => web}/cli.py | 78 ++++------- docs/cli.md | 46 +++++++ pydantic_ai_slim/pydantic_ai/_cli.py | 92 +++++++------ test_agent.py | 6 + tests/test_agent_discovery.py | 172 ------------------------- 9 files changed, 174 insertions(+), 461 deletions(-) delete mode 100644 clai/clai/chat/__init__.py delete mode 100644 clai/clai/chat/agent_discovery.py create mode 100644 clai/clai/web/__init__.py rename clai/clai/{chat => web}/cli.py (61%) create mode 100644 test_agent.py delete mode 100644 tests/test_agent_discovery.py diff --git a/clai/README.md b/clai/README.md index f36fc65f6c..406c8605be 100644 --- a/clai/README.md +++ b/clai/README.md @@ -51,10 +51,50 @@ Either way, running `clai` will start an interactive session where you can chat - `/multiline`: Toggle multiline input mode (use Ctrl+D to submit) - `/cp`: Copy the last response to clipboard +## Web Chat UI + +Launch a web-based chat interface for your agent: + +```bash +clai web module:agent_variable +``` + +For example, if you have an agent defined in `my_agent.py`: + +```python +from pydantic_ai import Agent + +my_agent = Agent('openai:gpt-5', system_prompt='You are a helpful assistant.') +``` + +Launch the web UI with: + +```bash +clai web my_agent:my_agent +``` + +This will start a web server (default: http://127.0.0.1:8000) with a chat interface for your agent. + +### Web Command Options + +- `--host`: Host to bind the server to (default: 127.0.0.1) +- `--port`: Port to bind the server to (default: 8000) +- `--config`: Path to custom `agent_options.py` config file +- `--no-auto-config`: Disable auto-discovery of `agent_options.py` in current directory + +You can also launch the web UI directly from an `Agent` instance using `Agent.to_web()`: + +```python +from pydantic_ai import Agent + +agent = Agent('openai:gpt-5') +app = agent.to_web() # Returns a FastAPI application +``` + ## Help ``` -usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [--no-stream] [--version] {web} ... [prompt] +usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [--no-stream] [--version] [prompt] Pydantic AI CLI v... @@ -65,8 +105,6 @@ Special prompts: * `/cp` - copy the last response to clipboard positional arguments: - {web} Available commands - web Launch web chat UI for discovered agents prompt AI Prompt, if omitted fall into interactive mode options: diff --git a/clai/clai/chat/__init__.py b/clai/clai/chat/__init__.py deleted file mode 100644 index d6a65e38a1..0000000000 --- a/clai/clai/chat/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Chat UI module for clai.""" - -from .agent_discovery import find_agents -from .cli import run_chat_command - -__all__ = ['find_agents', 'run_chat_command'] diff --git a/clai/clai/chat/agent_discovery.py b/clai/clai/chat/agent_discovery.py deleted file mode 100644 index e2397db86f..0000000000 --- a/clai/clai/chat/agent_discovery.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Agent discovery using AST parsing to find pydantic_ai.Agent objects.""" - -from __future__ import annotations - -import ast -from pathlib import Path -from typing import NamedTuple - - -class AgentInfo(NamedTuple): - """Information about a discovered agent.""" - - module_path: str # e.g., "chat.golden_gate_bridge:agent" - file_path: Path - agent_name: str - - -# Directories to exclude from search -EXCLUDE_DIRS = { - '.venv', - 'venv', - 'env', - 'node_modules', - '__pycache__', - '.pytest_cache', - '.git', - '.hg', - '.svn', - 'dist', - 'build', - '.eggs', - '.tox', - '.nox', - '.mypy_cache', - '.ruff_cache', - '.pydantic-work', -} - - -def _should_exclude_dir(dir_path: Path) -> bool: - return dir_path.name in EXCLUDE_DIRS or dir_path.name.startswith('.') - - -def _file_to_module_path(file_path: Path, root_dir: Path) -> str | None: - """Convert a file path to a Python module path. - - Args: - file_path: The file path to convert. - root_dir: The root directory of the project. - - Returns: - The module path (e.g., "chat.my_agent") or None if conversion fails. - """ - try: - rel_path = file_path.relative_to(root_dir) - - if rel_path.suffix != '.py': - return None - - parts = list(rel_path.parts[:-1]) + [rel_path.stem] - module_path = '.'.join(parts) - - return module_path - except (ValueError, AttributeError): - return None - - -def _parse_file_for_agents(file_path: Path, root_dir: Path) -> list[AgentInfo]: # noqa: C901 - """Parse a Python file for pydantic_ai.Agent instances using AST. - - Args: - file_path: The Python file to parse. - root_dir: The root directory of the project. - - Returns: - List of AgentInfo objects for agents found in the file. - """ - try: - content = file_path.read_text(encoding='utf-8') - except (OSError, UnicodeDecodeError): - # Skip files we can't read - return [] - - try: - tree = ast.parse(content, filename=str(file_path)) - except SyntaxError: - # Skip files with syntax errors - return [] - - # Track imports of Agent class - agent_names: set[str] = set() - - for node in ast.walk(tree): - # Handle: from pydantic_ai import Agent - if isinstance(node, ast.ImportFrom): - if node.module and 'pydantic_ai' in node.module: - for alias in node.names: - if alias.name == 'Agent': - agent_names.add(alias.asname or 'Agent') - - # Handle: import pydantic_ai (then pydantic_ai.Agent) - elif isinstance(node, ast.Import): - for alias in node.names: - if 'pydantic_ai' in alias.name: - # Track as the alias or original name - agent_names.add(alias.asname or alias.name) - - if not agent_names: - # No pydantic_ai imports, skip this file - return [] - - # Find agent instances - agents: list[AgentInfo] = [] - module_path = _file_to_module_path(file_path, root_dir) - - if not module_path: - return [] - - for node in ast.walk(tree): - # Look for assignments like: agent = Agent(...) - if isinstance(node, ast.Assign): - # Check if the value is a Call to Agent - if isinstance(node.value, ast.Call): - call_name = None - - # Handle direct call: Agent(...) - if isinstance(node.value.func, ast.Name): - if node.value.func.id in agent_names: - call_name = node.value.func.id - - # Handle attribute call: pydantic_ai.Agent(...) - elif isinstance(node.value.func, ast.Attribute): - if ( - node.value.func.attr == 'Agent' - and isinstance(node.value.func.value, ast.Name) - and node.value.func.value.id in agent_names - ): - call_name = 'Agent' - - if call_name: - # Extract variable names being assigned - for target in node.targets: - if isinstance(target, ast.Name): - agent_name = target.id - agents.append( - AgentInfo( - module_path=f'{module_path}:{agent_name}', - file_path=file_path, - agent_name=agent_name, - ) - ) - - return agents - - -def find_agents(root_dir: Path | None = None) -> list[AgentInfo]: - """Find all pydantic_ai.Agent instances in the current directory. - - Args: - root_dir: The root directory to search. Defaults to current working directory. - - Returns: - List of AgentInfo objects for all discovered agents. - """ - if root_dir is None: - root_dir = Path.cwd() - - agents: list[AgentInfo] = [] - visited_files: set[Path] = set() - - for path in root_dir.rglob('*.py'): - if path in visited_files: - continue - visited_files.add(path) - - try: - # Check if any parent directory should be excluded - if any(_should_exclude_dir(parent) for parent in path.parents): - continue - except (OSError, RuntimeError): - continue - - file_agents = _parse_file_for_agents(path, root_dir) - agents.extend(file_agents) - - return agents diff --git a/clai/clai/web/__init__.py b/clai/clai/web/__init__.py new file mode 100644 index 0000000000..38336ca1ce --- /dev/null +++ b/clai/clai/web/__init__.py @@ -0,0 +1,5 @@ +"""Chat UI module for clai.""" + +from .cli import run_chat_command + +__all__ = ['run_chat_command'] diff --git a/clai/clai/chat/cli.py b/clai/clai/web/cli.py similarity index 61% rename from clai/clai/chat/cli.py rename to clai/clai/web/cli.py index 961e3011b1..0a33be3f91 100644 --- a/clai/clai/chat/cli.py +++ b/clai/clai/web/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib import importlib.util import sys from pathlib import Path @@ -10,8 +11,6 @@ from pydantic_ai.builtin_tools import AbstractBuiltinTool from pydantic_ai.ui.web import AIModel, BuiltinTool, create_chat_app -from .agent_discovery import AgentInfo, find_agents - def load_agent_options( config_path: Path, @@ -47,89 +46,62 @@ def load_agent_options( return None, None, None -def select_agent(agents: list[AgentInfo]) -> AgentInfo | None: - """Prompt user to select an agent from the list.""" - if not agents: - print('No agents found in the current directory.') - return None - - if len(agents) == 1: - print(f'Found agent: {agents[0].agent_name} in {agents[0].file_path}') - return agents[0] - - print('Multiple agents found:') - for i, agent_info in enumerate(agents, 1): - print(f' {i}. {agent_info.agent_name} ({agent_info.file_path})') - - while True: - try: - choice = input('\nSelect an agent (enter number): ').strip() - index = int(choice) - 1 - if 0 <= index < len(agents): - return agents[index] - else: - print(f'Please enter a number between 1 and {len(agents)}') - except (ValueError, KeyboardInterrupt): - print('\nSelection cancelled.') - return None +def load_agent(agent_path: str) -> Agent | None: + """Load an agent from module path in uvicorn style. + Args: + agent_path: Path in format 'module:variable', e.g. 'test_agent:my_agent' -def load_agent(agent_info: AgentInfo) -> Agent | None: - """Load an agent from the given agent info.""" + Returns: + Agent instance or None if loading fails + """ sys.path.insert(0, str(Path.cwd())) try: - spec = importlib.util.spec_from_file_location(agent_info.module_path, agent_info.file_path) - if spec is None or spec.loader is None: - print(f'Error: Could not load module from {agent_info.file_path}') - return None + module_path, variable_name = agent_path.split(':') + except ValueError: + print('Error: Agent must be specified in "module:variable" format') + return None - module = importlib.util.module_from_spec(spec) - sys.modules[agent_info.module_path] = module - spec.loader.exec_module(module) + try: + module = importlib.import_module(module_path) + agent = getattr(module, variable_name, None) - agent = getattr(module, agent_info.agent_name, None) if agent is None: - print(f'Error: Agent {agent_info.agent_name} not found in module') + print(f'Error: {variable_name} not found in module {module_path}') return None if not isinstance(agent, Agent): - print(f'Error: {agent_info.agent_name} is not an Agent instance') + print(f'Error: {variable_name} is not an Agent instance') return None return agent # pyright: ignore[reportUnknownVariableType] + except ImportError as e: + print(f'Error: Could not import module {module_path}: {e}') + return None except Exception as e: print(f'Error loading agent: {e}') return None def run_chat_command( - root_dir: Path | None = None, + agent_path: str, host: str = '127.0.0.1', port: int = 8000, config_path: Path | None = None, auto_config: bool = True, ) -> int: - """Run the chat command to discover and serve an agent. + """Run the chat command to serve an agent via web UI. Args: - root_dir: Directory to search for agents (defaults to current directory) + agent_path: Agent path in 'module:variable' format, e.g. 'test_agent:my_agent' host: Host to bind the server to port: Port to bind the server to config_path: Path to agent_options.py config file auto_config: Auto-discover agent_options.py in current directory """ - search_dir = root_dir or Path.cwd() - - print(f'Searching for agents in {search_dir}...') - agents = find_agents(search_dir) - - selected = select_agent(agents) - if selected is None: - return 1 - - agent = load_agent(selected) + agent = load_agent(agent_path) if agent is None: return 1 @@ -145,7 +117,7 @@ def run_chat_command( app = create_chat_app(agent, models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs) - print(f'\nStarting chat UI for {selected.agent_name}...') + print(f'\nStarting chat UI for {agent_path}...') print(f'Open your browser at: http://{host}:{port}') print('Press Ctrl+C to stop the server\n') diff --git a/docs/cli.md b/docs/cli.md index 811d921011..a5d53b7c4e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,6 +48,52 @@ Either way, running `clai` will start an interactive session where you can chat - `/multiline`: Toggle multiline input mode (use Ctrl+D to submit) - `/cp`: Copy the last response to clipboard +### Web Chat UI + +Launch a web-based chat interface for your agent: + +```bash +clai web module:agent_variable +``` + +For example, if you have an agent defined in `my_agent.py`: + +```python +from pydantic_ai import Agent + +my_agent = Agent('openai:gpt-5', system_prompt='You are a helpful assistant.') +``` + +Launch the web UI with: + +```bash +clai web my_agent:my_agent +``` + +This will start a web server (default: http://127.0.0.1:8000) with a chat interface for your agent. + +#### Web Command Options + +- `--host`: Host to bind the server to (default: 127.0.0.1) +- `--port`: Port to bind the server to (default: 8000) +- `--config`: Path to custom `agent_options.py` config file +- `--no-auto-config`: Disable auto-discovery of `agent_options.py` in current directory + +You can also launch the web UI directly from an `Agent` instance using [`Agent.to_web()`][pydantic_ai.Agent.to_web]: + +```python +from pydantic_ai import Agent + +agent = Agent('openai:gpt-5') +app = agent.to_web() # Returns a FastAPI application +``` + +The returned FastAPI app can be run with your preferred ASGI server (uvicorn, hypercorn, etc.): + +```bash +uvicorn my_module:app --host 0.0.0.0 --port 8080 +``` + ### Help To get help on the CLI, use the `--help` flag: diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 91f030ed76..3d2cf47e36 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -106,6 +106,15 @@ def cli( # noqa: C901 args_list: Sequence[str] | None = None, *, prog_name: str = 'pai', default_model: str = 'openai:gpt-5' ) -> int: """Run the CLI and return the exit code for the process.""" + # Pre-check for web subcommand to avoid argparse conflict with prompt positional + check_args = args_list if args_list is not None else sys.argv[1:] + first_positional = next((arg for arg in check_args if not arg.startswith('-')), None) + is_web_subcommand = prog_name == 'clai' and first_positional == 'web' + + # we don't want to autocomplete or list models that don't include the provider, + # e.g. we want to show `openai:gpt-4o` but not `gpt-4o` + qualified_model_names = [n for n in get_literal_values(KnownModelName.__value__) if ':' in n] + parser = argparse.ArgumentParser( prog=prog_name, description=f"""\ @@ -120,14 +129,16 @@ def cli( # noqa: C901 formatter_class=argparse.RawTextHelpFormatter, ) - subparsers = parser.add_subparsers(dest='command', help='Available commands') - - # Web subcommand (only available for clai) - if prog_name == 'clai': - web_parser = subparsers.add_parser('web', help='Launch web chat UI for discovered agents') + if is_web_subcommand: + # Web subcommand mode - add subparsers only + subparsers = parser.add_subparsers(dest='command', help='Available commands') + web_parser = subparsers.add_parser('web', help='Launch web chat UI for an agent') + web_parser.add_argument( + 'agent', + help='Agent to load in "module:variable" format, e.g. "test_agent:my_agent"', + ) web_parser.add_argument('--host', default='127.0.0.1', help='Host to bind the server to (default: 127.0.0.1)') web_parser.add_argument('--port', type=int, default=8000, help='Port to bind the server to (default: 8000)') - web_parser.add_argument('--dir', type=Path, help='Directory to search for agents (default: current directory)') web_parser.add_argument( '--config', type=Path, help='Path to agent_options.py config file (overrides auto-discovery)' ) @@ -136,51 +147,50 @@ def cli( # noqa: C901 action='store_true', help='Disable auto-discovery of agent_options.py in current directory', ) - - parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode') - arg = parser.add_argument( - '-m', - '--model', - nargs='?', - help=f'Model to use, in format ":" e.g. "openai:gpt-5" or "anthropic:claude-sonnet-4-5". Defaults to "{default_model}".', - ) - # we don't want to autocomplete or list models that don't include the provider, - # e.g. we want to show `openai:gpt-4o` but not `gpt-4o` - qualified_model_names = [n for n in get_literal_values(KnownModelName.__value__) if ':' in n] - arg.completer = argcomplete.ChoicesCompleter(qualified_model_names) # type: ignore[reportPrivateUsage] - parser.add_argument( - '-a', - '--agent', - help='Custom Agent to use, in format "module:variable", e.g. "mymodule.submodule:my_agent"', - ) - parser.add_argument( - '-l', - '--list-models', - action='store_true', - help='List all available models and exit', - ) - parser.add_argument( - '-t', - '--code-theme', - nargs='?', - help='Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals.', - default='dark', - ) - parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model') - parser.add_argument('--version', action='store_true', help='Show version and exit') + else: + # Prompt mode - add prompt positional and flags + parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode') + + arg = parser.add_argument( + '-m', + '--model', + nargs='?', + help=f'Model to use, in format ":" e.g. "openai:gpt-5" or "anthropic:claude-sonnet-4-5". Defaults to "{default_model}".', + ) + arg.completer = argcomplete.ChoicesCompleter(qualified_model_names) # type: ignore[reportPrivateUsage] + parser.add_argument( + '-a', + '--agent', + help='Custom Agent to use, in format "module:variable", e.g. "mymodule.submodule:my_agent"', + ) + parser.add_argument( + '-l', + '--list-models', + action='store_true', + help='List all available models and exit', + ) + parser.add_argument( + '-t', + '--code-theme', + nargs='?', + help='Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals.', + default='dark', + ) + parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model') + parser.add_argument('--version', action='store_true', help='Show version and exit') argcomplete.autocomplete(parser) args = parser.parse_args(args_list) # Handle web subcommand - if args.command == 'web': + if getattr(args, 'command', None) == 'web': try: - from clai.chat.cli import run_chat_command + from clai.web.cli import run_chat_command except ImportError: print('Error: clai web command is only available when clai is installed.') return 1 return run_chat_command( - root_dir=args.dir, + agent_path=args.agent, host=args.host, port=args.port, config_path=args.config, diff --git a/test_agent.py b/test_agent.py new file mode 100644 index 0000000000..fa833d35e0 --- /dev/null +++ b/test_agent.py @@ -0,0 +1,6 @@ +"""Test agent for clai web command.""" + +from pydantic_ai import Agent + +# Simple test agent +my_agent = Agent('test', system_prompt='You are a helpful assistant.') diff --git a/tests/test_agent_discovery.py b/tests/test_agent_discovery.py deleted file mode 100644 index dd8c7791b0..0000000000 --- a/tests/test_agent_discovery.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Tests for agent discovery functionality.""" - -from __future__ import annotations - -import tempfile -from pathlib import Path - -import pytest -from inline_snapshot import snapshot - -from .conftest import try_import - -with try_import() as clai_import_successful: - from clai.chat.agent_discovery import AgentInfo, find_agents - -pytestmark = [ - pytest.mark.skipif(not clai_import_successful(), reason='clai not installed'), -] - - -def test_find_agents_empty_directory(): - """Test finding agents in an empty directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - agents = find_agents(Path(tmpdir)) - assert agents == [] - - -def test_find_agents_no_agents(): - """Test finding agents in a directory with Python files but no agents.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Create a Python file without any agents - (tmpdir_path / 'test.py').write_text('print("hello")') - - agents = find_agents(tmpdir_path) - assert agents == [] - - -def test_find_agents_single_agent(): - """Test finding a single agent.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Create a Python file with an agent - (tmpdir_path / 'my_agent.py').write_text( - """ -from pydantic_ai import Agent - -my_agent = Agent('openai:gpt-5') -""" - ) - - agents = find_agents(tmpdir_path) - assert len(agents) == snapshot() - - -def test_find_agents_multiple_agents(): - """Test finding multiple agents in different files.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Create multiple Python files with agents - (tmpdir_path / 'agent1.py').write_text( - """ -from pydantic_ai import Agent - -agent1 = Agent('openai:gpt-5') -""" - ) - (tmpdir_path / 'agent2.py').write_text( - """ -from pydantic_ai import Agent - -agent2 = Agent('anthropic:claude-sonnet-4-5') -""" - ) - - agents = find_agents(tmpdir_path) - assert len(agents) == 2 - - agent_names = {agent.agent_name for agent in agents} - assert 'agent1' in agent_names - assert 'agent2' in agent_names - - -def test_find_agents_multiple_in_same_file(): - """Test finding multiple agents in the same file.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Create a Python file with multiple agents - (tmpdir_path / 'agents.py').write_text( - """ -from pydantic_ai import Agent - -agent_a = Agent('openai:gpt-5') -agent_b = Agent('anthropic:claude-sonnet-4-5') -""" - ) - - agents = find_agents(tmpdir_path) - assert len(agents) == 2 - - agent_names = {agent.agent_name for agent in agents} - assert 'agent_a' in agent_names - assert 'agent_b' in agent_names - - -def test_find_agents_in_subdirectory(): - """Test finding agents in subdirectories.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Create a subdirectory with an agent - subdir = tmpdir_path / 'subdir' - subdir.mkdir() - (subdir / 'agent.py').write_text( - """ -from pydantic_ai import Agent - -sub_agent = Agent('openai:gpt-5') -""" - ) - - agents = find_agents(tmpdir_path) - assert len(agents) == 1 - assert agents[0].agent_name == 'sub_agent' - assert agents[0].file_path == subdir / 'agent.py' - - -def test_find_agents_excludes_venv(): - """Test that .venv directories are excluded.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Create a .venv directory with an agent - venv_dir = tmpdir_path / '.venv' / 'lib' - venv_dir.mkdir(parents=True) - (venv_dir / 'agent.py').write_text( - """ -from pydantic_ai import Agent - -venv_agent = Agent('openai:gpt-5') -""" - ) - - agents = find_agents(tmpdir_path) - assert len(agents) == 0 - - -def test_agent_info_structure(): - """Test the AgentInfo structure.""" - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - (tmpdir_path / 'test_agent.py').write_text( - """ -from pydantic_ai import Agent - -test_agent = Agent('openai:gpt-5') -""" - ) - - agents = find_agents(tmpdir_path) - assert len(agents) == 1 - - agent_info = agents[0] - assert isinstance(agent_info, AgentInfo) - assert agent_info.agent_name == 'test_agent' - assert agent_info.file_path == tmpdir_path / 'test_agent.py' - assert isinstance(agent_info.module_path, str) From e7f44ebb10176713c4cdf27b542aaed8b59bd8b9 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:09:34 -0500 Subject: [PATCH 08/15] rename command --- clai/clai/web/__init__.py | 4 ++-- clai/clai/web/cli.py | 2 +- pydantic_ai_slim/pydantic_ai/_cli.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/clai/clai/web/__init__.py b/clai/clai/web/__init__.py index 38336ca1ce..6baa595400 100644 --- a/clai/clai/web/__init__.py +++ b/clai/clai/web/__init__.py @@ -1,5 +1,5 @@ """Chat UI module for clai.""" -from .cli import run_chat_command +from .cli import run_web_command -__all__ = ['run_chat_command'] +__all__ = ['run_web_command'] diff --git a/clai/clai/web/cli.py b/clai/clai/web/cli.py index 0a33be3f91..9ca7be2156 100644 --- a/clai/clai/web/cli.py +++ b/clai/clai/web/cli.py @@ -85,7 +85,7 @@ def load_agent(agent_path: str) -> Agent | None: return None -def run_chat_command( +def run_web_command( agent_path: str, host: str = '127.0.0.1', port: int = 8000, diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 3d2cf47e36..3845c9e220 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -185,11 +185,11 @@ def cli( # noqa: C901 # Handle web subcommand if getattr(args, 'command', None) == 'web': try: - from clai.web.cli import run_chat_command + from clai.web.cli import run_web_command except ImportError: print('Error: clai web command is only available when clai is installed.') return 1 - return run_chat_command( + return run_web_command( agent_path=args.agent, host=args.host, port=args.port, From c4ffde308ea3c4f1ff39ef345a95419ce852f2a9 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:39:58 -0500 Subject: [PATCH 09/15] rename function --- clai/clai/web/cli.py | 4 ++-- pydantic_ai_slim/pydantic_ai/agent/__init__.py | 4 ++-- pydantic_ai_slim/pydantic_ai/ui/web/__init__.py | 4 ++-- pydantic_ai_slim/pydantic_ai/ui/web/app.py | 2 +- tests/test_ui_web.py | 10 +++++----- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/clai/clai/web/cli.py b/clai/clai/web/cli.py index 9ca7be2156..dec3d6f5a8 100644 --- a/clai/clai/web/cli.py +++ b/clai/clai/web/cli.py @@ -9,7 +9,7 @@ from pydantic_ai import Agent from pydantic_ai.builtin_tools import AbstractBuiltinTool -from pydantic_ai.ui.web import AIModel, BuiltinTool, create_chat_app +from pydantic_ai.ui.web import AIModel, BuiltinTool, create_web_app def load_agent_options( @@ -115,7 +115,7 @@ def run_web_command( print(f'Found config file: {default_config}') models, builtin_tools, builtin_tool_defs = load_agent_options(default_config) - app = create_chat_app(agent, models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs) + app = create_web_app(agent, models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs) print(f'\nStarting chat UI for {agent_path}...') print(f'Open your browser at: http://{host}:{port}') diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 2909bf905a..151cf6ad41 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -1495,9 +1495,9 @@ def get_weather(city: str) -> str: # Then run with: uvicorn app:app --reload ``` """ - from ..ui.web import create_chat_app + from ..ui.web import create_web_app - return create_chat_app(self) + return create_web_app(self) @asynccontextmanager @deprecated( diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py index c5fe89e785..b42d3b7009 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py @@ -10,10 +10,10 @@ BuiltinToolID, ) from .api import create_api_router -from .app import create_chat_app +from .app import create_web_app __all__ = [ - 'create_chat_app', + 'create_web_app', 'create_api_router', 'AI_MODELS', 'BUILTIN_TOOL_DEFS', diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/app.py b/pydantic_ai_slim/pydantic_ai/ui/web/app.py index ba040dae5e..6fd144c996 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/app.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/app.py @@ -21,7 +21,7 @@ OutputDataT = TypeVar('OutputDataT') -def create_chat_app( +def create_web_app( agent: Agent[AgentDepsT, OutputDataT], models: list[AIModel] | None = None, builtin_tools: dict[str, AbstractBuiltinTool] | None = None, diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index 0ac200a0dc..0be3e047df 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient - from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOL_DEFS, BUILTIN_TOOLS, create_chat_app + from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOL_DEFS, BUILTIN_TOOLS, create_web_app pytestmark = [ pytest.mark.skipif(not fastapi_import_successful(), reason='fastapi not installed'), @@ -22,7 +22,7 @@ def test_create_chat_app_basic(): """Test creating a basic chat app.""" agent = Agent('test') - app = create_chat_app(agent) + app = create_web_app(agent) assert isinstance(app, FastAPI) assert app.state.agent is agent @@ -40,7 +40,7 @@ def test_agent_to_web(): def test_chat_app_health_endpoint(): """Test the /api/health endpoint.""" agent = Agent('test') - app = create_chat_app(agent) + app = create_web_app(agent) with TestClient(app) as client: response = client.get('/api/health') @@ -51,7 +51,7 @@ def test_chat_app_health_endpoint(): def test_chat_app_configure_endpoint(): """Test the /api/configure endpoint.""" agent = Agent('test') - app = create_chat_app(agent) + app = create_web_app(agent) with TestClient(app) as client: response = client.get('/api/configure') @@ -68,7 +68,7 @@ def test_chat_app_configure_endpoint(): def test_chat_app_index_endpoint(): """Test that the index endpoint serves the UI from CDN.""" agent = Agent('test') - app = create_chat_app(agent) + app = create_web_app(agent) with TestClient(app) as client: response = client.get('/') From 0595c27f695456fdec19262bfe02581a4ea0e451 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:33:15 -0500 Subject: [PATCH 10/15] - define builtin tool ids - consolidate UI agent-options - fix cli commands - cache UI on client and server - offer choosable UI versions --- clai/README.md | 9 +- clai/clai/web/cli.py | 27 +++--- pydantic_ai_slim/pydantic_ai/_cli.py | 95 +++++++++---------- .../pydantic_ai/agent/__init__.py | 41 +++++++- pydantic_ai_slim/pydantic_ai/builtin_tools.py | 10 ++ .../pydantic_ai/ui/web/__init__.py | 12 +-- .../pydantic_ai/ui/web/agent_options.py | 41 +++++--- pydantic_ai_slim/pydantic_ai/ui/web/api.py | 23 +++-- pydantic_ai_slim/pydantic_ai/ui/web/app.py | 46 +++++---- tests/test_ui_web.py | 69 +++++++++++--- 10 files changed, 239 insertions(+), 134 deletions(-) diff --git a/clai/README.md b/clai/README.md index 406c8605be..00dfd8a3e8 100644 --- a/clai/README.md +++ b/clai/README.md @@ -94,7 +94,9 @@ app = agent.to_web() # Returns a FastAPI application ## Help ``` -usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [--no-stream] [--version] [prompt] +usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [--no-stream] [--version] [--web] [--host HOST] [--port PORT] [--config CONFIG] + [--no-auto-config] + [prompt] Pydantic AI CLI v... @@ -118,4 +120,9 @@ options: Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals. --no-stream Disable streaming from the model --version Show version and exit + --web Launch web chat UI for the agent (requires --agent) + --host HOST Host to bind the server to (default: 127.0.0.1) + --port PORT Port to bind the server to (default: 8000) + --config CONFIG Path to agent_options.py config file (overrides auto-discovery) + --no-auto-config Disable auto-discovery of agent_options.py in current directory ``` diff --git a/clai/clai/web/cli.py b/clai/clai/web/cli.py index dec3d6f5a8..b4e9737d1b 100644 --- a/clai/clai/web/cli.py +++ b/clai/clai/web/cli.py @@ -8,42 +8,37 @@ from pathlib import Path from pydantic_ai import Agent -from pydantic_ai.builtin_tools import AbstractBuiltinTool -from pydantic_ai.ui.web import AIModel, BuiltinTool, create_web_app +from pydantic_ai.ui.web import AIModel, BuiltinToolDef, create_web_app def load_agent_options( config_path: Path, -) -> tuple[list[AIModel] | None, dict[str, AbstractBuiltinTool] | None, list[BuiltinTool] | None]: +) -> tuple[list[AIModel] | None, list[BuiltinToolDef] | None]: """Load agent options from a config file. Args: config_path: Path to the config file (e.g., agent_options.py) - - Returns: - Tuple of (models, builtin_tools, builtin_tool_defs) or (None, None, None) if not found """ if not config_path.exists(): - return None, None, None + return None, None try: spec = importlib.util.spec_from_file_location('agent_options_config', config_path) if spec is None or spec.loader is None: print(f'Warning: Could not load config from {config_path}') - return None, None, None + return None, None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) models = getattr(module, 'AI_MODELS', None) - builtin_tools = getattr(module, 'BUILTIN_TOOLS', None) - builtin_tool_defs = getattr(module, 'BUILTIN_TOOL_DEFS', None) + builtin_tool_defs = getattr(module, 'DEFAULT_BUILTIN_TOOL_DEFS', getattr(module, 'BUILTIN_TOOL_DEFS', None)) - return models, builtin_tools, builtin_tool_defs + return models, builtin_tool_defs except Exception as e: print(f'Warning: Error loading config from {config_path}: {e}') - return None, None, None + return None, None def load_agent(agent_path: str) -> Agent | None: @@ -105,17 +100,17 @@ def run_web_command( if agent is None: return 1 - models, builtin_tools, builtin_tool_defs = None, None, None + models, builtin_tool_defs = None, None if config_path: print(f'Loading config from {config_path}...') - models, builtin_tools, builtin_tool_defs = load_agent_options(config_path) + models, builtin_tool_defs = load_agent_options(config_path) elif auto_config: default_config = Path.cwd() / 'agent_options.py' if default_config.exists(): print(f'Found config file: {default_config}') - models, builtin_tools, builtin_tool_defs = load_agent_options(default_config) + models, builtin_tool_defs = load_agent_options(default_config) - app = create_web_app(agent, models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs) + app = create_web_app(agent, models=models, builtin_tool_defs=builtin_tool_defs) print(f'\nStarting chat UI for {agent_path}...') print(f'Open your browser at: http://{host}:{port}') diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 3845c9e220..9f85c606b5 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -106,11 +106,6 @@ def cli( # noqa: C901 args_list: Sequence[str] | None = None, *, prog_name: str = 'pai', default_model: str = 'openai:gpt-5' ) -> int: """Run the CLI and return the exit code for the process.""" - # Pre-check for web subcommand to avoid argparse conflict with prompt positional - check_args = args_list if args_list is not None else sys.argv[1:] - first_positional = next((arg for arg in check_args if not arg.startswith('-')), None) - is_web_subcommand = prog_name == 'clai' and first_positional == 'web' - # we don't want to autocomplete or list models that don't include the provider, # e.g. we want to show `openai:gpt-4o` but not `gpt-4o` qualified_model_names = [n for n in get_literal_values(KnownModelName.__value__) if ':' in n] @@ -129,65 +124,65 @@ def cli( # noqa: C901 formatter_class=argparse.RawTextHelpFormatter, ) - if is_web_subcommand: - # Web subcommand mode - add subparsers only - subparsers = parser.add_subparsers(dest='command', help='Available commands') - web_parser = subparsers.add_parser('web', help='Launch web chat UI for an agent') - web_parser.add_argument( - 'agent', - help='Agent to load in "module:variable" format, e.g. "test_agent:my_agent"', - ) - web_parser.add_argument('--host', default='127.0.0.1', help='Host to bind the server to (default: 127.0.0.1)') - web_parser.add_argument('--port', type=int, default=8000, help='Port to bind the server to (default: 8000)') - web_parser.add_argument( - '--config', type=Path, help='Path to agent_options.py config file (overrides auto-discovery)' - ) - web_parser.add_argument( - '--no-auto-config', + parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode') + + arg = parser.add_argument( + '-m', + '--model', + nargs='?', + help=f'Model to use, in format ":" e.g. "openai:gpt-5" or "anthropic:claude-sonnet-4-5". Defaults to "{default_model}".', + ) + arg.completer = argcomplete.ChoicesCompleter(qualified_model_names) # type: ignore[reportPrivateUsage] + parser.add_argument( + '-a', + '--agent', + help='Custom Agent to use, in format "module:variable", e.g. "mymodule.submodule:my_agent"', + ) + parser.add_argument( + '-l', + '--list-models', + action='store_true', + help='List all available models and exit', + ) + parser.add_argument( + '-t', + '--code-theme', + nargs='?', + help='Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals.', + default='dark', + ) + parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model') + parser.add_argument('--version', action='store_true', help='Show version and exit') + + if prog_name == 'clai': + parser.add_argument( + '--web', action='store_true', - help='Disable auto-discovery of agent_options.py in current directory', + help='Launch web chat UI for the agent (requires --agent)', ) - else: - # Prompt mode - add prompt positional and flags - parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode') - - arg = parser.add_argument( - '-m', - '--model', - nargs='?', - help=f'Model to use, in format ":" e.g. "openai:gpt-5" or "anthropic:claude-sonnet-4-5". Defaults to "{default_model}".', - ) - arg.completer = argcomplete.ChoicesCompleter(qualified_model_names) # type: ignore[reportPrivateUsage] + parser.add_argument('--host', default='127.0.0.1', help='Host to bind the server to (default: 127.0.0.1)') + parser.add_argument('--port', type=int, default=8000, help='Port to bind the server to (default: 8000)') parser.add_argument( - '-a', - '--agent', - help='Custom Agent to use, in format "module:variable", e.g. "mymodule.submodule:my_agent"', + '--config', type=Path, help='Path to agent_options.py config file (overrides auto-discovery)' ) parser.add_argument( - '-l', - '--list-models', + '--no-auto-config', action='store_true', - help='List all available models and exit', - ) - parser.add_argument( - '-t', - '--code-theme', - nargs='?', - help='Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals.', - default='dark', + help='Disable auto-discovery of agent_options.py in current directory', ) - parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model') - parser.add_argument('--version', action='store_true', help='Show version and exit') argcomplete.autocomplete(parser) args = parser.parse_args(args_list) - # Handle web subcommand - if getattr(args, 'command', None) == 'web': + if prog_name == 'clai' and args.web: + if not args.agent: + console = Console() + console.print('[red]Error: --web requires --agent to be specified[/red]') + return 1 try: from clai.web.cli import run_web_command except ImportError: - print('Error: clai web command is only available when clai is installed.') + print('Error: clai --web command is only available when clai is installed.') return 1 return run_web_command( agent_path=args.agent, diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 151cf6ad41..3dfe7a0ed9 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -67,7 +67,10 @@ from .wrapper import WrapperAgent if TYPE_CHECKING: + from fastapi import FastAPI + from ..mcp import MCPServer + from ..ui.web.agent_options import AIModel, BuiltinToolDef __all__ = ( 'Agent', @@ -1470,12 +1473,25 @@ def _set_sampling_model(toolset: AbstractToolset[AgentDepsT]) -> None: self._get_toolset().apply(_set_sampling_model) - def to_web(self) -> Any: + def to_web( + self, + *, + models: list[AIModel] | None = None, + builtin_tool_defs: list[BuiltinToolDef] | None = None, + ) -> FastAPI: """Create a FastAPI app that serves a web chat UI for this agent. This method returns a pre-configured FastAPI application that provides a web-based - chat interface for interacting with the agent. The UI is served from a CDN and - includes support for model selection and builtin tool configuration. + chat interface for interacting with the agent. The UI is downloaded and cached on + first use, and includes support for model selection and builtin tool configuration. + + Args: + models: List of AI models to make available in the UI. If not provided, + defaults to a predefined set of models. You'll need to ensure you have valid API keys + configured for any models you wish to use. + builtin_tool_defs: Optional list of builtin tool definitions for the UI. Each + definition includes the tool ID, display name, and tool instance. If not + provided, defaults to a predefined set of tool definitions. Returns: A configured FastAPI application ready to be served (e.g., with uvicorn) @@ -1483,6 +1499,8 @@ def to_web(self) -> Any: Example: ```python from pydantic_ai import Agent + from pydantic_ai.builtin_tools import WebSearchTool + from pydantic_ai.ui.web import AIModel, BuiltinToolDef agent = Agent('openai:gpt-5') @@ -1490,14 +1508,29 @@ def to_web(self) -> Any: def get_weather(city: str) -> str: return f'The weather in {city} is sunny' + # Use defaults app = agent.to_web() + # Or customize models and tools + app = agent.to_web( + models=[ + AIModel(id='openai:gpt-5', name='GPT 5', builtin_tools=['web_search']), + ], + builtin_tool_defs=[ + BuiltinToolDef( + id='web_search', + name='Web Search', + tool=WebSearchTool(), + ) + ], + ) + # Then run with: uvicorn app:app --reload ``` """ from ..ui.web import create_web_app - return create_web_app(self) + return create_web_app(self, models=models, builtin_tool_defs=builtin_tool_defs) @asynccontextmanager @deprecated( diff --git a/pydantic_ai_slim/pydantic_ai/builtin_tools.py b/pydantic_ai_slim/pydantic_ai/builtin_tools.py index 5559b3124a..488d394a8c 100644 --- a/pydantic_ai_slim/pydantic_ai/builtin_tools.py +++ b/pydantic_ai_slim/pydantic_ai/builtin_tools.py @@ -339,3 +339,13 @@ def _tool_discriminator(tool_data: dict[str, Any] | AbstractBuiltinTool) -> str: return tool_data.get('kind', AbstractBuiltinTool.kind) else: return tool_data.kind + + +BUILTIN_TOOL_ID = Literal[ + 'web_search', + 'code_execution', + 'image_generation', + 'url_context', + 'memory', + 'mcp_server', +] diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py index b42d3b7009..137d358f1a 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py @@ -2,12 +2,10 @@ from .agent_options import ( AI_MODELS, - BUILTIN_TOOL_DEFS, - BUILTIN_TOOLS, + DEFAULT_BUILTIN_TOOL_DEFS, AIModel, AIModelID, - BuiltinTool, - BuiltinToolID, + BuiltinToolDef, ) from .api import create_api_router from .app import create_web_app @@ -16,10 +14,8 @@ 'create_web_app', 'create_api_router', 'AI_MODELS', - 'BUILTIN_TOOL_DEFS', - 'BUILTIN_TOOLS', + 'DEFAULT_BUILTIN_TOOL_DEFS', 'AIModel', 'AIModelID', - 'BuiltinTool', - 'BuiltinToolID', + 'BuiltinToolDef', ] diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py index 60f1daa859..0f92b0a166 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py @@ -2,9 +2,11 @@ from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field +from pydantic.alias_generators import to_camel from pydantic_ai.builtin_tools import ( + BUILTIN_TOOL_ID, AbstractBuiltinTool, CodeExecutionTool, ImageGenerationTool, @@ -16,36 +18,47 @@ 'openai-responses:gpt-5', 'google-gla:gemini-2.5-pro', ] -BuiltinToolID = Literal['web_search', 'image_generation', 'code_execution'] -class AIModel(BaseModel): +class AIModel(BaseModel, alias_generator=to_camel, populate_by_name=True): """Defines an AI model with its associated built-in tools.""" - id: str + id: AIModelID name: str - builtin_tools: list[str] + builtin_tools: list[BUILTIN_TOOL_ID] -class BuiltinTool(BaseModel): - """Defines a built-in tool.""" +class BuiltinToolDef(BaseModel): + """Defines a built-in tool. - id: str + Used by the web chat UI to display tool options. + """ + + id: BUILTIN_TOOL_ID name: str + tool: AbstractBuiltinTool = Field(exclude=True) -BUILTIN_TOOL_DEFS: list[BuiltinTool] = [ - BuiltinTool(id='web_search', name='Web Search'), - BuiltinTool(id='code_execution', name='Code Execution'), - BuiltinTool(id='image_generation', name='Image Generation'), -] +_default_tool_ids: list[BUILTIN_TOOL_ID] = ['web_search', 'code_execution', 'image_generation'] + +_id_to_ui_name: dict[BUILTIN_TOOL_ID, str] = { + 'web_search': 'Web Search', + 'code_execution': 'Code Execution', + 'image_generation': 'Image Generation', +} -BUILTIN_TOOLS: dict[str, AbstractBuiltinTool] = { +_id_to_builtin_tool: dict[BUILTIN_TOOL_ID, AbstractBuiltinTool] = { 'web_search': WebSearchTool(), 'code_execution': CodeExecutionTool(), 'image_generation': ImageGenerationTool(), } +DEFAULT_BUILTIN_TOOL_DEFS: list[BuiltinToolDef] = [ + BuiltinToolDef(id=tool_id, name=_id_to_ui_name[tool_id], tool=_id_to_builtin_tool[tool_id]) + for tool_id in _default_tool_ids +] + + AI_MODELS: list[AIModel] = [ AIModel( id='anthropic:claude-sonnet-4-5', diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/api.py b/pydantic_ai_slim/pydantic_ai/ui/web/api.py index d863e43192..af27c4bf8f 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/api.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/api.py @@ -7,10 +7,10 @@ from pydantic.alias_generators import to_camel from pydantic_ai import Agent -from pydantic_ai.builtin_tools import AbstractBuiltinTool +from pydantic_ai.builtin_tools import BUILTIN_TOOL_ID from pydantic_ai.ui.vercel_ai._adapter import VercelAIAdapter -from .agent_options import AI_MODELS, BUILTIN_TOOL_DEFS, BUILTIN_TOOLS, AIModel, BuiltinTool +from .agent_options import AI_MODELS, DEFAULT_BUILTIN_TOOL_DEFS, AIModel, BuiltinToolDef def get_agent(request: Request) -> Agent: @@ -23,8 +23,7 @@ def get_agent(request: Request) -> Agent: def create_api_router( models: list[AIModel] | None = None, - builtin_tools: dict[str, AbstractBuiltinTool] | None = None, - builtin_tool_defs: list[BuiltinTool] | None = None, + builtin_tool_defs: list[BuiltinToolDef] | None = None, ) -> APIRouter: """Create the API router for chat endpoints. @@ -34,8 +33,7 @@ def create_api_router( builtin_tool_defs: Optional list of builtin tool definitions (defaults to BUILTIN_TOOL_DEFS) """ _models = models or AI_MODELS - _builtin_tools: dict[str, AbstractBuiltinTool] = builtin_tools or BUILTIN_TOOLS - _builtin_tool_defs = builtin_tool_defs or BUILTIN_TOOL_DEFS + _builtin_tool_defs = builtin_tool_defs or DEFAULT_BUILTIN_TOOL_DEFS router = APIRouter() @@ -48,14 +46,14 @@ class ConfigureFrontend(BaseModel, alias_generator=to_camel, populate_by_name=Tr """Response model for frontend configuration.""" models: list[AIModel] - builtin_tools: list[BuiltinTool] + builtin_tool_defs: list[BuiltinToolDef] @router.get('/api/configure') async def configure_frontend() -> ConfigureFrontend: # pyright: ignore[reportUnusedFunction] """Endpoint to configure the frontend with available models and tools.""" return ConfigureFrontend( models=_models, - builtin_tools=_builtin_tool_defs, + builtin_tool_defs=_builtin_tool_defs, ) @router.get('/api/health') @@ -67,7 +65,7 @@ class ChatRequestExtra(BaseModel, extra='ignore', alias_generator=to_camel): """Extra data extracted from chat request.""" model: str | None = None - builtin_tools: list[str] = [] + builtin_tools: list[BUILTIN_TOOL_ID] = [] @router.post('/api/chat') async def post_chat( # pyright: ignore[reportUnusedFunction] @@ -76,11 +74,16 @@ async def post_chat( # pyright: ignore[reportUnusedFunction] """Handle chat requests via Vercel AI Adapter.""" adapter = await VercelAIAdapter.from_request(request, agent=agent) extra_data = ChatRequestExtra.model_validate(adapter.run_input.__pydantic_extra__) + builtin_tools = [ + builtin_tool_def.tool + for builtin_tool_def in _builtin_tool_defs + if builtin_tool_def.id in extra_data.builtin_tools + ] streaming_response = await VercelAIAdapter.dispatch_request( request, agent=agent, model=extra_data.model, - builtin_tools=[_builtin_tools[tool_id] for tool_id in extra_data.builtin_tools], + builtin_tools=builtin_tools, ) return streaming_response diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/app.py b/pydantic_ai_slim/pydantic_ai/ui/web/app.py index 6fd144c996..5dd56c4b99 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/app.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/app.py @@ -6,34 +6,35 @@ import fastapi import httpx -from fastapi import Request +from fastapi import Query, Request from fastapi.responses import HTMLResponse from pydantic_ai import Agent -from pydantic_ai.builtin_tools import AbstractBuiltinTool -from .agent_options import AIModel, BuiltinTool +from .agent_options import AIModel, BuiltinToolDef from .api import create_api_router -CDN_URL = 'https://cdn.jsdelivr.net/npm/@pydantic/ai-chat-ui/dist/index.html' +DEFAULT_UI_VERSION = 'latest' +CDN_URL_TEMPLATE = 'https://cdn.jsdelivr.net/npm/@pydantic/ai-chat-ui@{version}/dist/index.html' AgentDepsT = TypeVar('AgentDepsT') OutputDataT = TypeVar('OutputDataT') +_cached_ui_html: dict[str, bytes] = {} + def create_web_app( agent: Agent[AgentDepsT, OutputDataT], models: list[AIModel] | None = None, - builtin_tools: dict[str, AbstractBuiltinTool] | None = None, - builtin_tool_defs: list[BuiltinTool] | None = None, + builtin_tool_defs: list[BuiltinToolDef] | None = None, ) -> fastapi.FastAPI: """Create a FastAPI app that serves a web chat UI for the given agent. Args: agent: The Pydantic AI agent to serve models: Optional list of AI models (defaults to AI_MODELS) - builtin_tools: Optional dict of builtin tool instances (defaults to BUILTIN_TOOLS) - builtin_tool_defs: Optional list of builtin tool definitions (defaults to BUILTIN_TOOL_DEFS) + builtin_tool_defs: Optional list of builtin tool definitions. Each definition includes + the tool ID, display name, and tool instance (defaults to DEFAULT_BUILTIN_TOOL_DEFS) Returns: A configured FastAPI application ready to be served @@ -42,16 +43,29 @@ def create_web_app( app.state.agent = agent - app.include_router( - create_api_router(models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs) - ) + app.include_router(create_api_router(models=models, builtin_tool_defs=builtin_tool_defs)) @app.get('/') @app.get('/{id}') - async def index(request: Request): # pyright: ignore[reportUnusedFunction] - """Serve the chat UI from CDN.""" - async with httpx.AsyncClient() as client: - response = await client.get(CDN_URL) - return HTMLResponse(content=response.content, status_code=response.status_code) + async def index(request: Request, version: str | None = Query(None)): # pyright: ignore[reportUnusedFunction] + """Serve the chat UI from CDN, cached on the client on first use. + + Accepts an optional query param for the version to load (e.g. '1.0.0'). Defaults to pinned version. + """ + ui_version = version or DEFAULT_UI_VERSION + cdn_url = CDN_URL_TEMPLATE.format(version=ui_version) + + if ui_version not in _cached_ui_html: + async with httpx.AsyncClient() as client: + response = await client.get(cdn_url) + response.raise_for_status() + _cached_ui_html[ui_version] = response.content + + return HTMLResponse( + content=_cached_ui_html[ui_version], + headers={ + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + ) return app diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index 0be3e047df..12dc499609 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest +from inline_snapshot import snapshot from pydantic_ai import Agent @@ -12,7 +13,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient - from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOL_DEFS, BUILTIN_TOOLS, create_web_app + from pydantic_ai.ui.web import AI_MODELS, DEFAULT_BUILTIN_TOOL_DEFS, create_web_app pytestmark = [ pytest.mark.skipif(not fastapi_import_successful(), reason='fastapi not installed'), @@ -57,12 +58,32 @@ def test_chat_app_configure_endpoint(): response = client.get('/api/configure') assert response.status_code == 200 data = response.json() - assert 'models' in data - assert 'builtinTools' in data # camelCase due to alias generator - - # no snapshot bc we're checking against the actual model/tool definitions - assert len(data['models']) == len(AI_MODELS) - assert len(data['builtinTools']) == len(BUILTIN_TOOL_DEFS) + assert data == snapshot( + { + 'models': [ + { + 'id': 'anthropic:claude-sonnet-4-5', + 'name': 'Claude Sonnet 4.5', + 'builtinTools': ['web_search', 'code_execution'], + }, + { + 'id': 'openai-responses:gpt-5', + 'name': 'GPT 5', + 'builtinTools': ['web_search', 'code_execution', 'image_generation'], + }, + { + 'id': 'google-gla:gemini-2.5-pro', + 'name': 'Gemini 2.5 Pro', + 'builtinTools': ['web_search', 'code_execution'], + }, + ], + 'builtinTools': [ + {'id': 'web_search', 'name': 'Web Search'}, + {'id': 'code_execution', 'name': 'Code Execution'}, + {'id': 'image_generation', 'name': 'Image Generation'}, + ], + } + ) def test_chat_app_index_endpoint(): @@ -74,9 +95,25 @@ def test_chat_app_index_endpoint(): response = client.get('/') assert response.status_code == 200 assert response.headers['content-type'] == 'text/html; charset=utf-8' + assert 'cache-control' in response.headers + assert response.headers['cache-control'] == 'public, max-age=31536000, immutable' assert len(response.content) > 0 +def test_chat_app_index_caching(): + """Test that the UI HTML is cached after first fetch.""" + agent = Agent('test') + app = create_web_app(agent) + + with TestClient(app) as client: + response1 = client.get('/') + response2 = client.get('/') + + assert response1.content == response2.content + assert response1.status_code == 200 + assert response2.status_code == 200 + + def test_ai_models_configuration(): """Test that AI models are configured correctly.""" assert len(AI_MODELS) == 3 @@ -88,15 +125,17 @@ def test_ai_models_configuration(): def test_builtin_tools_configuration(): - """Test that builtin tools are configured correctly.""" - assert len(BUILTIN_TOOLS) == 3 + """Test that builtin tool definitions are configured correctly.""" + assert len(DEFAULT_BUILTIN_TOOL_DEFS) == 3 - assert 'web_search' in BUILTIN_TOOLS - assert 'code_execution' in BUILTIN_TOOLS - assert 'image_generation' in BUILTIN_TOOLS + tool_ids = {tool_def.id for tool_def in DEFAULT_BUILTIN_TOOL_DEFS} + assert 'web_search' in tool_ids + assert 'code_execution' in tool_ids + assert 'image_generation' in tool_ids from pydantic_ai.builtin_tools import CodeExecutionTool, ImageGenerationTool, WebSearchTool - assert isinstance(BUILTIN_TOOLS['web_search'], WebSearchTool) - assert isinstance(BUILTIN_TOOLS['code_execution'], CodeExecutionTool) - assert isinstance(BUILTIN_TOOLS['image_generation'], ImageGenerationTool) + tools_by_id = {tool_def.id: tool_def.tool for tool_def in DEFAULT_BUILTIN_TOOL_DEFS} + assert isinstance(tools_by_id['web_search'], WebSearchTool) + assert isinstance(tools_by_id['code_execution'], CodeExecutionTool) + assert isinstance(tools_by_id['image_generation'], ImageGenerationTool) From 0d2494197381d5278b823a1827ab853039228b01 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:55:56 -0500 Subject: [PATCH 11/15] fix tests --- pydantic_ai_slim/pydantic_ai/agent/__init__.py | 2 +- pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py | 2 +- tests/test_ui_web.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 3dfe7a0ed9..6683d8ef0f 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -1489,7 +1489,7 @@ def to_web( models: List of AI models to make available in the UI. If not provided, defaults to a predefined set of models. You'll need to ensure you have valid API keys configured for any models you wish to use. - builtin_tool_defs: Optional list of builtin tool definitions for the UI. Each + builtin_tool_defs: List of builtin tool definitions for the UI. Each definition includes the tool ID, display name, and tool instance. If not provided, defaults to a predefined set of tool definitions. diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py index 0f92b0a166..02ef7723e5 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py @@ -23,7 +23,7 @@ class AIModel(BaseModel, alias_generator=to_camel, populate_by_name=True): """Defines an AI model with its associated built-in tools.""" - id: AIModelID + id: str name: str builtin_tools: list[BUILTIN_TOOL_ID] diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index 12dc499609..13daa0f036 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -77,7 +77,7 @@ def test_chat_app_configure_endpoint(): 'builtinTools': ['web_search', 'code_execution'], }, ], - 'builtinTools': [ + 'builtinToolDefs': [ {'id': 'web_search', 'name': 'Web Search'}, {'id': 'code_execution', 'name': 'Code Execution'}, {'id': 'image_generation', 'name': 'Image Generation'}, From e5b30c29eb51f74f23ce4067c89d9fed71d198b4 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:54:59 -0500 Subject: [PATCH 12/15] - update CLI commands and improve agent loading mechanism - rename config vars to be nicer --- clai/README.md | 25 ++++++++- clai/clai/web/cli.py | 36 ++++++------- docs/cli.md | 51 ++++++++++++++++--- .../pydantic_ai/ui/web/__init__.py | 8 +-- .../pydantic_ai/ui/web/agent_options.py | 4 +- pydantic_ai_slim/pydantic_ai/ui/web/api.py | 16 +++--- pydantic_ai_slim/pydantic_ai/ui/web/app.py | 4 +- tests/test_ui_web.py | 12 ++--- 8 files changed, 107 insertions(+), 49 deletions(-) diff --git a/clai/README.md b/clai/README.md index 00dfd8a3e8..a3f555d659 100644 --- a/clai/README.md +++ b/clai/README.md @@ -56,7 +56,7 @@ Either way, running `clai` will start an interactive session where you can chat Launch a web-based chat interface for your agent: ```bash -clai web module:agent_variable +clai --web --agent module:agent_variable ``` For example, if you have an agent defined in `my_agent.py`: @@ -70,7 +70,7 @@ my_agent = Agent('openai:gpt-5', system_prompt='You are a helpful assistant.') Launch the web UI with: ```bash -clai web my_agent:my_agent +clai --web --agent my_agent:my_agent ``` This will start a web server (default: http://127.0.0.1:8000) with a chat interface for your agent. @@ -82,6 +82,27 @@ This will start a web server (default: http://127.0.0.1:8000) with a chat interf - `--config`: Path to custom `agent_options.py` config file - `--no-auto-config`: Disable auto-discovery of `agent_options.py` in current directory +### Configuring Models and Tools + +You can customize which AI models and builtin tools are available in the web UI by creating an `agent_options.py` file. For example: + +```python +from pydantic_ai.ui.web import AIModel, BuiltinToolDef +from pydantic_ai.builtin_tools import WebSearchTool + +models = [ + AIModel(id='openai:gpt-5', name='GPT 5', builtin_tools=['web_search']), +] + +builtin_tool_definitions = [ + BuiltinToolDef(id='web_search', name='Web Search', tool=WebSearchTool()), +] +``` + +See the [default configuration](https://github.com/pydantic/pydantic-ai/blob/main/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py) for more examples. + +If an `agent_options.py` file exists in your current directory, it will be automatically loaded when you run `clai --web`. You can also specify a custom config path with `--config`. + You can also launch the web UI directly from an `Agent` instance using `Agent.to_web()`: ```python diff --git a/clai/clai/web/cli.py b/clai/clai/web/cli.py index b4e9737d1b..113586f3b4 100644 --- a/clai/clai/web/cli.py +++ b/clai/clai/web/cli.py @@ -2,11 +2,12 @@ from __future__ import annotations -import importlib import importlib.util import sys from pathlib import Path +from pydantic import BaseModel, ImportString, ValidationError + from pydantic_ai import Agent from pydantic_ai.ui.web import AIModel, BuiltinToolDef, create_web_app @@ -31,8 +32,8 @@ def load_agent_options( module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - models = getattr(module, 'AI_MODELS', None) - builtin_tool_defs = getattr(module, 'DEFAULT_BUILTIN_TOOL_DEFS', getattr(module, 'BUILTIN_TOOL_DEFS', None)) + models = getattr(module, 'models', None) + builtin_tool_defs = getattr(module, 'builtin_tool_definitions', None) return models, builtin_tool_defs @@ -41,6 +42,12 @@ def load_agent_options( return None, None +class _AgentLoader(BaseModel): + """Helper model for loading agents using Pydantic's ImportString.""" + + agent: ImportString # type: ignore[valid-type] + + def load_agent(agent_path: str) -> Agent | None: """Load an agent from module path in uvicorn style. @@ -53,30 +60,17 @@ def load_agent(agent_path: str) -> Agent | None: sys.path.insert(0, str(Path.cwd())) try: - module_path, variable_name = agent_path.split(':') - except ValueError: - print('Error: Agent must be specified in "module:variable" format') - return None - - try: - module = importlib.import_module(module_path) - agent = getattr(module, variable_name, None) - - if agent is None: - print(f'Error: {variable_name} not found in module {module_path}') - return None + loader = _AgentLoader(agent=agent_path) + agent = loader.agent # type: ignore[reportUnknownVariableType] if not isinstance(agent, Agent): - print(f'Error: {variable_name} is not an Agent instance') + print(f'Error: {agent_path} is not an Agent instance') return None return agent # pyright: ignore[reportUnknownVariableType] - except ImportError as e: - print(f'Error: Could not import module {module_path}: {e}') - return None - except Exception as e: - print(f'Error loading agent: {e}') + except ValidationError as e: + print(f'Error loading agent from {agent_path}: {e}') return None diff --git a/docs/cli.md b/docs/cli.md index a5d53b7c4e..0ea5e58338 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -53,7 +53,7 @@ Either way, running `clai` will start an interactive session where you can chat Launch a web-based chat interface for your agent: ```bash -clai web module:agent_variable +clai --web --agent module:agent_variable ``` For example, if you have an agent defined in `my_agent.py`: @@ -67,7 +67,7 @@ my_agent = Agent('openai:gpt-5', system_prompt='You are a helpful assistant.') Launch the web UI with: ```bash -clai web my_agent:my_agent +clai --web --agent my_agent:my_agent ``` This will start a web server (default: http://127.0.0.1:8000) with a chat interface for your agent. @@ -79,6 +79,43 @@ This will start a web server (default: http://127.0.0.1:8000) with a chat interf - `--config`: Path to custom `agent_options.py` config file - `--no-auto-config`: Disable auto-discovery of `agent_options.py` in current directory +#### Configuring Models and Tools + +You can customize which AI models and builtin tools are available in the web UI by creating an `agent_options.py` file: + +```python title="agent_options.py" +from pydantic_ai.ui.web import AIModel, BuiltinToolDef +from pydantic_ai.builtin_tools import WebSearchTool, CodeExecutionTool + +models = [ + AIModel( + id='openai:gpt-5', + name='GPT 5', + builtin_tools=['web_search', 'code_execution'], + ), + AIModel( + id='anthropic:claude-sonnet-4-5', + name='Claude Sonnet 4.5', + builtin_tools=['web_search'], + ), +] + +builtin_tool_definitions = [ + BuiltinToolDef( + id='web_search', + name='Web Search', + tool=WebSearchTool(), + ), + BuiltinToolDef( + id='code_execution', + name='Code Execution', + tool=CodeExecutionTool(), + ), +] +``` + +If an `agent_options.py` file exists in your current directory, it will be automatically loaded when you run `clai --web`. You can also specify a custom config path with `--config`. + You can also launch the web UI directly from an `Agent` instance using [`Agent.to_web()`][pydantic_ai.Agent.to_web]: ```python @@ -91,7 +128,9 @@ app = agent.to_web() # Returns a FastAPI application The returned FastAPI app can be run with your preferred ASGI server (uvicorn, hypercorn, etc.): ```bash -uvicorn my_module:app --host 0.0.0.0 --port 8080 +# If you saved the code above in my_agent.py and created an app variable: +# app = agent.to_web() +uvicorn my_agent:app --host 0.0.0.0 --port 8080 ``` ### Help @@ -119,7 +158,7 @@ You can specify a custom agent using the `--agent` flag with a module path and v ```python {title="custom_agent.py" test="skip"} from pydantic_ai import Agent -agent = Agent('openai:gpt-5', instructions='You always respond in Italian.') +agent = Agent('openai:gpt-5', system_prompt='You always respond in Italian.') ``` Then run: @@ -138,7 +177,7 @@ Additionally, you can directly launch CLI mode from an `Agent` instance using `A ```python {title="agent_to_cli_sync.py" test="skip" hl_lines=4} from pydantic_ai import Agent -agent = Agent('openai:gpt-5', instructions='You always respond in Italian.') +agent = Agent('openai:gpt-5', system_prompt='You always respond in Italian.') agent.to_cli_sync() ``` @@ -147,7 +186,7 @@ You can also use the async interface with `Agent.to_cli()`: ```python {title="agent_to_cli.py" test="skip" hl_lines=6} from pydantic_ai import Agent -agent = Agent('openai:gpt-5', instructions='You always respond in Italian.') +agent = Agent('openai:gpt-5', system_prompt='You always respond in Italian.') async def main(): await agent.to_cli() diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py index 137d358f1a..f9564f192e 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py @@ -1,11 +1,11 @@ """Web-based chat UI for Pydantic AI agents.""" from .agent_options import ( - AI_MODELS, - DEFAULT_BUILTIN_TOOL_DEFS, AIModel, AIModelID, BuiltinToolDef, + builtin_tool_definitions, + models, ) from .api import create_api_router from .app import create_web_app @@ -13,8 +13,8 @@ __all__ = [ 'create_web_app', 'create_api_router', - 'AI_MODELS', - 'DEFAULT_BUILTIN_TOOL_DEFS', + 'models', + 'builtin_tool_definitions', 'AIModel', 'AIModelID', 'BuiltinToolDef', diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py index 02ef7723e5..74e007d972 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py @@ -53,13 +53,13 @@ class BuiltinToolDef(BaseModel): 'image_generation': ImageGenerationTool(), } -DEFAULT_BUILTIN_TOOL_DEFS: list[BuiltinToolDef] = [ +builtin_tool_definitions: list[BuiltinToolDef] = [ BuiltinToolDef(id=tool_id, name=_id_to_ui_name[tool_id], tool=_id_to_builtin_tool[tool_id]) for tool_id in _default_tool_ids ] -AI_MODELS: list[AIModel] = [ +models: list[AIModel] = [ AIModel( id='anthropic:claude-sonnet-4-5', name='Claude Sonnet 4.5', diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/api.py b/pydantic_ai_slim/pydantic_ai/ui/web/api.py index af27c4bf8f..6d7f8cf11d 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/api.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/api.py @@ -10,7 +10,12 @@ from pydantic_ai.builtin_tools import BUILTIN_TOOL_ID from pydantic_ai.ui.vercel_ai._adapter import VercelAIAdapter -from .agent_options import AI_MODELS, DEFAULT_BUILTIN_TOOL_DEFS, AIModel, BuiltinToolDef +from .agent_options import ( + AIModel, + BuiltinToolDef, + builtin_tool_definitions as default_builtin_tool_definitions, + models as default_models, +) def get_agent(request: Request) -> Agent: @@ -28,12 +33,11 @@ def create_api_router( """Create the API router for chat endpoints. Args: - models: Optional list of AI models (defaults to AI_MODELS) - builtin_tools: Optional dict of builtin tool instances (defaults to BUILTIN_TOOLS) - builtin_tool_defs: Optional list of builtin tool definitions (defaults to BUILTIN_TOOL_DEFS) + models: Optional list of AI models (defaults to default_models) + builtin_tool_defs: Optional list of builtin tool definitions (defaults to default_builtin_tool_definitions) """ - _models = models or AI_MODELS - _builtin_tool_defs = builtin_tool_defs or DEFAULT_BUILTIN_TOOL_DEFS + _models = models or default_models + _builtin_tool_defs = builtin_tool_defs or default_builtin_tool_definitions router = APIRouter() diff --git a/pydantic_ai_slim/pydantic_ai/ui/web/app.py b/pydantic_ai_slim/pydantic_ai/ui/web/app.py index 5dd56c4b99..d955732c5c 100644 --- a/pydantic_ai_slim/pydantic_ai/ui/web/app.py +++ b/pydantic_ai_slim/pydantic_ai/ui/web/app.py @@ -32,9 +32,9 @@ def create_web_app( Args: agent: The Pydantic AI agent to serve - models: Optional list of AI models (defaults to AI_MODELS) + models: Optional list of AI models (defaults to default models) builtin_tool_defs: Optional list of builtin tool definitions. Each definition includes - the tool ID, display name, and tool instance (defaults to DEFAULT_BUILTIN_TOOL_DEFS) + the tool ID, display name, and tool instance (defaults to default builtin tool definitions) Returns: A configured FastAPI application ready to be served diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index 13daa0f036..7e1f5ab2d2 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -13,7 +13,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient - from pydantic_ai.ui.web import AI_MODELS, DEFAULT_BUILTIN_TOOL_DEFS, create_web_app + from pydantic_ai.ui.web import builtin_tool_definitions, create_web_app, models pytestmark = [ pytest.mark.skipif(not fastapi_import_successful(), reason='fastapi not installed'), @@ -116,9 +116,9 @@ def test_chat_app_index_caching(): def test_ai_models_configuration(): """Test that AI models are configured correctly.""" - assert len(AI_MODELS) == 3 + assert len(models) == 3 - model_ids = {model.id for model in AI_MODELS} + model_ids = {model.id for model in models} assert 'anthropic:claude-sonnet-4-5' in model_ids assert 'openai-responses:gpt-5' in model_ids assert 'google-gla:gemini-2.5-pro' in model_ids @@ -126,16 +126,16 @@ def test_ai_models_configuration(): def test_builtin_tools_configuration(): """Test that builtin tool definitions are configured correctly.""" - assert len(DEFAULT_BUILTIN_TOOL_DEFS) == 3 + assert len(builtin_tool_definitions) == 3 - tool_ids = {tool_def.id for tool_def in DEFAULT_BUILTIN_TOOL_DEFS} + tool_ids = {tool_def.id for tool_def in builtin_tool_definitions} assert 'web_search' in tool_ids assert 'code_execution' in tool_ids assert 'image_generation' in tool_ids from pydantic_ai.builtin_tools import CodeExecutionTool, ImageGenerationTool, WebSearchTool - tools_by_id = {tool_def.id: tool_def.tool for tool_def in DEFAULT_BUILTIN_TOOL_DEFS} + tools_by_id = {tool_def.id: tool_def.tool for tool_def in builtin_tool_definitions} assert isinstance(tools_by_id['web_search'], WebSearchTool) assert isinstance(tools_by_id['code_execution'], CodeExecutionTool) assert isinstance(tools_by_id['image_generation'], ImageGenerationTool) From 8ca7a274894ec1335be04b465a25cad9c258b198 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:44:52 -0500 Subject: [PATCH 13/15] import sorting --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 0ea5e58338..e3e4211cf4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -84,8 +84,8 @@ This will start a web server (default: http://127.0.0.1:8000) with a chat interf You can customize which AI models and builtin tools are available in the web UI by creating an `agent_options.py` file: ```python title="agent_options.py" -from pydantic_ai.ui.web import AIModel, BuiltinToolDef from pydantic_ai.builtin_tools import WebSearchTool, CodeExecutionTool +from pydantic_ai.ui.web import AIModel, BuiltinToolDef models = [ AIModel( From fa3bb5f88ce29bf7f11cdbe7d8496821d8f25ae0 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:45:39 -0500 Subject: [PATCH 14/15] more import sorting --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index e3e4211cf4..2c756809d4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -84,7 +84,7 @@ This will start a web server (default: http://127.0.0.1:8000) with a chat interf You can customize which AI models and builtin tools are available in the web UI by creating an `agent_options.py` file: ```python title="agent_options.py" -from pydantic_ai.builtin_tools import WebSearchTool, CodeExecutionTool +from pydantic_ai.builtin_tools import CodeExecutionTool, WebSearchTool from pydantic_ai.ui.web import AIModel, BuiltinToolDef models = [ From e45c93f2ea97bbc3b318efd9faf4fa5bfc4735a2 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:25:29 -0500 Subject: [PATCH 15/15] covergae? --- tests/test_cli.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_ui_web.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index e95ff09141..af64e9d4c9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -349,3 +349,49 @@ def test_agent_to_cli_sync_with_message_history(mocker: MockerFixture, env: Test deps=None, message_history=test_messages, ) + + +def test_clai_web_without_agent(capfd: CaptureFixture[str]): + assert cli(['--web'], prog_name='clai') == 1 + assert 'Error: --web requires --agent to be specified' in capfd.readouterr().out + + +def test_clai_web_import_error(mocker: MockerFixture, create_test_module: Callable[..., None], env: TestEnv): + env.set('OPENAI_API_KEY', 'test') + test_agent = Agent(TestModel(custom_output_text='test')) + create_test_module(custom_agent=test_agent) + + # Mock the import to fail + import builtins + + original_import = builtins.__import__ + + def mock_import(name: str, *args: Any, **kwargs: Any): + if name == 'clai.web.cli': + raise ImportError('clai.web.cli not found') + return original_import(name, *args, **kwargs) + + mocker.patch('builtins.__import__', side_effect=mock_import) + + assert cli(['--web', '--agent', 'test_module:custom_agent'], prog_name='clai') == 1 + + +def test_clai_web_success(mocker: MockerFixture, create_test_module: Callable[..., None], env: TestEnv): + env.set('OPENAI_API_KEY', 'test') + test_agent = Agent(TestModel(custom_output_text='test')) + create_test_module(custom_agent=test_agent) + + # Mock the run_web_command function + mock_run_web = mocker.MagicMock(return_value=0) + mocker.patch.dict('sys.modules', {'clai.web.cli': mocker.MagicMock(run_web_command=mock_run_web)}) + + assert cli(['--web', '--agent', 'test_module:custom_agent'], prog_name='clai') == 0 + + # Verify run_web_command was called with correct args + mock_run_web.assert_called_once_with( + agent_path='test_module:custom_agent', + host='127.0.0.1', + port=8000, + config_path=None, + auto_config=True, + ) diff --git a/tests/test_ui_web.py b/tests/test_ui_web.py index 7e1f5ab2d2..42d1b37f56 100644 --- a/tests/test_ui_web.py +++ b/tests/test_ui_web.py @@ -139,3 +139,48 @@ def test_builtin_tools_configuration(): assert isinstance(tools_by_id['web_search'], WebSearchTool) assert isinstance(tools_by_id['code_execution'], CodeExecutionTool) assert isinstance(tools_by_id['image_generation'], ImageGenerationTool) + + +def test_get_agent_missing(): + """Test that get_agent raises RuntimeError when agent is not configured.""" + from pydantic_ai.ui.web.api import get_agent + + app = FastAPI() + + class FakeRequest: + def __init__(self, app: FastAPI): + self.app = app + + request = FakeRequest(app) + + with pytest.raises(RuntimeError, match='No agent configured'): + get_agent(request) # type: ignore[arg-type] + + +@pytest.mark.anyio +async def test_post_chat_endpoint(): + """Test the POST /api/chat endpoint.""" + from pydantic_ai.models.test import TestModel + + agent = Agent(TestModel(custom_output_text='Hello from test!')) + app = create_web_app(agent) + + with TestClient(app) as client: + response = client.post( + '/api/chat', + json={ + 'trigger': 'submit-message', + 'id': 'test-message-id', + 'messages': [ + { + 'id': 'msg-1', + 'role': 'user', + 'parts': [{'type': 'text', 'text': 'Hello'}], + } + ], + 'model': 'test', + 'builtinTools': [], + }, + ) + + assert response.status_code == 200