fix(sdk): enforce agent registration integration type
This commit is contained in:
1 parent
ecc70adf12
commit
c000855ebc
8 files changed
+200
-20
No files matched your search
@@ -8,6 +8,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a
|
||||
|
||||
- Verify every agent hook contract against current official provider documentation before changing an adapter.
|
||||
- Treat `../Elydora-Open-Source/integrations/catalog.json` as the cross-product provider inventory.
|
||||
- Keep the exported `INTEGRATION_TYPES` tuple aligned with that inventory, and require `integration_type` in every agent registration request.
|
||||
- Keep adapter delivery claims aligned with executable tests in this repository.
|
||||
- Synchronize completed provider behavior into the Node SDK, Go SDK, Open Source distribution, Console, Docs, and landing page through separate reviewed commits.
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ result = client.verify_operation(operation_id)
|
||||
# Register a new agent
|
||||
agent = client.register_agent({
|
||||
"agent_id": "my-agent",
|
||||
"integration_type": "codex",
|
||||
"display_name": "My Agent",
|
||||
"responsible_entity": "team@example.com",
|
||||
"keys": [{"kid": "key-v1", "public_key": "<base64url>", "algorithm": "ed25519"}],
|
||||
|
||||
@@ -32,6 +32,7 @@ from .types import (
|
||||
AdminAction,
|
||||
AgentStatus,
|
||||
ExportStatus,
|
||||
INTEGRATION_TYPES,
|
||||
IntegrationType,
|
||||
KeyStatus,
|
||||
RbacRole,
|
||||
@@ -90,6 +91,7 @@ __all__ = [
|
||||
"AdminAction",
|
||||
"AgentStatus",
|
||||
"ExportStatus",
|
||||
"INTEGRATION_TYPES",
|
||||
"IntegrationType",
|
||||
"KeyStatus",
|
||||
"RbacRole",
|
||||
|
||||
@@ -11,6 +11,7 @@ import aiohttp
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
@@ -27,6 +28,7 @@ from .types import (
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueApiTokenResponse,
|
||||
IntegrationType,
|
||||
ListAdminEventsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
@@ -248,6 +250,7 @@ class AsyncElydoraClient:
|
||||
|
||||
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return await self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
async def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
@@ -266,8 +269,9 @@ class AsyncElydoraClient:
|
||||
"""Unfreeze an agent."""
|
||||
return await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
|
||||
async def update_agent(self, agent_id: str, integration_type: str) -> UpdateAgentResponse:
|
||||
async def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
||||
"""Update an agent's integration type."""
|
||||
require_integration_type(integration_type)
|
||||
return await self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
|
||||
|
||||
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
|
||||
+5
-1
@@ -10,6 +10,7 @@ import requests
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
AuthLoginResponse,
|
||||
@@ -26,6 +27,7 @@ from .types import (
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueApiTokenResponse,
|
||||
IntegrationType,
|
||||
ListAdminEventsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
@@ -239,6 +241,7 @@ class ElydoraClient:
|
||||
|
||||
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
@@ -257,8 +260,9 @@ class ElydoraClient:
|
||||
"""Unfreeze an agent."""
|
||||
return self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
|
||||
def update_agent(self, agent_id: str, integration_type: str) -> UpdateAgentResponse:
|
||||
def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
||||
"""Update an agent's integration type."""
|
||||
require_integration_type(integration_type)
|
||||
return self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
|
||||
|
||||
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Runtime validation for agent integration types."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from .types import INTEGRATION_TYPES, IntegrationType
|
||||
|
||||
|
||||
def require_integration_type(value: object) -> IntegrationType:
|
||||
if not isinstance(value, str) or value not in INTEGRATION_TYPES:
|
||||
expected = ", ".join(INTEGRATION_TYPES)
|
||||
raise ValueError(f"Invalid integration_type {value!r}. Expected one of: {expected}")
|
||||
return cast(IntegrationType, value)
|
||||
+33
-18
@@ -3,15 +3,13 @@
|
||||
All types use TypedDict for structural typing, compatible with Python 3.9+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Literal, TypedDict
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
else:
|
||||
from typing_extensions import Literal, TypedDict
|
||||
from typing_extensions import Literal, NotRequired, TypedDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -29,15 +27,31 @@ RbacRole = Literal[
|
||||
"integration_engineer",
|
||||
]
|
||||
IntegrationType = Literal[
|
||||
"openai",
|
||||
"anthropic",
|
||||
"augment",
|
||||
"claudecode",
|
||||
"cline",
|
||||
"codex",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"droid",
|
||||
"gemini",
|
||||
"langchain",
|
||||
"llamaindex",
|
||||
"autogen",
|
||||
"crewai",
|
||||
"custom",
|
||||
"grok",
|
||||
"kimi",
|
||||
"kirocli",
|
||||
"kiroide",
|
||||
"letta",
|
||||
"opencode",
|
||||
"qwen",
|
||||
"enterprise",
|
||||
"gui",
|
||||
"sdk",
|
||||
"other",
|
||||
]
|
||||
INTEGRATION_TYPES: Tuple[IntegrationType, ...] = (
|
||||
"augment", "claudecode", "cline", "codex", "copilot", "cursor", "droid",
|
||||
"gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen",
|
||||
"enterprise", "gui", "sdk", "other",
|
||||
)
|
||||
AdminAction = Literal[
|
||||
"agent.freeze",
|
||||
"agent.unfreeze",
|
||||
@@ -78,7 +92,7 @@ class Agent(TypedDict):
|
||||
display_name: str
|
||||
responsible_entity: str
|
||||
status: AgentStatus
|
||||
integration_type: str
|
||||
integration_type: IntegrationType
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
@@ -212,11 +226,12 @@ class RegisterAgentKeyParam(TypedDict):
|
||||
algorithm: Literal["ed25519"]
|
||||
|
||||
|
||||
class RegisterAgentRequest(TypedDict, total=False):
|
||||
class RegisterAgentRequest(TypedDict):
|
||||
agent_id: str
|
||||
display_name: str
|
||||
responsible_entity: str
|
||||
integration_type: IntegrationType
|
||||
keys: List[RegisterAgentKeyParam]
|
||||
display_name: NotRequired[str]
|
||||
responsible_entity: NotRequired[str]
|
||||
|
||||
|
||||
class AuditQueryParams(TypedDict, total=False):
|
||||
@@ -410,8 +425,8 @@ class MemberEntry(TypedDict):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UpdateAgentRequest(TypedDict, total=False):
|
||||
integration_type: str
|
||||
class UpdateAgentRequest(TypedDict):
|
||||
integration_type: IntegrationType
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import get_args
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import (
|
||||
AsyncElydoraClient,
|
||||
ElydoraClient,
|
||||
INTEGRATION_TYPES,
|
||||
IntegrationType,
|
||||
RegisterAgentRequest,
|
||||
)
|
||||
|
||||
EXPECTED_INTEGRATION_TYPES = (
|
||||
"augment", "claudecode", "cline", "codex", "copilot", "cursor", "droid",
|
||||
"gemini", "grok", "kimi", "kirocli", "kiroide", "letta", "opencode", "qwen",
|
||||
"enterprise", "gui", "sdk", "other",
|
||||
)
|
||||
|
||||
|
||||
class RegistrationHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None:
|
||||
content_length = int(self.headers["Content-Length"])
|
||||
body = json.loads(self.rfile.read(content_length))
|
||||
self.server.requests.append({
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"authorization": self.headers.get("Authorization"),
|
||||
"body": body,
|
||||
})
|
||||
response = json.dumps({
|
||||
"agent": {
|
||||
"agent_id": body["agent_id"],
|
||||
"org_id": "org-1",
|
||||
"display_name": body.get("display_name", ""),
|
||||
"responsible_entity": body.get("responsible_entity", ""),
|
||||
"integration_type": body["integration_type"],
|
||||
"status": "active",
|
||||
"created_at": 1,
|
||||
"updated_at": 1,
|
||||
},
|
||||
"keys": [],
|
||||
}).encode()
|
||||
self.send_response(201)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registration_server():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), RegistrationHandler)
|
||||
server.requests = []
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
def registration_request():
|
||||
return {
|
||||
"agent_id": "agent-1",
|
||||
"integration_type": "grok",
|
||||
"display_name": "Grok Agent",
|
||||
"responsible_entity": "platform@example.com",
|
||||
"keys": [{"kid": "key-v1", "public_key": "public-key", "algorithm": "ed25519"}],
|
||||
}
|
||||
|
||||
|
||||
def expected_http_request():
|
||||
return {
|
||||
"method": "POST",
|
||||
"path": "/v1/agents/register",
|
||||
"authorization": "Bearer api-token",
|
||||
"body": registration_request(),
|
||||
}
|
||||
|
||||
|
||||
def test_integration_types_match_public_api_contract() -> None:
|
||||
assert INTEGRATION_TYPES == EXPECTED_INTEGRATION_TYPES
|
||||
assert get_args(IntegrationType) == EXPECTED_INTEGRATION_TYPES
|
||||
assert RegisterAgentRequest.__required_keys__ == frozenset({
|
||||
"agent_id", "integration_type", "keys",
|
||||
})
|
||||
assert RegisterAgentRequest.__optional_keys__ == frozenset({
|
||||
"display_name", "responsible_entity",
|
||||
})
|
||||
|
||||
|
||||
def test_sync_registration_requires_and_sends_integration_type(registration_server) -> None:
|
||||
host, port = registration_server.server_address
|
||||
client = ElydoraClient(
|
||||
"org-1", "admin-agent", "unused",
|
||||
base_url=f"http://{host}:{port}", max_retries=0, token="api-token",
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid integration_type None"):
|
||||
client.register_agent({"agent_id": "agent-1", "keys": []})
|
||||
with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"):
|
||||
client.register_agent({
|
||||
"agent_id": "agent-1", "integration_type": "future-cli", "keys": [],
|
||||
})
|
||||
with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"):
|
||||
client.update_agent("agent-1", "future-cli")
|
||||
assert registration_server.requests == []
|
||||
|
||||
response = client.register_agent(registration_request())
|
||||
|
||||
assert response["agent"]["integration_type"] == "grok"
|
||||
assert registration_server.requests == [expected_http_request()]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_registration_requires_and_sends_integration_type(registration_server) -> None:
|
||||
host, port = registration_server.server_address
|
||||
client = AsyncElydoraClient(
|
||||
"org-1", "admin-agent", "unused",
|
||||
base_url=f"http://{host}:{port}", max_retries=0, token="api-token",
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="Invalid integration_type None"):
|
||||
await client.register_agent({"agent_id": "agent-1", "keys": []})
|
||||
with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"):
|
||||
await client.update_agent("agent-1", "future-cli")
|
||||
assert registration_server.requests == []
|
||||
|
||||
response = await client.register_agent(registration_request())
|
||||
|
||||
assert response["agent"]["integration_type"] == "grok"
|
||||
assert registration_server.requests == [expected_http_request()]
|
||||
finally:
|
||||
await client.close()
|
||||
Reference in new issue
Block a user