first
This commit is contained in:
+16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from . import openai
|
||||
|
||||
__all__ = ["openai"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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"}}
|
||||
Reference in New Issue
Block a user