feat(phase-2): workstream 2 — Python SDK (sentryagent-idp)

Sync (requests) and async (httpx) clients with identical API surface
to the Node.js SDK.

Delivered:
- pyproject.toml — python>=3.9, hatchling build, mypy strict config
- types.py — all 14-endpoint request/response dataclasses
- errors.py — AgentIdPError with from_api_error, from_oauth2_error, network_error
- token_manager.py — thread-safe sync TokenManager, 60s refresh buffer
- async_token_manager.py — asyncio-safe AsyncTokenManager (httpx)
- _request.py — shared sync/async request helper (DRY)
- services/agents.py — AgentRegistryClient + AsyncAgentRegistryClient (5 methods each)
- services/credentials.py — CredentialClient + AsyncCredentialClient (4 methods each)
- services/token.py — TokenClient + AsyncTokenClient (introspect + revoke)
- services/audit.py — AuditClient + AsyncAuditClient (query + get)
- client.py — AgentIdPClient + AsyncAgentIdPClient
- __init__.py — barrel exports
- README.md — installation, quick start, full API reference

QA gates:
- mypy --strict: 0 errors (12 source files)
- pytest: 57/57 passed
- Coverage: 90.83% (required >= 80%)
- All 14 endpoints covered (sync + async)
- AgentIdPError raised on all failure paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:11:27 +00:00
parent 90a4addb21
commit c93562e685
38 changed files with 2645 additions and 13 deletions

BIN
sdk-python/.coverage Normal file

Binary file not shown.

214
sdk-python/README.md Normal file
View File

@@ -0,0 +1,214 @@
# sentryagent-idp
Python SDK for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — the open-source Identity Provider for AI agents.
Handles token acquisition and caching automatically. Covers all 14 AgentIdP API endpoints. Provides both synchronous (`requests`) and asynchronous (`httpx`) clients.
---
## Requirements
- Python 3.9 or later
- A running AgentIdP server
- A registered agent with a valid `client_id` and `client_secret`
---
## Installation
```bash
pip install sentryagent-idp
```
---
## Quick start
### Synchronous
```python
from sentryagent_idp import AgentIdPClient
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id", # the agent's agentId (UUID)
client_secret="your-client-secret",
)
# List agents — token is acquired and cached automatically
result = client.agents.list_agents()
print(result.data)
```
### Asynchronous
```python
import asyncio
from sentryagent_idp import AsyncAgentIdPClient
async def main() -> None:
client = AsyncAgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
result = await client.agents.list_agents()
print(result.data)
asyncio.run(main())
```
---
## Configuration
```python
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
client_secret="your-client-secret",
# Optional: restrict scopes. Defaults to all four.
scopes=["agents:read", "tokens:read"],
)
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `base_url` | Yes | Base URL of the AgentIdP server |
| `client_id` | Yes | The agent's `agentId` (UUID) |
| `client_secret` | Yes | The credential secret |
| `scopes` | No | OAuth 2.0 scopes to request. Defaults to all four. |
---
## Token management
The SDK fetches and caches access tokens automatically. A new token is requested when the cached token is within 60 seconds of expiry.
```python
# Force a fresh token on the next request (e.g. after rotating credentials)
client.clear_token_cache()
```
---
## Agent Registry
```python
from sentryagent_idp import RegisterAgentRequest, UpdateAgentRequest
# Register a new agent
agent = client.agents.register_agent(RegisterAgentRequest(
email="classifier-v2@myorg.ai",
agent_type="classifier",
version="2.0.0",
capabilities=["text-classification", "sentiment-analysis"],
owner="platform-team",
deployment_env="production",
))
print(agent.agent_id) # UUID assigned by AgentIdP
# List agents
result = client.agents.list_agents(status="active", limit=20)
# Get a single agent
agent = client.agents.get_agent("a1b2c3d4-...")
# Update an agent
updated = client.agents.update_agent("a1b2c3d4-...", UpdateAgentRequest(
version="2.1.0",
capabilities=["text-classification", "sentiment-analysis", "intent-detection"],
))
# Decommission (irreversible)
client.agents.decommission_agent("a1b2c3d4-...")
```
---
## Credentials
```python
# Generate a credential — client_secret shown once, store it securely
cred = client.credentials.generate_credential("a1b2c3d4-...")
print(cred.client_secret) # only available here
# List credentials
result = client.credentials.list_credentials("a1b2c3d4-...")
# Rotate — same credential_id, new secret, old secret immediately invalid
rotated = client.credentials.rotate_credential("a1b2c3d4-...", "cred-uuid")
print(rotated.client_secret) # new secret — store immediately
# Revoke
client.credentials.revoke_credential("a1b2c3d4-...", "cred-uuid")
```
---
## Token operations
```python
# Introspect — check whether a token is active
result = client.tokens.introspect_token(some_token)
if result.active:
print(f"Token valid, expires at {result.exp}")
else:
print("Token is expired or revoked")
# Revoke — immediately invalidates the token
client.tokens.revoke_token(some_token)
```
---
## Audit log
```python
# Query audit events
result = client.audit.query_audit_log(
agent_id="a1b2c3d4-...",
action="token.issued",
outcome="success",
from_date="2026-03-01T00:00:00Z",
to_date="2026-03-31T23:59:59Z",
limit=50,
)
# Get a single event
event = client.audit.get_audit_event("event-uuid")
```
---
## Error handling
All API errors are raised as `AgentIdPError`:
```python
from sentryagent_idp import AgentIdPClient, AgentIdPError
try:
client.agents.get_agent("non-existent-id")
except AgentIdPError as err:
print(err.code) # e.g. "AgentNotFoundError"
print(err.http_status) # e.g. 404
print(str(err)) # human-readable description
```
---
## Available scopes
| Scope | What it allows |
|-------|----------------|
| `agents:read` | Read agent records |
| `agents:write` | Create, update, decommission agents |
| `tokens:read` | Introspect tokens |
| `audit:read` | Query audit logs |
---
## License
Apache 2.0 — see `LICENSE` in the repository root.

61
sdk-python/pyproject.toml Normal file
View File

@@ -0,0 +1,61 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "sentryagent-idp"
version = "1.0.0"
description = "Python SDK for the SentryAgent.ai AgentIdP — Identity Provider for AI agents"
readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.9"
keywords = ["ai", "agents", "identity", "oauth2", "agntcy"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Security",
"Typing :: Typed",
]
dependencies = [
"requests>=2.28.0",
"httpx>=0.25.0",
]
[project.optional-dependencies]
dev = [
"mypy>=1.8.0",
"pytest>=7.4.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"respx>=0.20.0",
"responses>=0.24.0",
"types-requests>=2.31.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/sentryagent_idp"]
[tool.mypy]
strict = true
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_any_generics = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--cov=src/sentryagent_idp --cov-report=term-missing --cov-fail-under=80"

View File

@@ -0,0 +1,82 @@
"""
SentryAgent.ai AgentIdP Python SDK.
Provides synchronous and asynchronous clients for the AgentIdP API.
Example (sync)::
from sentryagent_idp import AgentIdPClient
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
result = client.agents.list_agents()
Example (async)::
from sentryagent_idp import AsyncAgentIdPClient
client = AsyncAgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
result = await client.agents.list_agents()
"""
from .client import AgentIdPClient, AsyncAgentIdPClient
from .errors import AgentIdPError
from .token_manager import TokenManager
from .async_token_manager import AsyncTokenManager
from .types import (
Agent,
AgentStatus,
AgentType,
AuditAction,
AuditEvent,
AuditOutcome,
Credential,
CredentialStatus,
CredentialWithSecret,
DeploymentEnv,
IntrospectResponse,
OAuthScope,
PaginatedAgents,
PaginatedAuditEvents,
PaginatedCredentials,
RegisterAgentRequest,
TokenResponse,
UpdateAgentRequest,
)
__all__ = [
# Clients
"AgentIdPClient",
"AsyncAgentIdPClient",
# Errors
"AgentIdPError",
# Token managers (for advanced use)
"TokenManager",
"AsyncTokenManager",
# Types
"Agent",
"AgentStatus",
"AgentType",
"AuditAction",
"AuditEvent",
"AuditOutcome",
"Credential",
"CredentialStatus",
"CredentialWithSecret",
"DeploymentEnv",
"IntrospectResponse",
"OAuthScope",
"PaginatedAgents",
"PaginatedAuditEvents",
"PaginatedCredentials",
"RegisterAgentRequest",
"TokenResponse",
"UpdateAgentRequest",
]

View File

@@ -0,0 +1,127 @@
"""
Internal HTTP request helpers shared by all service clients.
Not part of the public API.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import requests
import httpx
from .errors import AgentIdPError
def sync_request(
method: str,
base_url: str,
path: str,
token: str,
body: Optional[Any] = None,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""
Make a synchronous authenticated JSON request to the AgentIdP API.
Args:
method: HTTP method (GET, POST, PATCH, DELETE).
base_url: AgentIdP base URL.
path: API path (e.g. ``/api/v1/agents``).
token: Bearer access token.
body: Optional request body (serialised as JSON).
params: Optional query parameters (None values are excluded).
Returns:
Parsed JSON response body, or None for 204 responses.
Raises:
AgentIdPError: On any API or network failure.
"""
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
filtered_params: Optional[Dict[str, str]] = (
{k: str(v) for k, v in params.items() if v is not None}
if params
else None
)
try:
response = requests.request(
method=method,
url=url,
headers=headers,
json=body,
params=filtered_params,
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
if response.status_code == 204:
return None
resp_body = response.json() if response.content else {}
if not response.ok:
raise AgentIdPError.from_api_error(resp_body, response.status_code)
return resp_body
async def async_request(
method: str,
base_url: str,
path: str,
token: str,
body: Optional[Any] = None,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""
Make an asynchronous authenticated JSON request to the AgentIdP API.
Args:
method: HTTP method (GET, POST, PATCH, DELETE).
base_url: AgentIdP base URL.
path: API path.
token: Bearer access token.
body: Optional request body (serialised as JSON).
params: Optional query parameters (None values are excluded).
Returns:
Parsed JSON response body, or None for 204 responses.
Raises:
AgentIdPError: On any API or network failure.
"""
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
filtered_params: Optional[Dict[str, str]] = (
{k: str(v) for k, v in params.items() if v is not None}
if params
else None
)
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.request(
method=method,
url=url,
headers=headers,
json=body,
params=filtered_params,
)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
if response.status_code == 204:
return None
resp_body = response.json() if response.content else {}
if not response.is_success:
raise AgentIdPError.from_api_error(resp_body, response.status_code)
return resp_body

View File

@@ -0,0 +1,117 @@
"""
Asynchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
Uses httpx for async HTTP. Tokens are re-issued automatically when within 60 seconds of expiry.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import Optional
import httpx
from .errors import AgentIdPError
from .types import TokenResponse
#: Seconds before expiry at which a token refresh is triggered.
REFRESH_BUFFER_SECONDS = 60
@dataclass
class _CachedToken:
access_token: str
expires_at: float # Unix timestamp (seconds)
class AsyncTokenManager:
"""
Asyncio-safe asynchronous token manager.
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
Args:
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
client_id: The agent's ``agentId`` (UUID).
client_secret: The agent's credential secret.
scopes: Space-separated OAuth 2.0 scopes to request.
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: str,
) -> None:
self._base_url = base_url.rstrip("/")
self._client_id = client_id
self._client_secret = client_secret
self._scopes = scopes
self._cached: Optional[_CachedToken] = None
self._lock: Optional[asyncio.Lock] = None
def _get_lock(self) -> asyncio.Lock:
"""Lazily create the asyncio.Lock on first use (supports different event loops)."""
if self._lock is None:
self._lock = asyncio.Lock()
return self._lock
async def get_token(self) -> str:
"""
Return a valid access token, refreshing if necessary.
Returns:
A valid JWT access token string.
Raises:
AgentIdPError: If token acquisition fails.
"""
async with self._get_lock():
now = time.time()
if (
self._cached is not None
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
):
return self._cached.access_token
token_response = await self._issue_token()
self._cached = _CachedToken(
access_token=token_response.access_token,
expires_at=now + token_response.expires_in,
)
return self._cached.access_token
def clear_cache(self) -> None:
"""Clear the cached token, forcing re-acquisition on the next call."""
self._cached = None
async def _issue_token(self) -> TokenResponse:
"""
POST /api/v1/token to obtain a new access token.
Returns:
TokenResponse from the API.
Raises:
AgentIdPError: On authentication failure or network error.
"""
url = f"{self._base_url}/api/v1/token"
data = {
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
"scope": self._scopes,
}
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, data=data)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.is_success:
raise AgentIdPError.from_oauth2_error(body, response.status_code)
return TokenResponse.from_dict(body)

View File

@@ -0,0 +1,128 @@
"""
Top-level client for the SentryAgent.ai AgentIdP API.
Provides both synchronous (AgentIdPClient) and asynchronous (AsyncAgentIdPClient) variants.
"""
from __future__ import annotations
from typing import List, Optional
from .token_manager import TokenManager
from .async_token_manager import AsyncTokenManager
from .services.agents import AgentRegistryClient, AsyncAgentRegistryClient
from .services.credentials import CredentialClient, AsyncCredentialClient
from .services.token import TokenClient, AsyncTokenClient
from .services.audit import AuditClient, AsyncAuditClient
from .types import OAuthScope
_DEFAULT_SCOPES: List[OAuthScope] = [
"agents:read",
"agents:write",
"tokens:read",
"audit:read",
]
class AgentIdPClient:
"""
Synchronous client for the SentryAgent.ai AgentIdP API.
Composes all service clients under a single entry point. Handles token
acquisition and caching automatically via :class:`~.token_manager.TokenManager`.
Args:
base_url: Base URL of the AgentIdP server (e.g. ``http://localhost:3000``).
client_id: The agent's ``agentId`` (UUID).
client_secret: The credential secret.
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
Example::
from sentryagent_idp import AgentIdPClient, RegisterAgentRequest
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
agents = client.agents.list_agents()
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: Optional[List[OAuthScope]] = None,
) -> None:
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
self._token_manager = TokenManager(base_url, client_id, client_secret, scope_str)
get_token = self._token_manager.get_token
#: Agent Registry operations: register, list, get, update, decommission.
self.agents = AgentRegistryClient(base_url, get_token)
#: Credential operations: generate, list, rotate, revoke.
self.credentials = CredentialClient(base_url, get_token)
#: Token operations: introspect, revoke.
self.tokens = TokenClient(base_url, get_token)
#: Audit log operations: query, get event.
self.audit = AuditClient(base_url, get_token)
def clear_token_cache(self) -> None:
"""
Clear the cached access token.
The next API call will request a new token. Use this after rotating credentials.
"""
self._token_manager.clear_cache()
class AsyncAgentIdPClient:
"""
Asynchronous client for the SentryAgent.ai AgentIdP API.
All methods are coroutines and must be awaited. Token acquisition and caching
are handled automatically via :class:`~.async_token_manager.AsyncTokenManager`.
Args:
base_url: Base URL of the AgentIdP server.
client_id: The agent's ``agentId`` (UUID).
client_secret: The credential secret.
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
Example::
from sentryagent_idp import AsyncAgentIdPClient
client = AsyncAgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
agents = await client.agents.list_agents()
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: Optional[List[OAuthScope]] = None,
) -> None:
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
self._token_manager = AsyncTokenManager(base_url, client_id, client_secret, scope_str)
get_token = self._token_manager.get_token
#: Agent Registry operations (async).
self.agents = AsyncAgentRegistryClient(base_url, get_token)
#: Credential operations (async).
self.credentials = AsyncCredentialClient(base_url, get_token)
#: Token operations (async).
self.tokens = AsyncTokenClient(base_url, get_token)
#: Audit log operations (async).
self.audit = AsyncAuditClient(base_url, get_token)
def clear_token_cache(self) -> None:
"""Clear the cached access token."""
self._token_manager.clear_cache()

View File

@@ -0,0 +1,108 @@
"""
Error types for the SentryAgent.ai AgentIdP Python SDK.
All API failures are raised as AgentIdPError — never as raw requests/httpx exceptions.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
class AgentIdPError(Exception):
"""
Typed exception raised for all AgentIdP API failures.
Attributes:
code: Machine-readable error code from the API (e.g. ``AgentNotFoundError``).
http_status: HTTP status code of the failed response.
details: Optional structured details from the API error response.
"""
def __init__(
self,
code: str,
message: str,
http_status: int,
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.code = code
self.http_status = http_status
self.details = details
def __repr__(self) -> str:
return (
f"AgentIdPError(code={self.code!r}, "
f"http_status={self.http_status}, "
f"message={str(self)!r})"
)
@classmethod
def from_api_error(
cls, body: Any, http_status: int
) -> "AgentIdPError":
"""
Create an AgentIdPError from a standard API error response body.
Args:
body: Parsed response body (dict or unknown).
http_status: HTTP status code.
Returns:
AgentIdPError instance.
"""
if isinstance(body, dict) and "code" in body and "message" in body:
return cls(
code=str(body["code"]),
message=str(body["message"]),
http_status=http_status,
details=body.get("details"),
)
return cls(
code="UNKNOWN_ERROR",
message=str(body),
http_status=http_status,
)
@classmethod
def from_oauth2_error(
cls, body: Any, http_status: int
) -> "AgentIdPError":
"""
Create an AgentIdPError from an OAuth 2.0 error response body.
Args:
body: Parsed response body.
http_status: HTTP status code.
Returns:
AgentIdPError instance.
"""
if isinstance(body, dict):
return cls(
code=str(body.get("error", "unknown_error")),
message=str(body.get("error_description", "Token request failed.")),
http_status=http_status,
)
return cls(
code="unknown_error",
message=str(body),
http_status=http_status,
)
@classmethod
def network_error(cls, cause: Exception) -> "AgentIdPError":
"""
Create an AgentIdPError for a network-level failure (no HTTP response).
Args:
cause: The underlying exception.
Returns:
AgentIdPError with http_status=0.
"""
return cls(
code="NETWORK_ERROR",
message=f"Network error: {cause}",
http_status=0,
)

View File

@@ -0,0 +1 @@
# Services package

View File

@@ -0,0 +1,202 @@
"""
Agent Registry service clients — sync and async.
Covers all five agent endpoints: register, list, get, update, decommission.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine, Dict, Optional
from .._request import sync_request, async_request
from ..types import (
Agent,
AgentStatus,
AgentType,
PaginatedAgents,
RegisterAgentRequest,
UpdateAgentRequest,
)
class AgentRegistryClient:
"""
Synchronous client for the Agent Registry service.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url
self._get_token = get_token
def register_agent(self, request: RegisterAgentRequest) -> Agent:
"""
Register a new AI agent.
Args:
request: Agent registration parameters.
Returns:
The created Agent record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"POST", self._base_url, "/api/v1/agents",
self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
def list_agents(
self,
status: Optional[AgentStatus] = None,
agent_type: Optional[AgentType] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAgents:
"""
List all registered agents with optional filters.
Args:
status: Filter by lifecycle status.
agent_type: Filter by agent type.
page: Page number (1-based).
limit: Results per page.
Returns:
PaginatedAgents response.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, "/api/v1/agents",
self._get_token(),
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
)
return PaginatedAgents.from_dict(data)
def get_agent(self, agent_id: str) -> Agent:
"""
Get a single agent by its agentId.
Args:
agent_id: The agent UUID.
Returns:
Agent record.
Raises:
AgentIdPError: If agent not found or network failure.
"""
data = sync_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
self._get_token(),
)
return Agent.from_dict(data)
def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
"""
Update mutable fields on an existing agent.
Args:
agent_id: The agent UUID.
request: Fields to update.
Returns:
Updated Agent record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
def decommission_agent(self, agent_id: str) -> None:
"""
Decommission an agent. This is irreversible.
Args:
agent_id: The agent UUID.
Raises:
AgentIdPError: On API or network failure.
"""
sync_request(
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
self._get_token(),
)
class AsyncAgentRegistryClient:
"""
Asynchronous client for the Agent Registry service.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url
self._get_token = get_token
async def register_agent(self, request: RegisterAgentRequest) -> Agent:
"""Register a new AI agent (async)."""
data = await async_request(
"POST", self._base_url, "/api/v1/agents",
await self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
async def list_agents(
self,
status: Optional[AgentStatus] = None,
agent_type: Optional[AgentType] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAgents:
"""List all registered agents with optional filters (async)."""
data = await async_request(
"GET", self._base_url, "/api/v1/agents",
await self._get_token(),
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
)
return PaginatedAgents.from_dict(data)
async def get_agent(self, agent_id: str) -> Agent:
"""Get a single agent by its agentId (async)."""
data = await async_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
await self._get_token(),
)
return Agent.from_dict(data)
async def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
"""Update mutable fields on an existing agent (async)."""
data = await async_request(
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
await self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
async def decommission_agent(self, agent_id: str) -> None:
"""Decommission an agent — irreversible (async)."""
await async_request(
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
await self._get_token(),
)

View File

@@ -0,0 +1,144 @@
"""
Audit Log service clients — sync and async.
Covers query (list) and get-by-id operations.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine, Optional
from .._request import sync_request, async_request
from ..types import AuditAction, AuditEvent, AuditOutcome, PaginatedAuditEvents
class AuditClient:
"""
Synchronous client for the Audit Log service.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url
self._get_token = get_token
def query_audit_log(
self,
agent_id: Optional[str] = None,
action: Optional[AuditAction] = None,
outcome: Optional[AuditOutcome] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAuditEvents:
"""
Query audit log events with optional filters. Requires ``audit:read`` scope.
Events are retained for 90 days.
Args:
agent_id: Filter by agent UUID.
action: Filter by audit action type.
outcome: Filter by outcome (success or failure).
from_date: ISO 8601 start datetime (inclusive).
to_date: ISO 8601 end datetime (inclusive).
page: Page number (1-based).
limit: Results per page.
Returns:
PaginatedAuditEvents response.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, "/api/v1/audit",
self._get_token(),
params={
"agentId": agent_id,
"action": action,
"outcome": outcome,
"fromDate": from_date,
"toDate": to_date,
"page": page,
"limit": limit,
},
)
return PaginatedAuditEvents.from_dict(data)
def get_audit_event(self, event_id: str) -> AuditEvent:
"""
Get a single audit event by its eventId. Requires ``audit:read`` scope.
Args:
event_id: The audit event UUID.
Returns:
AuditEvent record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, f"/api/v1/audit/{event_id}",
self._get_token(),
)
return AuditEvent.from_dict(data)
class AsyncAuditClient:
"""
Asynchronous client for the Audit Log service.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url
self._get_token = get_token
async def query_audit_log(
self,
agent_id: Optional[str] = None,
action: Optional[AuditAction] = None,
outcome: Optional[AuditOutcome] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAuditEvents:
"""Query audit log events with optional filters (async)."""
data = await async_request(
"GET", self._base_url, "/api/v1/audit",
await self._get_token(),
params={
"agentId": agent_id,
"action": action,
"outcome": outcome,
"fromDate": from_date,
"toDate": to_date,
"page": page,
"limit": limit,
},
)
return PaginatedAuditEvents.from_dict(data)
async def get_audit_event(self, event_id: str) -> AuditEvent:
"""Get a single audit event by its eventId (async)."""
data = await async_request(
"GET", self._base_url, f"/api/v1/audit/{event_id}",
await self._get_token(),
)
return AuditEvent.from_dict(data)

View File

@@ -0,0 +1,209 @@
"""
Credential Management service clients — sync and async.
Covers generate, list, rotate, and revoke operations.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine, Optional
from .._request import sync_request, async_request
from ..types import (
Credential,
CredentialStatus,
CredentialWithSecret,
PaginatedCredentials,
)
class CredentialClient:
"""
Synchronous client for the Credential Management service.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url
self._get_token = get_token
def generate_credential(
self,
agent_id: str,
expires_at: Optional[str] = None,
) -> CredentialWithSecret:
"""
Generate a new credential for an agent.
The ``client_secret`` is shown **once** — store it securely immediately.
Args:
agent_id: The agent UUID.
expires_at: Optional ISO 8601 expiry date string.
Returns:
CredentialWithSecret including the one-time plain-text secret.
Raises:
AgentIdPError: On API or network failure.
"""
body = {"expiresAt": expires_at} if expires_at is not None else None
data = sync_request(
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
self._get_token(), body=body,
)
return CredentialWithSecret.from_dict(data)
def list_credentials(
self,
agent_id: str,
status: Optional[CredentialStatus] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedCredentials:
"""
List credentials for an agent. Secrets are never returned in list responses.
Args:
agent_id: The agent UUID.
status: Filter by credential status.
page: Page number (1-based).
limit: Results per page.
Returns:
PaginatedCredentials response.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
self._get_token(),
params={"status": status, "page": page, "limit": limit},
)
return PaginatedCredentials.from_dict(data)
def rotate_credential(
self, agent_id: str, credential_id: str
) -> CredentialWithSecret:
"""
Rotate a credential. The same ``credential_id`` is retained; a new secret is issued.
The old secret is immediately invalidated.
The new ``client_secret`` is shown **once** — store it securely immediately.
Args:
agent_id: The agent UUID.
credential_id: The credential UUID to rotate.
Returns:
CredentialWithSecret with the new one-time secret.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"POST",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
self._get_token(),
)
return CredentialWithSecret.from_dict(data)
def revoke_credential(
self, agent_id: str, credential_id: str
) -> Credential:
"""
Revoke a credential permanently.
Args:
agent_id: The agent UUID.
credential_id: The credential UUID to revoke.
Returns:
The revoked Credential record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"DELETE",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
self._get_token(),
)
return Credential.from_dict(data)
class AsyncCredentialClient:
"""
Asynchronous client for the Credential Management service.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url
self._get_token = get_token
async def generate_credential(
self,
agent_id: str,
expires_at: Optional[str] = None,
) -> CredentialWithSecret:
"""Generate a new credential for an agent (async)."""
body = {"expiresAt": expires_at} if expires_at is not None else None
data = await async_request(
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
await self._get_token(), body=body,
)
return CredentialWithSecret.from_dict(data)
async def list_credentials(
self,
agent_id: str,
status: Optional[CredentialStatus] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedCredentials:
"""List credentials for an agent (async)."""
data = await async_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
await self._get_token(),
params={"status": status, "page": page, "limit": limit},
)
return PaginatedCredentials.from_dict(data)
async def rotate_credential(
self, agent_id: str, credential_id: str
) -> CredentialWithSecret:
"""Rotate a credential (async)."""
data = await async_request(
"POST",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
await self._get_token(),
)
return CredentialWithSecret.from_dict(data)
async def revoke_credential(
self, agent_id: str, credential_id: str
) -> Credential:
"""Revoke a credential permanently (async)."""
data = await async_request(
"DELETE",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
await self._get_token(),
)
return Credential.from_dict(data)

View File

@@ -0,0 +1,154 @@
"""
Token service clients (introspect and revoke) — sync and async.
Token issuance is handled by TokenManager / AsyncTokenManager.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine
import requests
import httpx
from ..errors import AgentIdPError
from ..types import IntrospectResponse
class TokenClient:
"""
Synchronous client for token introspection and revocation.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url.rstrip("/")
self._get_token = get_token
def introspect_token(self, token_to_check: str) -> IntrospectResponse:
"""
Check whether a token is currently active.
Always returns successfully — check ``response.active`` for validity.
Args:
token_to_check: The JWT string to introspect.
Returns:
IntrospectResponse with ``active`` field set.
Raises:
AgentIdPError: On API or network failure.
"""
url = f"{self._base_url}/api/v1/token/introspect"
try:
response = requests.post(
url,
data={"token": token_to_check},
headers={
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/x-www-form-urlencoded",
},
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.ok:
raise AgentIdPError.from_api_error(body, response.status_code)
return IntrospectResponse.from_dict(body)
def revoke_token(self, token_to_revoke: str) -> None:
"""
Revoke a token immediately. Idempotent (RFC 7009).
Args:
token_to_revoke: The JWT string to revoke.
Raises:
AgentIdPError: On API or network failure.
"""
url = f"{self._base_url}/api/v1/token/revoke"
try:
response = requests.post(
url,
data={"token": token_to_revoke},
headers={
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/x-www-form-urlencoded",
},
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
if not response.ok:
body = response.json() if response.content else {}
raise AgentIdPError.from_api_error(body, response.status_code)
class AsyncTokenClient:
"""
Asynchronous client for token introspection and revocation.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url.rstrip("/")
self._get_token = get_token
async def introspect_token(self, token_to_check: str) -> IntrospectResponse:
"""Check whether a token is currently active (async)."""
url = f"{self._base_url}/api/v1/token/introspect"
token = await self._get_token()
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
url,
data={"token": token_to_check},
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.is_success:
raise AgentIdPError.from_api_error(body, response.status_code)
return IntrospectResponse.from_dict(body)
async def revoke_token(self, token_to_revoke: str) -> None:
"""Revoke a token immediately — idempotent (async)."""
url = f"{self._base_url}/api/v1/token/revoke"
token = await self._get_token()
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
url,
data={"token": token_to_revoke},
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
if not response.is_success:
body = response.json() if response.content else {}
raise AgentIdPError.from_api_error(body, response.status_code)

View File

@@ -0,0 +1,116 @@
"""
Synchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
Tokens are re-issued automatically when expired or within 60 seconds of expiry.
"""
from __future__ import annotations
import time
import threading
from dataclasses import dataclass
from typing import Optional
import requests
from .errors import AgentIdPError
from .types import TokenResponse
#: Seconds before expiry at which a token refresh is triggered.
REFRESH_BUFFER_SECONDS = 60
@dataclass
class _CachedToken:
access_token: str
expires_at: float # Unix timestamp (seconds)
class TokenManager:
"""
Thread-safe synchronous token manager.
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
Args:
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
client_id: The agent's ``agentId`` (UUID).
client_secret: The agent's credential secret.
scopes: Space-separated OAuth 2.0 scopes to request.
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: str,
) -> None:
self._base_url = base_url.rstrip("/")
self._client_id = client_id
self._client_secret = client_secret
self._scopes = scopes
self._cached: Optional[_CachedToken] = None
self._lock = threading.Lock()
def get_token(self) -> str:
"""
Return a valid access token, refreshing if necessary.
Returns:
A valid JWT access token string.
Raises:
AgentIdPError: If token acquisition fails.
"""
with self._lock:
now = time.time()
if (
self._cached is not None
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
):
return self._cached.access_token
token_response = self._issue_token()
self._cached = _CachedToken(
access_token=token_response.access_token,
expires_at=now + token_response.expires_in,
)
return self._cached.access_token
def clear_cache(self) -> None:
"""Clear the cached token, forcing re-acquisition on the next call."""
with self._lock:
self._cached = None
def _issue_token(self) -> TokenResponse:
"""
POST /api/v1/token to obtain a new access token.
Returns:
TokenResponse from the API.
Raises:
AgentIdPError: On authentication failure or network error.
"""
url = f"{self._base_url}/api/v1/token"
data = {
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
"scope": self._scopes,
}
try:
response = requests.post(
url,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.ok:
raise AgentIdPError.from_oauth2_error(body, response.status_code)
return TokenResponse.from_dict(body)

View File

@@ -0,0 +1,323 @@
"""
Type definitions for the SentryAgent.ai AgentIdP Python SDK.
All request and response shapes derived from the AgentIdP OpenAPI 3.0 specs.
"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from dataclasses import dataclass, field
# ─────────────────────────────────────────────────────────────────────────────
# Enums / Literal types
# ─────────────────────────────────────────────────────────────────────────────
AgentType = Literal[
"screener",
"classifier",
"orchestrator",
"extractor",
"summarizer",
"router",
"monitor",
"custom",
]
AgentStatus = Literal["active", "suspended", "decommissioned"]
DeploymentEnv = Literal["development", "staging", "production"]
CredentialStatus = Literal["active", "revoked"]
OAuthScope = Literal["agents:read", "agents:write", "tokens:read", "audit:read"]
AuditAction = Literal[
"agent.created",
"agent.updated",
"agent.decommissioned",
"agent.suspended",
"agent.reactivated",
"token.issued",
"token.revoked",
"token.introspected",
"credential.generated",
"credential.rotated",
"credential.revoked",
"auth.failed",
]
AuditOutcome = Literal["success", "failure"]
# ─────────────────────────────────────────────────────────────────────────────
# Agent Registry
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Agent:
"""A registered AI agent identity."""
agent_id: str
email: str
agent_type: AgentType
version: str
capabilities: List[str]
owner: str
deployment_env: DeploymentEnv
status: AgentStatus
created_at: str
updated_at: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Agent":
"""Deserialise from an API response dict."""
return cls(
agent_id=data["agentId"],
email=data["email"],
agent_type=data["agentType"],
version=data["version"],
capabilities=data["capabilities"],
owner=data["owner"],
deployment_env=data["deploymentEnv"],
status=data["status"],
created_at=data["createdAt"],
updated_at=data["updatedAt"],
)
@dataclass
class RegisterAgentRequest:
"""Request body for registering a new AI agent."""
email: str
agent_type: AgentType
version: str
capabilities: List[str]
owner: str
deployment_env: DeploymentEnv
def to_dict(self) -> Dict[str, Any]:
"""Serialise to API request dict."""
return {
"email": self.email,
"agentType": self.agent_type,
"version": self.version,
"capabilities": self.capabilities,
"owner": self.owner,
"deploymentEnv": self.deployment_env,
}
@dataclass
class UpdateAgentRequest:
"""Request body for partially updating an agent (all fields optional)."""
agent_type: Optional[AgentType] = None
version: Optional[str] = None
capabilities: Optional[List[str]] = None
owner: Optional[str] = None
deployment_env: Optional[DeploymentEnv] = None
status: Optional[AgentStatus] = None
def to_dict(self) -> Dict[str, Any]:
"""Serialise to API request dict, omitting None fields."""
out: Dict[str, Any] = {}
if self.agent_type is not None:
out["agentType"] = self.agent_type
if self.version is not None:
out["version"] = self.version
if self.capabilities is not None:
out["capabilities"] = self.capabilities
if self.owner is not None:
out["owner"] = self.owner
if self.deployment_env is not None:
out["deploymentEnv"] = self.deployment_env
if self.status is not None:
out["status"] = self.status
return out
@dataclass
class PaginatedAgents:
"""Paginated list of agents."""
data: List[Agent]
total: int
page: int
limit: int
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAgents":
return cls(
data=[Agent.from_dict(a) for a in d["data"]],
total=d["total"],
page=d["page"],
limit=d["limit"],
)
# ─────────────────────────────────────────────────────────────────────────────
# Credential Management
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Credential:
"""A credential record (client_secret never included)."""
credential_id: str
client_id: str
status: CredentialStatus
created_at: str
expires_at: Optional[str]
revoked_at: Optional[str]
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Credential":
return cls(
credential_id=data["credentialId"],
client_id=data["clientId"],
status=data["status"],
created_at=data["createdAt"],
expires_at=data.get("expiresAt"),
revoked_at=data.get("revokedAt"),
)
@dataclass
class CredentialWithSecret(Credential):
"""Credential with plain-text secret — returned once only on create/rotate."""
client_secret: str = field(default="")
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "CredentialWithSecret":
base = Credential.from_dict(data)
return cls(
credential_id=base.credential_id,
client_id=base.client_id,
status=base.status,
created_at=base.created_at,
expires_at=base.expires_at,
revoked_at=base.revoked_at,
client_secret=data["clientSecret"],
)
@dataclass
class PaginatedCredentials:
"""Paginated list of credentials."""
data: List[Credential]
total: int
page: int
limit: int
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedCredentials":
return cls(
data=[Credential.from_dict(c) for c in d["data"]],
total=d["total"],
page=d["page"],
limit=d["limit"],
)
# ─────────────────────────────────────────────────────────────────────────────
# OAuth 2.0 Tokens
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class TokenResponse:
"""OAuth 2.0 access token response."""
access_token: str
token_type: str
expires_in: int
scope: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TokenResponse":
return cls(
access_token=data["access_token"],
token_type=data["token_type"],
expires_in=data["expires_in"],
scope=data["scope"],
)
@dataclass
class IntrospectResponse:
"""Token introspection response (RFC 7662)."""
active: bool
sub: Optional[str] = None
client_id: Optional[str] = None
scope: Optional[str] = None
token_type: Optional[str] = None
iat: Optional[int] = None
exp: Optional[int] = None
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "IntrospectResponse":
return cls(
active=data["active"],
sub=data.get("sub"),
client_id=data.get("client_id"),
scope=data.get("scope"),
token_type=data.get("token_type"),
iat=data.get("iat"),
exp=data.get("exp"),
)
# ─────────────────────────────────────────────────────────────────────────────
# Audit Log
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class AuditEvent:
"""An immutable audit event record."""
event_id: str
agent_id: str
action: AuditAction
outcome: AuditOutcome
ip_address: str
user_agent: str
metadata: Dict[str, Any]
timestamp: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AuditEvent":
return cls(
event_id=data["eventId"],
agent_id=data["agentId"],
action=data["action"],
outcome=data["outcome"],
ip_address=data["ipAddress"],
user_agent=data["userAgent"],
metadata=data.get("metadata", {}),
timestamp=data["timestamp"],
)
@dataclass
class PaginatedAuditEvents:
"""Paginated list of audit events."""
data: List[AuditEvent]
total: int
page: int
limit: int
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAuditEvents":
return cls(
data=[AuditEvent.from_dict(e) for e in d["data"]],
total=d["total"],
page=d["page"],
limit=d["limit"],
)

View File

Binary file not shown.

View File

@@ -0,0 +1,52 @@
"""Tests for AgentIdPError."""
from sentryagent_idp.errors import AgentIdPError
def test_basic_construction() -> None:
err = AgentIdPError("AgentNotFoundError", "Agent not found.", 404)
assert err.code == "AgentNotFoundError"
assert err.http_status == 404
assert str(err) == "Agent not found."
assert err.details is None
def test_from_api_error_valid_body() -> None:
body = {"code": "AgentNotFoundError", "message": "Not found.", "details": {"id": "x"}}
err = AgentIdPError.from_api_error(body, 404)
assert err.code == "AgentNotFoundError"
assert err.http_status == 404
assert err.details == {"id": "x"}
def test_from_api_error_unknown_body() -> None:
err = AgentIdPError.from_api_error("plain string", 500)
assert err.code == "UNKNOWN_ERROR"
assert err.http_status == 500
def test_from_oauth2_error() -> None:
body = {"error": "invalid_client", "error_description": "Bad credentials."}
err = AgentIdPError.from_oauth2_error(body, 401)
assert err.code == "invalid_client"
assert str(err) == "Bad credentials."
assert err.http_status == 401
def test_from_oauth2_error_unknown() -> None:
err = AgentIdPError.from_oauth2_error("garbage", 400)
assert err.code == "unknown_error"
def test_network_error() -> None:
cause = ConnectionError("refused")
err = AgentIdPError.network_error(cause)
assert err.code == "NETWORK_ERROR"
assert err.http_status == 0
assert "refused" in str(err)
def test_repr() -> None:
err = AgentIdPError("CODE", "msg", 400)
assert "AgentIdPError" in repr(err)
assert "CODE" in repr(err)

View File

@@ -0,0 +1,349 @@
"""
Tests for all service clients — covers all 14 API endpoints (sync + async).
Uses `responses` for sync mocking and `respx` for async mocking.
"""
from __future__ import annotations
import pytest
import responses as resp_lib
import respx
import httpx
from sentryagent_idp.errors import AgentIdPError
from sentryagent_idp.services.agents import AgentRegistryClient, AsyncAgentRegistryClient
from sentryagent_idp.services.credentials import CredentialClient, AsyncCredentialClient
from sentryagent_idp.services.token import TokenClient, AsyncTokenClient
from sentryagent_idp.services.audit import AuditClient, AsyncAuditClient
from sentryagent_idp.types import RegisterAgentRequest, UpdateAgentRequest
BASE = "http://localhost:3000"
TOKEN = "test-bearer-token"
def get_token() -> str:
return TOKEN
async def async_get_token() -> str:
return TOKEN
# ─── Fixtures ─────────────────────────────────────────────────────────────────
AGENT = {
"agentId": "uuid-1", "email": "a@b.ai", "agentType": "screener",
"version": "1.0.0", "capabilities": ["read"], "owner": "team",
"deploymentEnv": "production", "status": "active",
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
}
PAGINATED_AGENTS = {"data": [AGENT], "total": 1, "page": 1, "limit": 20}
CRED = {
"credentialId": "cred-1", "clientId": "uuid-1", "status": "active",
"createdAt": "2026-01-01T00:00:00Z", "expiresAt": None, "revokedAt": None,
}
CRED_WITH_SECRET = {**CRED, "clientSecret": "sk_live_abc"}
PAGINATED_CREDS = {"data": [CRED], "total": 1, "page": 1, "limit": 20}
INTROSPECT_ACTIVE = {"active": True, "sub": "uuid-1", "exp": 9999999999}
INTROSPECT_INACTIVE = {"active": False}
AUDIT_EVENT = {
"eventId": "ev-1", "agentId": "uuid-1", "action": "token.issued",
"outcome": "success", "ipAddress": "1.2.3.4", "userAgent": "curl",
"metadata": {}, "timestamp": "2026-01-01T00:00:00Z",
}
PAGINATED_AUDIT = {"data": [AUDIT_EVENT], "total": 1, "page": 1, "limit": 20}
# ─── Agent Registry — Sync ────────────────────────────────────────────────────
@resp_lib.activate
def test_register_agent() -> None:
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents", json=AGENT, status=201)
client = AgentRegistryClient(BASE, get_token)
agent = client.register_agent(RegisterAgentRequest(
email="a@b.ai", agent_type="screener", version="1.0.0",
capabilities=["read"], owner="team", deployment_env="production",
))
assert agent.agent_id == "uuid-1"
@resp_lib.activate
def test_list_agents() -> None:
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents", json=PAGINATED_AGENTS, status=200)
client = AgentRegistryClient(BASE, get_token)
result = client.list_agents()
assert result.total == 1
@resp_lib.activate
def test_get_agent() -> None:
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents/uuid-1", json=AGENT, status=200)
client = AgentRegistryClient(BASE, get_token)
agent = client.get_agent("uuid-1")
assert agent.agent_id == "uuid-1"
@resp_lib.activate
def test_update_agent() -> None:
resp_lib.add(resp_lib.PATCH, f"{BASE}/api/v1/agents/uuid-1", json=AGENT, status=200)
client = AgentRegistryClient(BASE, get_token)
agent = client.update_agent("uuid-1", UpdateAgentRequest(version="2.0.0"))
assert agent.agent_id == "uuid-1"
@resp_lib.activate
def test_decommission_agent() -> None:
resp_lib.add(resp_lib.DELETE, f"{BASE}/api/v1/agents/uuid-1", status=204)
client = AgentRegistryClient(BASE, get_token)
result = client.decommission_agent("uuid-1")
assert result is None
@resp_lib.activate
def test_agent_not_found_raises() -> None:
resp_lib.add(
resp_lib.GET, f"{BASE}/api/v1/agents/bad-id",
json={"code": "AgentNotFoundError", "message": "Not found."}, status=404,
)
client = AgentRegistryClient(BASE, get_token)
with pytest.raises(AgentIdPError) as exc_info:
client.get_agent("bad-id")
assert exc_info.value.code == "AgentNotFoundError"
assert exc_info.value.http_status == 404
# ─── Credentials — Sync ───────────────────────────────────────────────────────
@resp_lib.activate
def test_generate_credential() -> None:
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents/uuid-1/credentials", json=CRED_WITH_SECRET, status=201)
client = CredentialClient(BASE, get_token)
cred = client.generate_credential("uuid-1")
assert cred.client_secret == "sk_live_abc"
@resp_lib.activate
def test_list_credentials() -> None:
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents/uuid-1/credentials", json=PAGINATED_CREDS, status=200)
client = CredentialClient(BASE, get_token)
result = client.list_credentials("uuid-1")
assert result.total == 1
@resp_lib.activate
def test_rotate_credential() -> None:
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1/rotate", json=CRED_WITH_SECRET, status=200)
client = CredentialClient(BASE, get_token)
cred = client.rotate_credential("uuid-1", "cred-1")
assert cred.client_secret == "sk_live_abc"
@resp_lib.activate
def test_revoke_credential() -> None:
revoked = {**CRED, "status": "revoked", "revokedAt": "2026-01-02T00:00:00Z"}
resp_lib.add(resp_lib.DELETE, f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1", json=revoked, status=200)
client = CredentialClient(BASE, get_token)
cred = client.revoke_credential("uuid-1", "cred-1")
assert cred.status == "revoked"
# ─── Token — Sync ─────────────────────────────────────────────────────────────
@resp_lib.activate
def test_introspect_token_active() -> None:
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/introspect", json=INTROSPECT_ACTIVE, status=200)
client = TokenClient(BASE, get_token)
result = client.introspect_token("some-token")
assert result.active is True
assert result.sub == "uuid-1"
@resp_lib.activate
def test_introspect_token_inactive() -> None:
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/introspect", json=INTROSPECT_INACTIVE, status=200)
client = TokenClient(BASE, get_token)
result = client.introspect_token("expired-token")
assert result.active is False
@resp_lib.activate
def test_revoke_token() -> None:
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/revoke", json={}, status=200)
client = TokenClient(BASE, get_token)
result = client.revoke_token("some-token")
assert result is None
# ─── Audit — Sync ─────────────────────────────────────────────────────────────
@resp_lib.activate
def test_query_audit_log() -> None:
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/audit", json=PAGINATED_AUDIT, status=200)
client = AuditClient(BASE, get_token)
result = client.query_audit_log(agent_id="uuid-1", action="token.issued")
assert result.total == 1
assert result.data[0].event_id == "ev-1"
@resp_lib.activate
def test_get_audit_event() -> None:
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/audit/ev-1", json=AUDIT_EVENT, status=200)
client = AuditClient(BASE, get_token)
event = client.get_audit_event("ev-1")
assert event.event_id == "ev-1"
# ─── Async — all 14 endpoints ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_async_register_agent() -> None:
with respx.mock:
respx.post(f"{BASE}/api/v1/agents").mock(return_value=httpx.Response(201, json=AGENT))
client = AsyncAgentRegistryClient(BASE, async_get_token)
agent = await client.register_agent(RegisterAgentRequest(
email="a@b.ai", agent_type="screener", version="1.0.0",
capabilities=["read"], owner="team", deployment_env="production",
))
assert agent.agent_id == "uuid-1"
@pytest.mark.asyncio
async def test_async_list_agents() -> None:
with respx.mock:
respx.get(f"{BASE}/api/v1/agents").mock(return_value=httpx.Response(200, json=PAGINATED_AGENTS))
client = AsyncAgentRegistryClient(BASE, async_get_token)
result = await client.list_agents()
assert result.total == 1
@pytest.mark.asyncio
async def test_async_get_agent() -> None:
with respx.mock:
respx.get(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(200, json=AGENT))
client = AsyncAgentRegistryClient(BASE, async_get_token)
agent = await client.get_agent("uuid-1")
assert agent.agent_id == "uuid-1"
@pytest.mark.asyncio
async def test_async_update_agent() -> None:
with respx.mock:
respx.patch(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(200, json=AGENT))
client = AsyncAgentRegistryClient(BASE, async_get_token)
agent = await client.update_agent("uuid-1", UpdateAgentRequest(version="2.0.0"))
assert agent.agent_id == "uuid-1"
@pytest.mark.asyncio
async def test_async_decommission_agent() -> None:
with respx.mock:
respx.delete(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(204))
client = AsyncAgentRegistryClient(BASE, async_get_token)
result = await client.decommission_agent("uuid-1")
assert result is None
@pytest.mark.asyncio
async def test_async_generate_credential() -> None:
with respx.mock:
respx.post(f"{BASE}/api/v1/agents/uuid-1/credentials").mock(
return_value=httpx.Response(201, json=CRED_WITH_SECRET))
client = AsyncCredentialClient(BASE, async_get_token)
cred = await client.generate_credential("uuid-1")
assert cred.client_secret == "sk_live_abc"
@pytest.mark.asyncio
async def test_async_list_credentials() -> None:
with respx.mock:
respx.get(f"{BASE}/api/v1/agents/uuid-1/credentials").mock(
return_value=httpx.Response(200, json=PAGINATED_CREDS))
client = AsyncCredentialClient(BASE, async_get_token)
result = await client.list_credentials("uuid-1")
assert result.total == 1
@pytest.mark.asyncio
async def test_async_rotate_credential() -> None:
with respx.mock:
respx.post(f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1/rotate").mock(
return_value=httpx.Response(200, json=CRED_WITH_SECRET))
client = AsyncCredentialClient(BASE, async_get_token)
cred = await client.rotate_credential("uuid-1", "cred-1")
assert cred.client_secret == "sk_live_abc"
@pytest.mark.asyncio
async def test_async_revoke_credential() -> None:
revoked = {**CRED, "status": "revoked", "revokedAt": "2026-01-02T00:00:00Z"}
with respx.mock:
respx.delete(f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1").mock(
return_value=httpx.Response(200, json=revoked))
client = AsyncCredentialClient(BASE, async_get_token)
cred = await client.revoke_credential("uuid-1", "cred-1")
assert cred.status == "revoked"
@pytest.mark.asyncio
async def test_async_introspect_token() -> None:
with respx.mock:
respx.post(f"{BASE}/api/v1/token/introspect").mock(
return_value=httpx.Response(200, json=INTROSPECT_ACTIVE))
client = AsyncTokenClient(BASE, async_get_token)
result = await client.introspect_token("tok")
assert result.active is True
@pytest.mark.asyncio
async def test_async_revoke_token() -> None:
with respx.mock:
respx.post(f"{BASE}/api/v1/token/revoke").mock(return_value=httpx.Response(200, json={}))
client = AsyncTokenClient(BASE, async_get_token)
await client.revoke_token("tok")
@pytest.mark.asyncio
async def test_async_query_audit_log() -> None:
with respx.mock:
respx.get(f"{BASE}/api/v1/audit").mock(return_value=httpx.Response(200, json=PAGINATED_AUDIT))
client = AsyncAuditClient(BASE, async_get_token)
result = await client.query_audit_log()
assert result.total == 1
@pytest.mark.asyncio
async def test_async_get_audit_event() -> None:
with respx.mock:
respx.get(f"{BASE}/api/v1/audit/ev-1").mock(return_value=httpx.Response(200, json=AUDIT_EVENT))
client = AsyncAuditClient(BASE, async_get_token)
event = await client.get_audit_event("ev-1")
assert event.event_id == "ev-1"
# ─── Error propagation ────────────────────────────────────────────────────────
@resp_lib.activate
def test_api_error_propagated_from_service() -> None:
resp_lib.add(
resp_lib.GET, f"{BASE}/api/v1/agents/bad",
json={"code": "AgentNotFoundError", "message": "Not found."}, status=404,
)
client = AgentRegistryClient(BASE, get_token)
with pytest.raises(AgentIdPError) as exc_info:
client.get_agent("bad")
assert exc_info.value.http_status == 404
@pytest.mark.asyncio
async def test_async_api_error_propagated() -> None:
with respx.mock:
respx.get(f"{BASE}/api/v1/agents/bad").mock(return_value=httpx.Response(
404, json={"code": "AgentNotFoundError", "message": "Not found."}
))
client = AsyncAgentRegistryClient(BASE, async_get_token)
with pytest.raises(AgentIdPError) as exc_info:
await client.get_agent("bad")
assert exc_info.value.http_status == 404

View File

@@ -0,0 +1,112 @@
"""Tests for TokenManager (sync) and AsyncTokenManager."""
import time
import pytest
import responses as resp_lib
import respx
import httpx
from sentryagent_idp.token_manager import TokenManager, REFRESH_BUFFER_SECONDS
from sentryagent_idp.async_token_manager import AsyncTokenManager
from sentryagent_idp.errors import AgentIdPError
BASE_URL = "http://localhost:3000"
TOKEN_URL = f"{BASE_URL}/api/v1/token"
TOKEN_RESP = {
"access_token": "eyJ.abc.def",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read",
}
# ─── Sync TokenManager ────────────────────────────────────────────────────────
@resp_lib.activate
def test_token_manager_issues_token() -> None:
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
token = tm.get_token()
assert token == "eyJ.abc.def"
assert len(resp_lib.calls) == 1
@resp_lib.activate
def test_token_manager_caches_token() -> None:
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
tm.get_token()
tm.get_token()
# Only one HTTP call because second call uses cache
assert len(resp_lib.calls) == 1
@resp_lib.activate
def test_token_manager_refreshes_near_expiry() -> None:
resp_lib.add(resp_lib.POST, TOKEN_URL, json={**TOKEN_RESP, "expires_in": 30}, status=200)
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
tm.get_token()
# Simulate cached token being nearly expired
assert tm._cached is not None
tm._cached.expires_at = time.time() + (REFRESH_BUFFER_SECONDS - 1)
tm.get_token()
assert len(resp_lib.calls) == 2
@resp_lib.activate
def test_token_manager_raises_on_auth_failure() -> None:
resp_lib.add(
resp_lib.POST, TOKEN_URL,
json={"error": "invalid_client", "error_description": "Bad creds."},
status=401,
)
tm = TokenManager(BASE_URL, "client-id", "bad-secret", "agents:read")
with pytest.raises(AgentIdPError) as exc_info:
tm.get_token()
assert exc_info.value.code == "invalid_client"
assert exc_info.value.http_status == 401
@resp_lib.activate
def test_token_manager_clear_cache() -> None:
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
tm.get_token()
tm.clear_cache()
tm.get_token()
assert len(resp_lib.calls) == 2
# ─── Async TokenManager ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_async_token_manager_issues_token() -> None:
with respx.mock:
respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
tm = AsyncTokenManager(BASE_URL, "client-id", "secret", "agents:read")
token = await tm.get_token()
assert token == "eyJ.abc.def"
@pytest.mark.asyncio
async def test_async_token_manager_caches_token() -> None:
with respx.mock:
route = respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
tm = AsyncTokenManager(BASE_URL, "client-id", "secret", "agents:read")
await tm.get_token()
await tm.get_token()
assert route.call_count == 1
@pytest.mark.asyncio
async def test_async_token_manager_raises_on_auth_failure() -> None:
with respx.mock:
respx.post(TOKEN_URL).mock(return_value=httpx.Response(
401, json={"error": "invalid_client", "error_description": "Bad creds."}
))
tm = AsyncTokenManager(BASE_URL, "client-id", "bad-secret", "agents:read")
with pytest.raises(AgentIdPError) as exc_info:
await tm.get_token()
assert exc_info.value.code == "invalid_client"

View File

@@ -0,0 +1,133 @@
"""Tests for dataclass deserialisation in types.py."""
from sentryagent_idp.types import (
Agent,
Credential,
CredentialWithSecret,
PaginatedAgents,
PaginatedCredentials,
TokenResponse,
IntrospectResponse,
AuditEvent,
PaginatedAuditEvents,
RegisterAgentRequest,
UpdateAgentRequest,
)
AGENT_DICT = {
"agentId": "uuid-1",
"email": "a@b.ai",
"agentType": "screener",
"version": "1.0.0",
"capabilities": ["read"],
"owner": "team",
"deploymentEnv": "production",
"status": "active",
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-02T00:00:00Z",
}
CREDENTIAL_DICT = {
"credentialId": "cred-1",
"clientId": "uuid-1",
"status": "active",
"createdAt": "2026-01-01T00:00:00Z",
"expiresAt": None,
"revokedAt": None,
}
def test_agent_from_dict() -> None:
agent = Agent.from_dict(AGENT_DICT)
assert agent.agent_id == "uuid-1"
assert agent.agent_type == "screener"
assert agent.capabilities == ["read"]
def test_register_agent_request_to_dict() -> None:
req = RegisterAgentRequest(
email="a@b.ai",
agent_type="classifier",
version="1.0.0",
capabilities=["read"],
owner="team",
deployment_env="production",
)
d = req.to_dict()
assert d["agentType"] == "classifier"
assert d["deploymentEnv"] == "production"
def test_update_agent_request_omits_none() -> None:
req = UpdateAgentRequest(version="2.0.0")
d = req.to_dict()
assert "version" in d
assert "agentType" not in d
def test_paginated_agents_from_dict() -> None:
result = PaginatedAgents.from_dict({"data": [AGENT_DICT], "total": 1, "page": 1, "limit": 20})
assert result.total == 1
assert result.data[0].agent_id == "uuid-1"
def test_credential_from_dict() -> None:
cred = Credential.from_dict(CREDENTIAL_DICT)
assert cred.credential_id == "cred-1"
assert cred.expires_at is None
def test_credential_with_secret_from_dict() -> None:
d = {**CREDENTIAL_DICT, "clientSecret": "sk_live_abc"}
cred = CredentialWithSecret.from_dict(d)
assert cred.client_secret == "sk_live_abc"
assert cred.credential_id == "cred-1"
def test_paginated_credentials_from_dict() -> None:
result = PaginatedCredentials.from_dict(
{"data": [CREDENTIAL_DICT], "total": 1, "page": 1, "limit": 20}
)
assert result.total == 1
def test_token_response_from_dict() -> None:
tr = TokenResponse.from_dict(
{"access_token": "tok", "token_type": "Bearer", "expires_in": 3600, "scope": "agents:read"}
)
assert tr.access_token == "tok"
assert tr.expires_in == 3600
def test_introspect_response_active() -> None:
ir = IntrospectResponse.from_dict({"active": True, "sub": "uuid-1", "exp": 9999999999})
assert ir.active is True
assert ir.sub == "uuid-1"
def test_introspect_response_inactive() -> None:
ir = IntrospectResponse.from_dict({"active": False})
assert ir.active is False
assert ir.sub is None
def test_audit_event_from_dict() -> None:
ev = AuditEvent.from_dict({
"eventId": "ev-1", "agentId": "uuid-1", "action": "token.issued",
"outcome": "success", "ipAddress": "1.2.3.4", "userAgent": "curl",
"metadata": {}, "timestamp": "2026-01-01T00:00:00Z",
})
assert ev.event_id == "ev-1"
assert ev.action == "token.issued"
def test_paginated_audit_events_from_dict() -> None:
ev_dict = {
"eventId": "ev-1", "agentId": "uuid-1", "action": "token.issued",
"outcome": "success", "ipAddress": "1.2.3.4", "userAgent": "curl",
"metadata": {}, "timestamp": "2026-01-01T00:00:00Z",
}
result = PaginatedAuditEvents.from_dict({"data": [ev_dict], "total": 1, "page": 1, "limit": 20})
assert result.total == 1
assert result.data[0].event_id == "ev-1"