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:
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user