fix: correct Docker setup, SDK async API, and docs

- Dockerfile: use explicit uvicorn command (uv run start fails without package mode)
- docker-compose: mount claude-auth volume to /root/.claude (worker runs as root)
- worker: update to Claude Code SDK async iterable API (query() returns iterator, not array)
- main.py: fix docs_url from /doc to /docs
- README: correct login instructions (exec not run --rm), add production URLs
- Add CLAUDE.md with full project documentation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-25 20:35:44 +00:00
parent 6c53dd33bc
commit 5d78d865a4
6 changed files with 150 additions and 29 deletions
+118
View File
@@ -0,0 +1,118 @@
# 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, 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()`) with optional session resume
4. Result stored in DB; 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.
## 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).
+1 -1
View File
@@ -11,4 +11,4 @@ COPY src/ src/
EXPOSE 8000 EXPOSE 8000
CMD ["uv", "run", "start"] CMD ["uv", "run", "uvicorn", "src.backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
+11 -7
View File
@@ -24,16 +24,18 @@ Due container Docker:
# Edita .env: imposta API_KEY e le altre variabili # Edita .env: imposta API_KEY e le altre variabili
``` ```
2. Autentica Claude Code nel worker: 2. Avvia il sistema:
```bash
docker compose run --rm claude-worker npx claude login
```
3. Avvia il sistema:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
3. Autentica Claude Code nel worker (nel container attivo, non con `run --rm`):
```bash
docker compose exec claude-worker npx claude login
```
Le credenziali vengono salvate nel volume Docker `claude-auth` (mount: `/root/.claude`)
e persistono tra riavvii e rebuild. Si perdono solo con `docker compose down -v`.
4. Verifica: 4. Verifica:
```bash ```bash
curl -H "X-Api-Key: TUA_KEY" http://localhost:8000/api/health curl -H "X-Api-Key: TUA_KEY" http://localhost:8000/api/health
@@ -56,7 +58,9 @@ uv run pytest -v
## API ## API
Documentazione completa: `http://localhost:8000/doc` Documentazione interattiva:
- Swagger UI: `https://opus-agent.tielogic.xyz/docs`
- ReDoc: `https://opus-agent.tielogic.xyz/redoc`
### Esempio d'uso ### Esempio d'uso
+1 -1
View File
@@ -33,7 +33,7 @@ services:
env_file: .env env_file: .env
volumes: volumes:
- opus-data:/data - opus-data:/data
- claude-auth:/home/node/.claude - claude-auth:/root/.claude
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/health"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/health"]
+1 -1
View File
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="OpusAgent", title="OpusAgent",
version="0.1.0", version="0.1.0",
docs_url="/doc", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
openapi_url="/openapi.json", openapi_url="/openapi.json",
lifespan=lifespan, lifespan=lifespan,
+18 -19
View File
@@ -31,22 +31,21 @@ app.post("/process", async (req, res) => {
options.resume = session_id; options.resume = session_id;
} }
const messages = await query({ const conv = query({ prompt, options });
prompt, const messages = [];
options, for await (const msg of conv) {
}); messages.push(msg);
}
const resultText = messages const resultMsg = messages.find((m) => m.type === "result");
.filter((m) => m.type === "text") if (resultMsg && resultMsg.is_error) {
.map((m) => m.text) return res.json({ success: false, error: resultMsg.result });
.join("\n"); }
const conversationId = messages.length > 0 ? messages[0].conversationId : null;
res.json({ res.json({
success: true, success: true,
result: resultText, result: resultMsg?.result || "",
session_id: conversationId || session_id, session_id: resultMsg?.session_id || session_id,
}); });
} catch (error) { } catch (error) {
console.error("Process error:", error.message); console.error("Process error:", error.message);
@@ -76,7 +75,7 @@ app.post("/summarize", async (req, res) => {
: `Summarize this exchange in 1-2 sentences:\n\n${exchangeText}`; : `Summarize this exchange in 1-2 sentences:\n\n${exchangeText}`;
try { try {
const messages = await query({ const conv = query({
prompt: summaryPrompt, prompt: summaryPrompt,
options: { options: {
model: DEFAULT_MODEL, model: DEFAULT_MODEL,
@@ -85,15 +84,15 @@ app.post("/summarize", async (req, res) => {
allowedTools: [], allowedTools: [],
}, },
}); });
const msgs = [];
for await (const msg of conv) {
msgs.push(msg);
}
const summaryText = messages const resultMsg = msgs.find((m) => m.type === "result");
.filter((m) => m.type === "text")
.map((m) => m.text)
.join("\n");
res.json({ res.json({
success: true, success: true,
summary: summaryText.trim(), summary: (resultMsg?.result || "").trim(),
}); });
} catch (error) { } catch (error) {
console.error("Summarize error:", error.message); console.error("Summarize error:", error.message);