Skip to content

Commit 1cfa48e

Browse files
committed
add Agent.to_web() method and web chat UI module #3295
1 parent 359c6d2 commit 1cfa48e

File tree

6 files changed

+348
-0
lines changed

6 files changed

+348
-0
lines changed

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,35 @@ def _set_sampling_model(toolset: AbstractToolset[AgentDepsT]) -> None:
14701470

14711471
self._get_toolset().apply(_set_sampling_model)
14721472

1473+
def to_web(self) -> Any:
1474+
"""Create a FastAPI app that serves a web chat UI for this agent.
1475+
1476+
This method returns a pre-configured FastAPI application that provides a web-based
1477+
chat interface for interacting with the agent. The UI is served from a CDN and
1478+
includes support for model selection and builtin tool configuration.
1479+
1480+
Returns:
1481+
A configured FastAPI application ready to be served (e.g., with uvicorn)
1482+
1483+
Example:
1484+
```python
1485+
from pydantic_ai import Agent
1486+
1487+
agent = Agent('openai:gpt-5')
1488+
1489+
@agent.tool
1490+
def get_weather(city: str) -> str:
1491+
return f"The weather in {city} is sunny"
1492+
1493+
app = agent.to_web()
1494+
1495+
# Then run with: uvicorn app:app --reload
1496+
```
1497+
"""
1498+
from ..ui.web import create_chat_app
1499+
1500+
return create_chat_app(self)
1501+
14731502
@asynccontextmanager
14741503
@deprecated(
14751504
'`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()`.'
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Web-based chat UI for Pydantic AI agents."""
2+
3+
from .agent_options import AI_MODELS, BUILTIN_TOOLS, AIModel, AIModelID, BuiltinTool, BuiltinToolID
4+
from .api import create_api_router
5+
from .app import create_chat_app
6+
7+
__all__ = [
8+
'create_chat_app',
9+
'create_api_router',
10+
'AI_MODELS',
11+
'BUILTIN_TOOLS',
12+
'AIModel',
13+
'AIModelID',
14+
'BuiltinTool',
15+
'BuiltinToolID',
16+
]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Model and builtin tool configurations for the web chat UI."""
2+
3+
from typing import Literal
4+
5+
from pydantic import BaseModel
6+
7+
from pydantic_ai.builtin_tools import (
8+
AbstractBuiltinTool,
9+
CodeExecutionTool,
10+
ImageGenerationTool,
11+
WebSearchTool,
12+
)
13+
14+
AIModelID = Literal[
15+
'anthropic:claude-sonnet-4-5',
16+
'openai-responses:gpt-5',
17+
'google-gla:gemini-2.5-pro',
18+
]
19+
BuiltinToolID = Literal['web_search', 'image_generation', 'code_execution']
20+
21+
22+
class AIModel(BaseModel):
23+
"""Defines an AI model with its associated built-in tools."""
24+
25+
id: AIModelID
26+
name: str
27+
builtin_tools: list[BuiltinToolID]
28+
29+
30+
class BuiltinTool(BaseModel):
31+
"""Defines a built-in tool."""
32+
33+
id: BuiltinToolID
34+
name: str
35+
36+
37+
BUILTIN_TOOL_DEFS: list[BuiltinTool] = [
38+
BuiltinTool(id='web_search', name='Web Search'),
39+
BuiltinTool(id='code_execution', name='Code Execution'),
40+
BuiltinTool(id='image_generation', name='Image Generation'),
41+
]
42+
43+
BUILTIN_TOOLS: dict[BuiltinToolID, AbstractBuiltinTool] = {
44+
'web_search': WebSearchTool(),
45+
'code_execution': CodeExecutionTool(),
46+
'image_generation': ImageGenerationTool(),
47+
}
48+
49+
AI_MODELS: list[AIModel] = [
50+
AIModel(
51+
id='anthropic:claude-sonnet-4-5',
52+
name='Claude Sonnet 4.5',
53+
builtin_tools=[
54+
'web_search',
55+
'code_execution',
56+
],
57+
),
58+
AIModel(
59+
id='openai-responses:gpt-5',
60+
name='GPT 5',
61+
builtin_tools=[
62+
'web_search',
63+
'code_execution',
64+
'image_generation',
65+
],
66+
),
67+
AIModel(
68+
id='google-gla:gemini-2.5-pro',
69+
name='Gemini 2.5 Pro',
70+
builtin_tools=[
71+
'web_search',
72+
'code_execution',
73+
],
74+
),
75+
]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""API router for the web chat UI."""
2+
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Depends, Request, Response
6+
from pydantic import BaseModel
7+
from pydantic.alias_generators import to_camel
8+
9+
from pydantic_ai import Agent
10+
from pydantic_ai.ui.vercel_ai._adapter import VercelAIAdapter
11+
12+
from .agent_options import (
13+
AI_MODELS,
14+
BUILTIN_TOOL_DEFS,
15+
BUILTIN_TOOLS,
16+
AIModel,
17+
AIModelID,
18+
BuiltinTool,
19+
BuiltinToolID,
20+
)
21+
22+
23+
def get_agent(request: Request) -> Agent:
24+
"""Get the agent from app state."""
25+
agent = getattr(request.app.state, 'agent', None)
26+
if agent is None:
27+
raise RuntimeError('No agent configured. Server must be started with a valid agent.')
28+
return agent
29+
30+
31+
def create_api_router() -> APIRouter:
32+
"""Create the API router for chat endpoints."""
33+
router = APIRouter()
34+
35+
@router.options('/api/chat')
36+
def options_chat(): # pyright: ignore[reportUnusedFunction]
37+
"""Handle CORS preflight requests."""
38+
pass
39+
40+
class ConfigureFrontend(BaseModel, alias_generator=to_camel, populate_by_name=True):
41+
"""Response model for frontend configuration."""
42+
43+
models: list[AIModel]
44+
builtin_tools: list[BuiltinTool]
45+
46+
@router.get('/api/configure')
47+
async def configure_frontend() -> ConfigureFrontend: # pyright: ignore[reportUnusedFunction]
48+
"""Endpoint to configure the frontend with available models and tools."""
49+
return ConfigureFrontend(
50+
models=AI_MODELS,
51+
builtin_tools=BUILTIN_TOOL_DEFS,
52+
)
53+
54+
@router.get('/api/health')
55+
async def health() -> dict[str, bool]: # pyright: ignore[reportUnusedFunction]
56+
"""Health check endpoint."""
57+
return {'ok': True}
58+
59+
class ChatRequestExtra(BaseModel, extra='ignore', alias_generator=to_camel):
60+
"""Extra data extracted from chat request."""
61+
62+
model: AIModelID | None = None
63+
builtin_tools: list[BuiltinToolID] = []
64+
65+
@router.post('/api/chat')
66+
async def post_chat( # pyright: ignore[reportUnusedFunction]
67+
request: Request, agent: Annotated[Agent, Depends(get_agent)]
68+
) -> Response:
69+
"""Handle chat requests via Vercel AI Adapter."""
70+
adapter = await VercelAIAdapter.from_request(request, agent=agent)
71+
extra_data = ChatRequestExtra.model_validate(adapter.run_input.__pydantic_extra__)
72+
streaming_response = await VercelAIAdapter.dispatch_request(
73+
request,
74+
agent=agent,
75+
model=extra_data.model,
76+
builtin_tools=[BUILTIN_TOOLS[tool_id] for tool_id in extra_data.builtin_tools],
77+
)
78+
return streaming_response
79+
80+
return router
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Factory function for creating a web chat app for a Pydantic AI agent."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TypeVar
6+
7+
import fastapi
8+
import httpx
9+
from fastapi import Request
10+
from fastapi.responses import HTMLResponse
11+
12+
from pydantic_ai import Agent
13+
14+
from .api import create_api_router
15+
16+
CDN_URL = 'https://cdn.jsdelivr.net/npm/@pydantic/[email protected]/dist/index.html'
17+
18+
AgentDepsT = TypeVar('AgentDepsT')
19+
OutputDataT = TypeVar('OutputDataT')
20+
21+
22+
def create_chat_app(
23+
agent: Agent[AgentDepsT, OutputDataT],
24+
) -> fastapi.FastAPI:
25+
"""Create a FastAPI app that serves a web chat UI for the given agent.
26+
27+
Args:
28+
agent: The Pydantic AI agent to serve
29+
30+
Returns:
31+
A configured FastAPI application ready to be served
32+
"""
33+
app = fastapi.FastAPI()
34+
35+
app.state.agent = agent
36+
37+
app.include_router(create_api_router())
38+
39+
@app.get('/')
40+
@app.get('/{id}')
41+
async def index(request: Request): # pyright: ignore[reportUnusedFunction]
42+
"""Serve the chat UI from CDN."""
43+
async with httpx.AsyncClient() as client:
44+
response = await client.get(CDN_URL)
45+
return HTMLResponse(content=response.content, status_code=response.status_code)
46+
47+
return app

tests/test_ui_web.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for the web chat UI module."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from pydantic_ai import Agent
8+
from pydantic_ai.ui.web import AI_MODELS, BUILTIN_TOOLS, create_chat_app
9+
10+
from .conftest import try_import
11+
12+
with try_import() as fastapi_import_successful:
13+
from fastapi import FastAPI
14+
from fastapi.testclient import TestClient
15+
16+
pytestmark = [
17+
pytest.mark.skipif(not fastapi_import_successful, reason='fastapi not installed'),
18+
]
19+
20+
21+
def test_create_chat_app_basic():
22+
"""Test creating a basic chat app."""
23+
agent = Agent('test')
24+
app = create_chat_app(agent)
25+
26+
assert isinstance(app, FastAPI)
27+
assert app.state.agent is agent
28+
29+
30+
def test_agent_to_web():
31+
"""Test the Agent.to_web() method."""
32+
agent = Agent('test')
33+
app = agent.to_web()
34+
35+
assert isinstance(app, FastAPI)
36+
assert app.state.agent is agent
37+
38+
39+
def test_chat_app_health_endpoint():
40+
"""Test the /api/health endpoint."""
41+
agent = Agent('test')
42+
app = create_chat_app(agent)
43+
44+
with TestClient(app) as client:
45+
response = client.get('/api/health')
46+
assert response.status_code == 200
47+
assert response.json() == {'ok': True}
48+
49+
50+
def test_chat_app_configure_endpoint():
51+
"""Test the /api/configure endpoint."""
52+
agent = Agent('test')
53+
app = create_chat_app(agent)
54+
55+
with TestClient(app) as client:
56+
response = client.get('/api/configure')
57+
assert response.status_code == 200
58+
data = response.json()
59+
assert 'models' in data
60+
assert 'builtinTools' in data # camelCase due to alias generator
61+
62+
# no snapshot bc we're checking against the actual model/tool definitions
63+
assert len(data['models']) == len(AI_MODELS)
64+
assert len(data['builtinTools']) == len(BUILTIN_TOOLS)
65+
66+
67+
def test_chat_app_index_endpoint():
68+
"""Test that the index endpoint serves the UI from CDN."""
69+
agent = Agent('test')
70+
app = create_chat_app(agent)
71+
72+
with TestClient(app) as client:
73+
response = client.get('/')
74+
assert response.status_code == 200
75+
assert response.headers['content-type'] == 'text/html; charset=utf-8'
76+
assert len(response.content) > 0
77+
78+
79+
def test_ai_models_configuration():
80+
"""Test that AI models are configured correctly."""
81+
assert len(AI_MODELS) == 3
82+
83+
model_ids = {model.id for model in AI_MODELS}
84+
assert 'anthropic:claude-sonnet-4-5' in model_ids
85+
assert 'openai-responses:gpt-5' in model_ids
86+
assert 'google-gla:gemini-2.5-pro' in model_ids
87+
88+
89+
def test_builtin_tools_configuration():
90+
"""Test that builtin tools are configured correctly."""
91+
assert len(BUILTIN_TOOLS) == 3
92+
93+
assert 'web_search' in BUILTIN_TOOLS
94+
assert 'code_execution' in BUILTIN_TOOLS
95+
assert 'image_generation' in BUILTIN_TOOLS
96+
97+
from pydantic_ai.builtin_tools import CodeExecutionTool, ImageGenerationTool, WebSearchTool
98+
99+
assert isinstance(BUILTIN_TOOLS['web_search'], WebSearchTool)
100+
assert isinstance(BUILTIN_TOOLS['code_execution'], CodeExecutionTool)
101+
assert isinstance(BUILTIN_TOOLS['image_generation'], ImageGenerationTool)

0 commit comments

Comments
 (0)