diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f2fb36..de2cd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to `intuno-sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] — 2026-04-23 + +### Added + +- **Service delegation.** `IntunoClient` and `AsyncIntunoClient` accept + an optional `act_as_user_id` kwarg. When set, the client sends + `X-Service-Key: ` + `X-On-Behalf-Of: ` instead of the + normal user auth headers. The backend attributes every call to the + delegated user. + + Intended for internal services (e.g. `wisdom-agents` hosting + multi-tenant entities) that need to make network / registry / broker + calls on behalf of specific users without holding those users' API + keys. Not for end-user SDK code. + + ```python + from uuid import UUID + from intuno_sdk import AsyncIntunoClient + + client = AsyncIntunoClient( + api_key=settings.AGENTS_SERVICE_API_KEY, # the service secret + act_as_user_id=UUID("..."), # the entity's owner + ) + ``` + ## [0.4.0] — 2026-04-22 ### Added @@ -102,6 +127,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm (`list_conversations`, `get_messages`), and LangChain / OpenAI integration helpers. +[0.5.0]: https://github.com/IntunoAI/intuno-sdk/releases/tag/v0.5.0 [0.4.0]: https://github.com/IntunoAI/intuno-sdk/releases/tag/v0.4.0 [0.3.0]: https://github.com/IntunoAI/intuno-sdk/releases/tag/v0.3.0 [0.2.2]: https://github.com/IntunoAI/intuno-sdk/releases/tag/v0.2.2 diff --git a/pyproject.toml b/pyproject.toml index 05b0c50..ddd2c3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "intuno-sdk" -version = "0.4.0" +version = "0.5.0" description = "The official Python SDK for the Intuno Agent Network." authors = ["Alquify Inc. "] license = "Apache-2.0" diff --git a/src/intuno_sdk/client.py b/src/intuno_sdk/client.py index 00026e5..1311455 100644 --- a/src/intuno_sdk/client.py +++ b/src/intuno_sdk/client.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union +from uuid import UUID as UUIDType import httpx from pydantic import ValidationError @@ -29,12 +30,25 @@ ) -def _build_auth_headers(api_key: str) -> dict: - """Build auth headers, supporting both API keys and JWT bearer tokens.""" +def _build_auth_headers( + api_key: str, + act_as_user_id: Optional[Union[str, "UUIDType"]] = None, +) -> dict: + """Build auth headers. + + - Plain user key / JWT: sends ``X-API-Key`` or ``Authorization: Bearer``. + - Service delegation (``act_as_user_id`` set): sends ``X-Service-Key`` + + ``X-On-Behalf-Of`` instead. The ``api_key`` argument is the + service secret in this mode. + """ headers = { "Content-Type": "application/json", "User-Agent": f"Intuno-SDK/{SDK_VERSION}", } + if act_as_user_id is not None: + headers["X-Service-Key"] = api_key + headers["X-On-Behalf-Of"] = str(act_as_user_id) + return headers # JWT tokens start with 'eyJ' (base64-encoded JSON header) if api_key.startswith("eyJ"): headers["Authorization"] = f"Bearer {api_key}" @@ -46,6 +60,14 @@ def _build_auth_headers(api_key: str) -> dict: class IntunoClient: """ The main synchronous client for interacting with the Intuno Agent Network. + + Service delegation + ------------------ + Pass ``act_as_user_id`` to operate on behalf of a specific user. The + ``api_key`` you provide is then used as a *service secret* — sent as + ``X-Service-Key`` alongside ``X-On-Behalf-Of`` — and the backend + attributes every call to the delegated user. Only internal services + (wisdom-agents today) should use this mode. """ def __init__( @@ -53,16 +75,19 @@ def __init__( api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, + *, + act_as_user_id: Optional[Union[str, UUIDType]] = None, ): if not api_key: raise APIKeyMissingError() self.api_key = api_key self.base_url = base_url + self.act_as_user_id = act_as_user_id self._http_client = httpx.Client( base_url=self.base_url, timeout=timeout, - headers=_build_auth_headers(api_key), + headers=_build_auth_headers(api_key, act_as_user_id=act_as_user_id), ) def discover(self, query: str, limit: int = 10) -> List[Agent]: @@ -1038,6 +1063,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): class AsyncIntunoClient: """ The main asynchronous client for interacting with the Intuno Agent Network. + + Service delegation: see ``IntunoClient`` — same ``act_as_user_id`` kwarg. """ def __init__( @@ -1045,16 +1072,19 @@ def __init__( api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, + *, + act_as_user_id: Optional[Union[str, UUIDType]] = None, ): if not api_key: raise APIKeyMissingError() self.api_key = api_key self.base_url = base_url + self.act_as_user_id = act_as_user_id self._http_client = httpx.AsyncClient( base_url=self.base_url, timeout=timeout, - headers=_build_auth_headers(api_key), + headers=_build_auth_headers(api_key, act_as_user_id=act_as_user_id), ) async def discover(self, query: str, limit: int = 10) -> List[Agent]: diff --git a/src/intuno_sdk/constants.py b/src/intuno_sdk/constants.py index f558af8..0ea9fdf 100644 --- a/src/intuno_sdk/constants.py +++ b/src/intuno_sdk/constants.py @@ -1,2 +1,2 @@ DEFAULT_BASE_URL = "https://api.intuno.net" -SDK_VERSION = "0.4.0" +SDK_VERSION = "0.5.0" diff --git a/tests/test_client.py b/tests/test_client.py index 80cae0e..f1b6059 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -69,6 +69,57 @@ def test_init_requires_api_key(): AsyncIntunoClient(api_key="") +# --- Service delegation (act_as_user_id) --- + + +def _header_keys(client): + return {k.lower() for k in client._http_client.headers.keys()} + + +def test_normal_mode_sends_x_api_key(): + c = IntunoClient(api_key="wsk_user_key") + keys = _header_keys(c) + assert "x-api-key" in keys + assert "x-service-key" not in keys + assert "x-on-behalf-of" not in keys + + +def test_delegation_sends_service_key_and_on_behalf_of(): + from uuid import UUID + + user_id = UUID("11111111-1111-4111-8111-111111111111") + c = IntunoClient(api_key="service_secret", act_as_user_id=user_id) + keys = _header_keys(c) + assert "x-service-key" in keys + assert "x-on-behalf-of" in keys + # Normal auth header is NOT sent in service mode + assert "x-api-key" not in keys + assert "authorization" not in keys + # UUID stringified + assert c._http_client.headers["X-On-Behalf-Of"] == str(user_id) + # Stored attribute for introspection + assert c.act_as_user_id == user_id + + +def test_delegation_accepts_string_user_id(): + c = IntunoClient( + api_key="service_secret", + act_as_user_id="22222222-2222-4222-8222-222222222222", + ) + assert c._http_client.headers["X-On-Behalf-Of"] == "22222222-2222-4222-8222-222222222222" + + +def test_async_delegation_sends_service_key_and_on_behalf_of(): + from uuid import UUID + + user_id = UUID("33333333-3333-4333-8333-333333333333") + c = AsyncIntunoClient(api_key="service_secret", act_as_user_id=user_id) + keys = _header_keys(c) + assert "x-service-key" in keys + assert "x-on-behalf-of" in keys + assert "x-api-key" not in keys + + # --- Synchronous Client Tests ---