Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2351450
Add tool approval integration for Vercel AI adapter
bendrucker Dec 19, 2025
8d4eeec
Add robustness improvements and tests for tool approval
bendrucker Dec 19, 2025
6b04e4b
Skip doc examples that are incomplete snippets
bendrucker Dec 19, 2025
04b4ba4
Also skip linting for incomplete doc snippets
bendrucker Dec 19, 2025
ce1a938
Add tests to cover edge cases for tool approval extraction
bendrucker Dec 19, 2025
b670068
Use public interface for denied_tool_ids tests
bendrucker Dec 19, 2025
b471351
Fix coverage: add pragma comments and caching test
bendrucker Dec 19, 2025
8a8e3f1
Address PR review: add tests and fix documentation
bendrucker Dec 20, 2025
5fbfa72
Address PR review comments for tool approval
bendrucker Dec 20, 2025
d11c76b
Use 'AI SDK UI v6' instead of 'AI SDK v6' for frontend requirement
bendrucker Dec 20, 2025
451b0c9
Remove unnecessary tool_call_id checks (always present)
bendrucker Dec 20, 2025
935a7e5
Inline extraction into from_request and remove private method tests
bendrucker Dec 20, 2025
b084f13
Consolidate tool approval tests to use from_request()
bendrucker Dec 20, 2025
c838e5b
Remove trailing blank lines (ruff)
bendrucker Dec 20, 2025
f374e45
Add edge case tests for tool approval extraction coverage
bendrucker Dec 20, 2025
0855421
Fix coverage: remove duplicate starlette check function
bendrucker Dec 20, 2025
8dd01ba
Add test for explicit deferred_tool_results parameter
bendrucker Dec 20, 2025
e8a57ca
rm dead pragma no cover
bendrucker Dec 20, 2025
a6f0e1a
Address PR review feedback for tool approval feature
bendrucker Jan 6, 2026
8c1497f
Fix coverage: remove unused stream_function in test
bendrucker Jan 6, 2026
7e2505f
Trigger CI rerun
bendrucker Jan 7, 2026
aa5f2d0
Add tool approval docs with correct parameter name
bendrucker Jan 7, 2026
907bd2b
Address PR review: move enable_tool_approval to Vercel-specific classes
bendrucker Jan 22, 2026
07bf987
Fix test snapshot: remove run_id from first request/response pair
bendrucker Jan 22, 2026
7c3145e
Fix test snapshot and import ordering
bendrucker Jan 22, 2026
e3d5e6a
Add timestamp field to ModelRequest snapshots for CI compatibility
bendrucker Jan 22, 2026
ac27619
Fix test snapshot: historical messages don't have timestamp/run_id
bendrucker Jan 22, 2026
39c6ac2
Preserve denial reason from ToolApprovalResponded
bendrucker Jan 30, 2026
f325008
Format test_vercel_ai.py
bendrucker Jan 30, 2026
c19330a
Merge origin/main and adapt tool approval to use sdk_version
bendrucker Feb 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions docs/ui/vercel-ai.md
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a closer look at docs later, will focus primarily on the code for now

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Vercel AI Data Stream Protocol

Pydantic AI natively supports the [Vercel AI Data Stream Protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol) to receive agent run input from, and stream events to, a [Vercel AI Elements](https://ai-sdk.dev/elements) frontend.
Pydantic AI natively supports the [Vercel AI Data Stream Protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol) to receive agent run input from, and stream events to, a frontend using [AI SDK UI](https://ai-sdk.dev/docs/ai-sdk-ui/overview) hooks like [`useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat). You can optionally use [AI Elements](https://ai-sdk.dev/elements) for pre-built UI components.

!!! note
By default, the adapter targets AI SDK v5 for backwards compatibility. To use features introduced in AI SDK v6, set `sdk_version=6` on the adapter.
Expand Down Expand Up @@ -39,7 +39,7 @@ async def chat(request: Request) -> Response:
If you're using a web framework not based on Starlette (e.g. Django or Flask) or need fine-grained control over the input or output, you can create a `VercelAIAdapter` instance and directly use its methods, which can be chained to accomplish the same thing as the `VercelAIAdapter.dispatch_request()` class method shown above:

1. The [`VercelAIAdapter.build_run_input()`][pydantic_ai.ui.vercel_ai.VercelAIAdapter.build_run_input] class method takes the request body as bytes and returns a Vercel AI [`RequestData`][pydantic_ai.ui.vercel_ai.request_types.RequestData] run input object, which you can then pass to the [`VercelAIAdapter()`][pydantic_ai.ui.vercel_ai.VercelAIAdapter] constructor along with the agent.
- You can also use the [`VercelAIAdapter.from_request()`][pydantic_ai.ui.UIAdapter.from_request] class method to build an adapter directly from a Starlette/FastAPI request.
- You can also use the [`VercelAIAdapter.from_request()`][pydantic_ai.ui.vercel_ai.VercelAIAdapter.from_request] class method to build an adapter directly from a Starlette/FastAPI request.
2. The [`VercelAIAdapter.run_stream()`][pydantic_ai.ui.UIAdapter.run_stream] method runs the agent and returns a stream of Vercel AI events. It supports the same optional arguments as [`Agent.run_stream_events()`](../agents.md#running-agents) and an optional `on_complete` callback function that receives the completed [`AgentRunResult`][pydantic_ai.agent.AgentRunResult] and can optionally yield additional Vercel AI events.
- You can also use [`VercelAIAdapter.run_stream_native()`][pydantic_ai.ui.UIAdapter.run_stream_native] to run the agent and return a stream of Pydantic AI events instead, which can then be transformed into Vercel AI events using [`VercelAIAdapter.transform_stream()`][pydantic_ai.ui.UIAdapter.transform_stream].
3. The [`VercelAIAdapter.encode_stream()`][pydantic_ai.ui.UIAdapter.encode_stream] method encodes the stream of Vercel AI events as SSE (HTTP Server-Sent Events) strings, which you can then return as a streaming response.
Expand Down Expand Up @@ -84,3 +84,27 @@ async def chat(request: Request) -> Response:
sse_event_stream = adapter.encode_stream(event_stream)
return StreamingResponse(sse_event_stream, media_type=accept)
```

## Tool Approval

!!! note
Tool approval requires AI SDK UI v6 or later on the frontend.

Pydantic AI supports human-in-the-loop tool approval workflows with AI SDK UI, allowing users to approve or deny tool executions before they run. See the [deferred tool calls documentation](../deferred-tools.md) for details on setting up tools that require approval.

To enable tool approval streaming, set `sdk_version=6` when creating the adapter:

```py {test="skip" lint="skip"}
@app.post('/chat')
async def chat(request: Request) -> Response:
adapter = await VercelAIAdapter.from_request(request, agent=agent, sdk_version=6)
return adapter.streaming_response(adapter.run_stream())
```

When `sdk_version=6`, the adapter will:

1. Emit `tool-approval-request` chunks when tools with `requires_approval=True` are called
2. Automatically extract approval responses from follow-up requests
3. Emit `tool-output-denied` chunks for rejected tools

On the frontend, AI SDK UI's [`useChat`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) hook handles the approval flow. You can use the [`Confirmation`](https://ai-sdk.dev/elements/components/confirmation) component from AI Elements for a pre-built approval UI, or build your own using the hook's `addToolResult` function.
8 changes: 8 additions & 0 deletions pydantic_ai_slim/pydantic_ai/ui/_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ def state(self) -> dict[str, Any] | None:
"""Frontend state from the protocol-specific run input."""
return None

@cached_property
def deferred_tool_results(self) -> DeferredToolResults | None:
"""Deferred tool results extracted from the request, used for tool approval workflows."""
return None

def transform_stream(
self,
stream: AsyncIterator[NativeEvent],
Expand Down Expand Up @@ -247,6 +252,9 @@ def run_stream_native(
output_type = [output_type or self.agent.output_type, DeferredToolRequests]
toolsets = [*(toolsets or []), toolset]

if deferred_tool_results is None:
deferred_tool_results = self.deferred_tool_results

if isinstance(deps, StateHandler):
raw_state = self.state or {}
if isinstance(deps.state, BaseModel):
Expand Down
54 changes: 50 additions & 4 deletions pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pydantic import TypeAdapter
from typing_extensions import assert_never

from ...agent import AbstractAgent
from ...messages import (
AudioUrl,
BinaryContent,
Expand All @@ -35,7 +36,7 @@
VideoUrl,
)
from ...output import OutputDataT
from ...tools import AgentDepsT
from ...tools import AgentDepsT, DeferredToolApprovalResult, DeferredToolResults, ToolDenied
from .. import MessagesBuilder, UIAdapter, UIEventStream
from ._event_stream import VercelAIEventStream
from ._utils import dump_provider_metadata, load_provider_metadata
Expand All @@ -53,6 +54,7 @@
SourceUrlUIPart,
StepStartUIPart,
TextUIPart,
ToolApprovalResponded,
ToolInputAvailablePart,
ToolOutputAvailablePart,
ToolOutputErrorPart,
Expand All @@ -63,8 +65,7 @@
from .response_types import BaseChunk

if TYPE_CHECKING:
pass

from starlette.requests import Request

__all__ = ['VercelAIAdapter']

Expand All @@ -77,17 +78,62 @@ class VercelAIAdapter(UIAdapter[RequestData, UIMessage, BaseChunk, AgentDepsT, O

_: KW_ONLY
sdk_version: Literal[5, 6] = 5
"""Vercel AI SDK version to target. Default is 5 for backwards compatibility."""
"""Vercel AI SDK version to target. Default is 5 for backwards compatibility.

Setting `sdk_version=6` enables tool approval streaming for human-in-the-loop workflows.
"""

@classmethod
def build_run_input(cls, body: bytes) -> RequestData:
"""Build a Vercel AI run input object from the request body."""
return request_data_ta.validate_json(body)

@classmethod
async def from_request(
cls,
request: Request,
*,
agent: AbstractAgent[AgentDepsT, OutputDataT],
sdk_version: Literal[5, 6] = 5,
) -> VercelAIAdapter[AgentDepsT, OutputDataT]:
"""Create a Vercel AI adapter from a request.

Args:
request: The incoming Starlette/FastAPI request.
agent: The Pydantic AI agent to run.
sdk_version: Vercel AI SDK version. Set to 6 to enable tool approval streaming.
"""
return cls(
agent=agent,
run_input=cls.build_run_input(await request.body()),
accept=request.headers.get('accept'),
sdk_version=sdk_version,
)

def build_event_stream(self) -> UIEventStream[RequestData, BaseChunk, AgentDepsT, OutputDataT]:
"""Build a Vercel AI event stream transformer."""
return VercelAIEventStream(self.run_input, accept=self.accept, sdk_version=self.sdk_version)

@cached_property
def deferred_tool_results(self) -> DeferredToolResults | None:
"""Extract deferred tool results from Vercel AI messages with approval responses."""
if self.sdk_version < 6:
return None
approvals: dict[str, bool | DeferredToolApprovalResult] = {}
for msg in self.run_input.messages:
if msg.role == 'assistant':
for part in msg.parts:
if isinstance(part, ToolUIPart | DynamicToolUIPart) and isinstance(
part.approval, ToolApprovalResponded
):
if part.approval.approved:
approvals[part.tool_call_id] = True
elif part.approval.reason:
approvals[part.tool_call_id] = ToolDenied(message=part.approval.reason)
else:
approvals[part.tool_call_id] = False
return DeferredToolResults(approvals=approvals) if approvals else None

@cached_property
def messages(self) -> list[ModelMessage]:
"""Pydantic AI messages from the Vercel AI run input."""
Expand Down
51 changes: 45 additions & 6 deletions pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

from collections.abc import AsyncIterator, Mapping
from dataclasses import KW_ONLY, dataclass
from functools import cached_property
from typing import Any, Literal
from uuid import uuid4

from pydantic_core import to_json

Expand All @@ -25,10 +27,15 @@
)
from ...output import OutputDataT
from ...run import AgentRunResultEvent
from ...tools import AgentDepsT
from ...tools import AgentDepsT, DeferredToolRequests
from .. import UIEventStream
from ._utils import dump_provider_metadata
from .request_types import RequestData
from .request_types import (
DynamicToolUIPart,
RequestData,
ToolApprovalResponded,
ToolUIPart,
)
from .response_types import (
BaseChunk,
DoneChunk,
Expand All @@ -45,10 +52,12 @@
TextDeltaChunk,
TextEndChunk,
TextStartChunk,
ToolApprovalRequestChunk,
ToolInputAvailableChunk,
ToolInputDeltaChunk,
ToolInputStartChunk,
ToolOutputAvailableChunk,
ToolOutputDeniedChunk,
ToolOutputErrorChunk,
)

Expand Down Expand Up @@ -78,11 +87,26 @@ class VercelAIEventStream(UIEventStream[RequestData, BaseChunk, AgentDepsT, Outp

_: KW_ONLY
sdk_version: Literal[5, 6] = 5
"""Vercel AI SDK version to target."""
"""Vercel AI SDK version to target. Setting to 6 enables tool approval streaming."""

_step_started: bool = False
_finish_reason: FinishReason = None

@cached_property
def _denied_tool_ids(self) -> set[str]:
"""Get the set of tool_call_ids that were denied by the user."""
denied: set[str] = set()
for msg in self.run_input.messages:
if msg.role == 'assistant':
for part in msg.parts:
if (
isinstance(part, ToolUIPart | DynamicToolUIPart)
and isinstance(part.approval, ToolApprovalResponded)
and not part.approval.approved
):
denied.add(part.tool_call_id)
return denied

@property
def response_headers(self) -> Mapping[str, str] | None:
return VERCEL_AI_DSP_HEADERS
Expand Down Expand Up @@ -110,6 +134,16 @@ async def handle_run_result(self, event: AgentRunResultEvent) -> AsyncIterator[B
pydantic_reason = event.result.response.finish_reason
if pydantic_reason:
self._finish_reason = _FINISH_REASON_MAP.get(pydantic_reason, 'other')

# Emit tool approval requests for deferred approvals (only when sdk_version >= 6)
output = event.result.output
if self.sdk_version >= 6 and isinstance(output, DeferredToolRequests):
for tool_call in output.approvals:
yield ToolApprovalRequestChunk(
approval_id=str(uuid4()),
tool_call_id=tool_call.tool_call_id,
)
return
return
yield

Expand Down Expand Up @@ -246,10 +280,15 @@ async def handle_file(self, part: FilePart) -> AsyncIterator[BaseChunk]:

async def handle_function_tool_result(self, event: FunctionToolResultEvent) -> AsyncIterator[BaseChunk]:
part = event.result
if isinstance(part, RetryPromptPart):
yield ToolOutputErrorChunk(tool_call_id=part.tool_call_id, error_text=part.model_response())
tool_call_id = part.tool_call_id

# Check if this tool was denied by the user (only when sdk_version >= 6)
if self.sdk_version >= 6 and tool_call_id in self._denied_tool_ids:
yield ToolOutputDeniedChunk(tool_call_id=tool_call_id)
elif isinstance(part, RetryPromptPart):
yield ToolOutputErrorChunk(tool_call_id=tool_call_id, error_text=part.model_response())
else:
yield ToolOutputAvailableChunk(tool_call_id=part.tool_call_id, output=self._tool_return_output(part))
yield ToolOutputAvailableChunk(tool_call_id=tool_call_id, output=self._tool_return_output(part))

# ToolCallResultEvent.content may hold user parts (e.g. text, images) that Vercel AI does not currently have events for

Expand Down
34 changes: 34 additions & 0 deletions pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Converted to Python from:
https://github.com/vercel/ai/blob/ai%406.0.57/packages/ai/src/ui/ui-messages.ts

Tool approval types (`ToolApprovalRequested`, `ToolApprovalResponded`) require AI SDK v6 or later.
"""

from abc import ABC
Expand Down Expand Up @@ -110,6 +112,30 @@ class DataUIPart(BaseUIPart):
data: Any


class ToolApprovalRequested(CamelBaseModel):
"""Tool approval in requested state (awaiting user response)."""

id: str
"""The approval request ID."""


class ToolApprovalResponded(CamelBaseModel):
"""Tool approval in responded state (user has approved or denied)."""

id: str
"""The approval request ID."""

approved: bool
"""Whether the user approved the tool call."""

reason: str | None = None
"""Optional reason for the approval or denial."""


ToolApproval = ToolApprovalRequested | ToolApprovalResponded
"""Union of tool approval states."""


# Tool part states as separate models
class ToolInputStreamingPart(BaseUIPart):
"""Tool part in input-streaming state."""
Expand All @@ -120,6 +146,7 @@ class ToolInputStreamingPart(BaseUIPart):
input: Any | None = None
provider_executed: bool | None = None
call_provider_metadata: ProviderMetadata | None = None
approval: ToolApproval | None = None


class ToolInputAvailablePart(BaseUIPart):
Expand All @@ -131,6 +158,7 @@ class ToolInputAvailablePart(BaseUIPart):
input: Any | None = None
provider_executed: bool | None = None
call_provider_metadata: ProviderMetadata | None = None
approval: ToolApproval | None = None


class ToolOutputAvailablePart(BaseUIPart):
Expand All @@ -144,6 +172,7 @@ class ToolOutputAvailablePart(BaseUIPart):
provider_executed: bool | None = None
call_provider_metadata: ProviderMetadata | None = None
preliminary: bool | None = None
approval: ToolApproval | None = None


class ToolOutputErrorPart(BaseUIPart):
Expand All @@ -157,6 +186,7 @@ class ToolOutputErrorPart(BaseUIPart):
error_text: str
provider_executed: bool | None = None
call_provider_metadata: ProviderMetadata | None = None
approval: ToolApproval | None = None


ToolUIPart = ToolInputStreamingPart | ToolInputAvailablePart | ToolOutputAvailablePart | ToolOutputErrorPart
Expand All @@ -173,6 +203,7 @@ class DynamicToolInputStreamingPart(BaseUIPart):
state: Literal['input-streaming'] = 'input-streaming'
input: Any | None = None
call_provider_metadata: ProviderMetadata | None = None
approval: ToolApproval | None = None


class DynamicToolInputAvailablePart(BaseUIPart):
Expand All @@ -184,6 +215,7 @@ class DynamicToolInputAvailablePart(BaseUIPart):
state: Literal['input-available'] = 'input-available'
input: Any
call_provider_metadata: ProviderMetadata | None = None
approval: ToolApproval | None = None


class DynamicToolOutputAvailablePart(BaseUIPart):
Expand All @@ -197,6 +229,7 @@ class DynamicToolOutputAvailablePart(BaseUIPart):
output: Any
call_provider_metadata: ProviderMetadata | None = None
preliminary: bool | None = None
approval: ToolApproval | None = None


class DynamicToolOutputErrorPart(BaseUIPart):
Expand All @@ -209,6 +242,7 @@ class DynamicToolOutputErrorPart(BaseUIPart):
input: Any
error_text: str
call_provider_metadata: ProviderMetadata | None = None
approval: ToolApproval | None = None


DynamicToolUIPart = (
Expand Down
Loading