Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions examples/fastapi-vite/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
# fastapi-chat

Chat demo using the Python Vercel AI SDK with a FastAPI backend and React frontend.
Includes **human-in-the-loop tool approval** — every tool call is gated
behind user confirmation before execution.

## Stack

- **Backend:** FastAPI + vercel-ai-sdk (Python 3.12)
- **Frontend:** Vite + React + AI SDK UI + AI Elements

## Human-in-the-Loop

The agent graph in `backend/agent.py` uses the `ToolApproval` hook to
suspend execution whenever the LLM wants to call a tool. The flow is:

1. LLM emits a tool call
2. Backend creates a `ToolApproval` hook — this emits an
`approval-requested` event on the SSE stream and suspends execution
3. The frontend renders Approve / Reject buttons via the
`<Confirmation>` component (from AI Elements)
4. When the user clicks a button, `addToolApprovalResponse()` patches
the message and sends a new request with the decision
5. The backend resumes from the checkpoint and either executes the tool
or marks it as denied

## Setup

```bash
Expand Down
1 change: 0 additions & 1 deletion examples/fastapi-vite/backend/__init__.py

This file was deleted.

52 changes: 45 additions & 7 deletions examples/fastapi-vite/backend/agent.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""Agent logic for the chat demo."""
"""Agent logic for the chat demo.

Demonstrates human-in-the-loop tool approval using ToolApproval hooks.
Every tool call is gated behind user approval before execution.
"""

import asyncio
from typing import Any

import vercel_ai_sdk as ai
Expand All @@ -19,16 +24,49 @@ def get_llm() -> ai.LanguageModel:
TOOLS: list[ai.Tool[..., Any]] = [talk_to_mothership]


async def _execute_with_approval(
tc: ai.ToolPart, message: ai.Message | None = None
) -> None:
"""Execute a tool call only after the user grants approval.

Creates a ToolApproval hook that suspends execution until the
frontend responds with an approve/reject decision.
"""
approval = await ai.ToolApproval.create( # type: ignore[attr-defined]
f"approve_{tc.tool_call_id}",
metadata={"tool_name": tc.tool_name, "tool_args": tc.tool_args},
)

if approval.granted:
await ai.execute_tool(tc, message=message)
else:
tc.set_error("Tool call was denied by the user.")


async def graph(
llm: ai.LanguageModel,
messages: list[ai.Message],
tools: list[ai.Tool[..., Any]],
) -> ai.StreamResult:
"""
Agent graph: stream LLM, execute tools, repeat until done.
"""Agent graph with human-in-the-loop tool approval.

This is a plain async function that goes through the Runtime queue
via stream_loop. When hooks are added later, they slot in here
between tool calls — no structural change needed.
Loops: stream LLM -> request approval -> execute tools -> repeat.
The ToolApproval hook suspends execution and emits an approval-
request event on the SSE stream. The frontend displays Approve /
Reject buttons and sends the decision back on the next request.
"""
return await ai.stream_loop(llm, messages, tools)
local_messages = list(messages)

while True:
result = await ai.stream_step(llm, local_messages, tools)

if not result.tool_calls:
return result

last_msg = result.last_message
assert last_msg is not None
local_messages.append(last_msg)

await asyncio.gather(
*(_execute_with_approval(tc, message=last_msg) for tc in result.tool_calls)
)
71 changes: 63 additions & 8 deletions examples/fastapi-vite/backend/main.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,85 @@
"""FastAPI application entry point."""

from __future__ import annotations

from collections.abc import AsyncGenerator

import agent
import fastapi
import fastapi.middleware.cors
from routes import chat
import fastapi.responses
import pydantic
import storage

api = fastapi.FastAPI(
import vercel_ai_sdk as ai
import vercel_ai_sdk.ai_sdk_ui

app = fastapi.FastAPI(
title="py-ai-fastapi-chat",
description="Chat demo using Python Vercel AI SDK",
)

api.add_middleware(
app.add_middleware(
fastapi.middleware.cors.CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

api.include_router(chat.router)


@api.get("/health")
@app.get("/health")
async def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok"}


app = fastapi.FastAPI()
app.mount("/api", api)
file_storage = storage.FileStorage()


class ChatRequest(pydantic.BaseModel):
"""Request body for the chat endpoint."""

messages: list[ai.ai_sdk_ui.UIMessage]
session_id: str | None = None


@app.post("/chat")
async def chat(request: ChatRequest) -> fastapi.responses.StreamingResponse:
"""Handle chat requests and stream responses."""
messages = ai.ai_sdk_ui.to_messages(request.messages)
session_id = request.session_id or "default"
checkpoint_key = f"checkpoint:{session_id}"

llm = agent.get_llm()

checkpoint = None
saved = await file_storage.get(checkpoint_key)
if saved:
checkpoint = ai.Checkpoint.model_validate(saved)

result = ai.run(
agent.graph,
llm,
messages,
agent.TOOLS,
checkpoint=checkpoint,
cancel_on_hooks=True,
)

async def stream_response() -> AsyncGenerator[str]:
async for chunk in ai.ai_sdk_ui.to_sse_stream(result):
yield chunk

if result.checkpoint.pending_hooks:
await file_storage.put(
checkpoint_key,
result.checkpoint.model_dump(),
)
else:
await file_storage.delete(checkpoint_key)

return fastapi.responses.StreamingResponse(
stream_response(),
headers=ai.ai_sdk_ui.UI_MESSAGE_STREAM_HEADERS,
)
1 change: 1 addition & 0 deletions examples/fastapi-vite/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dependencies = [
"fastapi[standard]>=0.128.1",
"vercel-ai-sdk>=0.0.1.dev5",
]

1 change: 0 additions & 1 deletion examples/fastapi-vite/backend/routes/__init__.py

This file was deleted.

60 changes: 0 additions & 60 deletions examples/fastapi-vite/backend/routes/chat.py

This file was deleted.

80 changes: 72 additions & 8 deletions examples/fastapi-vite/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from "ai";
import type { ToolUIPart } from "ai";
import { CheckIcon, XIcon } from "lucide-react";
import { Fragment } from "react";

import {
Confirmation,
ConfirmationAccepted,
ConfirmationAction,
ConfirmationActions,
ConfirmationRejected,
ConfirmationRequest,
ConfirmationTitle,
} from "@/components/ai-elements/confirmation";
import {
Conversation,
ConversationContent,
Expand All @@ -29,11 +42,16 @@ import {
import { TooltipProvider } from "@/components/ui/tooltip";

export default function App() {
const { messages, sendMessage, status, stop } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
});
const { messages, sendMessage, addToolApprovalResponse, status, stop } =
useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
// After the user approves/rejects a tool, automatically send the
// updated messages back to the backend so it can resume execution.
sendAutomaticallyWhen:
lastAssistantMessageIsCompleteWithApprovalResponses,
});

const isLoading = status === "submitted" || status === "streaming";

Expand Down Expand Up @@ -63,7 +81,8 @@ export default function App() {
// Handle tool parts (type starts with "tool-")
if (part.type.startsWith("tool-")) {
const toolPart = part as ToolUIPart;
const isComplete = toolPart.state === "output-available";
const isComplete =
toolPart.state === "output-available";

return (
<Tool
Expand All @@ -76,6 +95,51 @@ export default function App() {
/>
<ToolContent>
<ToolInput input={toolPart.input} />

{/* Human-in-the-loop approval UI */}
<Confirmation
approval={toolPart.approval}
state={toolPart.state}
>
<ConfirmationTitle>
<ConfirmationRequest>
Allow this tool to run?
</ConfirmationRequest>
<ConfirmationAccepted>
<CheckIcon className="size-4 text-green-500" />
<span>Approved</span>
</ConfirmationAccepted>
<ConfirmationRejected>
<XIcon className="size-4 text-destructive" />
<span>Rejected</span>
</ConfirmationRejected>
</ConfirmationTitle>
<ConfirmationActions>
<ConfirmationAction
variant="outline"
onClick={() =>
addToolApprovalResponse({
id: toolPart.approval!.id,
approved: false,
})
}
>
Reject
</ConfirmationAction>
<ConfirmationAction
variant="default"
onClick={() =>
addToolApprovalResponse({
id: toolPart.approval!.id,
approved: true,
})
}
>
Approve
</ConfirmationAction>
</ConfirmationActions>
</Confirmation>

<ToolOutput
output={toolPart.output}
errorText={toolPart.errorText}
Expand All @@ -86,7 +150,7 @@ export default function App() {
}

// Handle text parts
if (part.type === "text") {
if (part.type === "text") {
return (
<Message
key={`${message.id}-${partIndex}`}
Expand Down
Loading