refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts. Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
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