commit 2cec225860941a592c2401e645bc8c619b1ce0f4 Author: valenti Date: Thu Sep 24 05:19:06 2026 -0400 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f6f237 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +build/ +dist/ +.mypy_cache/ +.ruff_cache/ +.demo.pid +.demo.log +nohup.out + diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..f95fd22 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,104 @@ +# AGENT.md — Istruzioni per agenti di sviluppo su ApiPilot + +Questo documento definisce le regole e i confini per sviluppare e modificare ApiPilot, mantenendo la libreria generica, riutilizzabile e priva di riferimenti a domini specifici. + +## 1. Architettura e confini dei moduli + +ApiPilot ha un nucleo stabile e adattatori isolati per provider LLM: + +| Modulo | Responsabilità | Non deve contenere | +|--------------------------------------------------|-------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------| +| [apipilot/core.py](apipilot/core.py) | `ApiPilot` + configurazione + autorizzazione + esecuzione + size/timeout/errori. | Logiche di dominio applicativo (nomi di tabelle, entità, ruoli hardcoded). | +| [apipilot/fastapi.py](apipilot/fastapi.py) | `install_apipilot`, wiring FastAPI, endpoints HTTP opzionali. | Formati tool-calling specifici di provider LLM. | +| [apipilot/openapi_parser.py](apipilot/openapi_parser.py) | Parsing OpenAPI + generazione schema + validazione nomi duplicati. | Esecuzione runtime di endpoint. | +| [apipilot/validation.py](apipilot/validation.py) | Validazione argomenti via `jsonschema`. | Regole di business non standard. | +| [apipilot/tool.py](apipilot/tool.py) | `CompositeTool` + protocollo `PilotTool`. | Implementazioni specifiche di tool di dominio. | +| [apipilot/adapters/openai.py](apipilot/adapters/openai.py) | Adapter function calling OpenAI. | Qualsiasi dipendenza dal core oltre ToolDefinition/ExecuteResponse. | + +Regola aurea: **Il nucleo non deve sapere di specifici provider LLM. Gli adapter non devono sapere di FastAPI.** + +## 2. Installazione e test + +```bash +# Setup +python -m venv .venv && source .venv/bin/activate +pip install -e ".[test,dev]" + +# Test +pytest -q +pytest --cov=apipilot --cov-report=term-missing + +# Controlli statici +ruff check apipilot tests examples +mypy apipilot +``` + +Tutti i nuovi moduli o funzionalità richiedono **test di regressione corrispondenti** in `tests/`. + +## 3. Convenzioni di codice + +- **Python 3.10+** come minimo. Usa `from __future__ import annotations` in ogni modulo. +- **Tipizza esplicitamente** parametri e ritorni pubblici; per interni usa la miglior stima. +- **Nessun riferimento a entità di dominio** nel core (es. `order`, `user`, `product` come nomi di parametri speciali o hardcoded, al di fuori di esempi e test). +- **Errori chiari**: usa la gerarchia in [apipilot/errors.py](apipilot/errors.py) e aggiungi sottotipi nuovi, non eccezioni grezze. +- **Messaggi di errore non vernacolari**: nomi tool duplicati devono indicare le due rotte coinvolte. +- **Dati non strutturati?** Sanifica sempre dimensione (`max_response_size`) e tempo (`timeout`). +- **Log granulari con tag**: introdurre logging se necessario, con tag `[apipilot:]`. Non loggare credenziali, token, interi payload utente. + +## 4. Convenzioni di sicurezza + +- Non inoltrare mai header tecnologici (Authorization, X-*, Cookie) nello schema dei parametri dei tool. `openapi_parser` già salta i parametri `in: header`. +- L'utente AI non può scegliere URL/metodo HTTP/credenziali: tutto è fissato nella registrazione. +- Qualsiasi nuovo punto di ingresso (es. endpoints HTTP, WebSocket) deve rispettare `require_auth_for_http` e usare la `get_current_user` dell'app ospite. +- Prima di eseguire un tool controlla sempre `allow_tool(user, record)`: un tool negato è "non esistente" per quell'utente. +- Non serializzare direttamente eccezioni con traceback completo nella risposta `ExecuteResponse.error`. +- Non usare `exec`, `eval` o import dinamici basati su input agente. + +## 5. Aggiungere un adapter per provider LLM + +1. Crea `apipilot/adapters/.py`. +2. Implementa tre metodi statici: + - `to_tools(tool_definitions: list[ToolDefinition]) -> list[]` + - `parse_tool_call(tc_dict) -> tuple[tool_call_id, name, args_dict]` + - `make_tool_result(tool_call_id, name, execute_response) -> ` +3. Esporta il modulo da [apipilot/adapters/__init__.py](apipilot/adapters/__init__.py). +4. Aggiungi test in `tests/test_adapters_.py`. +5. Non aggiungere dipendenze opzionali dal provider nel core. + +## 6. Aggiungere un nuovo tipo di tool oltre a endpoint e CompositeTool + +1. Estendi la classe [apipilot/tool.py](apipilot/tool.py) (protocollo `PilotTool`). +2. Il tipo deve avere `name`, `description`, un `run(args, user)` asincrono. +3. Estendi `ApiPilot.add_tool(...)` e `_run_*` in [apipilot/core.py](apipilot/core.py) in modo minimale. +4. Aggiungi test per autorizzazione, timeout, troncamento, argomenti non validi. + +## 7. Endpoints HTTP — regole + +- Default **disabilitati**. L'utente passa `expose_http_endpoints=True` per attivarli. +- Ogni nuovo endpoint HTTP deve: + - usare `http_prefix` configurabile. + - rispettare `require_auth_for_http` e la `get_current_user` dell'ospite. + - restituire sempre JSON strutturato coerente con `ExecuteResponse` per le vie di errore. + - NON introdurre route che eseguano codice arbitrario, script SQL, comandi shell. + +## 8. Aggiornare documentazione e test + +- Ogni modifica pubblica va tracciata in [CHANGELOG.md](CHANGELOG.md) in formato: + ``` + ## YYYY-MM-DD — versione X.Y.Z + - [file.py#L1-L99] descrizione modifica / nuova feature / bug fix. + ``` +- Ogni nuova feature pubblica va documentata in [README.md](README.md) con un esempio minimo. +- `CLAUDE.md` rimanda a questo documento per le regole comuni; aggiungi in `CLAUDE.md` solo note specifiche per Claude se diverse dalle regole generali. +- I test devono coprire: percorsi happy, autorizzazione negata, tool sconosciuti, argomenti non validi, errori dell'endpoint, timeout, troncamento, tool composti, adapter. + +## 9. Processo di modifica suggerito per l'agente + +1. Leggi [CHANGELOG.md](CHANGELOG.md) e i test per capire l'impatto. +2. Modifica il modulo target. +3. Aggiungi/aggiorna test case. +4. Esegui `pytest -q && ruff check apipilot tests examples`. +5. Aggiorna CHANGELOG.md e README.md. +6. Non fare commit di token, chiavi, o payload di clienti. +7. always update documents + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b8cbd5e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# CHANGELOG.md — ApiPilot + +Tracciamento modifiche per data. Ogni entry include riferimenti precisi a file e righe. + +## 2026-09-23 — versione 0.1.1 + +Aggiornamenti post-implementazione iniziale. + +- [examples/demo_backend.py](examples/demo_backend.py#L264-L267) — Porta demo backend da 8000 a 9292. +- [examples/demo_backend.py](examples/demo_backend.py#L20-L41) — Demo senza autenticazione obbligatoria: introdotto `DEFAULT_USER = {"id": "demo-user", "role": "admin"}`, `get_current_user_optional` restituisce default se header manca o token sconosciuto. +- [examples/demo_backend.py](examples/demo_backend.py#L100-L172) — Rimosso 401 su endpoint demo: `create_order`, `restock_product` (senza blocco admin), `list_my_orders` ritornano sempre; ordini di default assegnati a `demo-user`. +- [examples/demo_backend.py](examples/demo_backend.py#L177-L185) — `install_apipilot` con `require_auth_for_http=False`. +- [examples/demo_backend.py](examples/demo_backend.py#L235-L260) — `/demo/chat` senza auth, con `auth_note` che spiega default user e token opzionali. +- [README.md](README.md#L15-L32) — Sezione Installazione con istruzioni `python -m venv .venv` + alternativa PEP 668. +- [README.md](README.md#L224-L260) — Sezione Demo backend: servizi `./demo.sh`, modalità veloce `./run_demo.sh`, manuale con venv; nota su demo senza auth obbligatoria. +- [run_demo.sh](run_demo.sh) — Script bash idempotente: crea `.venv` se mancante, installa `apipilot[test]`, avvia `uvicorn` su porta 9292 con reload. +- [demo.sh](demo.sh#L1-L208) — Servizio completo: `start` (background con PID+log), `stop` (graceful + SIGKILL fallback), `restart`, `status`, `logs [N]`, `help`. Nota auth aggiornata. +- [.gitignore](.gitignore) — Aggiunto `.venv/`, `.demo.pid`, `.demo.log`, `nohup.out`, cache pytest/mypy/ruff, `*.egg-info/`, build artifacts. +- Bump versione: [pyproject.toml](pyproject.toml#L7), [apipilot/__init__.py](apipilot/__init__.py#L38), [examples/demo_backend.py](examples/demo_backend.py#L11) e `/healthz` a 0.1.1. + +## 2026-09-23 — versione 0.1.0 + +Prima implementazione completa. + +- [pyproject.toml](pyproject.toml) — Scaffold progetto con deps FastAPI/Pydantic/httpx/jsonschema e optional [test]/[dev]. +- [apipilot/__init__.py](apipilot/__init__.py) — API pubblica: `ApiPilot`, `install_apipilot`, `CompositeTool`, errori, tipi. +- [apipilot/errors.py](apipilot/errors.py) — Gerarchia eccezioni: `ApiPilotError`, `InvalidConfigurationError`, `DuplicateToolError`, `UnsupportedSchemaError`, `ToolNotFoundError`, `ArgumentValidationError`, `ExecutionError`. +- [apipilot/types.py](apipilot/types.py) — TypedDict (`ToolDefinition`, `ExecuteResponse`, `ApiPilotOpenAPIExtra`) e dataclass (`OperationInfo`, `ParamSpec`, `ToolRecord`). +- [apipilot/openapi_parser.py](apipilot/openapi_parser.py) — `extract_operations`, `build_tool_definition` (path/query/body, risoluzione $ref, conversioni schema JSON), `validate_operations` (nomi, duplicati, config incomplete). +- [apipilot/validation.py](apipilot/validation.py) — `validate_arguments` via jsonschema con errori strutturati. +- [apipilot/tool.py](apipilot/tool.py) — Protocollo `PilotTool` e `CompositeTool` runnabile asincrono. +- [apipilot/core.py](apipilot/core.py) — Classe `ApiPilot`: `register_fastapi_app`, `add_tool`, `tools_for(user)` con `allow_tool`, `execute(user, name, arguments)` (validazione, size-cap, timeout, errori strutturati), dispatch endpoint vs composite. +- [apipilot/fastapi.py](apipilot/fastapi.py) — `install_apipilot(...)` con wiring FastAPI; `GET //tools` e `POST //execute` opzionali con autenticazione dell'app ospite. +- [apipilot/adapters/__init__.py](apipilot/adapters/__init__.py) + [adapters/openai.py](apipilot/adapters/openai.py) — Adapter OpenAI: `to_tools`, `parse_tool_call`, `make_tool_result` con `tool_call_id`. +- [tests/conftest.py](tests/conftest.py) — `build_sample_app` helper con endpoint di esempio (list/get/create/slow/huge/non-pilot), `get_current_user`, `allow_tool`, composite `find_cheapest_item`. +- [tests/test_openapi_parser.py](tests/test_openapi_parser.py) — selezione x-apipilot.enabled, conversione path/query/body, nomi duplicati, config incomplete. +- [tests/test_pilot.py](tests/test_pilot.py) — tools_for, autorizzazione per utente, execute happy path path+query+body, 404 endpoint, tool sconosciuto, argomenti invalidi, allow_tool, timeout, troncamento risposte grandi, tool composto, risultati vuoti, adapter OpenAI roundtrip. +- [tests/test_http_endpoints.py](tests/test_http_endpoints.py) — catalogo HTTP, execute HTTP successo/fallimento, argomenti invalidi → 422. +- [examples/demo_backend.py](examples/demo_backend.py) — FastAPI backend demo completo: prodotti, ordini, restock admin, ordini utente, tool composto `recommend_product`, `GET /demo/chat` esempio ciclo agente. +- [README.md](README.md) — panoramica, formato x-apipilot, installazione, esempi integrazione, sicurezza, test, demo. +- [AGENT.md](AGENT.md) — confini moduli, comandi installazione/test, convenzioni codice, sicurezza, aggiunta adapter/tool, aggiornamento docs+test, changelog. +- [CLAUDE.md](CLAUDE.md) — note specifiche per Claude: anti-thinking-leak, regole parser/endpoints/adapter, limiti modelli quantizzati. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b0ac0c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# CLAUDE.md — ApiPilot + +Questo file estende [AGENT.md](AGENT.md) con note specifiche per Claude / agenti di coding. Le regole comuni (architettura, sicurezza, testing, documentazione) si trovano in AGENT.md e sono obbligatorie. Consultalo prima di ogni modifica. + +## Prompt interno per Claude (non divulgare) + +Quando modifichi ApiPilot: + +1. **Inizia sempre leggendo AGENT.md e CHANGELOG.md** per capire lo stato attuale. +2. **Preferisci modifiche minimali e generiche**: la libreria è passthrough. La logica di dominio è nel backend ospite. +3. **Non introdurre riferimenti a specifiche entità** nel nucleo (es. `invoice`, `workspace`, `tenant`). +4. **Cap rigorosamente ogni lista/struttura iniettata** nel contesto del modello: dimensioni massime, troncamento chiaro. +5. **Anti-meta-commento / anti-thinking-leak**: + - Le funzioni pubbliche (specie in adapter) restituiscono dati pronti per essere mandati al modello. + - Non includere debug, pensieri o meta-commenti nei valori di ritorno. + - Nei messaggi di errore evita frasi autoriferite ("io", "questo tool") quando possibile. +6. **Per modifiche alle strutture `TypedDict`**: aggiorna in parallelo i test corrispondenti; rompere compatibilità qui richiede bump di versione appropriato in CHANGELOG. +7. **Quando tocchi `openapi_parser`**: verifica che i parametri `in: header` siano sempre esclusi dal catalogo. Non aggiungere parametri tecnologici per errore. +8. **Quando tocchi `core._run_endpoint`**: + - Non permettere all'argomento agente di risolvere arbitrariamente il nome della funzione Python; la mappatura è statica. + - Riporta in `ExecuteResponse.error` status_code ed endpoint senza stacktrace internamente. +9. **Quando tocchi gli endpoints HTTP in `fastapi.py`**: + - Assicurati che `require_auth_for_http=True` + `get_current_user != None` dia sempre 401 senza token. + - Nessun parametro URL permetta di scegliere nomi di file, rotte, credenziali. +10. **Ciclo di verifica minimo prima di dichiarare completato**: + ``` + pytest -q + ruff check apipilot tests examples + ``` + +## Regole specifiche ApiPilot in aggiunta ad AGENT.md + +- I nomi dei tool devono essere identificatori validi e corti: `a-zA-Z0-9_`. +- Le descrizioni dei tool sono scritte per il modello, non per l'utente finale: devono spiegare **quando** usare il tool, non **come** è implementato. +- `tools_for(user)` deve sempre ritornare una lista nuova (non condivisa) per evitare side-effect. +- Ogni adapter deve preservare `tool_call_id` in modo trasparente (cfr. `OpenAIAdapter.make_tool_result`). + +## Note su versionamento Qwen / modelli quantizzati + +- Le descrizioni dei parametri non devono contenere meta-stringhe tipo "pensa bene prima di...", ma essere istruzioni operative concrete. +- Evita enum molto lunghe (>20 valori) iniettate nello schema: meglio rimpiazzarle con `type: string` e descrizione che rimandi a un tool di lookup. +- Dove possibile, rimpicciolisci `max_response_size` prima di affidarti al troncamento generico; esponi come configurazione per-tool. + +Per qualsiasi domanda che questi due file non coprano, documenta la decisione nel CHANGELOG e aggiorna questo doc o AGENT.md coerentemente. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e28575c --- /dev/null +++ b/README.md @@ -0,0 +1,278 @@ +# ApiPilot + +_Libreria Python generica che trasforma endpoint di un'app FastAPI in tool eseguibili da agenti AI, senza riferimenti hardcoded a domini o progetti specifici._ + +## Che cosa fa ApiPilot + +- **Scansiona** l'OpenAPI della tua app FastAPI e registra solo gli endpoint marcati con `x-apipilot.enabled: true` +- **Genera** definizioni di tool nel formato function calling compatibile OpenAI (nome, descrizione, schema JSON parametri per path / query / body) +- **Esegue** i tool validando gli argomenti e applicando l'autenticazione e i permessi della tua app ospite +- **Gestisce** timeout, risposte troppo grandi, errori strutturati e risultati vuoti +- **Permette** di registrare tool composti Python (multi-step) non legati a un singolo endpoint +- **Separa** il nucleo dal codice dei provider LLM, con adapter iniziale per il formato OpenAI (incluso `tool_call_id`) +- **Opzionalmente** espone catalogo ed esecuzione via HTTP con autenticazione della tua app + +## Installazione + +```bash +# Crea e attiva l'ambiente virtuale (consigliato) +python -m venv .venv +source .venv/bin/activate # Linux/macOS +# .\.venv\Scripts\activate # Windows PowerShell + +# Installa ApiPilot (modalità editabile + dipendenze test/dev +pip install -e ".[test,dev]" +``` + +Senza venv (sconsigliato, a seconda della policy PEP 668): +```bash +pip install -e ".[test]" --break-system-packages +``` + +Requisiti minimi: Python 3.10+, FastAPI 0.100+, Pydantic v2. + +## Utilizzo base + +### 1. Abilita un endpoint con `x-apipilot` + +Aggiungi `openapi_extra={"x-apipilot": {...}}` ai decorator delle rotte che vuoi esporre come tool: + +```python +from fastapi import APIRouter + +router = APIRouter() + +@router.get( + "/items", + summary="Elenca gli elementi", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "list_items", + "description": ( + "Cerca gli elementi disponibili. Usalo quando " + "l'utente chiede un elenco o cerca elementi " + "di una categoria." + ), + } + }, +) +async def list_items(category: str | None = None, limit: int = 10): + ... +``` + +### 2. Installa ApiPilot sulla tua app + +```python +from fastapi import FastAPI +from apipilot.fastapi import install_apipilot + +app = FastAPI() + +def get_current_user(authorization: str | None = None): + # La tua logica di autenticazione esistente + ... + +pilot = install_apipilot( + app, + get_current_user=get_current_user, +) +``` + +### 3. Ottieni i tool disponibili per l'utente + +```python +tools = await pilot.tools_for(user) +# Ogni tool è già in formato: +# {"name": "...", "description": "...", "parameters": {...}} +``` + +### 4. Esegui un tool + +```python +result = await pilot.execute( + user=user, + name="list_items", + arguments={"category": "active"}, +) +# result: {"success": bool, "data": Any, "error": dict|None, "meta": dict} +``` + +### 5. Integra con un agente AI (es. OpenAI function calling) + +```python +from apipilot.adapters.openai import OpenAIAdapter + +# conversione -> messaggio tools per OpenAI +openai_tools = OpenAIAdapter.to_tools(tools) + +# ricevi la tool_call dal modello e parsala +tool_call_id, name, args = OpenAIAdapter.parse_tool_call(assistant_msg.tool_calls[0]) + +# esegui +resp = await pilot.execute(user=user, name=name, arguments=args) + +# costruisci il messaggio di risposta per il ciclo successivo +tool_result = OpenAIAdapter.make_tool_result(tool_call_id, name, resp) +``` + +## Formato di `x-apipilot` + +Ogni operazione abilitata dichiara in `openapi_extra["x-apipilot"]`: + +| Campo | Tipo | Obbligatorio | Default | Note | +|----------------------|----------|--------------|----------------------|------| +| `enabled` | `bool` | Sì | `false` | Deve essere `true` per essere preso in carico. | +| `name` | `string` | Consigliato | da `operationId` o route | Nome univoco del tool, usato anche da `execute`. | +| `description` | `string` | No | `summary` endpoint | Descrizione per l'agente AI (influisce sulla scelta). | +| `timeout` | `number` | No | `default_timeout` (30s) | Secondi oltre i quali la chiamata viene interrotta. | +| `max_response_size` | `number` | No | `1_000_000` (1 MB) | Limite byte risultato JSON oltre il quale viene troncato. | + +Nomi duplicati, configurazioni incomplete o schemi non supportati (`$ref` non risolti fuori standard) sollevano errori chiari in fase di installazione. + +## Autorizzazione (controllo per-tool) + +```python +def allow_tool(user, record) -> bool: + if user and user.get("role") != "admin" and record.name == "create_item": + return False + return True + +pilot = install_apipilot(app, get_current_user=get_current_user, allow_tool=allow_tool) +``` + +I tool non ammessi non compaiono in `tools_for` e `execute` solleva `ToolNotFoundError` se richiamati. + +## Tool composti (multi-step) + +```python +from apipilot.tool import CompositeTool + +async def find_cheapest(args, user): + res = await pilot.execute(user=user, name="list_items", arguments=args) + if not res["success"]: + return {"error": res["error"]} + items = (res["data"] or {}).get("items", []) + if not items: + return None + return {"cheapest": min(items, key=lambda it: it["price"])} + +pilot.add_tool( + CompositeTool( + name="find_cheapest_item", + description="Trova l'elemento più economico nella categoria.", + parameters_schema={ + "type": "object", + "properties": {"category": {"type": "string"}}, + "required": [], + }, + run_fn=find_cheapest, + ) +) +``` + +## Endpoints HTTP opzionali + +Passa `expose_http_endpoints=True`: + +```python +pilot = install_apipilot(app, get_current_user=get_current_user, expose_http_endpoints=True, http_prefix="/apipilot") +``` + +- `GET /apipilot/tools` — catalogo tool per l'utente autenticato. +- `POST /apipilot/execute` payload `{"name": "...", "arguments": {...}}`. + +Se `require_auth_for_http=True` (default) e `get_current_user` è fornito, gli endpoint HTTP usano la tua funzione di autenticazione. + +## Struttura risposta `ExecuteResponse` + +```python +{ + "success": True | False, + "data": Any | None, + "error": { + "code": "INVALID_ARGUMENTS" | "EXECUTION_ERROR" | "TIMEOUT" | "UNEXPECTED_EXECUTION_ERROR", + "message": str, + # altri campi specifici (status_code, endpoint, details, ...) + } | None, + "meta": { + "tool": str, + "truncated"?: True, + "max_response_size"?: int, + } +} +``` + +## Sicurezza — garanzie di ApiPilot + +- **Il modello non sceglie URL, metodo HTTP o credenziali**: ogni tool mappa in modo statico una rotta registrata. +- **Header tecnici e credenziali non sono esposti** nello schema dei parametri del tool (i parametri `in: header` sono saltati). +- **Autorizzazione a due livelli**: controllo `allow_tool(user, record)` prima ancora della validazione argomenti, più le dipendenze native FastAPI sull'endpoint target. +- **Validazione schema argomenti** tramite `jsonschema` prima di invocare qualsiasi funzione endpoint. +- **Timeout per esecuzione** e **capped size delle risposte** per evitare injection di contesto eccessivo. +- **Nomi tool univoci**: duplicati intercettati in fase di installazione. + +## Eseguire i test + +```bash +pip install -e ".[test]" +pytest -q +``` + +Coverage di riferimento: selezione operazioni, generazione parametri, autorizzazione, rifiuto tool sconosciuti, argomenti invalidi, gestione errori, timeout, troncamento risposte grandi, tool composti, endpoints HTTP. + +## Demo backend incluso + +**Servizio completo (start/stop/restart/status/logs):** +```bash +./demo.sh start # avvia in background, crea .venv se serve +./demo.sh status # verifica stato (PID e log path) +./demo.sh logs 50 # ultime 50 righe di log +./demo.sh restart # stop + start +./demo.sh stop # arresto graceful (con fallback SIGKILL) +./demo.sh help # elenco comandi + +# URL : http://127.0.0.1:9292/docs +# Auth : Demo senza autenticazione obbligatoria. +# Senza header: default user=demo-user, ruolo=admin +# Con header Authorization: Bearer admin-token / Bearer user-token per testare altri utenti +# Files: .demo.pid (PID), .demo.log (log), .venv/ (ambiente) +``` + +**Modo veloce, primo piano (con venv auto):** +```bash +./run_demo.sh +# → crea .venv se manca, installa dipendenze, avvia su http://127.0.0.1:9292 +``` + +**Modo manuale con venv:** +```bash +# Crea venv e installa (solo se non già fatto) +python -m venv .venv +source .venv/bin/activate +pip install -e ".[test]" + +# Avvia il demo +python -m examples.demo_backend +# → http://127.0.0.1:9292/docs +# Demo senza autenticazione obbligatoria. +# Senza header Authorization usa default user=demo-user (ruolo admin) +# Con Authorization: Bearer admin-token / Bearer user-token puoi testare altri utenti +``` + +Vedi [examples/demo_backend.py](examples/demo_backend.py) per uno scenario completo (catalogo prodotti, ordini, tool composto di raccomandazione, demo ciclo agente). + +## Moduli + +- [apipilot/core.py](apipilot/core.py) — classe `ApiPilot`, autorizzazione, esecuzione, gestione errori/size/timeout. +- [apipilot/fastapi.py](apipilot/fastapi.py) — `install_apipilot`, endpoints HTTP opzionali. +- [apipilot/openapi_parser.py](apipilot/openapi_parser.py) — parsing OpenAPI, generazione schema JSON, deduplica/valida. +- [apipilot/validation.py](apipilot/validation.py) — validazione argomenti via `jsonschema`. +- [apipilot/tool.py](apipilot/tool.py) — `CompositeTool` e protocollo `PilotTool`. +- [apipilot/errors.py](apipilot/errors.py) — gerarchia eccezioni. +- [apipilot/types.py](apipilot/types.py) — TypedDict e dataclass condivisi. +- [apipilot/adapters/openai.py](apipilot/adapters/openai.py) — adapter per function calling OpenAI (`to_tools`, `parse_tool_call`, `make_tool_result` con `tool_call_id`). + +## Modifiche & versionamento + +Vedere [CHANGELOG.md](CHANGELOG.md) per il tracciamento per data delle modifiche. diff --git a/apipilot/__init__.py b/apipilot/__init__.py new file mode 100644 index 0000000..155c05f --- /dev/null +++ b/apipilot/__init__.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from .core import ApiPilot +from .errors import ( + ApiPilotError, + ArgumentValidationError, + DuplicateToolError, + ExecutionError, + InvalidConfigurationError, + ToolNotFoundError, + UnsupportedSchemaError, +) +from .fastapi import install_apipilot +from .tool import CompositeTool, PilotTool +from .types import ( + ApiPilotOpenAPIExtra, + ExecuteResponse, + ToolDefinition, +) + +__all__ = [ + "ApiPilot", + "ApiPilotError", + "ApiPilotOpenAPIExtra", + "ArgumentValidationError", + "CompositeTool", + "DuplicateToolError", + "ExecuteResponse", + "ExecutionError", + "InvalidConfigurationError", + "PilotTool", + "ToolDefinition", + "ToolNotFoundError", + "UnsupportedSchemaError", + "install_apipilot", +] + +__version__ = "0.1.1" diff --git a/apipilot/adapters.py b/apipilot/adapters.py new file mode 100644 index 0000000..3ea5a12 --- /dev/null +++ b/apipilot/adapters.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from . import adapters as _adapters_pkg +from .adapters.openai import OpenAIAdapter as _OpenAIAdapter + +OpenAIAdapter = _OpenAIAdapter + +__all__ = ["adapters", "OpenAIAdapter"] + +adapters = _adapters_pkg diff --git a/apipilot/adapters/__init__.py b/apipilot/adapters/__init__.py new file mode 100644 index 0000000..a72885d --- /dev/null +++ b/apipilot/adapters/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from . import openai + +__all__ = ["openai"] diff --git a/apipilot/adapters/openai.py b/apipilot/adapters/openai.py new file mode 100644 index 0000000..f64d2db --- /dev/null +++ b/apipilot/adapters/openai.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any, TypedDict + +from ..types import ExecuteResponse, ToolDefinition + + +class OpenAIFunction(TypedDict): + name: str + description: str + parameters: dict[str, Any] + + +class OpenAITool(TypedDict): + type: str + function: OpenAIFunction + + +class OpenAIToolCall(TypedDict, total=False): + id: str + type: str + function: dict[str, Any] + + +class OpenAIToolResult(TypedDict): + tool_call_id: str + role: str + name: str + content: str + + +class OpenAIAdapter: + name = "openai" + + @staticmethod + def to_tools(tool_definitions: list[ToolDefinition]) -> list[OpenAITool]: + return [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t["description"], + "parameters": t["parameters"], + }, + } + for t in tool_definitions + ] + + @staticmethod + def parse_tool_call(tool_call: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: + tc_id = tool_call.get("id", "") + fn = tool_call.get("function") or {} + name = str(fn.get("name", "")) + import json as _json + + raw_args = fn.get("arguments", "{}") + if isinstance(raw_args, str): + try: + arguments = _json.loads(raw_args) + except _json.JSONDecodeError: + arguments = {"_raw": raw_args} + else: + arguments = raw_args if isinstance(raw_args, dict) else {} + return tc_id, name, arguments if isinstance(arguments, dict) else {} + + @staticmethod + def make_tool_result( + tool_call_id: str, + name: str, + execute_response: ExecuteResponse, + ) -> OpenAIToolResult: + import json as _json + + if execute_response["success"]: + data = execute_response.get("data") + if isinstance(data, (dict, list)): + content = _json.dumps(data, ensure_ascii=False, default=str) + elif data is None: + content = "" + else: + content = str(data) + else: + err = execute_response.get("error") or {} + content = _json.dumps({"error": err}, ensure_ascii=False) + return { + "tool_call_id": tool_call_id, + "role": "tool", + "name": name, + "content": content, + } diff --git a/apipilot/core.py b/apipilot/core.py new file mode 100644 index 0000000..6fad891 --- /dev/null +++ b/apipilot/core.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import asyncio +import json +import sys +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Iterable + +from fastapi import FastAPI + +from .errors import ( + ArgumentValidationError, + ExecutionError, + ToolNotFoundError, +) +from .openapi_parser import extract_operations, validate_operations +from .tool import CompositeTool, PilotTool +from .types import ExecuteResponse, OperationInfo, ToolDefinition, ToolRecord +from .validation import validate_arguments + + +UserT = Any +GetUserFn = Callable[[Any], Awaitable[UserT] | UserT] | None +AllowToolFn = Callable[[UserT, ToolRecord], Awaitable[bool] | bool] | None + + +@dataclass +class ApiPilotConfig: + default_timeout: float = 30.0 + default_max_response_size: int = 1_000_000 + expose_http_endpoints: bool = False + http_prefix: str = "/apipilot" + require_auth_for_http: bool = True + app: FastAPI | None = None + get_current_user: GetUserFn = None + allow_tool: AllowToolFn = None + + +class ApiPilot: + def __init__(self, config: ApiPilotConfig | None = None) -> None: + self.config = config or ApiPilotConfig() + self._tools: dict[str, ToolRecord] = {} + self._composites: dict[str, CompositeTool] = {} + self._user_dependent: bool = False + + def register_fastapi_app(self, app: FastAPI) -> None: + self.config.app = app + openapi_root = app.openapi() + operations = extract_operations(app) + results = validate_operations(operations, openapi_root) + for definition, op, specs in results: + extras = op.apipilot + timeout = float(extras.get("timeout", self.config.default_timeout)) + max_size = int(extras.get("max_response_size", self.config.default_max_response_size)) + record = ToolRecord( + name=definition["name"], + description=definition["description"], + tool_type="endpoint", + definition=definition, + operation=op, + param_specs=specs, + timeout=timeout, + max_response_size=max_size, + ) + self._tools[record.name] = record + + def add_tool(self, tool: CompositeTool | PilotTool) -> None: + name = tool.name + if name in self._tools or name in self._composites: + from .errors import DuplicateToolError + + raise DuplicateToolError(f"Nome tool duplicato in registrazione manuale: {name}") + params_schema = getattr(tool, "parameters_schema", None) + if params_schema is None: + params_schema = { + "type": "object", + "properties": {}, + "required": [], + } + definition: ToolDefinition = { + "name": name, + "description": tool.description, + "parameters": params_schema, + } + timeout = float(getattr(tool, "timeout", self.config.default_timeout)) + max_size = int(getattr(tool, "max_response_size", self.config.default_max_response_size)) + run_fn: Callable[..., Awaitable[Any]] | None = getattr(tool, "run_fn", None) + record = ToolRecord( + name=name, + description=tool.description, + tool_type="composite", + definition=definition, + composite_run=run_fn or (lambda args, user: tool.run(args, user)), + timeout=timeout, + max_response_size=max_size, + ) + self._tools[name] = record + if isinstance(tool, CompositeTool): + self._composites[name] = tool + + def list_tool_names(self) -> list[str]: + return list(self._tools.keys()) + + def get_tool(self, name: str) -> ToolRecord | None: + return self._tools.get(name) + + async def _is_tool_allowed(self, user: UserT, record: ToolRecord) -> bool: + allow_fn = self.config.allow_tool + if allow_fn is None: + return True + result = allow_fn(user, record) + if isinstance(result, Awaitable): + result = await result + return bool(result) + + async def tools_for(self, user: UserT = None) -> list[ToolDefinition]: + out: list[ToolDefinition] = [] + for record in self._tools.values(): + if await self._is_tool_allowed(user, record): + out.append(record.definition) + return out + + async def execute( + self, + *, + user: UserT = None, + name: str, + arguments: dict[str, Any] | None = None, + ) -> ExecuteResponse: + record = self._tools.get(name) + if record is None: + raise ToolNotFoundError(f"Tool non trovato: {name}") + if not await self._is_tool_allowed(user, record): + raise ToolNotFoundError(f"Tool non autorizzato per l'utente corrente: {name}") + + try: + validated = validate_arguments(arguments, record.definition["parameters"]) + except ArgumentValidationError as e: + return { + "success": False, + "data": None, + "error": { + "code": "INVALID_ARGUMENTS", + "message": str(e), + "details": e.errors, + }, + "meta": {"tool": name}, + } + + try: + if record.tool_type == "composite": + result = await self._run_composite(record, validated, user) + else: + result = await self._run_endpoint(record, validated, user) + except ExecutionError as e: + return { + "success": False, + "data": None, + "error": { + "code": "EXECUTION_ERROR", + "message": str(e), + "status_code": e.status_code, + "endpoint": e.endpoint, + }, + "meta": {"tool": name}, + } + except asyncio.TimeoutError as e: + return { + "success": False, + "data": None, + "error": { + "code": "TIMEOUT", + "message": f"Timeout dopo {record.timeout}s", + }, + "meta": {"tool": name}, + } + except Exception as e: # pragma: no cover - defensive + return { + "success": False, + "data": None, + "error": { + "code": "UNEXPECTED_EXECUTION_ERROR", + "message": str(e), + "type": type(e).__name__, + }, + "meta": {"tool": name}, + } + + serialized, truncated = self._enforce_size(result, record.max_response_size) + meta: dict[str, Any] = {"tool": name} + if truncated: + meta["truncated"] = True + meta["max_response_size"] = record.max_response_size + return { + "success": True, + "data": serialized, + "error": None, + "meta": meta, + } + + def _enforce_size(self, result: Any, max_size: int) -> tuple[Any, bool]: + if result is None: + return None, False + try: + serialized = json.dumps(result, ensure_ascii=False, default=str) + except Exception: + serialized = str(result) + if len(serialized) <= max_size: + return result, False + if isinstance(result, str): + return result[:max_size] + "... [truncated]", True + if isinstance(result, dict) and "items" in result and isinstance(result["items"], list): + items = result["items"] + sample: list[Any] = [] + current = len(json.dumps({**result, "items": sample}, ensure_ascii=False, default=str)) + for item in items: + s = len(json.dumps(item, ensure_ascii=False, default=str)) + if current + s + 32 > max_size: + break + sample.append(item) + current += s + trimmed = {**result, "items": sample, "_truncated": True, "_total": len(items)} + return trimmed, True + compact = serialized[:max_size] + "... [truncated]" + return compact, True + + async def _run_composite( + self, record: ToolRecord, arguments: dict[str, Any], user: UserT + ) -> Any: + fn = record.composite_run + if fn is None: + raise ExecutionError("Tool composto senza funzione run", endpoint=record.name) + try: + async with asyncio.timeout(record.timeout): + return await fn(arguments, user) + except asyncio.TimeoutError: + raise + except ExecutionError: + raise + except Exception as e: + raise ExecutionError(str(e), cause=e, endpoint=record.name) from e + + async def _run_endpoint( + self, record: ToolRecord, arguments: dict[str, Any], user: UserT + ) -> Any: + op = record.operation + if op is None: + raise ExecutionError("Operazione endpoint mancante", endpoint=record.name) + endpoint_fn = op.endpoint_fn + if endpoint_fn is None: + raise ExecutionError( + f"Impossibile trovare l'endpoint {op.method} {op.path}", + endpoint=op.path, + ) + + kwargs: dict[str, Any] = {} + sig_params = self._extract_signature_params(endpoint_fn) + for spec in record.param_specs: + if spec.name in arguments: + kwargs[spec.name] = arguments[spec.name] + elif spec.default is not None: + kwargs[spec.name] = spec.default + elif not spec.required: + kwargs[spec.name] = None + + if "user" in sig_params and "user" not in kwargs: + kwargs["user"] = user + for extra in ("request", "db", "session"): + if extra in sig_params and extra not in kwargs and hasattr(self, "_" + extra): + kwargs[extra] = getattr(self, "_" + extra) + + try: + async with asyncio.timeout(record.timeout): + if asyncio.iscoroutinefunction(endpoint_fn): + result = await endpoint_fn(**kwargs) + else: + result = await asyncio.to_thread(endpoint_fn, **kwargs) + return result + except asyncio.TimeoutError: + raise + except ExecutionError: + raise + except Exception as e: + status = getattr(e, "status_code", None) + raise ExecutionError( + str(e), + status_code=status, + endpoint=f"{op.method} {op.path}", + cause=e, + ) from e + + @staticmethod + def _extract_signature_params(fn: Callable[..., Any]) -> set[str]: + import inspect + + try: + sig = inspect.signature(fn) + return set(sig.parameters.keys()) + except (ValueError, TypeError): + return set() diff --git a/apipilot/errors.py b/apipilot/errors.py new file mode 100644 index 0000000..7dd600d --- /dev/null +++ b/apipilot/errors.py @@ -0,0 +1,42 @@ +from __future__ import annotations + + +class ApiPilotError(Exception): + pass + + +class InvalidConfigurationError(ApiPilotError): + pass + + +class DuplicateToolError(InvalidConfigurationError): + pass + + +class UnsupportedSchemaError(InvalidConfigurationError): + pass + + +class ToolNotFoundError(ApiPilotError): + pass + + +class ArgumentValidationError(ApiPilotError): + def __init__(self, message: str, errors: list[dict] | None = None) -> None: + super().__init__(message) + self.errors = errors or [] + + +class ExecutionError(ApiPilotError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + endpoint: str | None = None, + cause: Exception | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.endpoint = endpoint + self.cause = cause diff --git a/apipilot/fastapi.py b/apipilot/fastapi.py new file mode 100644 index 0000000..e79d420 --- /dev/null +++ b/apipilot/fastapi.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any, Awaitable, Callable + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from .core import ApiPilot, ApiPilotConfig, AllowToolFn, GetUserFn +from .errors import ApiPilotError +from .tool import CompositeTool, PilotTool +from .types import ExecuteResponse, ToolDefinition + + +class ExecuteRequest(BaseModel): + name: str + arguments: dict[str, Any] | None = None + + +class ToolExecuteResponse(BaseModel): + success: bool + data: Any = None + error: dict[str, Any] | None = None + meta: dict[str, Any] + + +def install_apipilot( + app: FastAPI, + *, + get_current_user: GetUserFn = None, + allow_tool: AllowToolFn = None, + default_timeout: float = 30.0, + default_max_response_size: int = 1_000_000, + expose_http_endpoints: bool = False, + http_prefix: str = "/apipilot", + require_auth_for_http: bool = True, +) -> ApiPilot: + config = ApiPilotConfig( + default_timeout=default_timeout, + default_max_response_size=default_max_response_size, + expose_http_endpoints=expose_http_endpoints, + http_prefix=http_prefix, + require_auth_for_http=require_auth_for_http, + app=app, + get_current_user=get_current_user, + allow_tool=allow_tool, + ) + pilot = ApiPilot(config) + pilot.register_fastapi_app(app) + + if expose_http_endpoints: + _mount_http_endpoints(pilot, app, get_current_user, require_auth_for_http, http_prefix) + + app.state.apipilot = pilot + return pilot + + +def _mount_http_endpoints( + pilot: ApiPilot, + app: FastAPI, + get_current_user: GetUserFn, + require_auth: bool, + prefix: str, +) -> None: + from fastapi import APIRouter + + router = APIRouter(prefix=prefix, tags=["apipilot"]) + + async def _resolve_user(request: Request) -> Any: + if not require_auth or get_current_user is None: + return None + if callable(get_current_user): + try: + result = get_current_user(request) + if isinstance(result, Awaitable): + result = await result + return result + except ApiPilotError: + raise + except Exception as e: + raise HTTPException(status_code=401, detail=str(e)) from e + return None + + @router.get("/tools", response_model=list[ToolDefinition]) + async def list_tools(request: Request) -> Any: + user = await _resolve_user(request) + return await pilot.tools_for(user) + + @router.post("/execute", response_model=ToolExecuteResponse) + async def run_tool(body: ExecuteRequest, request: Request) -> Any: + user = await _resolve_user(request) + try: + result: ExecuteResponse = await pilot.execute( + user=user, name=body.name, arguments=body.arguments + ) + except Exception as e: + return JSONResponse( + status_code=500, + content={ + "success": False, + "data": None, + "error": {"code": type(e).__name__, "message": str(e)}, + "meta": {"tool": body.name}, + }, + ) + status = 200 if result["success"] else 422 + return JSONResponse(status_code=status, content=result) + + app.include_router(router) diff --git a/apipilot/openapi_parser.py b/apipilot/openapi_parser.py new file mode 100644 index 0000000..7fc52b4 --- /dev/null +++ b/apipilot/openapi_parser.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import re +from typing import Any, Iterable + +from fastapi import FastAPI + +from .errors import ( + DuplicateToolError, + InvalidConfigurationError, + UnsupportedSchemaError, +) +from .types import OperationInfo, ParamSpec, ParametersSchema, PropertySchema, ToolDefinition + + +def _sanitize_name(s: str) -> str: + s = re.sub(r"[^a-zA-Z0-9_]", "_", s) + s = re.sub(r"_+", "_", s).strip("_") + return s or "tool" + + +def _convert_schema_to_property( + source: dict[str, Any], fallback_desc: str = "" +) -> PropertySchema: + if not isinstance(source, dict): + return {"type": "string", "description": fallback_desc} + + out: PropertySchema = {} + t = source.get("type") + if t: + out["type"] = t + elif "anyOf" in source or "oneOf" in source: + variants = source.get("anyOf") or source.get("oneOf") + non_null = [v for v in variants if v.get("type") != "null"] + if non_null: + return _convert_schema_to_property(non_null[0], fallback_desc) + out["type"] = "string" + elif "$ref" in source: + raise UnsupportedSchemaError( + f"Schema con $ref non supportato direttamente nel parametro: {source['$ref']}" + ) + else: + out["type"] = "string" + + if "description" in source: + out["description"] = source["description"] + elif fallback_desc: + out["description"] = fallback_desc + + if "items" in source: + out["items"] = _convert_schema_to_property(source["items"]) + + if "properties" in source: + props: dict[str, PropertySchema] = {} + for k, v in source["properties"].items(): + try: + props[k] = _convert_schema_to_property(v) + except UnsupportedSchemaError: + props[k] = {"type": "string", "description": v.get("description", k)} + out["properties"] = props + if "required" in source: + out["required"] = list(source["required"]) + + for key in ("enum", "default", "format", "minimum", "maximum", "minLength", "maxLength"): + if key in source: + out[key] = source[key] # type: ignore[literal-required] + + return out + + +def _dereference(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]: + if not isinstance(schema, dict): + return schema + if "$ref" in schema: + ref = schema["$ref"] + parts = ref.lstrip("#/").split("/") + node: Any = root + for p in parts: + if isinstance(node, dict) and p in node: + node = node[p] + else: + return schema + return _dereference(node, root) + return schema + + +def extract_operations( + app: FastAPI, +) -> list[OperationInfo]: + openapi_schema = app.openapi() + operations: list[OperationInfo] = [] + routes_by_key: dict[tuple[str, str], Any] = {} + + def _walk(routes: Iterable[Any], prefix: str = "") -> None: + for route in routes: + methods = getattr(route, "methods", None) + orig = getattr(route, "original_router", None) + if orig is not None: + ctx = getattr(route, "include_context", None) + nested_prefix = prefix + if ctx is not None: + nested_prefix = prefix + (getattr(ctx, "prefix", "") or "") + _walk(orig.routes, nested_prefix) + continue + if not methods: + continue + path = getattr(route, "path", "") or "" + full_path = prefix + path + endpoint = getattr(route, "endpoint", None) + for m in methods: + routes_by_key[(m.lower(), full_path)] = endpoint + + _walk(app.routes) + + paths = openapi_schema.get("paths", {}) + for path, methods_obj in paths.items(): + if not isinstance(methods_obj, dict): + continue + for method, operation in methods_obj.items(): + if method.lower() not in {"get", "post", "put", "delete", "patch", "options", "head"}: + continue + if not isinstance(operation, dict): + continue + extras = operation.get("x-apipilot") or {} + if not isinstance(extras, dict): + continue + if not extras.get("enabled"): + continue + endpoint_fn = routes_by_key.get((method.lower(), path)) + operations.append( + OperationInfo( + method=method.upper(), + path=path, + operation_id=operation.get("operationId"), + apipilot=extras, + openapi_operation=operation, + endpoint_fn=endpoint_fn, + ) + ) + return operations + + +def build_tool_definition( + op: OperationInfo, + openapi_root: dict[str, Any], +) -> tuple[ToolDefinition, list[ParamSpec]]: + extras = op.apipilot + raw_name = extras.get("name") or op.operation_id or f"{op.method.lower()}_{op.path}" + name = _sanitize_name(str(raw_name)) + description = extras.get("description") or op.openapi_operation.get("summary") or name + + operation = op.openapi_operation + properties: dict[str, PropertySchema] = {} + required: list[str] = [] + param_specs: list[ParamSpec] = [] + + parameters = operation.get("parameters", []) or [] + for param in parameters: + if not isinstance(param, dict): + continue + location = param.get("in", "query") + if location == "header": + continue + pname = param.get("name") + if not pname: + continue + schema_source = _dereference(param.get("schema", {"type": "string"}), openapi_root) + try: + prop = _convert_schema_to_property( + schema_source, fallback_desc=param.get("description", "") + ) + except UnsupportedSchemaError: + prop = {"type": "string", "description": param.get("description", pname)} + if "description" not in prop and "description" in param: + prop["description"] = param["description"] + is_required = bool(param.get("required", False)) + default = schema_source.get("default") + properties[pname] = prop + if is_required: + required.append(pname) + param_specs.append( + ParamSpec( + name=pname, + location=location, + schema=schema_source, + required=is_required, + default=default, + ) + ) + + request_body = operation.get("requestBody") + if isinstance(request_body, dict): + content = request_body.get("content", {}) + json_entry = content.get("application/json") or next( + (v for v in content.values() if isinstance(v, dict)), None + ) + if isinstance(json_entry, dict): + body_schema = _dereference(json_entry.get("schema", {}), openapi_root) + body_type = body_schema.get("type") + is_required_body = bool(request_body.get("required", False)) + if body_type == "object": + body_props = body_schema.get("properties", {}) or {} + body_required = set(body_schema.get("required", []) or []) + for pname, pschema in body_props.items(): + resolved = _dereference(pschema, openapi_root) + try: + prop = _convert_schema_to_property(resolved) + except UnsupportedSchemaError: + prop = {"type": "string", "description": pname} + properties[pname] = prop + if pname in body_required: + required.append(pname) + param_specs.append( + ParamSpec( + name=pname, + location="body", + schema=resolved, + required=pname in body_required, + default=resolved.get("default") if isinstance(resolved, dict) else None, + ) + ) + else: + single_name = "body" + try: + prop = _convert_schema_to_property(body_schema) + except UnsupportedSchemaError: + prop = {"type": "string"} + properties[single_name] = prop + if is_required_body: + required.append(single_name) + param_specs.append( + ParamSpec( + name=single_name, + location="body_root", + schema=body_schema, + required=is_required_body, + default=body_schema.get("default") if isinstance(body_schema, dict) else None, + ) + ) + + required = list(dict.fromkeys(required)) + parameters_schema: ParametersSchema = { + "type": "object", + "properties": properties, + "required": required, + } + definition: ToolDefinition = { + "name": name, + "description": description, + "parameters": parameters_schema, + } + return definition, param_specs + + +def validate_operations( + operations: list[OperationInfo], + openapi_root: dict[str, Any], +) -> list[tuple[ToolDefinition, OperationInfo, list[ParamSpec]]]: + seen: dict[str, OperationInfo] = {} + results: list[tuple[ToolDefinition, OperationInfo, list[ParamSpec]]] = [] + for op in operations: + extras = op.apipilot + if not extras.get("name") and not op.operation_id: + raise InvalidConfigurationError( + f"Operazione {op.method} {op.path} manca di x-apipilot.name o operationId" + ) + definition, specs = build_tool_definition(op, openapi_root) + name = definition["name"] + if name in seen: + prev = seen[name] + raise DuplicateToolError( + f"Nome tool duplicato '{name}': " + f"definito in {prev.method} {prev.path} e {op.method} {op.path}" + ) + seen[name] = op + results.append((definition, op, specs)) + return results diff --git a/apipilot/tool.py b/apipilot/tool.py new file mode 100644 index 0000000..21519ca --- /dev/null +++ b/apipilot/tool.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any, Awaitable, Callable, Protocol + + +class PilotTool(Protocol): + name: str + description: str + + async def run(self, arguments: dict[str, Any], user: Any) -> Any: ... + + +class CompositeTool: + def __init__( + self, + name: str, + description: str, + parameters_schema: dict[str, Any], + run_fn: Callable[[dict[str, Any], Any], Awaitable[Any]], + timeout: float = 60.0, + max_response_size: int = 2_000_000, + ) -> None: + self.name = name + self.description = description + self.parameters_schema = parameters_schema + self.run_fn = run_fn + self.timeout = timeout + self.max_response_size = max_response_size + + async def run(self, arguments: dict[str, Any], user: Any) -> Any: + return await self.run_fn(arguments, user) diff --git a/apipilot/types.py b/apipilot/types.py new file mode 100644 index 0000000..90daeeb --- /dev/null +++ b/apipilot/types.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, TypedDict + + +class ApiPilotOpenAPIExtra(TypedDict, total=False): + enabled: bool + name: str + description: str + timeout: float + max_response_size: int + + +class PropertySchema(TypedDict, total=False): + type: str + description: str + items: dict[str, Any] + properties: dict[str, Any] + required: list[str] + enum: list[Any] + default: Any + format: str + minimum: float + maximum: float + minLength: int + maxLength: int + + +class ParametersSchema(TypedDict): + type: str + properties: dict[str, PropertySchema] + required: list[str] + + +class ToolDefinition(TypedDict): + name: str + description: str + parameters: ParametersSchema + + +class ExecuteResponse(TypedDict): + success: bool + data: Any + error: dict[str, Any] | None + meta: dict[str, Any] + + +EndpointExecutor = Callable[..., Awaitable[Any]] + + +@dataclass +class OperationInfo: + method: str + path: str + operation_id: str | None + apipilot: ApiPilotOpenAPIExtra + openapi_operation: dict[str, Any] + endpoint_fn: Callable[..., Any] | None = None + + +@dataclass +class ParamSpec: + name: str + location: str + schema: dict[str, Any] + required: bool + default: Any = None + + +@dataclass +class ToolRecord: + name: str + description: str + tool_type: str + definition: ToolDefinition + operation: OperationInfo | None = None + composite_run: Callable[..., Awaitable[Any]] | None = None + param_specs: list[ParamSpec] = field(default_factory=list) + timeout: float = 30.0 + max_response_size: int = 1_000_000 diff --git a/apipilot/validation.py b/apipilot/validation.py new file mode 100644 index 0000000..d1636f2 --- /dev/null +++ b/apipilot/validation.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Any + +import jsonschema + +from .errors import ArgumentValidationError + + +def validate_arguments(arguments: dict[str, Any] | None, parameters_schema: dict[str, Any]) -> dict[str, Any]: + args = dict(arguments or {}) + try: + jsonschema.validate(instance=args, schema=parameters_schema) + except jsonschema.ValidationError as e: + errors = [] + for err in jsonschema.exceptions.ValidationError(e).context or [e]: + errors.append( + { + "path": list(err.absolute_path), + "message": err.message, + "validator": err.validator, + } + ) + raise ArgumentValidationError( + f"Argomenti non validi per lo schema: {e.message}", errors=errors + ) from e + except jsonschema.SchemaError as e: + raise ArgumentValidationError(f"Schema dei parametri non valido: {e.message}") from e + return args diff --git a/basicprompt.txt b/basicprompt.txt new file mode 100644 index 0000000..7e1baa6 --- /dev/null +++ b/basicprompt.txt @@ -0,0 +1,75 @@ +Sviluppa **ApiPilot**, una libreria Python riutilizzabile che trasforma operazioni di un’applicazione FastAPI in tool eseguibili da agenti AI. La libreria non deve contenere riferimenti a progetti, API o domini applicativi specifici. + +## Obiettivo + +Lo sviluppatore abilita un endpoint esistente tramite `x-apipilot` nel decorator FastAPI. ApiPilot genera le definizioni dei tool, valida ed esegue le chiamate e restituisce risultati strutturati. L’agente decide quando usare un tool; ApiPilot ne gestisce l’esecuzione. + +## Prima di implementare + +Esamina struttura del repository, versioni Python e FastAPI, convenzioni di autenticazione e test. Definisci un piano breve e segui le convenzioni del progetto. + +## Interfaccia desiderata + +```python +from apipilot.fastapi import install_apipilot + +pilot = install_apipilot( + app, + get_current_user=get_current_user, +) + +tools = pilot.tools_for(user) +result = await pilot.execute( + user=user, + name="list_items", + arguments={"category": "active"}, +) +``` + +Un endpoint deve poter essere abilitato così: + +```python +@router.get( + "/items", + summary="Elenca gli elementi", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "list_items", + "description": ( + "Cerca gli elementi disponibili. Usalo quando " + "l'utente chiede un elenco o cerca elementi " + "di una categoria." + ), + } + }, +) +async def list_items(category: str | None = None): + ... +``` + +Puoi migliorare l’interfaccia proposta se trovi una soluzione più semplice, mantenendo documentate le scelte. + +## Requisiti + +1. Leggi l’OpenAPI generato dall’app FastAPI e registra solo le operazioni con `x-apipilot.enabled: true`. +2. Genera nome, descrizione e schema JSON dei parametri nel formato function calling compatibile OpenAI. Supporta parametri di path, query e body. Non esporre al modello credenziali o header tecnici. +3. Rileva nomi duplicati, configurazioni incomplete e schemi non supportati con errori chiari. +4. Implementa `tools_for(user)` e `execute(user, name, arguments)`. +5. Esegui solo tool registrati. Valida gli argomenti e applica l’autenticazione e i permessi dell’app ospite. Il modello non deve poter scegliere liberamente URL, metodo HTTP o credenziali. +6. Gestisci timeout, errori degli endpoint, risultati vuoti e risposte troppo grandi. Restituisci errori strutturati. +7. Permetti di registrare tool Python composti che eseguono più passaggi e non corrispondono a un singolo endpoint OpenAPI. +8. Mantieni separati il nucleo di ApiPilot e gli adapter per specifici provider LLM. Implementa un adapter iniziale per il formato tool calling compatibile OpenAI, incluso il collegamento tra risultato e `tool_call_id`. +9. Se esponi catalogo ed esecuzione via HTTP, rendili opzionali e documenta autenticazione e contratto delle richieste. + +## Documentazione per gli agenti di sviluppo + +Genera nella radice del repository **`AGENT.md` e `CLAUDE.md`**. Devono contenere istruzioni pratiche e coerenti per chi sviluppa ApiPilot con agenti di coding: architettura e confini dei moduli, comandi di installazione e test, convenzioni del codice, requisiti di sicurezza, criteri per aggiungere adapter e regole per aggiornare documentazione e test. Evita duplicazioni divergenti: `CLAUDE.md` può rimandare ad `AGENT.md` e aggiungere solo istruzioni specifiche, se necessarie. + +## Verifica e consegna + +Aggiungi test per selezione delle operazioni, conversione dei parametri, autorizzazione, rifiuto di tool sconosciuti, argomenti invalidi, gestione degli errori e tool composti. Usa una piccola app FastAPI di esempio, senza dipendenze da sistemi esterni. + +Consegna codice installabile, README con esempi di integrazione, formato documentato di `x-apipilot`, `AGENT.md`, `CLAUDE.md` e comandi per eseguire i test. Evita comportamenti o nomi legati a una singola applicazione. + +Includi anche un esempio demo di un FastAPI backend che usa apipilot diff --git a/demo.sh b/demo.sh new file mode 100755 index 0000000..e40b7f8 --- /dev/null +++ b/demo.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="${ROOT}/.venv" +PY_BIN="${VENV_DIR}/bin/python" +UVICORN_BIN="${VENV_DIR}/bin/uvicorn" +PID_FILE="${ROOT}/.demo.pid" +LOG_FILE="${ROOT}/.demo.log" +APP_MODULE="examples.demo_backend:app" +HOST="127.0.0.1" +PORT="9292" + +BANNER="ApiPilot Demo Backend (port ${PORT})" + +msg() { echo -e "[demo] ${*}"; } +err() { echo -e "[demo][error] ${*}" >&2; } +ok() { echo -e "[demo][ok] ${*}"; } + +ensure_venv() { + if [ ! -x "${PY_BIN}" ]; then + msg "Creo virtualenv in ${VENV_DIR}..." + python3 -m venv "${VENV_DIR}" + fi + if ! "${PY_BIN}" -c "import apipilot" >/dev/null 2>&1; then + msg "Installo apipilot[test] in modalità editabile..." + "${PY_BIN}" -m pip install --upgrade pip >/dev/null + "${PY_BIN}" -m pip install -e "${ROOT}[test]" >/dev/null + fi + if [ ! -x "${UVICORN_BIN}" ]; then + msg "Installo uvicorn nel venv..." + "${PY_BIN}" -m pip install -q uvicorn >/dev/null + fi +} + +read_pid() { + if [ -f "${PID_FILE}" ]; then + local p + p="$(cat "${PID_FILE}" 2>/dev/null || true)" + if [ -n "${p}" ] && kill -0 "${p}" 2>/dev/null; then + echo "${p}" + return 0 + fi + fi + return 1 +} + +cmd_status() { + local pid + if pid="$(read_pid)"; then + ok "${BANNER} in esecuzione (PID=${pid}, log=${LOG_FILE})" + return 0 + else + msg "${BANNER} NON in esecuzione" + [ -f "${PID_FILE}" ] && rm -f "${PID_FILE}" + return 3 + fi +} + +cmd_start() { + local pid + if pid="$(read_pid)"; then + msg "Già in esecuzione (PID=${pid}). Usa 'demo.sh restart' per riavviare." + return 0 + fi + ensure_venv + msg "Avvio ${BANNER}..." + cd "${ROOT}" + nohup "${UVICORN_BIN}" "${APP_MODULE}" \ + --host "${HOST}" \ + --port "${PORT}" \ + --reload \ + >"${LOG_FILE}" 2>&1 & + local new_pid=$! + echo "${new_pid}" > "${PID_FILE}" + sleep 1 + if kill -0 "${new_pid}" 2>/dev/null; then + ok "Avviato. PID=${new_pid}" + msg " URL : http://${HOST}:${PORT}/docs" + msg " Log : tail -f ${LOG_FILE}" + msg " Auth: Demo senza auth obbligatoria (default: demo-user/admin). Opzionale: Bearer admin-token oppure Bearer user-token" + return 0 + else + err "Impossibile avviare il server. Controlla ${LOG_FILE}" + return 1 + fi +} + +cmd_stop() { + local pid + if ! pid="$(read_pid)"; then + msg "Nessun processo in esecuzione." + [ -f "${PID_FILE}" ] && rm -f "${PID_FILE}" + return 0 + fi + msg "Arresto processo PID=${pid}..." + kill "${pid}" 2>/dev/null || true + for i in 1 2 3 4 5 6 7 8 9 10; do + if ! kill -0 "${pid}" 2>/dev/null; then + break + fi + sleep 0.5 + done + if kill -0 "${pid}" 2>/dev/null; then + msg "Graceful stop fallito, invio SIGKILL..." + kill -9 "${pid}" 2>/dev/null || true + sleep 1 + fi + rm -f "${PID_FILE}" + if ! kill -0 "${pid}" 2>/dev/null; then + ok "Arrestato." + else + err "Impossibile arrestare PID=${pid}. Interrompi manualmente." + return 1 + fi +} + +cmd_restart() { + cmd_stop || true + sleep 0.5 + cmd_start +} + +cmd_logs() { + if [ -f "${LOG_FILE}" ]; then + tail -n "${1:-50}" "${LOG_FILE}" + else + msg "Nessun log disponibile (${LOG_FILE} non esiste)." + fi +} + +usage() { + cat < + +Comandi: + start Avvia ${BANNER} in background (con venv creato/aggiornato se serve) + stop Arresta l'istanza in background + restart stop + start + status Verifica se è in esecuzione (ritorna 3 se non in esecuzione) + logs [N] Mostra le ultime N righe del log (default: 50) + help Questo aiuto + +Percorsi: + PID : ${PID_FILE} + LOG : ${LOG_FILE} + VENV : ${VENV_DIR} +EOF +} + +main() { + local cmd="${1:-help}" + shift || true + case "${cmd}" in + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + status) cmd_status ;; + logs) cmd_logs "${1:-}" ;; + help|-h|--help) usage ;; + *) + err "Comando sconosciuto: '${cmd}'" + echo + usage + exit 2 + ;; + esac +} + +main "$@" diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/demo_backend.py b/examples/demo_backend.py new file mode 100644 index 0000000..da760f5 --- /dev/null +++ b/examples/demo_backend.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Query + +from apipilot.adapters.openai import OpenAIAdapter +from apipilot.fastapi import install_apipilot +from apipilot.tool import CompositeTool + +app = FastAPI(title="ApiPilot Demo Backend", version="0.1.1") + +PRODUCTS: dict[int, dict[str, Any]] = { + 1: {"id": 1, "name": "Mouse ergonomico", "category": "accessori", "price": 34.99, "stock": 12}, + 2: {"id": 2, "name": "Tastiera meccanica", "category": "accessori", "price": 89.99, "stock": 3}, + 3: {"id": 3, "name": "Monitor 27\"", "category": "schermi", "price": 219.0, "stock": 7}, + 4: {"id": 4, "name": "Laptop 14\"", "category": "computer", "price": 899.0, "stock": 0}, +} + +ORDERS: list[dict[str, Any]] = [ + {"id": 1001, "user": "demo-user", "items": [{"product_id": 1, "qty": 2}], "total": 69.98}, +] + + +DEFAULT_USER = {"id": "demo-user", "role": "admin"} + + +def get_current_user_optional(authorization: str | None = Header(default=None)) -> dict[str, Any]: + if not authorization: + return DEFAULT_USER + prefix = "Bearer " + token = authorization[len(prefix):] if authorization.startswith(prefix) else authorization + USERS = { + "admin-token": {"id": "admin", "role": "admin"}, + "user-token": {"id": "mario", "role": "user"}, + } + return USERS.get(token, DEFAULT_USER) + + +def allow_tool(user: Any, record: Any) -> bool: + return True + + +router = APIRouter(prefix="/api/v1") + + +@router.get( + "/products", + summary="Elenca prodotti", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "list_products", + "description": ( + "Elenca i prodotti disponibili. Puoi filtrare per categoria, " + "disponibilità in magazzino o ordinare per prezzo. Usa questo tool " + "quando l'utente chiede di vedere il catalogo o cercare prodotti." + ), + } + }, +) +async def list_products( + category: str | None = Query(default=None, description="Categoria prodotto"), + in_stock_only: bool = Query(default=False, description="Solo prodotti disponibili"), + min_price: float | None = Query(default=None, ge=0, description="Prezzo minimo"), + max_price: float | None = Query(default=None, ge=0, description="Prezzo massimo"), + limit: int = Query(default=20, ge=1, le=200), +): + data = list(PRODUCTS.values()) + if category: + data = [p for p in data if p["category"] == category] + if in_stock_only: + data = [p for p in data if p["stock"] > 0] + if min_price is not None: + data = [p for p in data if p["price"] >= min_price] + if max_price is not None: + data = [p for p in data if p["price"] <= max_price] + return {"items": data[:limit], "total": len(data)} + + +@router.get( + "/products/{product_id}", + summary="Dettaglio prodotto", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "get_product", + "description": "Ottieni i dettagli di un prodotto dal suo ID.", + } + }, +) +async def get_product(product_id: int): + p = PRODUCTS.get(product_id) + if not p: + raise HTTPException(status_code=404, detail="Product not found") + return p + + +@router.post( + "/orders", + summary="Crea ordine", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "create_order", + "description": ( + "Crea un nuovo ordine. " + "Specifica gli articoli come lista di product_id e qty." + ), + } + }, +) +async def create_order( + items: list[dict[str, int]], + user: dict[str, Any] = Depends(get_current_user_optional), +): + total = 0.0 + resolved: list[dict[str, Any]] = [] + for row in items: + pid = row.get("product_id") + qty = int(row.get("qty", 1)) + product = PRODUCTS.get(int(pid)) + if not product: + raise HTTPException(status_code=400, detail=f"Unknown product_id {pid}") + if product["stock"] < qty: + raise HTTPException( + status_code=400, detail=f"Insufficient stock for {product['name']}" + ) + product["stock"] -= qty + row_total = product["price"] * qty + total += row_total + resolved.append({"product_id": pid, "qty": qty, "unit_price": product["price"]}) + oid = 1000 + len(ORDERS) + 1 + order = {"id": oid, "user": user["id"], "items": resolved, "total": round(total, 2)} + ORDERS.append(order) + return order + + +@router.post( + "/products/{product_id}/restock", + summary="Rifornisci prodotto", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "restock_product", + "description": "Aggiungi unità a magazzino per un prodotto.", + } + }, +) +async def restock_product(product_id: int, quantity: int, user=Depends(get_current_user_optional)): + _ = user + p = PRODUCTS.get(product_id) + if not p: + raise HTTPException(status_code=404, detail="Product not found") + p["stock"] += quantity + return {"id": product_id, "stock": p["stock"]} + + +@router.get( + "/orders/my", + summary="I miei ordini", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "list_my_orders", + "description": "Elenca gli ordini dell'utente corrente (demo-user senza token).", + } + }, +) +async def list_my_orders(user=Depends(get_current_user_optional)): + mine = [o for o in ORDERS if o["user"] == user["id"]] + return {"items": mine, "total": len(mine), "user": user["id"]} + + +app.include_router(router) + +pilot = install_apipilot( + app, + get_current_user=get_current_user_optional, + allow_tool=allow_tool, + default_timeout=10.0, + expose_http_endpoints=True, + http_prefix="/apipilot", + require_auth_for_http=False, +) + + +async def _find_buy_recommendation(args: dict[str, Any], user: Any): + budget = float(args.get("budget") or 0) + category = args.get("category") + list_args: dict[str, Any] = {"in_stock_only": True, "limit": 50} + if category: + list_args["category"] = category + res = await pilot.execute(user=user, name="list_products", arguments=list_args) + if not res["success"]: + return {"error": res["error"]} + products = (res["data"] or {}).get("items", []) + if budget and budget > 0: + products = [p for p in products if p["price"] <= budget] + if not products: + return {"recommendation": None, "reason": "Nessun prodotto soddisfa i criteri"} + best = max(products, key=lambda p: p.get("stock", 0)) + return { + "recommendation": best, + "alternatives_count": len(products) - 1, + } + + +pilot.add_tool( + CompositeTool( + name="recommend_product", + description=( + "Consiglia un prodotto all'utente in base a budget e categoria. " + "Usa questo tool quando l'utente chiede un suggerimento o una raccomandazione." + ), + parameters_schema={ + "type": "object", + "properties": { + "budget": {"type": "number", "description": "Budget massimo disponibile."}, + "category": {"type": "string", "description": "Categoria desiderata (opzionale)."}, + }, + "required": [], + }, + run_fn=_find_buy_recommendation, + timeout=20.0, + ) +) + + +@app.get("/healthz") +async def healthz(): + return {"ok": True, "version": "0.1.1"} + + +@app.get("/demo/chat", tags=["demo"]) +async def demo_chat(user=Depends(get_current_user_optional)): + tools = await pilot.tools_for(user) + openai_tools = OpenAIAdapter.to_tools(tools) + return { + "user": user["id"], + "role": user.get("role"), + "auth_note": ( + "Demo senza autenticazione obbligatoria. " + "Senza header Authorization usi l'utente di default demo-user/admin. " + "Con Authorization: Bearer admin-token o Bearer user-token " + "puoi impersonare altri utenti per test." + ), + "available_tools": [t["function"]["name"] for t in openai_tools], + "openai_tools": openai_tools, + "example_agent_cycle": ( + "1. Usa tools_for(user) per ottenere i tool. " + "2. Convertili con OpenAIAdapter.to_tools. " + "3. Invia al modello. " + "4. Ricevi la tool_call. " + "5. Parse con OpenAIAdapter.parse_tool_call. " + "6. Esegui con pilot.execute. " + "7. Format risposta con OpenAIAdapter.make_tool_result. " + "8. Rimanda al modello per la risposta finale." + ), + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("examples.demo_backend:app", host="127.0.0.1", port=9292, reload=True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e2dd1f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "apipilot" +version = "0.1.1" +description = "Librietta Python che trasforma endpoint FastAPI in tool eseguibili da agenti AI." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [ + { name = "ApiPilot Contributors" }, +] +classifiers = [ + "Framework :: FastAPI", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "fastapi>=0.100", + "pydantic>=2.0", + "pydantic-settings>=2.0", + "httpx>=0.24", + "jsonschema>=4.18", +] + +[project.optional-dependencies] +test = [ + "pytest>=7.4", + "pytest-asyncio>=0.21", + "pytest-cov>=4.1", + "uvicorn>=0.23", + "starlette>=0.27", +] +dev = [ + "ruff>=0.1", + "mypy>=1.5", + "types-jsonschema", +] + +[tool.setuptools.packages.find] +include = ["apipilot*"] +exclude = ["tests*", "examples*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true diff --git a/run_demo.sh b/run_demo.sh new file mode 100755 index 0000000..ddc4121 --- /dev/null +++ b/run_demo.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="${ROOT}/.venv" +PY_BIN="${VENV_DIR}/bin/python" + +if [ ! -x "${PY_BIN}" ]; then + echo "[run_demo] Creo virtualenv in ${VENV_DIR}..." + python3 -m venv "${VENV_DIR}" +fi + +if ! "${PY_BIN}" -c "import apipilot" >/dev/null 2>&1; then + echo "[run_demo] Installo apipilot in modalità editabile + dipendenze test..." + "${PY_BIN}" -m pip install --upgrade pip >/dev/null + "${PY_BIN}" -m pip install -e "${ROOT}[test]" +fi + +"${PY_BIN}" -m pip install -q uvicorn >/dev/null 2>&1 || true + +echo "[run_demo] Avvio demo backend su http://127.0.0.1:9292 (Ctrl+C per fermarlo)" +cd "${ROOT}" +exec "${PY_BIN}" -m uvicorn examples.demo_backend:app --host 0.0.0.0 --port 9292 --reload diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5ddcb12 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, FastAPI, HTTPException, Query + +ITEMS: dict[int, dict[str, Any]] = { + 1: {"id": 1, "name": "Item A", "category": "active", "price": 9.99}, + 2: {"id": 2, "name": "Item B", "category": "active", "price": 19.99}, + 3: {"id": 3, "name": "Item C", "category": "archived", "price": 29.99}, +} + +USERS = { + "alice-token": {"id": "alice", "role": "admin"}, + "bob-token": {"id": "bob", "role": "user"}, +} + + +def get_current_user_from_header(authorization: str | None = None) -> dict[str, Any]: + if not authorization: + raise HTTPException(status_code=401, detail="Missing authorization") + prefix = "Bearer " + token = authorization[len(prefix):] if authorization.startswith(prefix) else authorization + user = USERS.get(token) + if not user: + raise HTTPException(status_code=401, detail="Invalid token") + return user + + +def build_sample_app( + *, + expose_http_endpoints: bool = False, + add_composite: bool = False, + add_deny: bool = False, +): + from apipilot.fastapi import install_apipilot + from apipilot.tool import CompositeTool + + app = FastAPI(title="SampleApp", version="1.0") + router = APIRouter() + + @router.get( + "/items", + summary="Elenca gli elementi", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "list_items", + "description": ( + "Cerca gli elementi disponibili. Usalo quando " + "l'utente chiede un elenco o cerca elementi di una categoria." + ), + } + }, + ) + async def list_items( + category: str | None = Query(default=None, description="Categoria filtro"), + limit: int = Query(default=10, ge=1, le=100, description="Massimo elementi"), + ): + data = list(ITEMS.values()) + if category: + data = [it for it in data if it["category"] == category] + return {"items": data[:limit], "total": len(data)} + + @router.get( + "/items/{item_id}", + summary="Ottieni dettaglio elemento", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "get_item", + "description": "Restituisce i dettagli di un elemento dato il suo ID.", + } + }, + ) + async def get_item(item_id: int): + it = ITEMS.get(item_id) + if not it: + raise HTTPException(status_code=404, detail="Item not found") + return it + + @router.post( + "/items", + summary="Crea un nuovo elemento", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "create_item", + "description": "Crea un nuovo elemento con nome, categoria e prezzo.", + } + }, + ) + async def create_item(name: str, category: str = "active", price: float = 0.0): + new_id = max(ITEMS.keys(), default=0) + 1 + ITEMS[new_id] = {"id": new_id, "name": name, "category": category, "price": price} + return ITEMS[new_id] + + @router.get( + "/items/{item_id}/slow", + summary="Dettaglio lento", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "get_slow_item", + "description": "Endpoint che simula un timeout per testare limiti.", + "timeout": 0.1, + } + }, + ) + async def get_slow_item(item_id: int): + import asyncio + + await asyncio.sleep(2) + return ITEMS.get(item_id) + + @router.get( + "/items/huge", + summary="Genera una risposta enorme", + openapi_extra={ + "x-apipilot": { + "enabled": True, + "name": "get_huge_items", + "description": "Restituisce una lista molto grande per testare il troncamento.", + "max_response_size": 1024, + } + }, + ) + async def get_huge_items(): + huge = [ + {"id": i, "name": f"Item {i}", "payload": "x" * 200} + for i in range(100) + ] + return {"items": huge, "total": len(huge)} + + @router.get( + "/not-pilot", + summary="Non abilitato", + ) + async def not_pilot(): + return {"ok": True} + + app.include_router(router) + + def allow_tool(user: Any, record: Any) -> bool: + if not add_deny: + return True + if user is None: + return True + role = user.get("role") if isinstance(user, dict) else "user" + if role != "admin" and record.name == "create_item": + return False + return True + + pilot = install_apipilot( + app, + get_current_user=get_current_user_from_header, + allow_tool=allow_tool, + default_timeout=5.0, + expose_http_endpoints=expose_http_endpoints, + require_auth_for_http=False, + ) + + if add_composite: + async def find_cheapest(args: dict[str, Any], user: Any): + cat = args.get("category") + r1 = await pilot.execute(user=user, name="list_items", arguments={"category": cat}) + if not r1["success"]: + return {"error": r1["error"]} + items = (r1["data"] or {}).get("items", []) + if not items: + return None + cheapest = min(items, key=lambda it: it.get("price", float("inf"))) + return {"cheapest": cheapest} + + pilot.add_tool( + CompositeTool( + name="find_cheapest_item", + description="Trova l'elemento più economico nella categoria specificata.", + parameters_schema={ + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Categoria degli elementi dove cercare.", + } + }, + "required": [], + }, + run_fn=find_cheapest, + ) + ) + + return app, pilot diff --git a/tests/test_http_endpoints.py b/tests/test_http_endpoints.py new file mode 100644 index 0000000..a8cbb71 --- /dev/null +++ b/tests/test_http_endpoints.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from .conftest import build_sample_app + + +@pytest.fixture +def client(): + app, _ = build_sample_app(expose_http_endpoints=True, add_composite=True) + return TestClient(app) + + +def test_http_list_tools(client): + res = client.get("/apipilot/tools") + assert res.status_code == 200 + names = {t["name"] for t in res.json()} + assert "list_items" in names + assert "get_item" in names + assert "create_item" in names + assert "find_cheapest_item" in names + + +def test_http_execute_success(client): + res = client.post( + "/apipilot/execute", + json={"name": "list_items", "arguments": {"category": "active"}}, + ) + assert res.status_code == 200 + body = res.json() + assert body["success"] is True + assert body["data"]["total"] == 2 + + +def test_http_execute_tool_not_found_via_unknown_tool(client): + res = client.post( + "/apipilot/execute", + json={"name": "does_not_exist", "arguments": {}}, + ) + assert res.status_code == 500 + + +def test_http_execute_invalid_args(client): + res = client.post( + "/apipilot/execute", + json={"name": "get_item", "arguments": {"item_id": "not-a-number"}}, + ) + assert res.status_code == 422 + body = res.json() + assert body["success"] is False + assert body["error"]["code"] == "INVALID_ARGUMENTS" diff --git a/tests/test_openapi_parser.py b/tests/test_openapi_parser.py new file mode 100644 index 0000000..5afdc1a --- /dev/null +++ b/tests/test_openapi_parser.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import pytest + +from apipilot.errors import DuplicateToolError, InvalidConfigurationError +from apipilot.openapi_parser import ( + build_tool_definition, + extract_operations, + validate_operations, +) + +from .conftest import build_sample_app + + +def test_extract_operations_picks_enabled_only(): + app, _ = build_sample_app() + ops = extract_operations(app) + names = { + (op.apipilot.get("name") or op.operation_id or op.path): op.apipilot.get("enabled") + for op in ops + } + assert "list_items" in names + assert "get_item" in names + assert "create_item" in names + assert all(v is True for v in names.values()) + + +def test_build_tool_definition_path_and_query(): + app, _ = build_sample_app() + root = app.openapi() + ops = extract_operations(app) + list_items_op = next(op for op in ops if op.apipilot.get("name") == "list_items") + definition, specs = build_tool_definition(list_items_op, root) + assert definition["name"] == "list_items" + props = definition["parameters"]["properties"] + assert "category" in props + assert props["category"]["type"] == "string" + assert "limit" in props + assert props["limit"]["type"] == "integer" + locs = {s.name: s.location for s in specs} + assert locs["category"] == "query" + assert locs["limit"] == "query" + assert "category" not in definition["parameters"]["required"] + + +def test_build_tool_definition_body(): + app, _ = build_sample_app() + root = app.openapi() + ops = extract_operations(app) + create_op = next(op for op in ops if op.apipilot.get("name") == "create_item") + definition, specs = build_tool_definition(create_op, root) + assert definition["name"] == "create_item" + props = definition["parameters"]["properties"] + assert "name" in props + assert props["name"]["type"] == "string" + assert "name" in definition["parameters"]["required"] + + +def test_validate_duplicate_raises(): + from fastapi import APIRouter, FastAPI + + app = FastAPI() + router = APIRouter() + + @router.get( + "/a", + openapi_extra={ + "x-apipilot": {"enabled": True, "name": "dup_name", "description": "first"} + }, + ) + async def a(): + return 1 + + @router.post( + "/b", + openapi_extra={ + "x-apipilot": {"enabled": True, "name": "dup_name", "description": "second"} + }, + ) + async def b(): + return 2 + + app.include_router(router) + with pytest.raises(DuplicateToolError): + from apipilot.openapi_parser import extract_operations as e, validate_operations as v + ops = e(app) + v(ops, app.openapi()) + + +def test_validate_missing_name_and_operation_id_raises(): + from fastapi import APIRouter, FastAPI + + app = FastAPI() + router = APIRouter() + + @router.get( + "/no-name", + openapi_extra={"x-apipilot": {"enabled": True, "description": "no name"}}, + ) + async def no_name(): + return 1 + + app.include_router(router) + from apipilot.openapi_parser import extract_operations as e, validate_operations as v + + # FastAPI generera' un operationId automatico se non fornito, ma simuliamo + # un caso senza assegnando None. + ops = e(app) + for op in ops: + op.operation_id = None + op.apipilot.pop("name", None) + with pytest.raises(InvalidConfigurationError): + v(ops, app.openapi()) diff --git a/tests/test_pilot.py b/tests/test_pilot.py new file mode 100644 index 0000000..e302309 --- /dev/null +++ b/tests/test_pilot.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import pytest + +from apipilot.adapters.openai import OpenAIAdapter +from apipilot.errors import ToolNotFoundError +from apipilot.tool import CompositeTool + +from .conftest import build_sample_app + + +@pytest.fixture +async def pilot(): + _, pilot = build_sample_app(add_composite=True, add_deny=True) + return pilot + + +@pytest.mark.asyncio +async def test_tools_for_returns_known_tools(pilot): + tools = await pilot.tools_for() + names = {t["name"] for t in tools} + assert "list_items" in names + assert "get_item" in names + assert "create_item" in names + assert "find_cheapest_item" in names + + +@pytest.mark.asyncio +async def test_tools_for_respects_allow_tool(pilot): + user = {"id": "bob", "role": "user"} + tools = await pilot.tools_for(user) + names = {t["name"] for t in tools} + assert "list_items" in names + assert "create_item" not in names + + +@pytest.mark.asyncio +async def test_execute_endpoint_query_params(pilot): + res = await pilot.execute(name="list_items", arguments={"category": "active"}) + assert res["success"] is True + data = res["data"] + assert data["total"] == 2 + assert {it["id"] for it in data["items"]} == {1, 2} + + +@pytest.mark.asyncio +async def test_execute_endpoint_path_param(pilot): + res = await pilot.execute(name="get_item", arguments={"item_id": 1}) + assert res["success"] is True + assert res["data"]["name"] == "Item A" + + +@pytest.mark.asyncio +async def test_execute_endpoint_path_param_not_found(pilot): + res = await pilot.execute(name="get_item", arguments={"item_id": 999}) + assert res["success"] is False + err = res["error"] + assert err["code"] == "EXECUTION_ERROR" + assert err["status_code"] == 404 + + +@pytest.mark.asyncio +async def test_execute_endpoint_body_params(pilot): + res = await pilot.execute( + name="create_item", + arguments={"name": "New Item", "category": "active", "price": 42}, + ) + assert res["success"] is True + assert res["data"]["name"] == "New Item" + assert res["data"]["price"] == 42 + + +@pytest.mark.asyncio +async def test_execute_unknown_tool_raises(pilot): + with pytest.raises(ToolNotFoundError): + await pilot.execute(name="does_not_exist", arguments={}) + + +@pytest.mark.asyncio +async def test_execute_invalid_arguments_returns_error(pilot): + res = await pilot.execute(name="get_item", arguments={"item_id": "not-a-number"}) + assert res["success"] is False + assert res["error"]["code"] == "INVALID_ARGUMENTS" + + +@pytest.mark.asyncio +async def test_execute_denied_tool_user_not_allowed(pilot): + user = {"id": "bob", "role": "user"} + with pytest.raises(ToolNotFoundError): + await pilot.execute(user=user, name="create_item", arguments={"name": "x"}) + + +@pytest.mark.asyncio +async def test_execute_timeout(pilot): + res = await pilot.execute(name="get_slow_item", arguments={"item_id": 1}) + assert res["success"] is False + assert res["error"]["code"] in {"TIMEOUT", "EXECUTION_ERROR"} + + +@pytest.mark.asyncio +async def test_execute_huge_response_truncates(pilot): + res = await pilot.execute(name="get_huge_items", arguments={}) + assert res["success"] is True + assert res["meta"].get("truncated") is True or res["data"].get("_truncated") is True + import json + + serialized = json.dumps(res["data"]) + assert len(serialized) <= 1024 + 2048 + + +@pytest.mark.asyncio +async def test_composite_tool(pilot): + res = await pilot.execute( + name="find_cheapest_item", arguments={"category": "active"} + ) + assert res["success"] is True + assert res["data"]["cheapest"]["id"] == 1 + + +@pytest.mark.asyncio +async def test_execute_empty_result_is_none(pilot): + res = await pilot.execute(name="list_items", arguments={"category": "nothing-here"}) + assert res["success"] is True + assert res["data"]["total"] == 0 + assert res["data"]["items"] == [] + + +def test_openai_adapter_tools_format(): + definitions = [ + { + "name": "list_items", + "description": "Elenca items", + "parameters": { + "type": "object", + "properties": {"category": {"type": "string"}}, + "required": [], + }, + } + ] + tools = OpenAIAdapter.to_tools(definitions) + assert tools[0]["type"] == "function" + assert tools[0]["function"]["name"] == "list_items" + assert tools[0]["function"]["parameters"]["properties"]["category"]["type"] == "string" + + +def test_openai_adapter_parse_tool_call(): + tc = { + "id": "call_123", + "type": "function", + "function": {"name": "list_items", "arguments": '{"category":"active"}'}, + } + tc_id, name, args = OpenAIAdapter.parse_tool_call(tc) + assert tc_id == "call_123" + assert name == "list_items" + assert args == {"category": "active"} + + +def test_openai_adapter_make_tool_result_success(): + from apipilot.types import ExecuteResponse + + resp: ExecuteResponse = { + "success": True, + "data": {"items": [1, 2, 3]}, + "error": None, + "meta": {"tool": "list_items"}, + } + tr = OpenAIAdapter.make_tool_result("call_1", "list_items", resp) + assert tr["role"] == "tool" + assert tr["tool_call_id"] == "call_1" + assert tr["name"] == "list_items" + import json + + assert json.loads(tr["content"]) == {"items": [1, 2, 3]} + + +def test_openai_adapter_make_tool_result_error(): + from apipilot.types import ExecuteResponse + + resp: ExecuteResponse = { + "success": False, + "data": None, + "error": {"code": "X", "message": "fail"}, + "meta": {"tool": "x"}, + } + tr = OpenAIAdapter.make_tool_result("call_2", "x", resp) + import json + + assert json.loads(tr["content"]) == {"error": {"code": "X", "message": "fail"}}