142 lines
5.2 KiB
Python
142 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from s2s_client.core import config as settings
|
|
from s2s_client.core import logging as app_logging
|
|
from s2s_client.models import requests as models_requests
|
|
from s2s_client.providers import apipilot as providers_apipilot
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/api/apipilot/tools", response_model=None)
|
|
async def apipilot_list_tools(
|
|
request: Request,
|
|
include_unauthorized: bool = True,
|
|
refresh: bool = False,
|
|
) -> dict[str, Any] | JSONResponse:
|
|
"""Public proxy for ApiPilot tool discovery (guide §2.1).
|
|
|
|
Forwards Authorization / x-demo-session headers from the caller to the
|
|
upstream backend verbatim and returns the list of `ToolDefinition` records
|
|
exactly as documented:
|
|
|
|
{ "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false }
|
|
|
|
A short in-process TTL cache is applied (default 5 min, configurable via
|
|
S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true`
|
|
(e.g. after a user just logged in and previously saw only requires_auth tools)."""
|
|
if not settings.S2S_APIPILOT_TOOLS_URL:
|
|
return JSONResponse(
|
|
status_code=501,
|
|
content={
|
|
"success": False,
|
|
"data": None,
|
|
"error": {
|
|
"code": "APIPILOT_NOT_CONFIGURED",
|
|
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
|
"status_code": 501,
|
|
"raw": None,
|
|
},
|
|
},
|
|
)
|
|
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
|
|
try:
|
|
tools = await providers_apipilot._apipilot_fetch_tools(
|
|
auth_header=auth,
|
|
demo_session=demo,
|
|
include_unauthorized=bool(include_unauthorized),
|
|
force_refresh=bool(refresh),
|
|
)
|
|
except HTTPException as exc:
|
|
# Normalize to envelope-style payload so downstream code is uniform.
|
|
detail = (
|
|
exc.detail
|
|
if isinstance(exc.detail, dict)
|
|
else {
|
|
"code": "APIPILOT_ERROR",
|
|
"message": str(exc.detail) if exc.detail else "Error",
|
|
"status_code": exc.status_code,
|
|
"raw": None,
|
|
}
|
|
)
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"success": False, "data": None, "error": detail},
|
|
)
|
|
app_logging.logger.info(
|
|
"[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s",
|
|
len(tools),
|
|
bool(include_unauthorized),
|
|
bool(refresh),
|
|
)
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"count": len(tools),
|
|
"include_unauthorized": bool(include_unauthorized),
|
|
"tools": tools,
|
|
},
|
|
"error": None,
|
|
}
|
|
|
|
|
|
@router.post("/api/apipilot/execute")
|
|
async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse:
|
|
"""Public proxy for ApiPilot /execute (guide §2.2).
|
|
|
|
Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool
|
|
discovery, `requires_auth` gate, strict JSON-Schema argument validation),
|
|
forwards the request to the upstream backend, and returns the envelope
|
|
`{success, data, error}`.
|
|
|
|
Show only `error.message` to the end user. `error.code`, `error.status_code`
|
|
and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
|
|
if not settings.S2S_APIPILOT_TOOLS_URL:
|
|
return JSONResponse(
|
|
status_code=501,
|
|
content={
|
|
"success": False,
|
|
"data": None,
|
|
"error": {
|
|
"code": "APIPILOT_NOT_CONFIGURED",
|
|
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
|
"status_code": 501,
|
|
"raw": None,
|
|
},
|
|
},
|
|
)
|
|
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
|
|
app_logging.logger.info(
|
|
"[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s",
|
|
(body.name or ""),
|
|
sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [],
|
|
("Y" if auth or demo else "none"),
|
|
)
|
|
envelope = await providers_apipilot._apipilot_execute_tool(
|
|
auth_header=auth,
|
|
demo_session=demo,
|
|
name=body.name,
|
|
arguments=body.arguments if isinstance(body.arguments, dict) else {},
|
|
)
|
|
status_code = 200
|
|
if (not envelope.get("success")) and isinstance(envelope.get("error"), dict):
|
|
code = envelope["error"].get("status_code")
|
|
try:
|
|
code_int = int(code)
|
|
if 400 <= code_int < 599:
|
|
status_code = code_int
|
|
except Exception:
|
|
pass
|
|
app_logging.logger.info(
|
|
"[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s",
|
|
body.name,
|
|
envelope.get("success"),
|
|
status_code,
|
|
)
|
|
return JSONResponse(status_code=status_code, content=envelope)
|