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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- uses: astral-sh/setup-uv@v5

- run: uv sync

- run: uv run pytest
19 changes: 19 additions & 0 deletions examples/fastapi-vite/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
dist/

# Node
node_modules/

# Local data (FileStorage)
data/

# Environment
.env
.env*.local

# Vercel
.vercel
46 changes: 46 additions & 0 deletions examples/fastapi-vite/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# fastapi-chat

Chat demo using the Python Vercel AI SDK with a FastAPI backend and React frontend.

## Stack

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

## Setup

```bash
# Backend
cd backend
uv sync
cp .env.example .env # add your AI_GATEWAY_API_KEY

# Frontend
cd frontend
pnpm install
```

## Development

```bash
# Terminal 1: Backend
cd backend && uv run fastapi dev main.py

# Terminal 2: Frontend
cd frontend && pnpm dev
```

The frontend dev server proxies `/api` requests to the backend at `localhost:8000`.

## Environment Variables

| Variable | Description |
|----------|-------------|
| `AI_GATEWAY_API_KEY` | Vercel AI Gateway API key |

## Storage

Checkpoints are persisted to `./data/` as JSON files via `FileStorage`.
The storage backend implements a simple `Storage` protocol — swap in
Redis, Postgres, or any async key-value store by providing a different
implementation.
1 change: 1 addition & 0 deletions examples/fastapi-vite/backend/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
1 change: 1 addition & 0 deletions examples/fastapi-vite/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Backend package
38 changes: 38 additions & 0 deletions examples/fastapi-vite/backend/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Agent logic for the chat demo."""

import os

import vercel_ai_sdk as ai


@ai.tool
async def talk_to_mothership(question: str) -> str:
"""Contact the mothership for important decisions."""
return f"Mothership says: {question} -> Soon."


def get_llm() -> ai.LanguageModel:
"""Create the LLM instance."""
return ai.openai.OpenAIModel(
model="anthropic/claude-sonnet-4",
base_url="https://ai-gateway.vercel.sh/v1",
api_key=os.environ.get("AI_GATEWAY_API_KEY"),
)


TOOLS: list[ai.Tool] = [talk_to_mothership]


async def graph(
llm: ai.LanguageModel,
messages: list[ai.Message],
tools: list[ai.Tool],
):
"""
Agent graph: stream LLM, execute tools, repeat until done.

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.
"""
return await ai.stream_loop(llm, messages, tools)
27 changes: 27 additions & 0 deletions examples/fastapi-vite/backend/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""FastAPI application entry point."""

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from .routes import chat

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

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

app.include_router(chat.router, prefix="/api")


@app.get("/api/health")
async def health():
"""Health check endpoint."""
return {"status": "ok"}
12 changes: 12 additions & 0 deletions examples/fastapi-vite/backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "py-ai-fastapi-chat"
version = "0.1.0"
description = "Chat demo using Python Vercel AI SDK with FastAPI"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.128.1",
"vercel-ai-sdk",
]

[tool.uv.sources]
vercel-ai-sdk = { path = "../../..", editable = true }
1 change: 1 addition & 0 deletions examples/fastapi-vite/backend/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Routes package
74 changes: 74 additions & 0 deletions examples/fastapi-vite/backend/routes/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Chat route — streams LLM responses via the AI SDK UI protocol."""

from __future__ import annotations

from collections.abc import AsyncGenerator

from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

import vercel_ai_sdk as ai
from vercel_ai_sdk.ai_sdk_ui import (
UI_MESSAGE_STREAM_HEADERS,
UIMessage,
to_messages,
to_sse_stream,
)

from ..agent import TOOLS, get_llm, graph
from ..storage import FileStorage

router = APIRouter()
storage = FileStorage()


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

messages: list[UIMessage]
session_id: str | None = None


async def _iter_result(
result: ai.RunResult,
) -> AsyncGenerator[ai.Message, None]:
"""Unwrap RunResult into an AsyncGenerator for to_sse_stream."""
async for msg in result:
yield msg


@router.post("/chat")
async def chat(request: ChatRequest):
"""Handle chat requests and stream responses."""
messages = to_messages(request.messages)
session_id = request.session_id or "default"
checkpoint_key = f"checkpoint:{session_id}"

llm = get_llm()

# Checkpoints resume an *interrupted* run (e.g. a hook that needed
# user input in serverless mode). Each normal chat turn is a fresh
# run — the frontend carries the full message history — so we only
# load a checkpoint when one was saved from a previous incomplete run.
saved = await storage.get(checkpoint_key)
checkpoint = ai.Checkpoint.deserialize(saved) if saved else None

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

async def stream_response():
async for chunk in to_sse_stream(_iter_result(result)):
yield chunk

# If the run completed (no pending hooks), clear the checkpoint
# so the next request starts fresh. If hooks are pending, save
# the checkpoint so the next request can resume from here.
if result.pending_hooks:
await storage.put(checkpoint_key, result.checkpoint.serialize())
else:
await storage.delete(checkpoint_key)

return StreamingResponse(
stream_response(),
headers=UI_MESSAGE_STREAM_HEADERS,
)
55 changes: 55 additions & 0 deletions examples/fastapi-vite/backend/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Pluggable storage for checkpoints and session data.

Provides a minimal Storage protocol and a FileStorage implementation
that persists data as JSON files on disk. Swap in any backend that
satisfies the protocol (Redis, Postgres, etc.) without changing the
rest of the app.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Protocol, runtime_checkable


@runtime_checkable
class Storage(Protocol):
"""Async key-value storage interface."""

async def get(self, key: str) -> dict[str, Any] | None: ...
async def put(self, key: str, value: dict[str, Any]) -> None: ...
async def delete(self, key: str) -> None: ...


class FileStorage:
"""
JSON-file-per-key storage backend.

Each key is stored as ``{directory}/{key}.json``. Good enough for
local development; replace with a real database for production.
"""

def __init__(self, directory: str | Path = "./data") -> None:
self._dir = Path(directory)
self._dir.mkdir(parents=True, exist_ok=True)

def _path(self, key: str) -> Path:
# Sanitise the key so it's safe as a filename
safe = key.replace("/", "__").replace(":", "_")
return self._dir / f"{safe}.json"

async def get(self, key: str) -> dict[str, Any] | None:
path = self._path(key)
if not path.exists():
return None
return json.loads(path.read_text())

async def put(self, key: str, value: dict[str, Any]) -> None:
path = self._path(key)
path.write_text(json.dumps(value, indent=2))

async def delete(self, key: str) -> None:
path = self._path(key)
path.unlink(missing_ok=True)
Loading