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
32 changes: 24 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@ jobs:
name: Test Python Package
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.12", "3.13", "3.14"]

defaults:
run:
working-directory: packages/llmpane-py

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
Expand All @@ -31,26 +34,36 @@ jobs:
run: uv sync --all-extras

- name: Run linting
run: uv run ruff check llmpane
run: uv run ruff check llmpane tests

- name: Check formatting
run: uv run ruff format --check llmpane tests

- name: Type check
run: uv run mypy llmpane

- name: Run tests
run: uv run pytest -v

test-react:
name: Test React Package
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ["22", "24"]

defaults:
run:
working-directory: packages/llmpane-react

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Set up Node.js
uses: actions/setup-node@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: "20"
node-version: ${{ matrix.node-version }}
cache: "npm"
cache-dependency-path: packages/llmpane-react/package-lock.json

Expand All @@ -60,6 +73,9 @@ jobs:
- name: Run linting
run: npm run lint

- name: Type check
run: npm run typecheck

- name: Run tests
run: npm run test:run

Expand Down
20 changes: 10 additions & 10 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ jobs:
working-directory: packages/llmpane-react

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: packages/llmpane-react/package-lock.json

Expand All @@ -46,7 +46,7 @@ jobs:
run: npm run build

- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: npm-package
path: |
Expand All @@ -64,12 +64,12 @@ jobs:
working-directory: packages/llmpane-react

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: packages/llmpane-react/package-lock.json

Expand All @@ -93,12 +93,12 @@ jobs:
working-directory: packages/llmpane-react

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: "20"
node-version: "22"
registry-url: "https://registry.npmjs.org"
cache: "npm"
cache-dependency-path: packages/llmpane-react/package-lock.json
Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/publish-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ jobs:
working-directory: packages/llmpane-py

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v9.0.0

- name: Set up Python
run: uv python install 3.12
Expand All @@ -33,7 +33,7 @@ jobs:
run: uv build

- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: python-package
path: packages/llmpane-py/dist/
Expand All @@ -48,10 +48,10 @@ jobs:
working-directory: packages/llmpane-py

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v9.0.0

- name: Set up Python
run: uv python install 3.12
Expand All @@ -73,7 +73,7 @@ jobs:

steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: python-package
path: dist/
Expand All @@ -94,7 +94,7 @@ jobs:

steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: python-package
path: dist/
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ llmpane/

## Prerequisites

- Python 3.10+
- Python 3.12+
- [uv](https://docs.astral.sh/uv/) (Python package manager)
- Node.js 18+
- Node.js 22+
- npm 9+

## Development Setup
Expand Down Expand Up @@ -125,7 +125,7 @@ npm run lint
- Use Pydantic models over dicts and tuples
- Prefer early returns over deeply nested conditionals
- Use `match/case` over `elif` chains
- Use modern Python 3.10+ typing syntax
- Use modern Python 3.12+ typing syntax
- Write descriptive names: classes are nouns (`ChatMessage`), functions are verbs (`process_message()`)

### TypeScript
Expand Down
30 changes: 25 additions & 5 deletions packages/llmpane-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Type-safe streaming chat backend for Python with a drop-in React UI. Pydantic AI

- **Pydantic AI integration** — `ChatSession` wraps any Pydantic AI agent with conversation persistence and streaming
- **Tool calling support** — Automatic tool execution with status streaming to the frontend
- **Conversation persistence** — Pluggable stores (in-memory, Redis, SQL) for multi-turn conversations
- **Conversation persistence** — Pluggable stores (in-memory, file, Redis, SQL) for multi-turn conversations
- **Multimodal support** — Send images alongside text for vision-capable models
- **Type-safe streaming** — Pydantic models for requests, responses, and SSE chunks

Expand Down Expand Up @@ -182,23 +182,43 @@ async def chat(request: ChatRequest):

### Conversation Stores

Conversations are persisted automatically. The default is in-memory, but you can use Redis or SQL:
Conversations are persisted automatically. The default is in-memory, but file, Redis, and SQL backends are available:

```python
from llmpane.store import create_store

# In-memory (default)
# In-memory (default) — no extra required
store = create_store("memory", max_conversations=1000)

# Redis
# One JSON file per conversation — pip install 'llmpane[file]'
store = create_store("file", root=".llmpane/conversations")

# Redis — pip install 'llmpane[redis]'
store = create_store("redis", url="redis://localhost:6379")

# SQLite/PostgreSQL
# SQLite/PostgreSQL — pip install 'llmpane[sql]'
store = create_store("sql", url="sqlite+aiosqlite:///chats.db")

session = ChatSession(agent=agent, store=store)
```

Every backend implements the same `ConversationStore` protocol, so they are
interchangeable. Install all of them at once with `pip install 'llmpane[stores]'`.

A few backend-specific notes:

- **`file`** constrains conversation IDs to `[A-Za-z0-9_.-]` because they become
filenames; anything else raises `ValueError`. Writes are atomic
(write-then-rename).
- **`redis`** keeps a sorted set alongside the conversation values so
`list_conversations()` paginates without scanning the keyspace. Pass `ttl` to
expire conversations.
- **`sql`** mirrors `created_at`/`updated_at` into real columns for ordering and
creates its table on first use (`create_tables=False` to manage it yourself).

`redis` and `sql` also accept a pre-built `client=` / `engine=`. When you supply
one, the store will not close it — you keep ownership.

### Tool Call Streaming

When the agent calls tools, llmpane streams `ToolUseMetadata` to the frontend so you can show pending/completed states:
Expand Down
4 changes: 3 additions & 1 deletion packages/llmpane-py/llmpane/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Agent adapter layer for Pydantic AI integration."""

from typing import Any

from llmpane.agent.models import ToolResult, ToolUse, ToolUseMetadata
from llmpane.agent.session import ChatSession

Expand All @@ -12,7 +14,7 @@
]


def __getattr__(name: str):
def __getattr__(name: str) -> Any:
"""Lazy import stream_agent to avoid requiring pydantic-ai at import time."""
if name == "stream_agent":
from llmpane.agent.pydantic_ai import stream_agent
Expand Down
14 changes: 7 additions & 7 deletions packages/llmpane-py/llmpane/agent/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ async def run(
history = self._to_pydantic_history(conv.messages)

# Persist user message after building history to avoid duplication
user_msg = ChatMessage(role=MessageRole.USER, content=message)
user_msg: ChatMessage[Any] = ChatMessage(role=MessageRole.USER, content=message)
await self.store.add_message(conv.id, user_msg)

# Convert message content to Pydantic AI prompt format
Expand Down Expand Up @@ -145,13 +145,13 @@ async def run(

# Persist assistant message (after streaming completes)
if accumulated:
assistant_msg = ChatMessage(
assistant_msg: ChatMessage[Any] = ChatMessage(
role=MessageRole.ASSISTANT,
content=accumulated,
)
await self.store.add_message(conv.id, assistant_msg)

def _to_pydantic_prompt(self, content: MessageContent) -> str | list:
def _to_pydantic_prompt(self, content: MessageContent) -> str | list[Any]:
"""Convert llmpane MessageContent to Pydantic AI prompt format.

Args:
Expand All @@ -169,7 +169,7 @@ def _to_pydantic_prompt(self, content: MessageContent) -> str | list:
# Fall back to text-only if BinaryContent not available
return get_text_content(content)

parts: list = []
parts: list[Any] = []
for part in content:
if isinstance(part, TextPart):
parts.append(part.text)
Expand All @@ -180,7 +180,7 @@ def _to_pydantic_prompt(self, content: MessageContent) -> str | list:

return parts

def _convert_content_to_pydantic(self, content: MessageContent) -> str | list:
def _convert_content_to_pydantic(self, content: MessageContent) -> str | list[Any]:
"""Convert llmpane MessageContent to Pydantic AI UserPromptPart content format.

This is used for converting message history content.
Expand All @@ -193,7 +193,7 @@ def _convert_content_to_pydantic(self, content: MessageContent) -> str | list:
except ImportError:
return get_text_content(content)

parts: list = []
parts: list[Any] = []
for part in content:
if isinstance(part, TextPart):
parts.append(part.text)
Expand All @@ -203,7 +203,7 @@ def _convert_content_to_pydantic(self, content: MessageContent) -> str | list:

return parts

def _to_pydantic_history(self, messages: list[ChatMessage]) -> list[ModelMessage]:
def _to_pydantic_history(self, messages: list[ChatMessage[Any]]) -> list[ModelMessage]:
"""Convert llmpane ChatMessages to Pydantic AI ModelMessages.

This handles the format conversion between llmpane's message format
Expand Down
Loading
Loading