Compare commits

...

9 Commits

Author SHA1 Message Date
Adriano Dal Pastro c55facf7ac docs: add API guide for AI agents
Complete reference for interacting with OpusAgent: auth, async flow,
multi-turn sessions, model resolution, summarization, and error handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:12:38 +00:00
Adriano Dal Pastro 5e763a2e37 docs: add /api/models endpoint to README and CLAUDE.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:08:15 +00:00
Adriano Dal Pastro df068bc0f8 feat: add GET /api/models endpoint
Returns available Claude models from SDK and the configured default model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:07:12 +00:00
Adriano Dal Pastro e54f828722 test: update tests for new session_id behavior
session_id "new" now returns null at creation (set from SDK after completion).
Session tests use custom session_ids directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:02:11 +00:00
Adriano Dal Pastro ad38cf7eaf docs: document sdk_conversation_id and multi-turn session flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:01:03 +00:00
Adriano Dal Pastro 9b68f772f6 fix: session handling and SDK async iteration
- Add sdk_conversation_id column to track SDK conversation separately from user session_id
- Queue processor looks up SDK conversation ID for resume instead of passing user session_id
- session_id "new" stores NULL initially, gets set from SDK on completion
- Custom session_ids are preserved (not overwritten by SDK)
- Worker catches SDK process exit error after messages are collected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:59:55 +00:00
Adriano Dal Pastro 98ad65d248 feat: protect /docs and /redoc with API key
Adds middleware requiring X-Api-Key header or ?api_key= query param
to access Swagger UI and ReDoc. OpenAPI schema remains public.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:45:21 +00:00
Adriano Dal Pastro 5d78d865a4 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>
2026-05-25 20:35:44 +00:00
Adriano 6c53dd33bc chore: update uv.lock
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:42:22 +02:00
14 changed files with 561 additions and 53 deletions
+125
View File
@@ -0,0 +1,125 @@
# 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)
- `GET /api/models` — list available Claude models (aliases + default)
- `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).
+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"]
+40 -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,18 @@ 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`
### Modelli disponibili
```bash
curl http://localhost:8000/api/models -H "X-Api-Key: TUA_KEY"
```
Ritorna i modelli disponibili dall'SDK (`default`, `opus`, `sonnet`, `opusplan`) e il modello
di default configurato. Gli alias possono essere usati nel campo `model` di topic e richieste.
### Esempio d'uso ### Esempio d'uso
@@ -77,3 +90,23 @@ curl -X POST http://localhost:8000/api/requests \
curl http://localhost:8000/api/requests/ID_RICHIESTA \ curl http://localhost:8000/api/requests/ID_RICHIESTA \
-H "X-Api-Key: TUA_KEY" -H "X-Api-Key: TUA_KEY"
``` ```
### Sessioni multi-turn
```bash
# Primo messaggio: session_id "new" o un ID personalizzato
curl -X POST http://localhost:8000/api/requests \
-H "X-Api-Key: TUA_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "Mi chiamo Marco.", "topic_id": "ID_TOPIC", "session_id": "new"}'
# Il risultato contiene il session_id (generato dall'SDK)
# Usalo per i messaggi successivi nella stessa conversazione
curl -X POST http://localhost:8000/api/requests \
-H "X-Api-Key: TUA_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "Come mi chiamo?", "topic_id": "ID_TOPIC", "session_id": "SESSION_ID_DAL_RISULTATO"}'
```
Il campo `sdk_conversation_id` nella risposta contiene l'ID interno della conversazione
Claude Code SDK, usato automaticamente per il resume nei turni successivi.
+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"]
+264
View File
@@ -0,0 +1,264 @@
# OpusAgent - Guida per Agenti AI
Questo documento spiega come interagire con l'API REST di OpusAgent.
OpusAgent e' un proxy che inoltra richieste ai modelli Claude tramite Claude Code SDK.
## Informazioni base
- **Base URL**: `https://opus-agent.tielogic.xyz`
- **Autenticazione**: header `X-Api-Key` obbligatorio su ogni richiesta
- **Formato risposte**: JSON con envelope `{"success": bool, "data": any, "error": {"code": str, "message": str} | null}`
- **Elaborazione asincrona**: le richieste vengono accodate e processate sequenzialmente (FIFO)
## Flusso operativo
### 1. Crea un topic
Un topic definisce il contesto della conversazione (system prompt) e opzionalmente un modello di default.
```
POST /api/topics
Content-Type: application/json
X-Api-Key: TUA_KEY
{
"name": "analisi-dati",
"system_prompt": "Sei un esperto analista dati. Rispondi in italiano.",
"model": "sonnet" // opzionale, default dal server
}
```
Risposta (201):
```json
{
"success": true,
"data": {
"id": "uuid-del-topic",
"name": "analisi-dati",
"system_prompt": "...",
"summary": "",
"model": "sonnet",
"created_at": "...",
"updated_at": "..."
}
}
```
### 2. Invia una richiesta
Le richieste vengono accodate e processate in background. La risposta immediata e' un 202 Accepted con l'ID della richiesta.
```
POST /api/requests
Content-Type: application/json
X-Api-Key: TUA_KEY
{
"topic_id": "uuid-del-topic",
"prompt": "Analizza i trend di vendita Q1 2026",
"model": "opus", // opzionale, sovrascrive il modello del topic
"session_id": "new", // opzionale, per conversazioni multi-turn
"summarize": true // opzionale, genera riassunto incrementale nel topic
}
```
Risposta (202):
```json
{
"success": true,
"data": {
"id": "uuid-della-richiesta",
"session_id": null,
"status": "pending",
"created_at": "..."
}
}
```
### 3. Attendi il risultato (polling)
Interroga periodicamente lo stato della richiesta fino a `status: "completed"` o `status: "failed"`.
```
GET /api/requests/{id}
X-Api-Key: TUA_KEY
```
Risposta quando completata:
```json
{
"success": true,
"data": {
"id": "uuid-della-richiesta",
"session_id": "sdk-conversation-id",
"topic_id": "uuid-del-topic",
"prompt": "Analizza i trend di vendita Q1 2026",
"model": "opus",
"summarize": true,
"status": "completed",
"result": "Risposta di Claude...",
"error": null,
"created_at": "...",
"completed_at": "...",
"sdk_conversation_id": "sdk-uuid"
}
}
```
**Intervallo di polling consigliato**: 2-3 secondi. Il tempo medio di risposta e' 3-5 secondi per richieste semplici.
**Stati possibili**: `pending` -> `processing` -> `completed` | `failed`
## Conversazioni multi-turn
Per mantenere il contesto tra messaggi successivi, usa le sessioni.
### Prima richiesta
Invia con `"session_id": "new"` oppure un ID personalizzato a tua scelta.
```json
{
"topic_id": "uuid",
"prompt": "Mi chiamo Marco. Lavorero' su un progetto Python.",
"session_id": "new"
}
```
Il `session_id` nella risposta 202 sara' `null`. Dopo il completamento, il campo `session_id` conterra' l'ID della conversazione generato dall'SDK.
### Richieste successive
Usa il `session_id` ottenuto dal risultato della prima richiesta completata.
```json
{
"topic_id": "uuid",
"prompt": "Come mi chiamo? Su cosa lavoriamo?",
"session_id": "session-id-dal-risultato-precedente"
}
```
Claude ricordera' tutto il contesto della conversazione.
### Session ID personalizzato
Puoi passare un tuo ID (es. `"progetto-alpha-chat-1"`) invece di `"new"`. Il sistema lo mantiene come identificativo della sessione e gestisce internamente la mappatura con la conversazione SDK.
### Chiudere una sessione
```
DELETE /api/sessions/{session_id}
X-Api-Key: TUA_KEY
```
Dopo la chiusura, nuove richieste con lo stesso `session_id` verranno rifiutate con status 410 Gone.
## Risoluzione del modello
Il modello usato segue questa priorita' (dal piu' alto al piu' basso):
1. **Campo `model` nella richiesta** - sovrascrive tutto
2. **Campo `model` nel topic** - default per il topic
3. **Variabile d'ambiente `CLAUDE_MODEL`** - default globale del server
### Valori accettati per il campo model
Alias corti (consigliati):
- `default` - Opus o Sonnet in base ai limiti d'uso
- `opus` - Opus 4.1, per task complessi
- `sonnet` - Sonnet 4, per uso quotidiano
- `opusplan` - Opus in plan mode, Sonnet altrimenti
Model ID completi:
- `claude-opus-4-7`
- `claude-sonnet-4-6`
- `claude-haiku-4-5-20251001`
Per ottenere la lista aggiornata dei modelli disponibili:
```
GET /api/models
X-Api-Key: TUA_KEY
```
## Riassunti automatici
### Riassunto incrementale
Aggiungi `"summarize": true` alla richiesta. Dopo il completamento, una sintesi di 1-2 frasi viene appesa al campo `summary` del topic con la data.
### Riassunto completo
Rigenera il riassunto del topic analizzando tutte le richieste completate:
```
POST /api/topics/{id}/summarize
X-Api-Key: TUA_KEY
```
Il summary viene sovrascritto con un'overview completa di tutte le interazioni.
## Riferimento completo degli endpoint
| Metodo | Endpoint | Descrizione |
|--------|----------|-------------|
| GET | `/api/health` | Stato del sistema (DB, worker) |
| GET | `/api/models` | Modelli Claude disponibili |
| POST | `/api/topics` | Crea topic |
| GET | `/api/topics` | Lista topic |
| GET | `/api/topics/{id}` | Dettaglio topic |
| PUT | `/api/topics/{id}` | Aggiorna topic |
| DELETE | `/api/topics/{id}` | Elimina topic (fallisce se ha richieste pendenti) |
| POST | `/api/topics/{id}/summarize` | Rigenera riassunto completo |
| POST | `/api/requests` | Invia richiesta (202 Accepted) |
| GET | `/api/requests` | Lista richieste (filtri: `?status=`, `?topic_id=`, `?session_id=`) |
| GET | `/api/requests/{id}` | Stato e risultato richiesta |
| DELETE | `/api/requests/{id}` | Elimina richiesta pendente |
| GET | `/api/sessions` | Lista sessioni attive |
| DELETE | `/api/sessions/{id}` | Chiudi sessione |
## Gestione errori
Le risposte di errore seguono lo stesso formato envelope:
```json
{
"success": false,
"data": null,
"error": {
"code": "NOT_FOUND",
"message": "Topic not found"
}
}
```
Codici HTTP comuni:
- **202**: richiesta accettata e accodata
- **400**: parametri mancanti o invalidi
- **403**: API key mancante o errata
- **404**: risorsa non trovata
- **409**: conflitto (es. nome topic duplicato)
- **410**: sessione chiusa
- **429**: rate limit superato
## Rate limiting
Le richieste `POST /api/requests` sono limitate per API key:
- **Per minuto**: configurabile (default 10)
- **Per ora**: configurabile (default 100)
Le altre operazioni (lettura, topic CRUD) non hanno limiti.
## Esempio completo di workflow
```
1. GET /api/models -> scegli il modello
2. POST /api/topics -> crea il contesto
3. POST /api/requests (session_id=new) -> primo messaggio
4. GET /api/requests/{id} -> polling fino a completed
5. POST /api/requests (session_id=...) -> messaggi successivi
6. GET /api/requests/{id} -> polling risultato
7. POST /api/topics/{id}/summarize -> riassunto finale
8. DELETE /api/sessions/{id} -> chiudi sessione
```
+16 -1
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from src.backend.api.dependencies import verify_api_key from src.backend.api.dependencies import verify_api_key
from src.backend.config import settings
from src.backend.db.database import get_db from src.backend.db.database import get_db
from src.backend.models.api import ok from src.backend.models.api import ok, fail
from src.backend.services.worker_client import WorkerClient from src.backend.services.worker_client import WorkerClient
router = APIRouter(dependencies=[Depends(verify_api_key)]) router = APIRouter(dependencies=[Depends(verify_api_key)])
@@ -26,3 +27,17 @@ async def health():
"database": db_ok, "database": db_ok,
"worker": worker_ok, "worker": worker_ok,
}) })
@router.get("/models", summary="List available models", tags=["system"])
async def list_models():
worker = WorkerClient()
result = await worker.models()
if not result.get("success"):
return fail("WORKER_ERROR", result.get("error", "Failed to fetch models"))
return ok({
"default_model": settings.claude_model,
"models": result["models"],
})
+19 -12
View File
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS topics (
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT, session_id TEXT,
sdk_conversation_id TEXT,
topic_id TEXT NOT NULL REFERENCES topics(id), topic_id TEXT NOT NULL REFERENCES topics(id),
prompt TEXT NOT NULL, prompt TEXT NOT NULL,
model TEXT, model TEXT,
@@ -156,7 +157,7 @@ async def create_request(
now = _now() now = _now()
if session_id == "new": if session_id == "new":
session_id = _new_id() session_id = None
await db.execute( await db.execute(
"INSERT INTO requests (id, session_id, topic_id, prompt, model, summarize, status, created_at) " "INSERT INTO requests (id, session_id, topic_id, prompt, model, summarize, status, created_at) "
@@ -246,19 +247,14 @@ async def complete_request(
db: aiosqlite.Connection, db: aiosqlite.Connection,
request_id: str, request_id: str,
result: str, result: str,
session_id: str | None = None, sdk_conversation_id: str | None = None,
) -> None: ) -> None:
now = _now() now = _now()
if session_id: await db.execute(
await db.execute( "UPDATE requests SET status = 'completed', result = ?, completed_at = ?, "
"UPDATE requests SET status = 'completed', result = ?, completed_at = ?, session_id = ? WHERE id = ?", "sdk_conversation_id = ?, session_id = COALESCE(session_id, ?) WHERE id = ?",
(result, now, session_id, request_id), (result, now, sdk_conversation_id, sdk_conversation_id, request_id),
) )
else:
await db.execute(
"UPDATE requests SET status = 'completed', result = ?, completed_at = ? WHERE id = ?",
(result, now, request_id),
)
await db.commit() await db.commit()
@@ -297,6 +293,17 @@ async def list_sessions(db: aiosqlite.Connection) -> list[dict]:
return [dict(row) for row in rows] return [dict(row) for row in rows]
async def get_session_sdk_id(db: aiosqlite.Connection, session_id: str) -> str | None:
cursor = await db.execute(
"SELECT sdk_conversation_id FROM requests "
"WHERE session_id = ? AND status = 'completed' AND sdk_conversation_id IS NOT NULL "
"ORDER BY completed_at DESC LIMIT 1",
(session_id,),
)
row = await cursor.fetchone()
return row[0] if row else None
async def session_exists(db: aiosqlite.Connection, session_id: str) -> bool: async def session_exists(db: aiosqlite.Connection, session_id: str) -> bool:
cursor = await db.execute( cursor = await db.execute(
"SELECT 1 FROM requests WHERE session_id = ? LIMIT 1", (session_id,) "SELECT 1 FROM requests WHERE session_id = ? LIMIT 1", (session_id,)
+18 -1
View File
@@ -4,10 +4,14 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from src.backend.api.dependencies import limiter from src.backend.api.dependencies import limiter
from src.backend.config import settings
from src.backend.api.routes.health import router as health_router from src.backend.api.routes.health import router as health_router
from src.backend.api.routes.requests import router as requests_router from src.backend.api.routes.requests import router as requests_router
from src.backend.api.routes.sessions import router as sessions_router from src.backend.api.routes.sessions import router as sessions_router
@@ -35,12 +39,25 @@ 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,
) )
PROTECTED_DOCS_PATHS = {"/docs", "/redoc"}
class DocsAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path in PROTECTED_DOCS_PATHS:
key = request.headers.get("X-Api-Key") or request.query_params.get("api_key")
if key != settings.api_key:
return JSONResponse(status_code=403, content={"detail": "API key required"})
return await call_next(request)
app.add_middleware(DocsAuthMiddleware)
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+8 -2
View File
@@ -9,6 +9,7 @@ from src.backend.db.database import (
complete_request, complete_request,
fail_request, fail_request,
append_topic_summary, append_topic_summary,
get_session_sdk_id,
) )
from src.backend.services.worker_client import WorkerClient from src.backend.services.worker_client import WorkerClient
@@ -38,6 +39,11 @@ async def process_one(worker: WorkerClient) -> bool:
return True return True
model = resolve_model(req["model"], topic["model"]) model = resolve_model(req["model"], topic["model"])
# Only resume if there's a previous completed request with an SDK conversation ID
resume_id = None
if req["session_id"]:
resume_id = await get_session_sdk_id(db, req["session_id"])
finally: finally:
await db.close() await db.close()
@@ -46,7 +52,7 @@ async def process_one(worker: WorkerClient) -> bool:
prompt=req["prompt"], prompt=req["prompt"],
system_prompt=topic["system_prompt"], system_prompt=topic["system_prompt"],
model=model, model=model,
session_id=req["session_id"], session_id=resume_id,
) )
except Exception as e: except Exception as e:
db = await get_db() db = await get_db()
@@ -63,7 +69,7 @@ async def process_one(worker: WorkerClient) -> bool:
db, db,
req["id"], req["id"],
result["result"], result["result"],
session_id=result.get("session_id"), sdk_conversation_id=result.get("session_id"),
) )
if req["summarize"]: if req["summarize"]:
+5
View File
@@ -38,6 +38,11 @@ class WorkerClient:
) )
return resp.json() return resp.json()
async def models(self) -> dict:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(f"{self.base_url}/models")
return resp.json()
async def health(self) -> bool: async def health(self) -> bool:
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
+2 -2
View File
@@ -49,8 +49,8 @@ async def test_create_request_with_new_session(client, topic_id):
) )
assert resp.status_code == 202 assert resp.status_code == 202
data = resp.json() data = resp.json()
assert data["data"]["session_id"] is not None # session_id is None at creation; gets set from SDK after completion
assert data["data"]["session_id"] != "new" assert data["data"]["session_id"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
+7 -7
View File
@@ -37,12 +37,12 @@ async def test_list_sessions_empty(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_sessions_with_data(client, topic_id): async def test_list_sessions_with_data(client, topic_id):
resp1 = await client.post( # Use a custom session_id (not "new") so it's set immediately
session_id = "test-session-abc"
await client.post(
"/api/requests", "/api/requests",
json={"prompt": "First", "topic_id": topic_id, "session_id": "new"}, json={"prompt": "First", "topic_id": topic_id, "session_id": session_id},
) )
session_id = resp1.json()["data"]["session_id"]
await client.post( await client.post(
"/api/requests", "/api/requests",
json={"prompt": "Second", "topic_id": topic_id, "session_id": session_id}, json={"prompt": "Second", "topic_id": topic_id, "session_id": session_id},
@@ -58,11 +58,11 @@ async def test_list_sessions_with_data(client, topic_id):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_session(client, topic_id): async def test_delete_session(client, topic_id):
resp1 = await client.post( session_id = "test-session-delete"
await client.post(
"/api/requests", "/api/requests",
json={"prompt": "Hello", "topic_id": topic_id, "session_id": "new"}, json={"prompt": "Hello", "topic_id": topic_id, "session_id": session_id},
) )
session_id = resp1.json()["data"]["session_id"]
resp = await client.delete(f"/api/sessions/{session_id}") resp = await client.delete(f"/api/sessions/{session_id}")
assert resp.status_code == 200 assert resp.status_code == 200
Generated
+12
View File
@@ -226,6 +226,12 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20" }, { name = "aiosqlite", specifier = ">=0.20" },
@@ -241,6 +247,12 @@ requires-dist = [
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
+43 -19
View File
@@ -11,6 +11,20 @@ app.get("/health", (_req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });
}); });
app.get("/models", async (_req, res) => {
try {
const conv = query({
prompt: ".",
options: { model: "claude-sonnet-4-6", allowedTools: [] },
});
const models = await conv.supportedModels();
await conv.return();
res.json({ success: true, models });
} catch (error) {
res.json({ success: false, error: error.message });
}
});
app.post("/process", async (req, res) => { app.post("/process", async (req, res) => {
const { prompt, system_prompt, model, session_id } = req.body; const { prompt, system_prompt, model, session_id } = req.body;
@@ -31,22 +45,28 @@ 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, try {
}); for await (const msg of conv) {
messages.push(msg);
}
} catch (_) {
// SDK throws after process exits; messages already collected
}
const resultText = messages const resultMsg = messages.find((m) => m.type === "result");
.filter((m) => m.type === "text") if (!resultMsg) {
.map((m) => m.text) return res.json({ success: false, error: "No result received from Claude" });
.join("\n"); }
if (resultMsg.is_error) {
const conversationId = messages.length > 0 ? messages[0].conversationId : null; return res.json({ success: false, error: resultMsg.result });
}
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 +96,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 +105,19 @@ app.post("/summarize", async (req, res) => {
allowedTools: [], allowedTools: [],
}, },
}); });
const msgs = [];
try {
for await (const msg of conv) {
msgs.push(msg);
}
} catch (_) {
// SDK throws after process exits; messages already collected
}
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);