Merge pull request #246 from grillazz/245-lets-try-httpx2

245 lets try httpx2
This commit is contained in:
Jakub Miazek
2026-10-22 12:04:13 +02:00
committed by GitHub
3 changed files with 689 additions and 548 deletions
+60
View File
@@ -33,6 +33,7 @@
<li><a href="#testing-strategy">Testing Strategy</a></li> <li><a href="#testing-strategy">Testing Strategy</a></li>
<li><a href="#uv-knowledge-and-inspirations">UV knowledge and inspirations</a></li> <li><a href="#uv-knowledge-and-inspirations">UV knowledge and inspirations</a></li>
<li><a href="#large-language-model">Integration with local LLM</a></li> <li><a href="#large-language-model">Integration with local LLM</a></li>
<li><a href="#websocket-ai-chat-service">Websocket AI chat service</a></li>
<li><a href="#ha-sample-with-nginx-as-load-balancer">High Availability sample with nginx as load balancer</a></li> <li><a href="#ha-sample-with-nginx-as-load-balancer">High Availability sample with nginx as load balancer</a></li>
<li><a href="#performance-profiling-with-pyinstrument">Performance Profiling with Pyinstrument</a></li> <li><a href="#performance-profiling-with-pyinstrument">Performance Profiling with Pyinstrument</a></li>
</ul> </ul>
@@ -179,6 +180,65 @@ ollama run llama3.2
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
### Websocket AI chat service
A general-purpose, websocket-based chat service lives at `/v1/chat/ws`, built with FastAPI 0.139.0
and Pydantic models for every message shape exchanged over the wire. It follows the
[Pydantic AI chat-app](https://github.com/pydantic/pydantic-ai/blob/main/docs/web.md) interaction
pattern - a session id, role-labeled messages, and streamed assistant chunks - so a UI can be wired
up later without changing the backend contract.
**Module layout**
- `app/schemas/chat.py` — Pydantic `BaseModel`s for `ChatMessage` (role/content) and the
client/server websocket event envelopes (discriminated on a `type` field).
- `app/services/chat_agent.py` — the pluggable async `ChatAgent` adapter `Protocol`, a
dependency-free `LocalEchoAgent` stub (default, no API keys/network required), and an
`OllamaChatAgent` example that streams from an OpenAI-compatible `/chat/completions` endpoint.
- `app/services/chat_session.py` — a small in-memory `ChatSessionManager` tracking one
`ChatSession` (id + message history) per open websocket connection.
- `app/api/chat.py` (re-exported as `app/ws.py`) — the `/ws` websocket route implementing the
chat flow.
**Lifespan wiring**: `app/main.py`'s `lifespan` builds the configured `ChatAgent` via
`build_chat_agent(settings.chat)` and a fresh `ChatSessionManager` on startup (`app.chat_agent`
/ `app.state.chat_sessions`), and calls `chat_agent.aclose()` on shutdown to release any held
connections.
**Configuration** (`ChatConfig` in `app/config.py`, all overridable via environment variables):
| Env var | Default | Description |
| --- | --- | --- |
| `CHAT_BACKEND` | `stub` | `stub` (local echo, no network) or `ollama` (OpenAI-compatible streaming) |
| `CHAT_BASE_URL` | `http://localhost:11434/v1` | Base URL used by the `ollama` backend |
| `CHAT_MODEL` | `llama3.2` | Model name sent to the `ollama` backend |
| `CHAT_STREAM_DELAY` | `0.02` | Per-word delay (seconds) the stub uses to emulate token streaming |
**Example websocket flow** (one JSON object per text frame):
```
-> (connect to ws://localhost:8080/v1/chat/ws)
<- {"type": "connected", "session_id": "..."}
-> {"type": "start", "system_prompt": "You are a helpful assistant."}
<- {"type": "started", "session_id": "..."}
-> {"type": "user_message", "content": "Hello there"}
<- {"type": "assistant_chunk", "index": 0, "content": "Hello "}
<- {"type": "assistant_chunk", "index": 1, "content": "there! "}
<- {"type": "assistant_message", "message": {"role": "assistant", "content": "Hello there! "}}
-> {"type": "history_request"}
<- {"type": "history", "messages": [...]}
-> {"type": "end"}
```
**Swapping in a real model**: implement the `ChatAgent` protocol (an async `stream_reply(messages)`
generator yielding text chunks, plus `aclose()`) in a new class - e.g. wrapping the OpenAI SDK - and
return it from `build_chat_agent` for a new `backend` value. No changes are needed in
`app/api/chat.py` or the session manager.
Run the tests for this service locally with:
```shell
pytest tests/api/test_chat_ws.py -vv
```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
### HA sample with nginx as load balancer ### HA sample with nginx as load balancer
Sample high availability setup with nginx as load balancer and 2 uvicorn instances running on different ports. Sample high availability setup with nginx as load balancer and 2 uvicorn instances running on different ports.
```shell ```shell
+25 -24
View File
@@ -1,50 +1,51 @@
[project] [project]
name = "fastapi-sqlalchemy-asyncpg" name = "fastapi-sqlalchemy-asyncpg"
version = "3.0.0" version = "3.0.0"
description = "A modern FastAPI application with SQLAlchemy 2.0 and AsyncPG for high-performance async database operations. Features include JWT authentication with Redis token storage, password hashing, connection pooling, data processing with Polars, Rich logging, task scheduling with APScheduler, and Shakespeare datasets integration." description = "A modern FastAPI application with SQLAlchemy 2.1 and AsyncPG for high-performance async database operations. Features include JWT authentication with Redis token storage, password hashing, connection pooling, data processing with Polars, Rich logging, task scheduling with APScheduler, and Shakespeare datasets integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.14,<3.15" requires-python = ">=3.14,<3.15"
dependencies = [ dependencies = [
"fastapi==0.139.2", "fastapi==0.159.1",
"pydantic[email]==2.13.4", "pydantic[email]==2.14.1",
"pydantic-settings==2.14.2", "pydantic-settings==2.16.1",
"sqlalchemy==2.0.51", "sqlalchemy==2.1.6",
"uvicorn[standard]==0.51.0", "uvicorn[standard]==0.54.0",
"asyncpg==0.31.0", "asyncpg==0.32.0",
"alembic==1.18.5", "alembic==1.20.1",
"httpx==0.28.1", "httpx==0.28.1",
"uvloop==0.22.1", "uvloop==0.23.0",
"httptools==0.8.0", "httptools==0.9.0",
"rich==15.0.0", "rich==15.0.0",
"pyjwt==2.13.0", "pyjwt==2.15.1",
"redis==8.0.1", "redis==8.1.0",
"bcrypt==5.0.0", "bcrypt==5.0.0",
"polars==1.42.1", "polars==2.0.0",
"python-multipart==0.0.32", "python-multipart==0.0.32",
"fastexcel==0.20.2", "fastexcel==0.21.0",
"granian==2.7.9", "granian==2.8.5",
"apscheduler[redis,sqlalchemy]==4.0.0a6", "apscheduler[redis,sqlalchemy]==4.0.0a6",
"rotoger==0.3.0", "rotoger==0.3.0",
"pyinstrument>=5.1.2", "pyinstrument==5.1.3",
"websockets>=16.1.1", "websockets==17.3",
"jinja2>=3.1.6", "jinja2==3.1.6",
"whenever==0.10.5",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"ruff==0.15.22", "ruff==0.17.1",
"devtools[pygments]==0.12.2", "devtools[pygments]==0.13.0",
"pyupgrade==3.21.2", "pyupgrade==3.22.0",
"ipython==9.15.0", "ipython==9.17.1",
"tryceratops==2.4.1", "tryceratops==2.4.1",
] ]
test = [ test = [
"pytest==9.1.1", "pytest==9.1.1",
"pytest-cov==7.1.0", "pytest-cov==7.1.0",
"inline-snapshot==0.35.2", "inline-snapshot==0.36.1",
"dirty-equals==0.11", "dirty-equals==0.11",
"polyfactory==3.3.0", "polyfactory==3.5.0",
] ]
[tool.pyrefly.errors] [tool.pyrefly.errors]
Generated
+604 -524
View File
File diff suppressed because it is too large Load Diff