"""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 ``) or an API key (sent as ``X-API-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"]