Compare commits
9 Commits
da2a518f8a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c55facf7ac | |||
| 5e763a2e37 | |||
| df068bc0f8 | |||
| e54f828722 | |||
| ad38cf7eaf | |||
| 9b68f772f6 | |||
| 98ad65d248 | |||
| 5d78d865a4 | |||
| 6c53dd33bc |
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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,18 @@ 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`
|
||||
|
||||
### 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
|
||||
|
||||
@@ -77,3 +90,23 @@ curl -X POST http://localhost:8000/api/requests \
|
||||
curl http://localhost:8000/api/requests/ID_RICHIESTA \
|
||||
-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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
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.models.api import ok
|
||||
from src.backend.models.api import ok, fail
|
||||
from src.backend.services.worker_client import WorkerClient
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_api_key)])
|
||||
@@ -26,3 +27,17 @@ async def health():
|
||||
"database": db_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"],
|
||||
})
|
||||
|
||||
+17
-10
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS topics (
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
sdk_conversation_id TEXT,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id),
|
||||
prompt TEXT NOT NULL,
|
||||
model TEXT,
|
||||
@@ -156,7 +157,7 @@ async def create_request(
|
||||
now = _now()
|
||||
|
||||
if session_id == "new":
|
||||
session_id = _new_id()
|
||||
session_id = None
|
||||
|
||||
await db.execute(
|
||||
"INSERT INTO requests (id, session_id, topic_id, prompt, model, summarize, status, created_at) "
|
||||
@@ -246,18 +247,13 @@ async def complete_request(
|
||||
db: aiosqlite.Connection,
|
||||
request_id: str,
|
||||
result: str,
|
||||
session_id: str | None = None,
|
||||
sdk_conversation_id: str | None = None,
|
||||
) -> None:
|
||||
now = _now()
|
||||
if session_id:
|
||||
await db.execute(
|
||||
"UPDATE requests SET status = 'completed', result = ?, completed_at = ?, session_id = ? WHERE id = ?",
|
||||
(result, now, session_id, request_id),
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
"UPDATE requests SET status = 'completed', result = ?, completed_at = ? WHERE id = ?",
|
||||
(result, now, request_id),
|
||||
"UPDATE requests SET status = 'completed', result = ?, completed_at = ?, "
|
||||
"sdk_conversation_id = ?, session_id = COALESCE(session_id, ?) WHERE id = ?",
|
||||
(result, now, sdk_conversation_id, sdk_conversation_id, request_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -297,6 +293,17 @@ async def list_sessions(db: aiosqlite.Connection) -> list[dict]:
|
||||
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:
|
||||
cursor = await db.execute(
|
||||
"SELECT 1 FROM requests WHERE session_id = ? LIMIT 1", (session_id,)
|
||||
|
||||
+18
-1
@@ -4,10 +4,14 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
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.config import settings
|
||||
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.sessions import router as sessions_router
|
||||
@@ -35,12 +39,25 @@ 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,
|
||||
)
|
||||
|
||||
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.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.backend.db.database import (
|
||||
complete_request,
|
||||
fail_request,
|
||||
append_topic_summary,
|
||||
get_session_sdk_id,
|
||||
)
|
||||
from src.backend.services.worker_client import WorkerClient
|
||||
|
||||
@@ -38,6 +39,11 @@ async def process_one(worker: WorkerClient) -> bool:
|
||||
return True
|
||||
|
||||
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:
|
||||
await db.close()
|
||||
|
||||
@@ -46,7 +52,7 @@ async def process_one(worker: WorkerClient) -> bool:
|
||||
prompt=req["prompt"],
|
||||
system_prompt=topic["system_prompt"],
|
||||
model=model,
|
||||
session_id=req["session_id"],
|
||||
session_id=resume_id,
|
||||
)
|
||||
except Exception as e:
|
||||
db = await get_db()
|
||||
@@ -63,7 +69,7 @@ async def process_one(worker: WorkerClient) -> bool:
|
||||
db,
|
||||
req["id"],
|
||||
result["result"],
|
||||
session_id=result.get("session_id"),
|
||||
sdk_conversation_id=result.get("session_id"),
|
||||
)
|
||||
|
||||
if req["summarize"]:
|
||||
|
||||
@@ -38,6 +38,11 @@ class WorkerClient:
|
||||
)
|
||||
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:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
|
||||
@@ -49,8 +49,8 @@ async def test_create_request_with_new_session(client, topic_id):
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert data["data"]["session_id"] is not None
|
||||
assert data["data"]["session_id"] != "new"
|
||||
# session_id is None at creation; gets set from SDK after completion
|
||||
assert data["data"]["session_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -37,12 +37,12 @@ async def test_list_sessions_empty(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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",
|
||||
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(
|
||||
"/api/requests",
|
||||
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
|
||||
async def test_delete_session(client, topic_id):
|
||||
resp1 = await client.post(
|
||||
session_id = "test-session-delete"
|
||||
await client.post(
|
||||
"/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}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -226,6 +226,12 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
||||
@@ -241,6 +247,12 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
|
||||
+43
-19
@@ -11,6 +11,20 @@ app.get("/health", (_req, res) => {
|
||||
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) => {
|
||||
const { prompt, system_prompt, model, session_id } = req.body;
|
||||
|
||||
@@ -31,22 +45,28 @@ app.post("/process", async (req, res) => {
|
||||
options.resume = session_id;
|
||||
}
|
||||
|
||||
const messages = await query({
|
||||
prompt,
|
||||
options,
|
||||
});
|
||||
const conv = query({ prompt, options });
|
||||
const messages = [];
|
||||
try {
|
||||
for await (const msg of conv) {
|
||||
messages.push(msg);
|
||||
}
|
||||
} catch (_) {
|
||||
// SDK throws after process exits; messages already collected
|
||||
}
|
||||
|
||||
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) {
|
||||
return res.json({ success: false, error: "No result received from Claude" });
|
||||
}
|
||||
if (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 +96,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 +105,19 @@ app.post("/summarize", async (req, res) => {
|
||||
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
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user