first
This commit is contained in:
@@ -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