Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
a2da46c
adding vercel AI chat
samuelcolvin Sep 16, 2025
0018e11
fix sqlite
samuelcolvin Sep 16, 2025
e39612d
Merge branch 'main' into vercel-ai-chat
DouweM Oct 8, 2025
bdd321d
refactoring
DouweM Oct 8, 2025
f0a03d9
Claude-assisted refactoring to unify AG-UI and Vercel AI adapters and…
DouweM Oct 8, 2025
6cae960
Flesh out Adapter and EventStream
DouweM Oct 9, 2025
2acc1c3
fix typecheck, tests, linter
DouweM Oct 10, 2025
2d7c781
Fix Vercel
DouweM Oct 10, 2025
03862a5
cleanup
DouweM Oct 10, 2025
6f51053
Refactor AG-UI streaming
DouweM Oct 10, 2025
013c43b
Start fixing up Vercel events
DouweM Oct 10, 2025
7d4b187
Improvements
DouweM Oct 14, 2025
eef9832
Merge branch 'main' into vercel-ai-chat
DouweM Oct 14, 2025
365f14f
misc
DouweM Oct 14, 2025
45e757e
Add PartEndEvent
DouweM Oct 14, 2025
47a396b
Properly finish Vercel steps and messages
DouweM Oct 14, 2025
7ba0cc7
Update tests for new PartEndEvent
DouweM Oct 14, 2025
f97d6c8
update snapshots
DouweM Oct 15, 2025
6caa37e
Remove extra item in stream_output iteration
DouweM Oct 15, 2025
a09f6ce
resolve some todos
DouweM Oct 15, 2025
b9019e0
resolve some todos, fix snapshots
DouweM Oct 15, 2025
a5c205b
Deduplicate more stream_output messages
DouweM Oct 15, 2025
ab246e8
update snapshots
DouweM Oct 15, 2025
f02834c
add coverage todos
DouweM Oct 16, 2025
c9dec71
Add warning for scenario in https://github.com/pydantic/pydantic-ai/i…
DouweM Oct 16, 2025
3ffd5ed
Start test UI Adapter and EventStream
DouweM Oct 16, 2025
0bf0c97
Fix Groq thinking out of order
DouweM Oct 16, 2025
42e39e2
coverage
DouweM Oct 16, 2025
0b1dea3
tests
DouweM Oct 16, 2025
4622eb5
tests
DouweM Oct 16, 2025
bd6cbc3
coverage
DouweM Oct 16, 2025
aebf039
Merge branch 'main' into vercel-ai-chat
DouweM Oct 27, 2025
5544992
Set Content-Type header on StreamingResponse
DouweM Oct 27, 2025
2b9b830
fix snapshots
DouweM Oct 27, 2025
5bcc597
Fix 3.10 lint
DouweM Oct 27, 2025
0871ac7
Add UIApp, AGUIApp, VercelAIApp
DouweM Oct 27, 2025
d9feb52
Refactoring
DouweM Oct 27, 2025
5cf8802
Clean up Pydantic AI message building
DouweM Oct 27, 2025
e545e5c
fix lint
DouweM Oct 27, 2025
40f4695
coverage
DouweM Oct 28, 2025
7b50c11
Merge branch 'main' into vercel-ai-chat
DouweM Oct 28, 2025
f8be256
fix lint
DouweM Oct 28, 2025
d834583
Reset chat app example
DouweM Oct 28, 2025
3d628b8
AG-UI docs
DouweM Oct 28, 2025
7b4ad0d
Docs
DouweM Oct 28, 2025
d59fdac
coverage
DouweM Oct 29, 2025
50b13ce
Merge branch 'main' into vercel-ai-chat
DouweM Oct 29, 2025
9dbcee8
Remove UIApp
DouweM Oct 29, 2025
92834a9
Merge branch 'main' into vercel-ai-chat
DouweM Oct 29, 2025
1d56cb2
Docs
DouweM Oct 29, 2025
81be052
fix docs
DouweM Oct 29, 2025
e770176
fix docs links
DouweM Oct 29, 2025
52a0a15
Merge branch 'main' into vercel-ai-chat
DouweM Oct 29, 2025
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ env*/
/TODO.md
/postgres-data/
.DS_Store
examples/pydantic_ai_examples/.chat_app_messages.sqlite
.chat_app_messages.sqlite
.cache/
.vscode/
/question_graph_history.json
Expand Down
3 changes: 3 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ async def main():
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='Mexico is Mexico ')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='City.')),
PartEndEvent(
index=0, part=TextPart(content='The capital of Mexico is Mexico City.')
),
AgentRunResultEvent(
result=AgentRunResult(output='The capital of Mexico is Mexico City.')
),
Expand Down
2 changes: 1 addition & 1 deletion docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,6 @@ async def main():
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the '}
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the dog and the pyr'}
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the dog and the pyramid'}
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the dog and the pyramid'}
```

_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
Expand Down Expand Up @@ -662,6 +661,7 @@ async def main():
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the dog and the pyr'}
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the dog and the pyramid'}
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the dog and the pyramid'}
#> {'name': 'Ben', 'dob': date(1990, 1, 28), 'bio': 'Likes the chain the dog and the pyramid'}
```

1. [`stream_responses`][pydantic_ai.result.StreamedRunResult.stream_responses] streams the data as [`ModelResponse`][pydantic_ai.messages.ModelResponse] objects, thus iteration can't fail with a `ValidationError`.
Expand Down
239 changes: 60 additions & 179 deletions examples/pydantic_ai_examples/chat_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,216 +7,97 @@

from __future__ import annotations as _annotations

import asyncio
import json
import sqlite3
from collections.abc import AsyncIterator, Callable
from concurrent.futures.thread import ThreadPoolExecutor
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Annotated, Any, Literal, TypeVar

import fastapi
import logfire
from fastapi import Depends, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from typing_extensions import LiteralString, ParamSpec, TypedDict

from pydantic_ai import (
Agent,
ModelMessage,
ModelMessagesTypeAdapter,
ModelRequest,
ModelResponse,
TextPart,
UnexpectedModelBehavior,
UserPromptPart,
)
from fastapi import Depends, Request, Response

from pydantic_ai import Agent, RunContext
from pydantic_ai.ui.vercel_ai import VercelAIAdapter

from .sqlite_database import Database

# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()

agent = Agent('openai:gpt-4o')
THIS_DIR = Path(__file__).parent
sql_schema = """
create table if not exists memory(
id integer primary key,
user_id integer not null,
value text not null,
unique(user_id, value)
);"""


@asynccontextmanager
async def lifespan(_app: fastapi.FastAPI):
async with Database.connect() as db:
async with Database.connect(sql_schema) as db:
yield {'db': db}


app = fastapi.FastAPI(lifespan=lifespan)
logfire.instrument_fastapi(app)

@dataclass
class Deps:
conn: Database
user_id: int

@app.get('/')
async def index() -> FileResponse:
return FileResponse((THIS_DIR / 'chat_app.html'), media_type='text/html')

chat_agent = Agent(
'openai:gpt-4.1',
deps_type=Deps,
instructions="""
You are a helpful assistant.

@app.get('/chat_app.ts')
async def main_ts() -> FileResponse:
"""Get the raw typescript code, it's compiled in the browser, forgive me."""
return FileResponse((THIS_DIR / 'chat_app.ts'), media_type='text/plain')
Always reply with markdown. ALWAYS use code fences for code examples and lines of code.
""",
)


async def get_db(request: Request) -> Database:
return request.state.db
@chat_agent.tool
async def record_memory(ctx: RunContext[Deps], value: str) -> str:
"""Use this tool to store information in memory."""
await ctx.deps.conn.execute(
'insert into memory(user_id, value) values(?, ?) on conflict do nothing',
ctx.deps.user_id,
value,
commit=True,
)
return 'Value added to memory.'


@app.get('/chat/')
async def get_chat(database: Database = Depends(get_db)) -> Response:
msgs = await database.get_messages()
return Response(
b'\n'.join(json.dumps(to_chat_message(m)).encode('utf-8') for m in msgs),
media_type='text/plain',
@chat_agent.tool
async def retrieve_memories(ctx: RunContext[Deps], memory_contains: str) -> str:
"""Get all memories about the user."""
rows = await ctx.deps.conn.fetchall(
'select value from memory where user_id = ? and value like ?',
ctx.deps.user_id,
f'%{memory_contains}%',
)
return '\n'.join([row[0] for row in rows])


class ChatMessage(TypedDict):
"""Format of messages sent to the browser."""

role: Literal['user', 'model']
timestamp: str
content: str


def to_chat_message(m: ModelMessage) -> ChatMessage:
first_part = m.parts[0]
if isinstance(m, ModelRequest):
if isinstance(first_part, UserPromptPart):
assert isinstance(first_part.content, str)
return {
'role': 'user',
'timestamp': first_part.timestamp.isoformat(),
'content': first_part.content,
}
elif isinstance(m, ModelResponse):
if isinstance(first_part, TextPart):
return {
'role': 'model',
'timestamp': m.timestamp.isoformat(),
'content': first_part.content,
}
raise UnexpectedModelBehavior(f'Unexpected message type for chat app: {m}')


@app.post('/chat/')
async def post_chat(
prompt: Annotated[str, fastapi.Form()], database: Database = Depends(get_db)
) -> StreamingResponse:
async def stream_messages():
"""Streams new line delimited JSON `Message`s to the client."""
# stream the user prompt so that can be displayed straight away
yield (
json.dumps(
{
'role': 'user',
'timestamp': datetime.now(tz=timezone.utc).isoformat(),
'content': prompt,
}
).encode('utf-8')
+ b'\n'
)
# get the chat history so far to pass as context to the agent
messages = await database.get_messages()
# run the agent with the user prompt and the chat history
async with agent.run_stream(prompt, message_history=messages) as result:
async for text in result.stream_output(debounce_by=0.01):
# text here is a `str` and the frontend wants
# JSON encoded ModelResponse, so we create one
m = ModelResponse(parts=[TextPart(text)], timestamp=result.timestamp())
yield json.dumps(to_chat_message(m)).encode('utf-8') + b'\n'

# add new messages (e.g. the user prompt and the agent response in this case) to the database
await database.add_messages(result.new_messages_json())

return StreamingResponse(stream_messages(), media_type='text/plain')


P = ParamSpec('P')
R = TypeVar('R')
app = fastapi.FastAPI(lifespan=lifespan)
logfire.instrument_fastapi(app)


@dataclass
class Database:
"""Rudimentary database to store chat messages in SQLite.

The SQLite standard library package is synchronous, so we
use a thread pool executor to run queries asynchronously.
"""

con: sqlite3.Connection
_loop: asyncio.AbstractEventLoop
_executor: ThreadPoolExecutor

@classmethod
@asynccontextmanager
async def connect(
cls, file: Path = THIS_DIR / '.chat_app_messages.sqlite'
) -> AsyncIterator[Database]:
with logfire.span('connect to DB'):
loop = asyncio.get_event_loop()
executor = ThreadPoolExecutor(max_workers=1)
con = await loop.run_in_executor(executor, cls._connect, file)
slf = cls(con, loop, executor)
try:
yield slf
finally:
await slf._asyncify(con.close)

@staticmethod
def _connect(file: Path) -> sqlite3.Connection:
con = sqlite3.connect(str(file))
con = logfire.instrument_sqlite3(con)
cur = con.cursor()
cur.execute(
'CREATE TABLE IF NOT EXISTS messages (id INT PRIMARY KEY, message_list TEXT);'
)
con.commit()
return con

async def add_messages(self, messages: bytes):
await self._asyncify(
self._execute,
'INSERT INTO messages (message_list) VALUES (?);',
messages,
commit=True,
)
await self._asyncify(self.con.commit)

async def get_messages(self) -> list[ModelMessage]:
c = await self._asyncify(
self._execute, 'SELECT message_list FROM messages order by id'
)
rows = await self._asyncify(c.fetchall)
messages: list[ModelMessage] = []
for row in rows:
messages.extend(ModelMessagesTypeAdapter.validate_json(row[0]))
return messages

def _execute(
self, sql: LiteralString, *args: Any, commit: bool = False
) -> sqlite3.Cursor:
cur = self.con.cursor()
cur.execute(sql, args)
if commit:
self.con.commit()
return cur

async def _asyncify(
self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs
) -> R:
return await self._loop.run_in_executor( # type: ignore
self._executor,
partial(func, **kwargs),
*args, # type: ignore
)
async def get_db(request: Request) -> Database:
return request.state.db


@app.options('/api/chat')
def options_chat():
pass


@app.post('/api/chat')
async def get_chat(request: Request, database: Database = Depends(get_db)) -> Response:
return await VercelAIAdapter[Deps].dispatch_request(
chat_agent, request, deps=Deps(database, 123)
)


if __name__ == '__main__':
Expand Down
81 changes: 81 additions & 0 deletions examples/pydantic_ai_examples/sqlite_database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations as _annotations

import asyncio
import sqlite3
from collections.abc import AsyncIterator, Callable
from concurrent.futures.thread import ThreadPoolExecutor
from contextlib import asynccontextmanager
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import Any, LiteralString, ParamSpec, TypeVar

import logfire

P = ParamSpec('P')
R = TypeVar('R')


@dataclass
class Database:
"""Rudimentary database to store chat messages in SQLite.

The SQLite standard library package is synchronous, so we
use a thread pool executor to run queries asynchronously.
"""

con: sqlite3.Connection
_loop: asyncio.AbstractEventLoop
_executor: ThreadPoolExecutor

@classmethod
@asynccontextmanager
async def connect(
cls, schema_sql: str, file: Path = Path('.chat_app_messages.sqlite')
) -> AsyncIterator[Database]:
with logfire.span('connect to DB'):
loop = asyncio.get_event_loop()
executor = ThreadPoolExecutor(max_workers=1)
con = await loop.run_in_executor(executor, cls._connect, schema_sql, file)
slf = cls(con, loop, executor)
try:
yield slf
finally:
await slf._asyncify(con.close)

@staticmethod
def _connect(schema_sql: str, file: Path) -> sqlite3.Connection:
con = sqlite3.connect(str(file))
con = logfire.instrument_sqlite3(con)
cur = con.cursor()
cur.execute(schema_sql)
con.commit()
return con

async def execute(self, sql: LiteralString, *args: Any, commit: bool = False):
await self._asyncify(self._execute, sql, *args, commit=True)
if commit:
await self._asyncify(self.con.commit)

async def fetchall(self, sql: LiteralString, *args: Any) -> list[tuple[str, ...]]:
c = await self._asyncify(self._execute, sql, *args)
rows = await self._asyncify(c.fetchall)
return [tuple(row) for row in rows]

def _execute(
self, sql: LiteralString, *args: Any, commit: bool = False
) -> sqlite3.Cursor:
cur = self.con.cursor()
cur.execute(sql, args)
if commit:
self.con.commit()
return cur

async def _asyncify(
self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs
) -> R:
return await self._loop.run_in_executor( # type: ignore
self._executor,
partial(func, **kwargs),
*args, # type: ignore
)
Loading
Loading