Files
pallas/pallas/health.py
Robert Helewka ca7d714a31 docs(pallas): document sampling parameters and Prometheus metrics
Add two new sections to the Pallas documentation:

- Sampling parameters: explain that temperature/top_p/top_k are
  configured via the fast-agent decorator's `request_params`, with a
  provider support matrix and a note on Claude Opus 4.7 stripping these
  params in favor of `output_config.effort`.
- Metrics: document the Prometheus `/metrics` endpoint exposed on the
  registry port, including scrape config, full metrics reference table,
  and notes on where each metric is captured.
2026-05-23 07:49:21 -04:00

722 lines
28 KiB
Python

"""
Health check module for Pallas.
Probes downstream MCP server connectivity and exposes a ``get_health`` MCP
tool. At startup, :func:`validate_llm_providers` runs a cheap, per-provider
preflight so Daedalus (or any headless consumer) can see *why* an agent
might be degraded when there is no fast-agent TUI to surface it — see
``docs/pallas_integration.md`` § Runtime get_health tool.
Preflight dispatch matrix:
========================== ======================================== =======================================
Provider Probe Success criterion
========================== ======================================== =======================================
``anthropic`` (direct) ``GET {base_url}/models/{model}`` HTTP 200
``anthropic`` (Mantle) ``GET {mantle_root}/v1/models/{wire}`` HTTP 200 (wire-name via mantle_shims)
``openai`` ``GET {base_url}/models`` HTTP 200 and active model in list
``generic`` ``GET {base_url}/models`` HTTP 200 (body not inspected)
``bedrock`` none ok if AWS creds resolvable
unknown provider none error — surfaces honestly to Daedalus
========================== ======================================== =======================================
API keys are resolved via :class:`fast_agent.llm.provider_key_manager.ProviderKeyManager`
so this module sees identical secret-loading behaviour to the real LLM
client path. We never duplicate key-resolution logic here.
Endpoints that cost inference tokens are deliberately avoided. Mantle's
anthropic probe uses the (token-free) model catalogue at the region root
(``/v1/models/{wire}``), *not* ``POST /anthropic/v1/messages``.
"""
import asyncio
import json
import logging
import os
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
import yaml
logger = logging.getLogger(__name__)
def _config_root() -> Path:
"""Return the working directory where agents.yaml and fastagent configs live."""
return Path.cwd()
def _load_deployment_name() -> str:
"""Read the deployment name from agents.yaml (or PALLAS_AGENTS_CONFIG override)."""
config_path = _config_root() / os.environ.get("PALLAS_AGENTS_CONFIG", "agents.yaml")
if config_path.exists():
data = yaml.safe_load(config_path.read_text()) or {}
return data.get("name", "pallas")
return "pallas"
_DEPLOY_NAME = _load_deployment_name()
# ── Default endpoints (only used when the provider section is missing) ───────
_ANTHROPIC_DEFAULT_API = "https://api.anthropic.com/v1"
_OPENAI_DEFAULT_API = "https://api.openai.com/v1"
_GENERIC_DEFAULT_API = "http://localhost:11434/v1"
# Populated by validate_llm_providers() at startup, refreshed on a TTL by
# _refresh_llm_status_if_stale() from inside the get_health MCP tool. The
# TTL re-probe is what makes the agent self-heal when an upstream LLM was
# briefly unreachable at boot — without it the cached error sticks until
# the process restarts. See docs/pallas.md § Health System.
_llm_status: dict[str, dict] = {}
_active_provider: str = ""
_llm_status_ts: float = 0.0 # time.monotonic() of last successful probe; 0 = never
_llm_refresh_lock = asyncio.Lock()
_LLM_HEALTH_TTL_S = float(os.environ.get("PALLAS_LLM_HEALTH_TTL", "60"))
# ── Config loading ───────────────────────────────────────────────────────────
def _load_dotenv() -> None:
"""Load .env file into os.environ (without overwriting existing vars)."""
env_path = _config_root() / ".env"
if not env_path.exists():
return
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if key and key not in os.environ:
os.environ[key] = value
def _expand_env(value: str) -> str:
"""Replace ${VAR} placeholders with environment variable values."""
return re.sub(
r"\$\{([^}]+)\}",
lambda m: os.environ.get(m.group(1), ""),
value,
)
def _expand_env_in_tree(obj: Any) -> Any:
"""Recursively expand ${ENV_VAR} placeholders inside a parsed YAML tree."""
if isinstance(obj, str):
return _expand_env(obj)
if isinstance(obj, dict):
return {k: _expand_env_in_tree(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_expand_env_in_tree(v) for v in obj]
return obj
def _load_config() -> tuple[dict, dict]:
"""Load fastagent config and secrets YAML from the working directory."""
root = _config_root()
config = yaml.safe_load((root / "fastagent.config.yaml").read_text()) or {}
secrets_path = root / "fastagent.secrets.yaml"
secrets = yaml.safe_load(secrets_path.read_text()) if secrets_path.exists() else {}
return config, secrets
def _merge_for_key_manager(config: dict, secrets: dict) -> dict:
"""Produce the merged, env-expanded dict that ProviderKeyManager expects.
``ProviderKeyManager.get_config_file_key`` looks for ``<provider>.api_key``
in a single flat dict. It does not apply ``${ENV_VAR}`` expansion itself,
so we pre-expand the whole tree.
"""
merged: dict = {}
for source in (config or {}, secrets or {}):
for provider_name, settings in (source or {}).items():
if isinstance(settings, dict):
target = merged.setdefault(provider_name, {})
if isinstance(target, dict):
target.update(settings)
return _expand_env_in_tree(merged)
# ── Per-provider probes ──────────────────────────────────────────────────────
async def _check_anthropic(
client: httpx.AsyncClient, api_key: str, model_id: str, base_url: str
) -> str | None:
"""Validate an Anthropic model via ``GET {base_url}/models/{model_id}``.
Works for both the public ``api.anthropic.com`` endpoint and for AWS
Bedrock Mantle's region-root model catalogue
(``https://bedrock-mantle.{region}.api.aws/v1/models/{wire_id}``).
The caller is responsible for passing the correct ``base_url`` and the
wire-name form of ``model_id`` for Mantle — see ``pallas.mantle_shims``.
"""
try:
resp = await client.get(
f"{base_url.rstrip('/')}/models/{model_id}",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
)
except Exception as exc:
return f"API unreachable ({type(exc).__name__})"
if resp.status_code == 200:
return None
if resp.status_code == 404:
return f"model '{model_id}' not found"
return f"API request failed ({resp.status_code})"
async def _list_openai_models(
client: httpx.AsyncClient, api_key: str, base_url: str
) -> tuple[str | None, list[str]]:
"""List models from an OpenAI-compatible API. Returns (error, model_ids)."""
try:
resp = await client.get(
f"{base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
except Exception as exc:
return f"API unreachable ({type(exc).__name__})", []
if resp.status_code != 200:
return f"API request failed ({resp.status_code})", []
try:
data = resp.json()
except Exception:
return "response was not valid JSON", []
models = [m["id"] for m in data.get("data", []) if isinstance(m, dict) and "id" in m]
return None, models
async def _check_generic(
client: httpx.AsyncClient, base_url: str
) -> str | None:
"""Status-code-only probe against ``{base_url}/models``.
The generic provider targets local/on-prem OpenAI-compatible servers
(llama.cpp, Ollama, vLLM, …) whose ``/v1/models`` payloads are not all
identical — llama.cpp mixes an Ollama-style ``models`` list with the
OpenAI ``data`` list, for example. We deliberately don't require the
configured model name to appear in the response because users hot-swap
models by name all the time; as long as the server is up and returns
200 for its catalogue we call it ok.
"""
try:
resp = await client.get(f"{base_url.rstrip('/')}/models")
except Exception as exc:
return f"API unreachable ({type(exc).__name__})"
if resp.status_code == 200:
return None
return f"API request failed ({resp.status_code})"
# ── Mantle helpers ───────────────────────────────────────────────────────────
def _mantle_root_from_anthropic_base(base: str) -> str:
"""Return the region root for a Mantle anthropic base_url.
Mantle publishes its inference path under ``/anthropic`` but the
catalogue (``GET /v1/models/{wire_id}``) lives at the region root.
Example:
``https://bedrock-mantle.us-east-1.api.aws/anthropic``
→ ``https://bedrock-mantle.us-east-1.api.aws``
Any other trailing paths are returned untouched.
"""
stripped = base.rstrip("/")
if stripped.endswith("/anthropic"):
return stripped[: -len("/anthropic")]
return stripped
# ── Preflight orchestration ──────────────────────────────────────────────────
async def _preflight_anthropic(
client: httpx.AsyncClient, config: dict, secrets: dict, active_model: str
) -> dict:
from fast_agent.core.exceptions import ProviderKeyError
from fast_agent.llm.provider_key_manager import ProviderKeyManager
from pallas.mantle_shims import MANTLE_WIRE_NAMES, is_mantle_base_url
merged = _merge_for_key_manager(config, secrets)
try:
api_key = ProviderKeyManager.get_api_key("anthropic", merged)
except ProviderKeyError as exc:
return {"status": "error", "message": str(exc)}
anthropic_base = _expand_env(
(config.get("anthropic", {}) or {}).get("base_url", "")
) or os.environ.get("ANTHROPIC_BASE_URL", "") or _ANTHROPIC_DEFAULT_API
if is_mantle_base_url(anthropic_base):
# Mantle hosts the model catalogue at the region root, not under
# /anthropic. Wire-name translation (claude-opus-4-7 →
# anthropic.claude-opus-4-7) keeps us consistent with mantle_shims.
probe_base = f"{_mantle_root_from_anthropic_base(anthropic_base)}/v1"
wire_id = MANTLE_WIRE_NAMES.get(active_model, active_model)
err = await _check_anthropic(client, api_key, wire_id, probe_base)
if err:
logger.warning("anthropic (mantle, %s): %s", anthropic_base, err)
return {"status": "error", "model": wire_id, "message": err}
logger.info("anthropic (mantle, %s): %s ready", anthropic_base, wire_id)
return {"status": "ok", "model": wire_id}
err = await _check_anthropic(client, api_key, active_model, anthropic_base)
if err:
logger.warning("anthropic (%s): %s", anthropic_base, err)
return {"status": "error", "model": active_model, "message": err}
logger.info("anthropic (%s): %s ready", anthropic_base, active_model)
return {"status": "ok", "model": active_model}
async def _preflight_openai(
client: httpx.AsyncClient, config: dict, secrets: dict, active_model: str
) -> dict:
from fast_agent.core.exceptions import ProviderKeyError
from fast_agent.llm.provider_key_manager import ProviderKeyManager
merged = _merge_for_key_manager(config, secrets)
try:
api_key = ProviderKeyManager.get_api_key("openai", merged)
except ProviderKeyError as exc:
return {"status": "error", "message": str(exc)}
openai_base = _expand_env(
(config.get("openai", {}) or {}).get("base_url", "")
) or os.environ.get("OPENAI_BASE_URL", "") or _OPENAI_DEFAULT_API
err, models = await _list_openai_models(client, api_key, openai_base)
if err:
logger.warning("openai (%s): %s", openai_base, err)
return {"status": "error", "message": err}
if active_model and active_model not in models:
label = ", ".join(models) if models else "none"
msg = f"model '{active_model}' not found (available: {label})"
logger.warning("openai (%s): %s", openai_base, msg)
return {"status": "error", "model": active_model, "message": msg}
logger.info("openai (%s): %s ready", openai_base, active_model or "(any)")
return {"status": "ok", "model": active_model, "models": models}
async def _preflight_generic(
client: httpx.AsyncClient, config: dict, secrets: dict, active_model: str
) -> dict:
# generic has a synthetic "ollama" key via ProviderKeyManager, so there's
# nothing to authenticate against — we just need the endpoint.
generic_base = _expand_env(
(config.get("generic", {}) or {}).get("base_url", "")
) or os.environ.get("GENERIC_BASE_URL", "") or _GENERIC_DEFAULT_API
err = await _check_generic(client, generic_base)
if err:
logger.warning("generic (%s): %s", generic_base, err)
return {"status": "error", "model": active_model, "message": err}
logger.info("generic (%s): %s ready", generic_base, active_model or "(any)")
return {"status": "ok", "model": active_model}
def _preflight_bedrock(config: dict, secrets: dict, active_model: str) -> dict:
"""Bedrock uses the AWS credential chain — no outbound HTTP here.
We report ``ok`` whenever any of the usual credential sources is present
(long-term bedrock key, explicit access key pair, or a nonempty AWS
profile). If nothing is set we mark it degraded so Daedalus shows the
operator *why* the first real request will fail; we don't actually call
STS or Bedrock ourselves.
"""
have_bearer = bool(os.environ.get("AWS_BEARER_TOKEN_BEDROCK"))
have_access_key = bool(os.environ.get("AWS_ACCESS_KEY_ID")) and bool(
os.environ.get("AWS_SECRET_ACCESS_KEY")
)
have_profile = bool(os.environ.get("AWS_PROFILE"))
creds_path = Path.home() / ".aws" / "credentials"
have_file = creds_path.exists()
if have_bearer or have_access_key or have_profile or have_file:
logger.info("bedrock: credentials resolvable (no preflight request issued)")
return {"status": "ok", "model": active_model}
msg = "no AWS credentials found (set AWS_BEARER_TOKEN_BEDROCK or configure AWS CLI)"
logger.warning("bedrock: %s", msg)
return {"status": "error", "model": active_model, "message": msg}
async def _probe_active_provider(
client: httpx.AsyncClient,
config: dict,
secrets: dict,
active_provider: str,
active_model: str,
) -> dict:
"""Run the preflight probe for a single provider and return its result dict.
Shared by the startup path (:func:`validate_llm_providers`) and the
runtime TTL refresh path (:func:`_refresh_llm_status_if_stale`). Keeps
the dispatch matrix from being duplicated.
"""
if active_provider == "anthropic":
return await _preflight_anthropic(client, config, secrets, active_model)
if active_provider == "openai":
return await _preflight_openai(client, config, secrets, active_model)
if active_provider == "generic":
return await _preflight_generic(client, config, secrets, active_model)
if active_provider == "bedrock":
return _preflight_bedrock(config, secrets, active_model)
# Known to fast-agent? Surface that gap explicitly rather than silently
# reporting "error" from an empty dict lookup later.
try:
from fast_agent.llm.provider_types import Provider
Provider(active_provider) # raises ValueError if unknown
msg = (
f"preflight for provider '{active_provider}' is not "
"implemented in pallas.health; LLM health will be "
"validated on first inference call"
)
logger.info("%s: %s", active_provider, msg)
return {"status": "ok", "model": active_model, "message": msg}
except ValueError:
msg = f"unknown provider '{active_provider}' in default_model"
logger.warning(msg)
return {"status": "error", "message": msg}
async def _refresh_llm_status_if_stale(timeout: float = 5.0) -> None:
"""Re-probe the active LLM provider when the cached status is older than
``PALLAS_LLM_HEALTH_TTL`` seconds. No-op on a fresh cache or when no
active provider has been resolved yet.
On any unexpected exception, the previous cache entry is left intact —
a real probe result is better signal than our own internal failure.
"""
global _llm_status_ts
if not _active_provider:
return
if time.monotonic() - _llm_status_ts < _LLM_HEALTH_TTL_S:
return
async with _llm_refresh_lock:
# Re-check under the lock — another coroutine may have refreshed
# while we were waiting.
if time.monotonic() - _llm_status_ts < _LLM_HEALTH_TTL_S:
return
try:
_load_dotenv()
config, secrets = _load_config()
default_model = config.get("default_model", "") or ""
if "." not in default_model:
return
active_provider, active_model = default_model.split(".", 1)
if active_provider != _active_provider:
# default_model was edited under us — defer to the next
# full validate_llm_providers() rather than guessing.
return
async with httpx.AsyncClient(timeout=timeout) as client:
result = await _probe_active_provider(
client, config, secrets, active_provider, active_model
)
_llm_status[_active_provider] = result
_llm_status_ts = time.monotonic()
except Exception as exc:
logger.warning(
"llm health re-probe failed (%s); keeping previous cache: %s",
_active_provider,
exc,
)
async def validate_llm_providers(timeout: float = 5.0) -> dict[str, dict]:
"""
Validate the configured LLM provider and populate the module-level cache
read by :func:`get_health` on every MCP ``get_health`` tool call.
Only the *active* provider (the one named by ``default_model``) is
preflighted — that's the one whose failure would actually break the
agent, and it keeps the startup surface small. Other provider sections
are ignored here even if they're configured.
Returns a dict keyed by provider name with validation results. Shape:
.. code-block:: python
{"anthropic": {"status": "ok", "model": "anthropic.claude-opus-4-7"}}
{"generic": {"status": "ok", "model": "Qwen3.5-..."}}
{"openai": {"status": "error", "message": "API request failed (401)"}}
{"unknown": {"status": "error", "message": "unknown provider 'foo'"}}
"""
global _active_provider, _llm_status_ts
_load_dotenv()
config, secrets = _load_config()
default_model = config.get("default_model", "") or ""
if "." not in default_model:
msg = (
f"default_model '{default_model}' is missing a provider prefix "
"(expected '<provider>.<model>')"
)
logger.warning(msg)
results = {"unknown": {"status": "error", "message": msg}}
_llm_status.clear()
_llm_status.update(results)
_active_provider = "unknown"
_llm_status_ts = time.monotonic()
return results
active_provider, active_model = default_model.split(".", 1)
async with httpx.AsyncClient(timeout=timeout) as client:
result = await _probe_active_provider(
client, config, secrets, active_provider, active_model
)
results: dict[str, dict] = {active_provider: result}
_llm_status.clear()
_llm_status.update(results)
_active_provider = active_provider
_llm_status_ts = time.monotonic()
try:
from pallas import metrics as _pallas_metrics
_pallas_metrics.llm_provider_up.labels(provider=active_provider).set(
1.0 if result.get("status") == "ok" else 0.0
)
except Exception:
pass
return results
# ── Downstream MCP server probing ────────────────────────────────────────────
async def check_downstream_health(
servers: dict[str, dict], timeout: float = 3.0
) -> dict:
"""
Probe downstream MCP servers and return aggregate health status.
Args:
servers: Mapping of server name to {"url": str, "headers": dict}.
Headers may contain ${ENV_VAR} placeholders which are expanded
before the request is sent.
timeout: Per-request timeout in seconds.
Returns:
{"status": "ok"|"degraded", "timestamp": "...", "message": "..."}
"""
_load_dotenv()
async def _probe(
client: httpx.AsyncClient, name: str, cfg: dict
) -> tuple[str, bool, str]:
url = cfg.get("url", "")
raw_headers = cfg.get("headers", {})
headers = {k: _expand_env(str(v)) for k, v in raw_headers.items()}
try:
common_headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
**headers,
}
resp = await client.post(
url,
headers=common_headers,
json={
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": f"{_DEPLOY_NAME}-health",
"version": "1.0.0",
},
},
},
)
if resp.status_code >= 400:
return name, False, f"HTTP {resp.status_code}"
# Tear down the session so we don't leak server-side state.
session_id = resp.headers.get("mcp-session-id")
if session_id:
try:
await client.delete(
url,
headers={**headers, "mcp-session-id": session_id},
)
except Exception:
pass # best-effort cleanup
return name, True, ""
except Exception as exc:
return name, False, type(exc).__name__
async with httpx.AsyncClient(timeout=timeout) as client:
results = await asyncio.gather(
*(_probe(client, name, cfg) for name, cfg in servers.items())
)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
failures = sorted(
(f"{name} ({reason})" if reason else name)
for name, ok, reason in results
if not ok
)
if not failures:
return {"status": "ok", "timestamp": now}
return {
"status": "degraded",
"timestamp": now,
"message": f"Unreachable: {', '.join(failures)}",
}
def register_health_tool(
mcp_server, servers: dict[str, dict], agent_name: str = "unknown"
) -> None:
"""Register a get_health MCP tool on the given FastMCP server instance.
``agent_name`` labels the Prometheus gauges populated as a side effect
of every probe (downstream reachability + overall status).
"""
from pallas import metrics as _pallas_metrics
@mcp_server.tool(
name="get_health",
description="Returns the health status of this agent and its downstream dependencies.",
)
async def get_health() -> str:
await _refresh_llm_status_if_stale()
result, per_server_ok = await _check_downstream_with_breakdown(servers)
# Include LLM provider status from startup preflight (active provider only)
llm_for_metrics: dict[str, str] = {}
if _active_provider:
active = _llm_status.get(_active_provider)
llm_for_metrics[_active_provider] = (
active.get("status", "error") if active else "error"
)
if active is None:
# Should be unreachable after the rewrite (validate_llm_providers
# always populates _llm_status for _active_provider). Keep a
# belt-and-braces path so a future refactor can't regress into
# silently reporting "error".
err_msg = (
f"LLM: {_active_provider}: provider not preflighted"
)
result["status"] = "degraded"
existing = result.get("message", "")
result["message"] = f"{existing}; {err_msg}" if existing else err_msg
elif active.get("status") != "ok":
err_msg = (
f"LLM: {_active_provider}: "
f"{active.get('message', 'unknown error')}"
)
result["status"] = "degraded"
existing = result.get("message", "")
result["message"] = f"{existing}; {err_msg}" if existing else err_msg
try:
_pallas_metrics.record_health_probe(
agent_name,
overall_status=result.get("status", "error"),
downstream=per_server_ok,
llm=llm_for_metrics,
)
except Exception:
pass
return json.dumps(result)
async def _check_downstream_with_breakdown(
servers: dict[str, dict], timeout: float = 3.0
) -> tuple[dict, dict[str, bool]]:
"""Like :func:`check_downstream_health` but also returns per-server ok flags.
Kept as a thin wrapper so external callers of ``check_downstream_health``
(if any) stay on the original dict-only contract.
"""
_load_dotenv()
async def _probe(
client: httpx.AsyncClient, name: str, cfg: dict
) -> tuple[str, bool, str]:
url = cfg.get("url", "")
raw_headers = cfg.get("headers", {})
headers = {k: _expand_env(str(v)) for k, v in raw_headers.items()}
try:
common_headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
**headers,
}
resp = await client.post(
url,
headers=common_headers,
json={
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": f"{_DEPLOY_NAME}-health",
"version": "1.0.0",
},
},
},
)
if resp.status_code >= 400:
return name, False, f"HTTP {resp.status_code}"
session_id = resp.headers.get("mcp-session-id")
if session_id:
try:
await client.delete(
url,
headers={**headers, "mcp-session-id": session_id},
)
except Exception:
pass
return name, True, ""
except Exception as exc:
return name, False, type(exc).__name__
async with httpx.AsyncClient(timeout=timeout) as client:
results = await asyncio.gather(
*(_probe(client, name, cfg) for name, cfg in servers.items())
)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
per_server_ok = {name: ok for name, ok, _ in results}
failures = sorted(
(f"{name} ({reason})" if reason else name)
for name, ok, reason in results
if not ok
)
if not failures:
return {"status": "ok", "timestamp": now}, per_server_ok
return (
{
"status": "degraded",
"timestamp": now,
"message": f"Unreachable: {', '.join(failures)}",
},
per_server_ok,
)