diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..51f9c89 --- /dev/null +++ b/CLAUDE.md @@ -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). diff --git a/Dockerfile b/Dockerfile index 827aff2..a48a493 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,4 +11,4 @@ COPY src/ src/ EXPOSE 8000 -CMD ["uv", "run", "start"] +CMD ["uv", "run", "uvicorn", "src.backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 8e1a7b9..7b4080a 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,18 @@ Due container Docker: # Edita .env: imposta API_KEY e le altre variabili ``` -2. Autentica Claude Code nel worker: - ```bash - docker compose run --rm claude-worker npx claude login - ``` - -3. Avvia il sistema: +2. Avvia il sistema: ```bash 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: ```bash curl -H "X-Api-Key: TUA_KEY" http://localhost:8000/api/health @@ -56,7 +58,9 @@ uv run pytest -v ## 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 diff --git a/docker-compose.yml b/docker-compose.yml index e3f952e..addd2e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: env_file: .env volumes: - opus-data:/data - - claude-auth:/home/node/.claude + - claude-auth:/root/.claude restart: unless-stopped healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/health"] diff --git a/src/backend/main.py b/src/backend/main.py index c96c624..f1e1923 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -35,7 +35,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="OpusAgent", version="0.1.0", - docs_url="/doc", + docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", lifespan=lifespan, diff --git a/worker/index.js b/worker/index.js index a585b60..e201dbe 100644 --- a/worker/index.js +++ b/worker/index.js @@ -31,22 +31,21 @@ app.post("/process", async (req, res) => { options.resume = session_id; } - const messages = await query({ - prompt, - options, - }); + const conv = query({ prompt, options }); + const messages = []; + for await (const msg of conv) { + messages.push(msg); + } - const resultText = messages - .filter((m) => m.type === "text") - .map((m) => m.text) - .join("\n"); - - const conversationId = messages.length > 0 ? messages[0].conversationId : null; + const resultMsg = messages.find((m) => m.type === "result"); + if (resultMsg && resultMsg.is_error) { + return res.json({ success: false, error: resultMsg.result }); + } res.json({ success: true, - result: resultText, - session_id: conversationId || session_id, + result: resultMsg?.result || "", + session_id: resultMsg?.session_id || session_id, }); } catch (error) { 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}`; try { - const messages = await query({ + const conv = query({ prompt: summaryPrompt, options: { model: DEFAULT_MODEL, @@ -85,15 +84,15 @@ app.post("/summarize", async (req, res) => { allowedTools: [], }, }); + const msgs = []; + for await (const msg of conv) { + msgs.push(msg); + } - const summaryText = messages - .filter((m) => m.type === "text") - .map((m) => m.text) - .join("\n"); - + const resultMsg = msgs.find((m) => m.type === "result"); res.json({ success: true, - summary: summaryText.trim(), + summary: (resultMsg?.result || "").trim(), }); } catch (error) { console.error("Summarize error:", error.message);