diff --git a/clai/README.md b/clai/README.md index da0ed9bc27..00dfd8a3e8 100644 --- a/clai/README.md +++ b/clai/README.md @@ -51,10 +51,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()`: + +```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 +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/__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..b4e9737d1b --- /dev/null +++ b/clai/clai/web/cli.py @@ -0,0 +1,133 @@ +"""CLI command for launching a web chat UI for discovered agents.""" + +from __future__ import annotations + +import importlib +import importlib.util +import sys +from pathlib import Path + +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, 'AI_MODELS', None) + builtin_tool_defs = getattr(module, 'DEFAULT_BUILTIN_TOOL_DEFS', getattr(module, 'BUILTIN_TOOL_DEFS', None)) + + return models, builtin_tool_defs + + except Exception as e: + print(f'Warning: Error loading config from {config_path}: {e}') + return None, 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' + + Returns: + Agent instance or None if loading fails + """ + 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 + + if not isinstance(agent, Agent): + 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_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..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 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..137d358f1a --- /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 ( + AI_MODELS, + DEFAULT_BUILTIN_TOOL_DEFS, + AIModel, + AIModelID, + BuiltinToolDef, +) +from .api import create_api_router +from .app import create_web_app + +__all__ = [ + 'create_web_app', + 'create_api_router', + 'AI_MODELS', + 'DEFAULT_BUILTIN_TOOL_DEFS', + '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..02ef7723e5 --- /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(), +} + +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', + 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..af27c4bf8f --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/ui/web/api.py @@ -0,0 +1,90 @@ +"""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 AI_MODELS, DEFAULT_BUILTIN_TOOL_DEFS, AIModel, BuiltinToolDef + + +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 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_tool_defs = builtin_tool_defs or DEFAULT_BUILTIN_TOOL_DEFS + + 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..5dd56c4b99 --- /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 AI_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) + + 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 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/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_ui_web.py b/tests/test_ui_web.py new file mode 100644 index 0000000000..13daa0f036 --- /dev/null +++ b/tests/test_ui_web.py @@ -0,0 +1,141 @@ +"""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 AI_MODELS, DEFAULT_BUILTIN_TOOL_DEFS, create_web_app + +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(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 tool definitions are configured correctly.""" + assert len(DEFAULT_BUILTIN_TOOL_DEFS) == 3 + + 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 + + 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) 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" },