This commit is contained in:
2026-09-24 05:19:06 -04:00
commit 2cec225860
27 changed files with 2640 additions and 0 deletions
+16
View File
@@ -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
+104
View File
@@ -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:<modulo>]`. 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/<provider>.py`.
2. Implementa tre metodi statici:
- `to_tools(tool_definitions: list[ToolDefinition]) -> list[<ProviderTool>]`
- `parse_tool_call(tc_dict) -> tuple[tool_call_id, name, args_dict]`
- `make_tool_result(tool_call_id, name, execute_response) -> <ProviderToolMessage>`
3. Esporta il modulo da [apipilot/adapters/__init__.py](apipilot/adapters/__init__.py).
4. Aggiungi test in `tests/test_adapters_<provider>.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
+42
View File
@@ -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 /<prefix>/tools` e `POST /<prefix>/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.
+44
View File
@@ -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.
+278
View File
@@ -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.
+38
View File
@@ -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"
+10
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
from . import openai
__all__ = ["openai"]
+90
View File
@@ -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,
}
+300
View File
@@ -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()
+42
View File
@@ -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
+109
View File
@@ -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)
+277
View File
@@ -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
+31
View File
@@ -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)
+81
View File
@@ -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
+29
View File
@@ -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
+75
View File
@@ -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
Executable
+170
View File
@@ -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 <<EOF
Uso: $(basename "$0") <COMANDO>
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 "$@"
View File
+266
View File
@@ -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)
+64
View File
@@ -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
Executable
+23
View File
@@ -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
View File
+193
View File
@@ -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
+52
View File
@@ -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"
+113
View File
@@ -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())
+188
View File
@@ -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"}}