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 s2s_client.core import config as settings from s2s_client.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: # - Authorization: Bearer # - x-demo-session: # 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), }, }