refactor(api): scope uv to s2s-client and validate compatible responses
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from s2s_client.api.responses import validated_json_response
|
||||
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.models.responses import ToolCatalogResponse, ToolExecutionResponse
|
||||
from s2s_client.providers import apipilot as providers_apipilot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/apipilot/tools", response_model=ToolCatalogResponse)
|
||||
async def apipilot_list_tools(
|
||||
request: Request,
|
||||
include_unauthorized: bool = True,
|
||||
refresh: bool = False,
|
||||
) -> 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 validated_json_response(
|
||||
model=ToolCatalogResponse,
|
||||
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 validated_json_response(
|
||||
model=ToolCatalogResponse,
|
||||
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 validated_json_response(
|
||||
{
|
||||
"success": True,
|
||||
"data": {
|
||||
"count": len(tools),
|
||||
"include_unauthorized": bool(include_unauthorized),
|
||||
"tools": tools,
|
||||
},
|
||||
"error": None,
|
||||
},
|
||||
ToolCatalogResponse,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/apipilot/execute", response_model=ToolExecutionResponse)
|
||||
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 validated_json_response(
|
||||
model=ToolExecutionResponse,
|
||||
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 validated_json_response(envelope, ToolExecutionResponse, status_code=status_code)
|
||||
Reference in New Issue
Block a user