chore(vocero): remove the legacy ApiPilot integration (#3)
Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline. Release notes: - Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution. - Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery. - Updated regression coverage and operating documentation for the retired routes and settings. Reviewed-on: AquaPortal/s2s#3
This commit is contained in:
@@ -1,597 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from time import monotonic
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.core import logging as app_logging
|
||||
|
||||
# In-process cache and asynchronous lock: one caller refreshes while others
|
||||
# read the stale entry. `_APIPILOT_CACHE[auth_fingerprint] = (fetched_at_monotonic, tools_list)`.
|
||||
_APIPILOT_CACHE: dict[str, tuple[float, list[dict]]] = {}
|
||||
_APIPILOT_CACHE_LOCK = asyncio.Lock()
|
||||
|
||||
# Monotonic time helper for duration measurements unaffected by NTP drift.
|
||||
_monotonic_now: Callable[[], float] = monotonic
|
||||
|
||||
|
||||
def _apipilot_auth_fingerprint(auth_header: str | None, demo_session: str | None) -> str:
|
||||
"""Return a short string used as cache key scoped to the caller identity.
|
||||
|
||||
We don't want to share cached tool-lists across users with different auth
|
||||
tokens (requires_auth flags can be token-dependent via /tools?include_unauthorized=true
|
||||
because the back-end may still attach per-role metadata)."""
|
||||
parts: list[str] = []
|
||||
if auth_header:
|
||||
# Don't cache the raw bearer. SHA-256 truncated 12 chars = safe fingerprint.
|
||||
import hashlib
|
||||
|
||||
parts.append("h:" + hashlib.sha256(auth_header.encode("utf-8")).hexdigest()[:12])
|
||||
if demo_session:
|
||||
parts.append("d:" + demo_session)
|
||||
return "|".join(parts) if parts else "anon"
|
||||
|
||||
|
||||
def _apipilot_parse_authorization(request: Request | None) -> tuple[str | None, str | None]:
|
||||
"""Extract (Authorization header value, x-demo-session header value) from a FastAPI request.
|
||||
|
||||
Returns a tuple of two optional strings. Both are forwarded to the upstream
|
||||
ApiPilot backend verbatim when non-empty (see docs/client/tool-calling.md).
|
||||
"""
|
||||
if request is None:
|
||||
return None, None
|
||||
auth = None
|
||||
demo = None
|
||||
try:
|
||||
raw_auth = request.headers.get("authorization") or request.headers.get("Authorization")
|
||||
if isinstance(raw_auth, str) and raw_auth.strip():
|
||||
auth = raw_auth.strip()
|
||||
raw_demo = request.headers.get("x-demo-session") or request.headers.get("X-Demo-Session")
|
||||
if isinstance(raw_demo, str) and raw_demo.strip():
|
||||
demo = raw_demo.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return auth, demo
|
||||
|
||||
|
||||
def _apipilot_resolve_execute_url() -> str:
|
||||
"""Resolve the final POST URL for ApiPilot tool execution.
|
||||
|
||||
Priority (guide §2.2 and explicit environment override):
|
||||
1. When S2S_APIPILOT_TOOLS_EXECUTE_URL is set, append "/execute" if needed.
|
||||
2. Fallback S2S_APIPILOT_TOOLS_URL + "/execute".
|
||||
If neither URL is configured, the caller (usually _apipilot_execute_tool)
|
||||
must return an APIPILOT_NOT_CONFIGURED error envelope before calling this.
|
||||
"""
|
||||
base = settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL
|
||||
base = base.strip().rstrip("/")
|
||||
if not base:
|
||||
return ""
|
||||
if base.lower().endswith("/execute"):
|
||||
return base
|
||||
return f"{base}/execute"
|
||||
|
||||
|
||||
def _apipilot_strip_tool_for_llm(tool_def: dict) -> dict:
|
||||
"""Return a tool definition that is safe for LLM function-calling providers.
|
||||
|
||||
Guide §4 forbids passing raw /tools output directly to the model because
|
||||
requires_auth is outside the function-calling standard and some providers
|
||||
reject it. Project the native `ToolDefinition` into the OpenAI-compatible
|
||||
`{type: "function", function: {name, description, parameters}}`.
|
||||
"""
|
||||
if not isinstance(tool_def, dict):
|
||||
return {}
|
||||
name = tool_def.get("name") if isinstance(tool_def.get("name"), str) else ""
|
||||
description = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
|
||||
parameters = tool_def.get("parameters")
|
||||
if not isinstance(parameters, dict):
|
||||
parameters = {"type": "object", "properties": {}, "required": []}
|
||||
out: dict = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
async def _apipilot_fetch_tools(
|
||||
*,
|
||||
auth_header: str | None,
|
||||
demo_session: str | None,
|
||||
include_unauthorized: bool = True,
|
||||
force_refresh: bool = False,
|
||||
refresh: bool | None = None,
|
||||
) -> list[dict]:
|
||||
"""Fetch ApiPilot tool definitions with a short in-process TTL cache.
|
||||
|
||||
Implements §2.1 (GET /tools?include_unauthorized=true). The cache key is
|
||||
scoped by caller auth fingerprint (see `_apipilot_auth_fingerprint`) so
|
||||
per-token variations in `requires_auth` or list length don't leak between
|
||||
identities.
|
||||
|
||||
Returns a list of tool definitions (parsed JSON, may be empty). Raises
|
||||
`HTTPException` only when `S2S_APIPILOT_TOOLS_URL` is set but the backend
|
||||
replied with a non-2xx / non-JSON response. When env is empty → returns
|
||||
`[]` silently (passthrough).
|
||||
"""
|
||||
# 2026-09-25 compatibility alias: WS connect uses `refresh`; when present,
|
||||
# apply it to force_refresh.
|
||||
if refresh is not None:
|
||||
force_refresh = bool(refresh) or bool(force_refresh)
|
||||
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||
return []
|
||||
import time as _time_mod
|
||||
|
||||
# 2026-09-25 fingerprint consistency: determine effective auth/demo values
|
||||
# first (SUPER API KEY takes precedence), then calculate the cache fingerprint.
|
||||
# An anonymous fingerprint with APIKEY headers would mismatch the cache and
|
||||
# demo session, causing Aquachile user_type=null and a 403 response.
|
||||
if settings.S2S_APIPILOT_API_KEY:
|
||||
final_auth: str | None = "BYPASS-API-KEY"
|
||||
_demo_used: str | None = settings.S2S_APIPILOT_API_KEY
|
||||
_effective_auth_for_fp = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||
_effective_demo_for_fp = settings.S2S_APIPILOT_API_KEY
|
||||
else:
|
||||
final_auth = auth_header or (
|
||||
f"Bearer {settings.S2S_APIPILOT_SERVICE_TOKEN}" if settings.S2S_APIPILOT_SERVICE_TOKEN else None
|
||||
)
|
||||
_demo_used = demo_session
|
||||
_effective_auth_for_fp = (
|
||||
final_auth if isinstance(final_auth, str) and final_auth.strip() else (auth_header or "")
|
||||
)
|
||||
_effective_demo_for_fp = _demo_used if isinstance(_demo_used, str) and _demo_used.strip() else ""
|
||||
fp = _apipilot_auth_fingerprint(_effective_auth_for_fp, _effective_demo_for_fp)
|
||||
now = _time_mod.monotonic()
|
||||
cached = _APIPILOT_CACHE.get(fp)
|
||||
if (not force_refresh) and cached and (now - cached[0]) < settings.APIPILOT_CACHE_TTL_SEC:
|
||||
app_logging.logger.debug(
|
||||
"[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d",
|
||||
settings.APIPILOT_CACHE_TTL_SEC - (now - cached[0]),
|
||||
fp,
|
||||
len(cached[1]),
|
||||
)
|
||||
return cached[1]
|
||||
# Prefer to serve stale (if any) rather than block many callers.
|
||||
stale_tools = cached[1] if cached else None
|
||||
async with _APIPILOT_CACHE_LOCK:
|
||||
# Double-check after acquiring lock (another coroutine may have refreshed).
|
||||
now2 = _time_mod.monotonic()
|
||||
recheck = _APIPILOT_CACHE.get(fp)
|
||||
if (not force_refresh) and recheck and (now2 - recheck[0]) < settings.APIPILOT_CACHE_TTL_SEC:
|
||||
return recheck[1]
|
||||
url = f"{settings.S2S_APIPILOT_TOOLS_URL}/tools"
|
||||
params = {"include_unauthorized": "true" if include_unauthorized else "false"}
|
||||
headers = {"accept": "application/json"}
|
||||
# 2026-09-25 SUPER API KEY overrides other authentication and sends all
|
||||
# three header forms simultaneously for maximum compatibility.
|
||||
if settings.S2S_APIPILOT_API_KEY:
|
||||
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
|
||||
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
|
||||
else:
|
||||
if final_auth:
|
||||
headers["Authorization"] = final_auth
|
||||
if _demo_used:
|
||||
headers["x-demo-session"] = _demo_used
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s",
|
||||
url,
|
||||
params["include_unauthorized"],
|
||||
fp,
|
||||
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
|
||||
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
|
||||
resp = await http.get(url, params=params, headers=headers)
|
||||
except Exception as exc:
|
||||
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc)
|
||||
if stale_tools is not None:
|
||||
app_logging.logger.warning(
|
||||
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
|
||||
)
|
||||
return stale_tools
|
||||
raise HTTPException( # noqa: B904
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": "APIPILOT_NETWORK_ERROR",
|
||||
"message": f"Error al contactar ApiPilot tools backend: {exc}",
|
||||
},
|
||||
)
|
||||
if resp.status_code >= 300:
|
||||
app_logging.logger.error(
|
||||
"[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400]
|
||||
)
|
||||
if stale_tools is not None:
|
||||
app_logging.logger.warning(
|
||||
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
|
||||
)
|
||||
return stale_tools
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": "APIPILOT_BAD_UPSTREAM",
|
||||
"message": f"Backend ApiPilot tools devolvió HTTP {resp.status_code}",
|
||||
"status_code": resp.status_code,
|
||||
},
|
||||
)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500])
|
||||
if stale_tools is not None:
|
||||
return stale_tools
|
||||
raise HTTPException( # noqa: B904
|
||||
status_code=502,
|
||||
detail={"code": "APIPILOT_BAD_JSON", "message": "La respuesta de ApiPilot tools no es JSON válido."},
|
||||
)
|
||||
# Backend might return: list OR {"result": [...]} OR {"tools": [...]} OR
|
||||
# {"data": {"tools": [...]}}. Unwrap generic wrapper-style payloads.
|
||||
tools: list[dict] = []
|
||||
if isinstance(payload, list):
|
||||
tools = [t for t in payload if isinstance(t, dict)]
|
||||
elif isinstance(payload, dict):
|
||||
for key in ("tools", "result", "data"):
|
||||
cand = payload.get(key)
|
||||
if isinstance(cand, list):
|
||||
tools = [t for t in cand if isinstance(t, dict)]
|
||||
break
|
||||
if isinstance(cand, dict):
|
||||
sub = cand.get("tools") if isinstance(cand.get("tools"), list) else cand.get("result")
|
||||
if isinstance(sub, list):
|
||||
tools = [t for t in sub if isinstance(t, dict)]
|
||||
break
|
||||
# Validate minimal ToolDefinition contract §2.1: name + description + parameters.
|
||||
sanitized: list[dict] = []
|
||||
for raw in tools:
|
||||
name = raw.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
description = raw.get("description") if isinstance(raw.get("description"), str) else ""
|
||||
parameters = raw.get("parameters")
|
||||
if not isinstance(parameters, dict):
|
||||
parameters = {"type": "object", "properties": {}, "required": []}
|
||||
requires_auth = raw.get("requires_auth", False)
|
||||
if not isinstance(requires_auth, bool):
|
||||
# Some backends return 0/1; normalize to bool as required.
|
||||
try:
|
||||
requires_auth = bool(int(requires_auth))
|
||||
except Exception:
|
||||
requires_auth = False
|
||||
tool_def = {
|
||||
"name": name.strip(),
|
||||
"description": description,
|
||||
"parameters": parameters,
|
||||
"requires_auth": requires_auth,
|
||||
}
|
||||
sanitized.append(tool_def)
|
||||
_APIPILOT_CACHE[fp] = (now2, sanitized)
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-DISCOVERY] ← OK tools=%s fp=%s status=%s upstreamFmt=%s cachedTools=%d",
|
||||
len(sanitized),
|
||||
fp,
|
||||
resp.status_code,
|
||||
"list" if isinstance(payload, list) else "object",
|
||||
len(sanitized),
|
||||
)
|
||||
if settings.S2S_DEBUG_PROMPT and sanitized:
|
||||
app_logging.logger.debug(
|
||||
"[APIPILOT-DISCOVERY] raw first tool preview: %s",
|
||||
json.dumps(sanitized[0], ensure_ascii=False)[:400],
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _apipilot_validate_arguments(arguments: dict | None, tool_def: dict) -> tuple[dict, list[str]]:
|
||||
"""Validate / execute arguments strictly against the tool's JSON-Schema parameters.
|
||||
|
||||
Implements §2.2 PRE-CHECK #3:
|
||||
• Remove unknown keys only when parameters.properties is nonempty.
|
||||
• AquaPortal tool.get_user_briefing has parameters.properties={} and
|
||||
required=[]: no explicit schema, so arguments remain flexible. The
|
||||
backend resolves phone from the token; preserve supplied arguments.
|
||||
• Validate required parameters and return errors for missing values.
|
||||
• Never invent parameters or add keys.
|
||||
|
||||
Returns `(filtered_args, list_of_validation_errors_in_spanish)`.
|
||||
If the returned errors list is non-empty, MUST block the execution (see
|
||||
the `_apipilot_execute_tool` caller).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
schema = cast(dict[str, Any], tool_def.get("parameters")) if isinstance(tool_def.get("parameters"), dict) else {}
|
||||
properties = cast(dict[str, Any], schema.get("properties")) if isinstance(schema.get("properties"), dict) else {}
|
||||
required = cast(list[Any], schema.get("required")) if isinstance(schema.get("required"), list) else []
|
||||
required = [x for x in required if isinstance(x, str)]
|
||||
args = arguments if isinstance(arguments, dict) else {}
|
||||
|
||||
# 2026-09-25 empty-schema flexibility: preserve all arguments when no
|
||||
# properties are explicitly declared, as with AquaPortal get_user_briefing.
|
||||
schema_is_open: bool = (not isinstance(properties, dict)) or len(properties) == 0
|
||||
requires_auth_flag = bool(tool_def.get("requires_auth"))
|
||||
# 2026-09-25 auth-only tools: requires_auth=true, empty properties and
|
||||
# required=[], with a valid-session description, resolve user data from
|
||||
# the bearer JWT. Send arguments={} to avoid conflicts between a phone
|
||||
# argument and the token's user, which would produce a 403 response.
|
||||
auth_only_tool = False
|
||||
if schema_is_open and requires_auth_flag and len(required) == 0:
|
||||
_d = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
|
||||
if (not _d) or "valid session token" in _d.lower() or "authenticated" in _d.lower():
|
||||
auth_only_tool = True
|
||||
if auth_only_tool:
|
||||
cleaned = {}
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-VALIDATE] tool=%s requires only an authenticated session → arguments forced to {} (avoid phone-argument/user-token conflicts)",
|
||||
tool_def.get("name") or "?",
|
||||
)
|
||||
elif schema_is_open:
|
||||
cleaned = dict(args)
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-VALIDATE] tool=%s open schema (empty parameters.properties) → preserve all arguments N=%d keys=%s",
|
||||
tool_def.get("name") or "?",
|
||||
len(cleaned),
|
||||
sorted(list(cleaned.keys())),
|
||||
)
|
||||
else:
|
||||
cleaned = {}
|
||||
for key in args.keys():
|
||||
if key in properties:
|
||||
cleaned[key] = args[key]
|
||||
else:
|
||||
app_logging.logger.warning(
|
||||
"[APIPILOT-VALIDATE] tool=%s removing unknown arg=%s",
|
||||
tool_def.get("name") or "?",
|
||||
key,
|
||||
)
|
||||
# Check required fields only for a closed schema; open required=[] is vacuous.
|
||||
if not schema_is_open:
|
||||
for req in required:
|
||||
if req not in cleaned:
|
||||
errors.append(f"Falta el parámetro requerido: {req}")
|
||||
return cleaned, errors
|
||||
|
||||
|
||||
async def _apipilot_execute_tool(
|
||||
*,
|
||||
auth_header: str | None,
|
||||
demo_session: str | None,
|
||||
name: str,
|
||||
arguments: dict | None,
|
||||
) -> dict:
|
||||
"""Run a single ApiPilot tool via POST /execute.
|
||||
|
||||
Implements the full PRE-CHECK flow (§2.2) BEFORE sending the HTTP
|
||||
request:
|
||||
1. Fresh discovery (TTL cache; force_refresh=True if a tool is missing).
|
||||
2. requires_auth: return HTTP 401 when authentication is required but absent.
|
||||
3. Strict JSON-Schema validation: filter unknown parameters and check required.
|
||||
|
||||
Always return the standard `{success, data, error}` envelope (guide §2.2).
|
||||
Pre-check failures populate error.{code,message,status_code}, with success=false.
|
||||
"""
|
||||
name_clean = (name or "").strip() if isinstance(name, str) else ""
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=EXECUTE_ENTRYPOINT name=%r argsKeys=%s authHead=%.20s demo=%.20s",
|
||||
name_clean,
|
||||
sorted(list((arguments or {}).keys())) if isinstance(arguments, dict) else [],
|
||||
str(auth_header or "")[:20],
|
||||
str(demo_session or "")[:20],
|
||||
)
|
||||
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "APIPILOT_NOT_CONFIGURED",
|
||||
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
||||
"status_code": 501,
|
||||
"raw": None,
|
||||
},
|
||||
}
|
||||
if not name_clean:
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "TOOL_NAME_REQUIRED",
|
||||
"message": "Falta el campo obligatorio: name (nombre de la herramienta).",
|
||||
"status_code": 400,
|
||||
"raw": None,
|
||||
},
|
||||
}
|
||||
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=False)
|
||||
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
|
||||
if tool_def is None:
|
||||
# Unknown tool: refresh discovery and retry once.
|
||||
app_logging.logger.info("[APIPILOT-EXEC] tool=%s not found → forced re-discovery", name_clean)
|
||||
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=True)
|
||||
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
|
||||
if tool_def is None:
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "TOOL_NOT_FOUND",
|
||||
"message": (
|
||||
f"Herramienta «{name_clean}» no encontrada en el catálogo ApiPilot. "
|
||||
"Actualice la lista de herramientas y vuelva a intentarlo."
|
||||
),
|
||||
"status_code": 404,
|
||||
"raw": None,
|
||||
},
|
||||
}
|
||||
# ── PRE-CHECK 2: requires_auth ────────────────────────────────────────────
|
||||
requires_auth = bool(tool_def.get("requires_auth"))
|
||||
has_token = bool(auth_header) or bool(demo_session) or bool(settings.S2S_APIPILOT_SERVICE_TOKEN)
|
||||
if requires_auth and not has_token:
|
||||
app_logging.logger.warning("[APIPILOT-EXEC] tool=%s requires_auth=true blocked (no token)", name_clean)
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "AUTH_REQUIRED",
|
||||
"message": (
|
||||
f"La herramienta «{name_clean}» requiere autenticación. "
|
||||
"Inicie sesión o proporcione un token para continuar."
|
||||
),
|
||||
"status_code": 401,
|
||||
"raw": None,
|
||||
},
|
||||
}
|
||||
# Pre-check 3: strict JSON-Schema argument validation.
|
||||
cleaned_args, errors = _apipilot_validate_arguments(arguments, tool_def)
|
||||
if errors:
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "INVALID_ARGUMENTS",
|
||||
"message": " / ".join(errors),
|
||||
"status_code": 400,
|
||||
"raw": {
|
||||
"removed": sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
|
||||
},
|
||||
},
|
||||
}
|
||||
# ── HTTP: POST /execute ───────────────────────────────────────────────────
|
||||
url = _apipilot_resolve_execute_url()
|
||||
if not url:
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "APIPILOT_NOT_CONFIGURED",
|
||||
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL y S2S_APIPILOT_TOOLS_EXECUTE_URL están vacíos).",
|
||||
"status_code": 501,
|
||||
"raw": None,
|
||||
},
|
||||
}
|
||||
headers = {"Content-Type": "application/json", "accept": "application/json,text/plain,*/*"}
|
||||
# 2026-09-25 SUPER API KEY (highest priority): bypass AquaPortal authentication.
|
||||
# Send all three header forms simultaneously for maximum compatibility:
|
||||
# - X-API-Key: <key>
|
||||
# - Authorization: Bearer <key>
|
||||
# - x-demo-session: <key>
|
||||
# Ignore service tokens and login_by_phone when the API KEY is set.
|
||||
if settings.S2S_APIPILOT_API_KEY:
|
||||
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
|
||||
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
|
||||
final_auth: str | None = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||
else:
|
||||
# Authentication priority: service-token override, then caller auth_header
|
||||
# from phone login/discovery/pass-2 fallback, then no authentication.
|
||||
if settings.S2S_APIPILOT_SERVICE_TOKEN:
|
||||
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
|
||||
final_auth = (f"Bearer {_svc}" if (not _svc.lower().startswith("bearer ")) else _svc) if _svc else None
|
||||
else:
|
||||
final_auth = auth_header if isinstance(auth_header, str) and auth_header.strip() else None
|
||||
if final_auth:
|
||||
headers["Authorization"] = final_auth
|
||||
if demo_session:
|
||||
headers["x-demo-session"] = demo_session
|
||||
payload = {"name": name_clean, "arguments": cleaned_args}
|
||||
removed = sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-EXEC] → POST %s name=%s argsKeys=%s removedKeys=%s auth=%s demo=%s",
|
||||
url,
|
||||
name_clean,
|
||||
sorted(cleaned_args.keys()),
|
||||
removed,
|
||||
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
|
||||
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
|
||||
resp = await http.post(url, json=payload, headers=headers)
|
||||
except Exception as exc:
|
||||
app_logging.logger.error("[APIPILOT-EXEC] ✗ network error: %s", exc)
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "EXECUTION_ERROR",
|
||||
"message": f"Error de red al ejecutar «{name_clean}»: {exc}",
|
||||
"status_code": 502,
|
||||
"raw": None,
|
||||
},
|
||||
}
|
||||
# Parse a standard envelope; construct a matching error for non-JSON/5xx responses.
|
||||
raw_text = resp.text
|
||||
# Detailed diagnostics are always enabled for HTTP >= 400: request payload,
|
||||
# unmasked demo session, response status and bounded raw response body.
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
payload_dump = __import__("json").dumps(payload, ensure_ascii=False)
|
||||
except Exception: # noqa: BLE001
|
||||
payload_dump = str(payload)
|
||||
app_logging.logger.warning(
|
||||
"[APIPILOT-EXEC][DIAGNOSTIC 4xx] HTTP=%d url=%s name=%s demoSession=%.60s AUTH=%s requestPAYLOAD=%s |RAW RESPONSE BODY (max 1800 chars)=|%s|",
|
||||
resp.status_code,
|
||||
url,
|
||||
name_clean,
|
||||
(demo_session if isinstance(demo_session, str) else "?"),
|
||||
("YES len=" + str(len(final_auth)) if isinstance(final_auth, str) and final_auth.strip() else "-"),
|
||||
payload_dump,
|
||||
(raw_text or "")[:1800],
|
||||
)
|
||||
try:
|
||||
parsed = resp.json()
|
||||
except Exception:
|
||||
app_logging.logger.error("[APIPILOT-EXEC] ✗ non-JSON body HTTP %s: %s", resp.status_code, raw_text[:500])
|
||||
return {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "EXECUTION_ERROR",
|
||||
"message": f"La respuesta de ejecución «{name_clean}» no es JSON válido (HTTP {resp.status_code}).",
|
||||
"status_code": resp.status_code,
|
||||
"raw": raw_text[:2000] if raw_text else None,
|
||||
},
|
||||
}
|
||||
# Return standard {success,data,error} envelopes unchanged.
|
||||
if isinstance(parsed, dict) and "success" in parsed and isinstance(parsed.get("success"), bool):
|
||||
envelope_ok = parsed["success"] and parsed.get("error") in (None, {})
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-EXEC] ← %s name=%s success=%s http=%s dataKeys=%s",
|
||||
"OK" if envelope_ok else "FAIL",
|
||||
name_clean,
|
||||
bool(parsed.get("success")),
|
||||
resp.status_code,
|
||||
sorted(list(cast(dict, parsed.get("data")).keys())) if isinstance(parsed.get("data"), dict) else "-",
|
||||
)
|
||||
if (not envelope_ok) and isinstance(parsed.get("error"), dict):
|
||||
err = parsed["error"]
|
||||
app_logging.logger.warning(
|
||||
"[APIPILOT-EXEC] ERROR: code=%s status=%s msg=%s",
|
||||
err.get("code"),
|
||||
err.get("status_code"),
|
||||
(str(err.get("message"))[:200] if err.get("message") is not None else "-"),
|
||||
)
|
||||
return parsed
|
||||
# Wrap raw backend data in the required envelope (guide §2.2) for stable
|
||||
# downstream behavior when an upstream response omits the wrapper.
|
||||
app_logging.logger.warning(
|
||||
"[APIPILOT-EXEC] backend omitted standard envelope → wrapping response http=%s", resp.status_code
|
||||
)
|
||||
return {
|
||||
"success": 200 <= resp.status_code < 300,
|
||||
"data": parsed if (200 <= resp.status_code < 300) else None,
|
||||
"error": None
|
||||
if (200 <= resp.status_code < 300)
|
||||
else {
|
||||
"code": "EXECUTION_ERROR",
|
||||
"message": (f"Ejecución «{name_clean}» devolvió HTTP {resp.status_code} sin envelope estándar."),
|
||||
"status_code": resp.status_code,
|
||||
"raw": (raw_text[:2000] if raw_text else None),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user