fischer-agentkit/src/agentkit/cli/admin_client.py

171 lines
5.9 KiB
Python

"""HTTP client for admin API calls with JWT or API key auth.
This module is intentionally synchronous (uses :mod:`httpx`'s sync
``Client``) so that Typer command callbacks can call it directly without
``asyncio.run`` boilerplate. The client reads credentials from (in
priority order): explicit args, environment variables, or the config
file at ``~/.agentkit/admin_config.yaml``.
"""
from __future__ import annotations
import os
from pathlib import Path
import httpx
import yaml
DEFAULT_SERVER_URL = "http://localhost:8001"
DEFAULT_CONFIG_PATH = Path.home() / ".agentkit" / "admin_config.yaml"
DEFAULT_TIMEOUT = 30.0
class AdminHttpClient:
"""Synchronous HTTP client for admin API calls.
Authentication is provided via either a JWT ``access_token`` (sent
as ``Authorization: Bearer <token>``) or an API key (sent as
``X-API-Key: <key>``). When neither is supplied, requests are made
unauthenticated (the server will return 401/403).
"""
def __init__(
self,
server_url: str,
token: str | None = None,
api_key: str | None = None,
) -> None:
self._base_url = server_url.rstrip("/")
self._token = token
self._api_key = api_key
# ------------------------------------------------------------------
# Construction helpers
# ------------------------------------------------------------------
@classmethod
def from_config(
cls,
server_url: str | None = None,
token: str | None = None,
api_key: str | None = None,
config_path: Path | str | None = None,
) -> AdminHttpClient:
"""Build a client from args, env vars, or config file.
Priority (highest first):
1. Explicit kwargs (``server_url``, ``token``, ``api_key``)
2. Environment variables: ``AGENTKIT_SERVER_URL``,
``AGENTKIT_ADMIN_TOKEN``, ``AGENTKIT_ADMIN_API_KEY``
3. Config file at ``~/.agentkit/admin_config.yaml``
4. Hard-coded defaults (server URL only)
"""
path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH
file_cfg: dict[str, object] = {}
if path.exists():
try:
with path.open(encoding="utf-8") as f:
loaded = yaml.safe_load(f)
if isinstance(loaded, dict):
file_cfg = loaded
except (yaml.YAMLError, OSError):
# Corrupt or unreadable config — fall back to defaults.
file_cfg = {}
resolved_url = (
server_url
or os.environ.get("AGENTKIT_SERVER_URL")
or file_cfg.get("server_url")
or DEFAULT_SERVER_URL
)
resolved_token = token or os.environ.get("AGENTKIT_ADMIN_TOKEN") or file_cfg.get("token")
resolved_key = (
api_key or os.environ.get("AGENTKIT_ADMIN_API_KEY") or file_cfg.get("api_key")
)
return cls(
server_url=resolved_url,
token=resolved_token,
api_key=resolved_key,
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
elif self._api_key:
headers["X-API-Key"] = self._api_key
return headers
def _request(
self,
method: str,
path: str,
*,
params: dict[str, object] | None = None,
json: dict[str, object] | None = None,
timeout: float = DEFAULT_TIMEOUT,
) -> httpx.Response:
url = f"{self._base_url}{path}"
with httpx.Client(timeout=timeout) as client:
resp = client.request(
method,
url,
params=params,
json=json,
headers=self._headers(),
)
resp.raise_for_status()
return resp
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def base_url(self) -> str:
return self._base_url
def get(self, path: str, params: dict[str, object] | None = None) -> object:
resp = self._request("GET", path, params=params)
return resp.json()
def get_text(self, path: str, params: dict[str, object] | None = None) -> str:
"""GET returning response text (for CSV exports)."""
resp = self._request("GET", path, params=params)
return resp.text
def post(self, path: str, json: dict[str, object] | None = None) -> object:
resp = self._request("POST", path, json=json)
return resp.json()
def put(self, path: str, json: dict[str, object] | None = None) -> object:
resp = self._request("PUT", path, json=json)
return resp.json()
def patch(self, path: str, json: dict[str, object] | None = None) -> object:
resp = self._request("PATCH", path, json=json)
return resp.json()
def delete(self, path: str, params: dict[str, object] | None = None) -> object:
resp = self._request("DELETE", path, params=params)
return resp.json()
def login(self, username: str, password: str) -> str:
"""Authenticate with username/password and return an access token.
Calls ``POST /api/v1/auth/login``. Raises
:class:`httpx.HTTPStatusError` on non-2xx responses (e.g. 401
for invalid credentials).
"""
with httpx.Client(timeout=10.0) as client:
resp = client.post(
f"{self._base_url}/api/v1/auth/login",
json={"username": username, "password": password},
)
resp.raise_for_status()
data = resp.json()
return data["access_token"]