diff --git a/clai/README.md b/clai/README.md index da0ed9bc27..a3f555d659 100644 --- a/clai/README.md +++ b/clai/README.md @@ -51,10 +51,73 @@ 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 --agent 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 --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. + +### 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 + +### 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 +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] [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... @@ -78,4 +141,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/__init__.py b/clai/clai/web/__init__.py new file mode 100644 index 0000000000..6baa595400 --- /dev/null +++ b/clai/clai/web/__init__.py @@ -0,0 +1,5 @@ +"""Chat UI module for clai.""" + +from .cli import run_web_command + +__all__ = ['run_web_command'] diff --git a/clai/clai/web/cli.py b/clai/clai/web/cli.py new file mode 100644 index 0000000000..113586f3b4 --- /dev/null +++ b/clai/clai/web/cli.py @@ -0,0 +1,127 @@ +"""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 import BaseModel, ImportString, ValidationError + +from pydantic_ai import Agent +from pydantic_ai.ui.web import AIModel, BuiltinToolDef, create_web_app + + +def load_agent_options( + config_path: Path, +) -> 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) + """ + if not config_path.exists(): + 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 + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + models = getattr(module, 'models', None) + builtin_tool_defs = getattr(module, 'builtin_tool_definitions', None) + + return models, builtin_tool_defs + + except Exception as e: + print(f'Warning: Error loading config from {config_path}: {e}') + 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. + + Args: + agent_path: Path in format 'module:variable', e.g. 'test_agent:my_agent' + + Returns: + Agent instance or None if loading fails + """ + sys.path.insert(0, str(Path.cwd())) + + try: + loader = _AgentLoader(agent=agent_path) + agent = loader.agent # type: ignore[reportUnknownVariableType] + + if not isinstance(agent, Agent): + print(f'Error: {agent_path} is not an Agent instance') + return None + + return agent # pyright: ignore[reportUnknownVariableType] + + except ValidationError as e: + print(f'Error loading agent from {agent_path}: {e}') + return None + + +def run_web_command( + 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 serve an agent via web UI. + + Args: + 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 + """ + agent = load_agent(agent_path) + if agent is None: + return 1 + + models, builtin_tool_defs = None, None + if config_path: + print(f'Loading config from {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_tool_defs = load_agent_options(default_config) + + 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}') + 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/docs/cli.md b/docs/cli.md index 811d921011..2c756809d4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,6 +48,91 @@ 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 --agent 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 --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. + +#### 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 + +#### 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.builtin_tools import CodeExecutionTool, WebSearchTool +from pydantic_ai.ui.web import AIModel, BuiltinToolDef + +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 +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 +# 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 To get help on the CLI, use the `--help` flag: @@ -73,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: @@ -92,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() ``` @@ -101,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/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 4e4889bdfc..9f85c606b5 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -106,6 +106,10 @@ 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.""" + # 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"""\ @@ -119,16 +123,15 @@ def cli( # noqa: C901 """, formatter_class=argparse.RawTextHelpFormatter, ) + 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', @@ -151,9 +154,44 @@ def cli( # noqa: C901 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='Launch web chat UI for the agent (requires --agent)', + ) + 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( + '--config', type=Path, help='Path to agent_options.py config file (overrides auto-discovery)' + ) + parser.add_argument( + '--no-auto-config', + action='store_true', + help='Disable auto-discovery of agent_options.py in current directory', + ) + argcomplete.autocomplete(parser) args = parser.parse_args(args_list) + 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.') + return 1 + return run_web_command( + agent_path=args.agent, + 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/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 4cd353b44a..6683d8ef0f 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,6 +1473,65 @@ def _set_sampling_model(toolset: AbstractToolset[AgentDepsT]) -> None: self._get_toolset().apply(_set_sampling_model) + 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 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: 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) + + 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') + + @agent.tool_plain + 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, models=models, builtin_tool_defs=builtin_tool_defs) + @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/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 new file mode 100644 index 0000000000..f9564f192e --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/__init__.py @@ -0,0 +1,21 @@ +"""Web-based chat UI for Pydantic AI agents.""" + +from .agent_options import ( + AIModel, + AIModelID, + BuiltinToolDef, + builtin_tool_definitions, + models, +) +from .api import create_api_router +from .app import create_web_app + +__all__ = [ + 'create_web_app', + 'create_api_router', + '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 new file mode 100644 index 0000000000..74e007d972 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/agent_options.py @@ -0,0 +1,88 @@ +"""Model and builtin tool configurations for the web chat UI.""" + +from typing import Literal + +from pydantic import BaseModel, Field +from pydantic.alias_generators import to_camel + +from pydantic_ai.builtin_tools import ( + BUILTIN_TOOL_ID, + AbstractBuiltinTool, + CodeExecutionTool, + ImageGenerationTool, + WebSearchTool, +) + +AIModelID = Literal[ + 'anthropic:claude-sonnet-4-5', + 'openai-responses:gpt-5', + 'google-gla:gemini-2.5-pro', +] + + +class AIModel(BaseModel, alias_generator=to_camel, populate_by_name=True): + """Defines an AI model with its associated built-in tools.""" + + id: str + name: str + builtin_tools: list[BUILTIN_TOOL_ID] + + +class BuiltinToolDef(BaseModel): + """Defines a built-in tool. + + Used by the web chat UI to display tool options. + """ + + id: BUILTIN_TOOL_ID + name: str + tool: AbstractBuiltinTool = Field(exclude=True) + + +_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', +} + +_id_to_builtin_tool: dict[BUILTIN_TOOL_ID, AbstractBuiltinTool] = { + 'web_search': WebSearchTool(), + 'code_execution': CodeExecutionTool(), + 'image_generation': ImageGenerationTool(), +} + +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 +] + + +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..6d7f8cf11d --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/api.py @@ -0,0 +1,94 @@ +"""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.builtin_tools import BUILTIN_TOOL_ID +from pydantic_ai.ui.vercel_ai._adapter import VercelAIAdapter + +from .agent_options import ( + AIModel, + BuiltinToolDef, + builtin_tool_definitions as default_builtin_tool_definitions, + models as default_models, +) + + +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( + models: list[AIModel] | None = None, + builtin_tool_defs: list[BuiltinToolDef] | None = None, +) -> APIRouter: + """Create the API router for chat endpoints. + + Args: + 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 default_models + _builtin_tool_defs = builtin_tool_defs or default_builtin_tool_definitions + + 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_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_tool_defs=_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: str | None = None + builtin_tools: list[BUILTIN_TOOL_ID] = [] + + @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__) + 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, + ) + 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..d955732c5c --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/app.py @@ -0,0 +1,71 @@ +"""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 Query, Request +from fastapi.responses import HTMLResponse + +from pydantic_ai import Agent + +from .agent_options import AIModel, BuiltinToolDef +from .api import create_api_router + +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_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 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 definitions) + + Returns: + A configured FastAPI application ready to be served + """ + app = fastapi.FastAPI() + + app.state.agent = agent + + app.include_router(create_api_router(models=models, builtin_tool_defs=builtin_tool_defs)) + + @app.get('/') + @app.get('/{id}') + 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/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 2059991bd0..925cc1bc8f 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -104,6 +104,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/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_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 new file mode 100644 index 0000000000..42d1b37f56 --- /dev/null +++ b/tests/test_ui_web.py @@ -0,0 +1,186 @@ +"""Tests for the web chat UI module.""" + +from __future__ import annotations + +import pytest +from inline_snapshot import snapshot + +from pydantic_ai import Agent + +from .conftest import try_import + +with try_import() as fastapi_import_successful: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + 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'), +] + + +def test_create_chat_app_basic(): + """Test creating a basic chat app.""" + agent = Agent('test') + app = create_web_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_web_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_web_app(agent) + + with TestClient(app) as client: + response = client.get('/api/configure') + assert response.status_code == 200 + data = response.json() + 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'], + }, + ], + 'builtinToolDefs': [ + {'id': 'web_search', 'name': 'Web Search'}, + {'id': 'code_execution', 'name': 'Code Execution'}, + {'id': 'image_generation', 'name': 'Image Generation'}, + ], + } + ) + + +def test_chat_app_index_endpoint(): + """Test that the index endpoint serves the UI from CDN.""" + agent = Agent('test') + app = create_web_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 '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(models) == 3 + + 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 + + +def test_builtin_tools_configuration(): + """Test that builtin tool definitions are configured correctly.""" + assert len(builtin_tool_definitions) == 3 + + 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 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) + + +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 diff --git a/uv.lock b/uv.lock index f603af183f..058443bed4 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" }, @@ -5654,6 +5655,10 @@ vertexai = [ { name = "google-auth" }, { name = "requests" }, ] +web = [ + { name = "fastapi" }, + { name = "httpx" }, +] [package.metadata] requires-dist = [ @@ -5666,6 +5671,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.40" }, { name = "google-auth", marker = "extra == 'vertexai'", specifier = ">=2.36.0" }, @@ -5673,6 +5679,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.18.0" }, @@ -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", "openrouter", "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", "openrouter", "outlines-llamacpp", "outlines-mlxlm", "outlines-sglang", "outlines-transformers", "outlines-vllm-offline", "prefect", "retries", "tavily", "temporal", "ui", "vertexai", "web"] [[package]] name = "pydantic-core" @@ -8591,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" },