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:
2026-10-06 19:26:55 -03:00
parent e0304b2bdb
commit abd03ed07d
105 changed files with 353 additions and 312 deletions
+597
View File
@@ -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),
},
}