ad38cf7eaf
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
5.3 KiB
Markdown
125 lines
5.3 KiB
Markdown
# OpusAgent
|
|
|
|
Self-hosted REST API proxy to Claude models via Claude Code SDK, leveraging a Claude Pro/Max subscription.
|
|
|
|
## Architecture
|
|
|
|
Two Docker containers behind Traefik reverse proxy:
|
|
|
|
```
|
|
Client → Traefik (HTTPS) → FastAPI (Python, :8000) ↔ Node.js Worker (:3001, internal)
|
|
↓ ↓
|
|
SQLite (WAL) Claude Code SDK
|
|
```
|
|
|
|
- **fastapi-service**: API REST, auth, rate limiting, async queue processing
|
|
- **claude-worker**: Express server wrapping `@anthropic-ai/claude-code` SDK
|
|
|
|
## Key Paths
|
|
|
|
| Area | Path |
|
|
|------|------|
|
|
| FastAPI entrypoint | `src/backend/main.py` |
|
|
| Config (Pydantic Settings) | `src/backend/config.py` |
|
|
| Database layer (aiosqlite) | `src/backend/db/database.py` |
|
|
| API models + envelope | `src/backend/models/api.py` |
|
|
| Routes | `src/backend/api/routes/{health,topics,requests,sessions}.py` |
|
|
| Auth + rate limiter | `src/backend/api/dependencies.py` |
|
|
| Worker HTTP client | `src/backend/services/worker_client.py` |
|
|
| Queue processor | `src/backend/services/queue.py` |
|
|
| Node.js worker | `worker/index.js` |
|
|
| Tests | `tests/` |
|
|
| Design spec (Italian) | `docs/superpowers/specs/` |
|
|
| Implementation plan | `docs/superpowers/plans/` |
|
|
|
|
## Tech Stack
|
|
|
|
- **Python 3.11+**: FastAPI, uvicorn, aiosqlite, httpx, slowapi, pydantic-settings
|
|
- **Node.js 20**: Express, @anthropic-ai/claude-code SDK (v1.0.128+, async iterable API)
|
|
- **Package manager**: uv (Python), npm (Node.js)
|
|
- **Database**: SQLite with WAL mode, foreign keys enabled
|
|
- **Infra**: Docker Compose, Traefik v3 (TLS via Let's Encrypt)
|
|
|
|
## Database Schema
|
|
|
|
Three tables in SQLite:
|
|
|
|
- **topics**: id (UUID), name (unique), system_prompt, summary, model (nullable), timestamps
|
|
- **requests**: id (UUID), session_id, sdk_conversation_id, topic_id (FK), prompt, result, error, model, summarize (bool), status (pending/processing/completed/failed), timestamps
|
|
- **closed_sessions**: session_id (PK), closed_at
|
|
|
|
## API Endpoints
|
|
|
|
All require `X-Api-Key` header. All responses use envelope: `{success, data, error}`.
|
|
|
|
- `GET /api/health` — health check (DB + worker)
|
|
- `POST|GET /api/topics`, `GET|PUT|DELETE /api/topics/{id}` — topic CRUD
|
|
- `POST /api/topics/{id}/summarize` — regenerate complete topic summary
|
|
- `POST /api/requests` (202) — submit prompt (async queue), `GET|DELETE /api/requests[/{id}]`
|
|
- `GET /api/sessions`, `DELETE /api/sessions/{id}` — session management
|
|
|
|
## Processing Flow
|
|
|
|
1. `POST /api/requests` → 202 Accepted (queued)
|
|
2. Background loop picks oldest pending, calls worker `POST /process`
|
|
3. Worker runs Claude Code SDK (`query()` async iterable) with optional session resume
|
|
4. Result stored in DB; SDK conversation ID saved in `sdk_conversation_id`; optional incremental summary appended to topic
|
|
5. Client polls `GET /api/requests/{id}` for result
|
|
|
|
Model resolution: request-level > topic-level > `CLAUDE_MODEL` env var.
|
|
|
|
### Multi-turn sessions
|
|
|
|
- `session_id: "new"` → stored as NULL; after completion, set to SDK's conversation ID
|
|
- Custom `session_id` → preserved as-is; SDK conversation ID stored separately in `sdk_conversation_id`
|
|
- Resume logic: queue looks up `sdk_conversation_id` from previous completed requests in the same session; only passes it to worker if found (avoids resume on non-existent SDK sessions)
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# Development
|
|
uv sync --all-groups
|
|
uv run dev # uvicorn with --reload
|
|
|
|
# Production
|
|
docker compose up -d # both services
|
|
docker compose exec claude-worker npx claude login # first-time auth (must run on active container, NOT run --rm)
|
|
|
|
# Tests
|
|
uv run pytest -v
|
|
|
|
# Logs
|
|
docker compose logs -f fastapi-service
|
|
docker compose logs -f claude-worker
|
|
```
|
|
|
|
## Environment Variables (.env)
|
|
|
|
| Variable | Default | Purpose |
|
|
|----------|---------|---------|
|
|
| `API_KEY` | (required) | Auth key for all endpoints |
|
|
| `API_HOST` | 0.0.0.0 | Server bind address |
|
|
| `API_PORT` | 8000 | Server port |
|
|
| `WORKER_URL` | http://claude-worker:3001 | Internal worker address |
|
|
| `DB_PATH` | /data/opus-agent.db | SQLite database path |
|
|
| `CLAUDE_MODEL` | claude-sonnet-4-6 | Default Claude model |
|
|
| `RATE_LIMIT_PER_MINUTE` | 10 | Rate limit per API key |
|
|
| `RATE_LIMIT_PER_HOUR` | 100 | Rate limit per API key |
|
|
|
|
## Design Decisions
|
|
|
|
- **Sequential processing**: one request at a time (FIFO), no concurrency — simplifies Claude Code SDK session handling
|
|
- **Async Python**: FastAPI + aiosqlite for non-blocking I/O while queue runs in background
|
|
- **SQLite + WAL**: sufficient for single-writer pattern, no external DB needed
|
|
- **Envelope pattern**: consistent `{success, data, error}` on all responses
|
|
- **Session = conversation**: session_id maps to Claude Code SDK `resume` option
|
|
- **Worker isolation**: not exposed to host, only reachable from fastapi-service via Docker internal network
|
|
- **Claude auth volume**: credentials stored at `/root/.claude` (not `/home/node/.claude`) because the worker process runs as root inside the container
|
|
- **SDK async iteration**: `query()` returns an async iterable (not a promise); iterate with `for await...of` and extract the message with `type === "result"`
|
|
|
|
## Deployment
|
|
|
|
Domain: `opus-agent.tielogic.xyz` → Traefik → fastapi-service:8000
|
|
|
|
Traefik config: `/opt/docker/traefik/dynamic.yml` (file provider, auto-reload).
|