171 lines
5.9 KiB
Python
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:18001"
|
|
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"]
|