mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 12:06:29 +03:00
Add litellm/, an operational tool skill for the LiteLLM AI gateway (proxy) and Python SDK, in the same vein as the vllm and llama-cpp engine skills. Contents: - SKILL.md: operating contract, operating loop, verification boundaries, and hard boundaries; concise core sections routing depth to references - README.md: human-facing install/use guide with required sections - references/: nine dated, source-indexed references (source index, quickstart + SDK, config & routing, keys/teams/budgets/spend, caching & guardrails, observability & logging, deployment, security & public hosting, troubleshooting), researched against litellm 1.97.0 (2026-08-22) including a live proxy probe of the health endpoints - scripts/litellm-health: read-only GET-only probe (liveliness, readiness, /v1/models, /model/info); stdlib-only Python 3.9+, --json, --help without a server - tests/test_litellm_health.py: 18 deterministic tests against a local stub HTTP server, including the observed-traffic GET-only contract - templates/proxy-config-record.md and proxy-deployment.md: fillable records; the config record is the rollback unit - evals/evals.json: schema_version 1, six output-quality cases Also regenerates tracked catalog artifacts (.claude-plugin/marketplace.json, .codex-plugin/plugin.json, llms.txt) and adds the root README catalog entry plus the skill-triggers.md index row. AI assistance: authored with AI assistance (Factory Droid) under human direction; facts verified against litellm 1.97.0 and official docs dated 2026-08-22. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
261 lines
8.5 KiB
Python
Executable File
261 lines
8.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""litellm-health - read-only probe for a LiteLLM AI gateway (proxy server).
|
|
|
|
Answers, over HTTP(S), four operational questions about a running proxy and
|
|
nothing else: Is it alive? Is it ready (database reachable)? Which model
|
|
aliases does this key see? How many deployments are registered? The probe
|
|
issues GET requests only, never writes files, never mutates proxy state, and
|
|
never sends data anywhere except the gateway you point it at.
|
|
|
|
Standard library only; --help works with no gateway running. Output is
|
|
bounded JSON with --json or short human-readable lines otherwise.
|
|
|
|
Exit codes: 0 all checks passed, 1 issues found or a fatal error,
|
|
2 usage error, 124 timeout.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
PROBE_NAME = "litellm-health"
|
|
DEFAULT_URL = "http://127.0.0.1:4000"
|
|
DEFAULT_TIMEOUT = 10
|
|
MODEL_INFO_BOUND_BYTES = 64 * 1024
|
|
|
|
CHECKS = (
|
|
"health",
|
|
"readiness",
|
|
"models",
|
|
"model_info",
|
|
)
|
|
|
|
# Checks whose routes sit behind master-key/virtual-key authentication.
|
|
KEYED_CHECKS = frozenset({"models", "model_info"})
|
|
|
|
|
|
class ProbeTimeout(Exception):
|
|
"""A single request exceeded its time budget."""
|
|
|
|
|
|
def parse_args(argv: List[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog=PROBE_NAME,
|
|
description=(
|
|
"Read-only probe for a running LiteLLM AI gateway: liveliness, "
|
|
"readiness, visible model aliases, and bounded deployment info."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--url",
|
|
default=DEFAULT_URL,
|
|
help=f"Base URL of the LiteLLM gateway (default: {DEFAULT_URL})",
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="append",
|
|
choices=list(CHECKS),
|
|
help="Run only these checks; repeat the flag for several (default: all)",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="Emit bounded JSON output")
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=DEFAULT_TIMEOUT,
|
|
help=f"Seconds allowed per request (default: {DEFAULT_TIMEOUT})",
|
|
)
|
|
parser.add_argument(
|
|
"--key",
|
|
default=None,
|
|
help="Bearer key (master or virtual) needed by models/model_info",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
class Gateway:
|
|
"""Thin GET-only view of one gateway's HTTP surface."""
|
|
|
|
def __init__(self, base_url: str, timeout: float, key: Optional[str]):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.timeout = timeout
|
|
self.key = key
|
|
|
|
def get(self, path: str) -> tuple[int, bytes]:
|
|
url = urllib.parse.urljoin(self.base_url + "/", path.lstrip("/"))
|
|
headers = {"Authorization": f"Bearer {self.key}"} if self.key else {}
|
|
request = urllib.request.Request(url, method="GET", headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
return response.status, response.read()
|
|
except (socket.timeout, TimeoutError) as error:
|
|
raise ProbeTimeout(
|
|
f"request timed out after {self.timeout}s"
|
|
) from error
|
|
except urllib.error.HTTPError as error:
|
|
return error.code, error.read()
|
|
except urllib.error.URLError as error:
|
|
raise RuntimeError(f"connection failed: {error.reason}") from error
|
|
|
|
|
|
def _auth_hint(result: Dict[str, Any], status: int) -> Dict[str, Any]:
|
|
if status in (401, 403):
|
|
result["hint"] = "route requires --key <master-or-virtual-key>"
|
|
return result
|
|
|
|
|
|
def check_health(gateway: Gateway) -> Dict[str, Any]:
|
|
status, body = gateway.get("/health/liveliness")
|
|
text = body.decode("utf-8", errors="replace")
|
|
# A live 1.97.0 gateway answers this unauthenticated route with 200 "I'm alive!".
|
|
ok = status == 200 and "alive" in text.lower()
|
|
return {"name": "health", "status_code": status, "ok": ok, "body": text[:200]}
|
|
|
|
|
|
def check_readiness(gateway: Gateway) -> Dict[str, Any]:
|
|
status, body = gateway.get("/health/readiness")
|
|
result: Dict[str, Any] = {
|
|
"name": "readiness",
|
|
"status_code": status,
|
|
"ok": status == 200,
|
|
}
|
|
if status == 200:
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
if isinstance(payload, dict):
|
|
result["status"] = payload.get("status", "")
|
|
result["db"] = payload.get("db", "")
|
|
except (ValueError, TypeError):
|
|
pass
|
|
elif status == 503:
|
|
result["hint"] = "503 usually means the configured database is unreachable"
|
|
return result
|
|
|
|
|
|
def check_models(gateway: Gateway) -> Dict[str, Any]:
|
|
status, body = gateway.get("/v1/models")
|
|
aliases: List[str] = []
|
|
if status == 200:
|
|
try:
|
|
payload = json.loads(body.decode("utf-8"))
|
|
entries = payload.get("data", []) if isinstance(payload, dict) else []
|
|
aliases = [
|
|
str(entry["id"])
|
|
for entry in entries
|
|
if isinstance(entry, dict) and entry.get("id")
|
|
]
|
|
except (ValueError, TypeError, KeyError):
|
|
aliases = []
|
|
result = {
|
|
"name": "models",
|
|
"status_code": status,
|
|
"ok": bool(aliases),
|
|
"model_ids": aliases[:20],
|
|
}
|
|
return _auth_hint(result, status)
|
|
|
|
|
|
def check_model_info(gateway: Gateway) -> Dict[str, Any]:
|
|
status, raw = gateway.get("/model/info")
|
|
raw = raw[: MODEL_INFO_BOUND_BYTES + 1]
|
|
truncated = len(raw) > MODEL_INFO_BOUND_BYTES
|
|
deployments = 0
|
|
if status == 200:
|
|
try:
|
|
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
|
if isinstance(payload, list):
|
|
deployments = len(payload)
|
|
elif isinstance(payload, dict) and isinstance(payload.get("data"), list):
|
|
deployments = len(payload["data"])
|
|
except (ValueError, TypeError):
|
|
deployments = 0
|
|
result = {
|
|
"name": "model_info",
|
|
"status_code": status,
|
|
"ok": status == 200,
|
|
"deployment_count": deployments,
|
|
"truncated": truncated,
|
|
}
|
|
return _auth_hint(result, status)
|
|
|
|
|
|
CHECK_RUNNERS: Dict[str, Callable[[Gateway], Dict[str, Any]]] = {
|
|
"health": check_health,
|
|
"readiness": check_readiness,
|
|
"models": check_models,
|
|
"model_info": check_model_info,
|
|
}
|
|
|
|
|
|
def run_checks(
|
|
gateway: Gateway, selected: List[str], key: Optional[str]
|
|
) -> List[Dict[str, Any]]:
|
|
results: List[Dict[str, Any]] = []
|
|
for name in selected:
|
|
if name in KEYED_CHECKS and not key:
|
|
results.append(
|
|
{
|
|
"name": name,
|
|
"ok": False,
|
|
"skipped_missing_key": True,
|
|
"hint": "requires --key <master-or-virtual-key>",
|
|
}
|
|
)
|
|
continue
|
|
try:
|
|
results.append(CHECK_RUNNERS[name](gateway))
|
|
except ProbeTimeout as error:
|
|
results.append(
|
|
{"name": name, "ok": False, "timed_out": True, "error": str(error)}
|
|
)
|
|
except RuntimeError as error:
|
|
results.append({"name": name, "ok": False, "error": str(error)})
|
|
return results
|
|
|
|
|
|
def render_text(results: List[Dict[str, Any]]) -> None:
|
|
for result in results:
|
|
verdict = "OK" if result.get("ok") else "FAIL"
|
|
detail = result.get("error") or result.get("status_code") or ""
|
|
print(f"[{verdict}] {result.get('name')} {detail}")
|
|
for alias in result.get("model_ids", []):
|
|
print(f" model: {alias}")
|
|
hint = result.get("hint")
|
|
if not result.get("ok") and hint:
|
|
print(f" hint: {hint}")
|
|
|
|
|
|
def exit_code(results: List[Dict[str, Any]]) -> int:
|
|
if any(result.get("timed_out") for result in results):
|
|
return 124
|
|
if any(not result.get("ok") for result in results):
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def main(argv: List[str]) -> int:
|
|
try:
|
|
args = parse_args(argv)
|
|
except SystemExit as error:
|
|
return int(error.code) if error.code is not None else 2
|
|
|
|
if args.timeout <= 0:
|
|
print("error: --timeout must be positive", file=sys.stderr)
|
|
return 2
|
|
|
|
gateway = Gateway(args.url, args.timeout, args.key)
|
|
results = run_checks(gateway, args.check or list(CHECKS), args.key)
|
|
|
|
if args.json:
|
|
print(json.dumps({"url": gateway.base_url, "checks": results}, indent=2))
|
|
else:
|
|
render_text(results)
|
|
return exit_code(results)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|