Fix async genesis hash bug, add 7 missing methods, document exports

- Fix AsyncElydoraClient _prev_chain_hash from empty string to GENESIS_CHAIN_HASH
- Add list_agents, unfreeze_agent, delete_agent, get_me, issue_token, download_export, health
- Add methods to both sync and async clients
- Add corresponding response types to types.py
- Export new types from __init__.py
- Document all previously undocumented exports in README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-01 15:53:53 -05:00
1 parent 3c77b4a31d
commit eef18a8520
5 files changed
+214 -1

No files matched your search

+10
View File
@@ -27,6 +27,8 @@ from .types import (
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
DeleteAgentResponse,
SubmitOperationResponse,
GetOperationResponse,
VerifyOperationResponse,
@@ -39,6 +41,9 @@ from .types import (
JWKSResponse,
AuthRegisterResponse,
AuthLoginResponse,
GetMeResponse,
IssueTokenResponse,
HealthResponse,
)
__version__ = "1.0.0"
@@ -71,6 +76,8 @@ __all__ = [
"RegisterAgentRequest",
"RegisterAgentResponse",
"GetAgentResponse",
"ListAgentsResponse",
"DeleteAgentResponse",
"SubmitOperationResponse",
"GetOperationResponse",
"VerifyOperationResponse",
@@ -83,4 +90,7 @@ __all__ = [
"JWKSResponse",
"AuthRegisterResponse",
"AuthLoginResponse",
"GetMeResponse",
"IssueTokenResponse",
"HealthResponse",
]
+55 -1
View File
@@ -15,12 +15,17 @@ from .types import (
AuthLoginResponse,
AuthRegisterResponse,
CreateExportResponse,
DeleteAgentResponse,
EOR,
GetAgentResponse,
GetEpochResponse,
GetExportResponse,
GetMeResponse,
GetOperationResponse,
HealthResponse,
IssueTokenResponse,
JWKSResponse,
ListAgentsResponse,
ListEpochsResponse,
ListExportsResponse,
RegisterAgentRequest,
@@ -30,6 +35,10 @@ from .types import (
)
from .utils import generate_nonce, generate_uuidv7
# The genesis chain hash: base64url encoding of 32 zero bytes.
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
class AsyncElydoraClient:
"""Asynchronous client for the Elydora API.
@@ -63,7 +72,7 @@ class AsyncElydoraClient:
self.max_retries = max_retries
self.token = token
self._prev_chain_hash = ""
self._prev_chain_hash = GENESIS_CHAIN_HASH
self._kid = ""
self._session: Optional[aiohttp.ClientSession] = None
@@ -186,6 +195,17 @@ class AsyncElydoraClient:
) as resp:
return await AsyncElydoraClient._handle_response(resp)
async def get_me(self) -> GetMeResponse:
"""Get the current user's profile."""
return await self._request("GET", "/v1/auth/me")
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse:
"""Issue an API token with an optional TTL."""
body: Dict[str, Any] = {}
if ttl_seconds is not None:
body["ttl_seconds"] = ttl_seconds
return await self._request("POST", "/v1/auth/token", json_body=body)
# -----------------------------------------------------------------
# Agent management
# -----------------------------------------------------------------
@@ -202,6 +222,18 @@ class AsyncElydoraClient:
"""Freeze an agent."""
await self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
async def list_agents(self) -> ListAgentsResponse:
"""List all agents for the organization."""
return await self._request("GET", "/v1/agents")
async def unfreeze_agent(self, agent_id: str, reason: str) -> None:
"""Unfreeze an agent."""
await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
"""Delete an agent."""
return await self._request("DELETE", f"/v1/agents/{agent_id}")
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
"""Revoke an agent's key."""
await self._request(
@@ -347,6 +379,17 @@ class AsyncElydoraClient:
"""Retrieve export status and download URL."""
return await self._request("GET", f"/v1/exports/{export_id}")
async def download_export(self, export_id: str) -> bytes:
"""Download an export file as raw bytes."""
url = f"{self.base_url}/v1/exports/{export_id}/download"
session = await self._get_session()
async with session.get(
url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status >= 400:
await self._handle_response(resp)
return await resp.read()
# -----------------------------------------------------------------
# JWKS
# -----------------------------------------------------------------
@@ -357,3 +400,14 @@ class AsyncElydoraClient:
session = await self._get_session()
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
return await self._handle_response(resp)
# -----------------------------------------------------------------
# Health
# -----------------------------------------------------------------
async def health(self) -> HealthResponse:
"""Check API health (public, no auth required)."""
url = f"{self.base_url}/v1/health"
session = await self._get_session()
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
return await self._handle_response(resp)
+46
View File
@@ -14,12 +14,17 @@ from .types import (
AuthLoginResponse,
AuthRegisterResponse,
CreateExportResponse,
DeleteAgentResponse,
EOR,
GetAgentResponse,
GetEpochResponse,
GetExportResponse,
GetMeResponse,
GetOperationResponse,
HealthResponse,
IssueTokenResponse,
JWKSResponse,
ListAgentsResponse,
ListEpochsResponse,
ListExportsResponse,
RegisterAgentRequest,
@@ -181,6 +186,17 @@ class ElydoraClient:
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
return ElydoraClient._handle_response(resp)
def get_me(self) -> GetMeResponse:
"""Get the current user's profile."""
return self._request("GET", "/v1/auth/me")
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse:
"""Issue an API token with an optional TTL."""
body: Dict[str, Any] = {}
if ttl_seconds is not None:
body["ttl_seconds"] = ttl_seconds
return self._request("POST", "/v1/auth/token", json_body=body)
# -----------------------------------------------------------------
# Agent management
# -----------------------------------------------------------------
@@ -197,6 +213,18 @@ class ElydoraClient:
"""Freeze an agent."""
self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
def list_agents(self) -> ListAgentsResponse:
"""List all agents for the organization."""
return self._request("GET", "/v1/agents")
def unfreeze_agent(self, agent_id: str, reason: str) -> None:
"""Unfreeze an agent."""
self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
"""Delete an agent."""
return self._request("DELETE", f"/v1/agents/{agent_id}")
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
"""Revoke an agent's key."""
self._request(
@@ -344,6 +372,14 @@ class ElydoraClient:
"""Retrieve export status and download URL."""
return self._request("GET", f"/v1/exports/{export_id}")
def download_export(self, export_id: str) -> bytes:
"""Download an export file as raw bytes."""
url = f"{self.base_url}/v1/exports/{export_id}/download"
resp = self._session.get(url, headers=self._headers(), timeout=30)
if resp.status_code >= 400:
self._handle_response(resp)
return resp.content
# -----------------------------------------------------------------
# JWKS
# -----------------------------------------------------------------
@@ -353,3 +389,13 @@ class ElydoraClient:
url = f"{self.base_url}/.well-known/elydora/jwks.json"
resp = self._session.get(url, timeout=30)
return self._handle_response(resp)
# -----------------------------------------------------------------
# Health
# -----------------------------------------------------------------
def health(self) -> HealthResponse:
"""Check API health (public, no auth required)."""
url = f"{self.base_url}/v1/health"
resp = self._session.get(url, timeout=30)
return self._handle_response(resp)
+24
View File
@@ -291,3 +291,27 @@ class AuthRegisterResponse(TypedDict):
class AuthLoginResponse(TypedDict):
user: User
token: str
class ListAgentsResponse(TypedDict):
agents: List[Agent]
class DeleteAgentResponse(TypedDict):
deleted: bool
class GetMeResponse(TypedDict):
user: User
class IssueTokenResponse(TypedDict):
token: str
expires_at: Optional[int]
class HealthResponse(TypedDict):
status: str
version: str
protocol_version: str
timestamp: int