Skip to content
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
49 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
508e5fc
Merge remote-tracking branch 'origin/main' into vercel-ai-tool-approval
bendrucker Feb 6, 2026
04ecd93
Add sdk_version parameter to VercelAIAdapter.dispatch_request
bendrucker Feb 7, 2026
c659eb6
Extract shared helper for tool approval iteration logic
bendrucker Feb 7, 2026
5857583
Replace error-path deferred tool results test with happy-path round-trip
bendrucker Feb 7, 2026
dbdfb07
Fix import ordering in Vercel AI adapter
bendrucker Feb 7, 2026
de2a4bc
Eliminate dispatch_request duplication in VercelAIAdapter
bendrucker Feb 11, 2026
6459809
Add missing starlette skipif marker to dispatch_request tool approval…
bendrucker Feb 11, 2026
c3d7ee9
Add missing **kwargs docstring entries to fix D417 lint errors
bendrucker Feb 11, 2026
5a6c9d9
Forward **kwargs in VercelAIAdapter.dispatch_request to super()
bendrucker Feb 11, 2026
29820c4
Forward **kwargs in VercelAIAdapter.from_request to cls()
bendrucker Feb 13, 2026
e6597de
Simplify VercelAIAdapter.dispatch_request docstring
bendrucker Feb 13, 2026
bc40714
Move annotation-only imports under TYPE_CHECKING in Vercel adapter
bendrucker Feb 13, 2026
3bc65fc
Update docs to reference addToolApprovalResponse (AI SDK v6)
bendrucker Feb 13, 2026
fdfa3f4
Prefix internal helpers with _ in request_types
bendrucker Feb 13, 2026
973daa8
Remove Args section from dispatch_request override docstring to fix D417
bendrucker Feb 13, 2026
4c7f023
Merge remote-tracking branch 'origin/main' into vercel-ai-tool-approval
bendrucker Feb 13, 2026
ff93775
Move iter_tool_approval_responses to _utils to avoid reportPrivateUsage
bendrucker Feb 14, 2026
79cecc3
Update test snapshots for approval field after merge
bendrucker Feb 14, 2026
c1918bf
Delegate VercelAIAdapter.from_request to super() and use Self return …
bendrucker Feb 16, 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
25 changes: 24 additions & 1 deletion docs/ui/vercel-ai.md
Comment thread
bendrucker marked this conversation as resolved.
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 @@ -84,3 +84,26 @@ 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#human-in-the-loop-tool-approval) for details on setting up tools that require approval.

To enable tool approval streaming, pass `sdk_version=6` to `dispatch_request`:

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

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.
Comment thread
bendrucker marked this conversation as resolved.
Outdated
24 changes: 21 additions & 3 deletions pydantic_ai_slim/pydantic_ai/ui/_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,18 @@ class UIAdapter(ABC, Generic[RunInputT, MessageT, EventT, AgentDepsT, OutputData

@classmethod
async def from_request(
cls, request: Request, *, agent: AbstractAgent[AgentDepsT, OutputDataT]
cls, request: Request, *, agent: AbstractAgent[AgentDepsT, OutputDataT], **kwargs: Any
) -> UIAdapter[RunInputT, MessageT, EventT, AgentDepsT, OutputDataT]:
"""Create an adapter from a request."""
"""Create an adapter from a request.

Extra keyword arguments are forwarded to the adapter constructor, allowing subclasses
to accept additional adapter-specific parameters.
"""
return cls(
agent=agent,
run_input=cls.build_run_input(await request.body()),
accept=request.headers.get('accept'),
**kwargs,
)

@classmethod
Expand Down Expand Up @@ -174,6 +179,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(
Comment thread
bendrucker marked this conversation as resolved.
self,
stream: AsyncIterator[NativeEvent],
Expand Down Expand Up @@ -247,6 +257,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 Expand Up @@ -356,9 +369,13 @@ async def dispatch_request(
toolsets: Sequence[AbstractToolset[DispatchDepsT]] | None = None,
builtin_tools: Sequence[AbstractBuiltinTool] | None = None,
on_complete: OnCompleteFunc[EventT] | None = None,
**kwargs: Any,
) -> Response:
"""Handle a protocol-specific HTTP request by running the agent and returning a streaming response of protocol-specific events.

Extra keyword arguments are forwarded to [`from_request`][pydantic_ai.ui.UIAdapter.from_request],
allowing subclasses to accept additional adapter-specific parameters.

Args:
request: The incoming Starlette/FastAPI request.
agent: The agent to run.
Expand All @@ -379,6 +396,7 @@ async def dispatch_request(
builtin_tools: Optional additional builtin tools to use for this run.
on_complete: Optional callback function called when the agent run completes successfully.
The callback receives the completed [`AgentRunResult`][pydantic_ai.agent.AgentRunResult] and can optionally yield additional protocol-specific events.
**kwargs: Additional keyword arguments forwarded to [`from_request`][pydantic_ai.ui.UIAdapter.from_request].

Returns:
A streaming Starlette response with protocol-specific events encoded per the request's `Accept` header value.
Expand All @@ -395,7 +413,7 @@ async def dispatch_request(
# The DepsT and OutputDataT come from `agent`, not from `cls`; the cast is necessary to explain this to pyright
adapter = cast(
UIAdapter[RunInputT, MessageT, EventT, DispatchDepsT, DispatchOutputDataT],
await cls.from_request(request, agent=cast(AbstractAgent[AgentDepsT, OutputDataT], agent)),
await cls.from_request(request, agent=cast(AbstractAgent[AgentDepsT, OutputDataT], agent), **kwargs),
)
except ValidationError as e: # pragma: no cover
return Response(
Expand Down
134 changes: 129 additions & 5 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,9 @@
from pydantic import TypeAdapter
from typing_extensions import assert_never

from ...agent import AbstractAgent
Comment thread
bendrucker marked this conversation as resolved.
Outdated
from ...agent.abstract import AgentMetadata, Instructions
from ...builtin_tools import AbstractBuiltinTool
from ...messages import (
AudioUrl,
BinaryContent,
Expand All @@ -34,9 +37,15 @@
UserPromptPart,
VideoUrl,
)
from ...output import OutputDataT
from ...tools import AgentDepsT
from ...models import KnownModelName, Model
from ...output import OutputDataT, OutputSpec
from ...settings import ModelSettings
from ...tools import AgentDepsT, DeferredToolApprovalResult, DeferredToolResults, ToolDenied
from ...toolsets import AbstractToolset
from ...usage import RunUsage, UsageLimits
from .. import MessagesBuilder, UIAdapter, UIEventStream
from .._adapter import DispatchDepsT, DispatchOutputDataT
from .._event_stream import OnCompleteFunc
from ._event_stream import VercelAIEventStream
from ._utils import dump_provider_metadata, load_provider_metadata
from .request_types import (
Expand All @@ -59,12 +68,13 @@
ToolUIPart,
UIMessage,
UIMessagePart,
iter_tool_approval_responses,
)
from .response_types import BaseChunk

if TYPE_CHECKING:
pass

from starlette.requests import Request
from starlette.responses import Response

__all__ = ['VercelAIAdapter']

Expand All @@ -77,17 +87,131 @@ 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,
**kwargs: Any,
) -> 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.
**kwargs: Additional keyword arguments (unused, accepted for forward compatibility).
"""
return cls(
agent=agent,
run_input=cls.build_run_input(await request.body()),
accept=request.headers.get('accept'),
Comment thread
bendrucker marked this conversation as resolved.
Outdated
sdk_version=sdk_version,
)
Comment thread
bendrucker marked this conversation as resolved.
Outdated
Comment thread
DouweM marked this conversation as resolved.
Outdated

@classmethod
async def dispatch_request(
cls,
request: Request,
*,
agent: AbstractAgent[DispatchDepsT, DispatchOutputDataT],
sdk_version: Literal[5, 6] = 5,
message_history: Sequence[ModelMessage] | None = None,
deferred_tool_results: DeferredToolResults | None = None,
model: Model | KnownModelName | str | None = None,
instructions: Instructions[DispatchDepsT] = None,
deps: DispatchDepsT = None,
output_type: OutputSpec[Any] | None = None,
model_settings: ModelSettings | None = None,
usage_limits: UsageLimits | None = None,
usage: RunUsage | None = None,
metadata: AgentMetadata[DispatchDepsT] | None = None,
infer_name: bool = True,
toolsets: Sequence[AbstractToolset[DispatchDepsT]] | None = None,
builtin_tools: Sequence[AbstractBuiltinTool] | None = None,
on_complete: OnCompleteFunc[BaseChunk] | None = None,
**kwargs: Any,
) -> Response:
"""Handle a Vercel AI HTTP request by running the agent and returning a streaming response.
Comment thread
bendrucker marked this conversation as resolved.
Outdated

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.
message_history: History of the conversation so far.
deferred_tool_results: Optional results for deferred tool calls in the message history.
model: Optional model to use for this run, required if `model` was not set when creating the agent.
instructions: Optional additional instructions to use for this run.
deps: Optional dependencies to use for this run.
output_type: Custom output type to use for this run, `output_type` may only be used if the agent has no
output validators since output validators would expect an argument that matches the agent's output type.
model_settings: Optional settings to use for this model's request.
usage_limits: Optional limits on model request count or token usage.
usage: Optional usage to start with, useful for resuming a conversation or agents used in tools.
metadata: Optional metadata to attach to this run. Accepts a dictionary or a callable taking
[`RunContext`][pydantic_ai.tools.RunContext]; merged with the agent's configured metadata.
infer_name: Whether to try to infer the agent name from the call frame if it's not set.
toolsets: Optional additional toolsets for this run.
builtin_tools: Optional additional builtin tools to use for this run.
on_complete: Optional callback function called when the agent run completes successfully.
The callback receives the completed [`AgentRunResult`][pydantic_ai.agent.AgentRunResult]
and can optionally yield additional Vercel AI events.
**kwargs: Additional keyword arguments forwarded to the base class.

Returns:
A streaming Starlette response with Vercel AI events encoded per the request's `Accept` header value.
"""
return await super().dispatch_request(
request,
agent=agent,
sdk_version=sdk_version,
message_history=message_history,
deferred_tool_results=deferred_tool_results,
model=model,
instructions=instructions,
deps=deps,
output_type=output_type,
model_settings=model_settings,
usage_limits=usage_limits,
usage=usage,
metadata=metadata,
infer_name=infer_name,
toolsets=toolsets,
builtin_tools=builtin_tools,
on_complete=on_complete,
)
Comment thread
bendrucker marked this conversation as resolved.
Comment thread
bendrucker marked this conversation as resolved.

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:
Comment thread
bendrucker marked this conversation as resolved.
"""Extract deferred tool results from Vercel AI messages with approval responses."""
if self.sdk_version < 6:
return None
approvals: dict[str, bool | DeferredToolApprovalResult] = {}
Comment thread
bendrucker marked this conversation as resolved.
for tool_call_id, approval in iter_tool_approval_responses(self.run_input.messages):
if approval.approved:
approvals[tool_call_id] = True
elif approval.reason:
approvals[tool_call_id] = ToolDenied(message=approval.reason)
else:
approvals[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
43 changes: 37 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,13 @@
)
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 (
RequestData,
iter_tool_approval_responses,
)
from .response_types import (
BaseChunk,
DoneChunk,
Expand All @@ -45,10 +50,12 @@
TextDeltaChunk,
TextEndChunk,
TextStartChunk,
ToolApprovalRequestChunk,
ToolInputAvailableChunk,
ToolInputDeltaChunk,
ToolInputStartChunk,
ToolOutputAvailableChunk,
ToolOutputDeniedChunk,
ToolOutputErrorChunk,
)

Expand Down Expand Up @@ -78,11 +85,20 @@ 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."""
return {
tool_call_id
for tool_call_id, approval in iter_tool_approval_responses(self.run_input.messages)
if not approval.approved
}

@property
def response_headers(self) -> Mapping[str, str] | None:
return VERCEL_AI_DSP_HEADERS
Expand Down Expand Up @@ -110,6 +126,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 +272,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
Loading