Skip to content
67 changes: 56 additions & 11 deletions src/sap_cloud_sdk/agent_memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@
Usage::
from sap_cloud_sdk.agent_memory import create_client
from sap_cloud_sdk.agent_memory import create_client, AccessStrategy
client = create_client()
# Subscriber tenant — strategy and tenant set once, inherited by all calls
client = create_client(
access_strategy=AccessStrategy.SUBSCRIBER,
tenant="my-tenant-subdomain",
)
memories = client.list_memories(agent_id="my-agent", invoker_id="user-123")
"""

from typing import Optional

from sap_cloud_sdk.agent_memory._http_transport import HttpTransport
from sap_cloud_sdk.agent_memory.client import AgentMemoryClient
from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig, _load_config_from_env
from sap_cloud_sdk.agent_memory.config import (
AgentMemoryConfig,
_load_config_for_instance,
_load_config_from_env,
)
from sap_cloud_sdk.agent_memory.exceptions import (
AgentMemoryConfigError,
AgentMemoryError,
Expand All @@ -24,6 +32,7 @@
AgentMemoryValidationError,
)
from sap_cloud_sdk.agent_memory._models import (
AccessStrategy,
Memory,
Message,
MessageRole,
Expand All @@ -33,14 +42,35 @@
from sap_cloud_sdk.agent_memory.utils._odata import FilterDefinition


def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryClient:
def create_client(
*,
config: Optional[AgentMemoryConfig] = None,
access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER,
tenant: Optional[str] = None,
) -> AgentMemoryClient:
"""Create an :class:`AgentMemoryClient` with automatic credential detection.
The binding loaded depends on ``access_strategy`` and ``tenant``:
- ``SUBSCRIBER`` with ``tenant="acme-corp"`` — loads the subscriber
binding from ``/etc/secrets/appfnd/hana-agent-memory/acme-corp/`` (or
``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_*`` env vars). Per-call
tenant overrides load additional bindings lazily and cache them.
- ``PROVIDER`` — loads the provider binding from
``/etc/secrets/appfnd/hana-agent-memory/default/`` (or
``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` env vars).
- Explicit ``config`` — uses the provided configuration for all calls.
Per-call tenant overrides are not supported when ``config`` is provided.
Args:
config: Optional explicit configuration. If ``None``, credentials are
loaded from the mounted volume at
``/etc/secrets/appfnd/hana-agent-memory/default/`` or from
``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables.
config: Optional explicit configuration. When provided, no binding
discovery is performed and per-call tenant overrides are disabled.
access_strategy: Default tenant access strategy for all client operations.
Defaults to ``SUBSCRIBER``. Individual method calls may override
this value.
tenant: Default subscriber tenant subdomain. Required when
``access_strategy=SUBSCRIBER``. Individual method calls may
override this value.
Returns:
A ready-to-use :class:`AgentMemoryClient`.
Expand All @@ -49,9 +79,23 @@ def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryC
AgentMemoryConfigError: If configuration is missing or invalid.
"""
try:
resolved_config = config if config is not None else _load_config_from_env()
transport = HttpTransport(resolved_config)
return AgentMemoryClient(transport)
if config is not None:
initial_config = config
loader = None
elif access_strategy is AccessStrategy.SUBSCRIBER and tenant:
initial_config = _load_config_for_instance(tenant)
loader = _load_config_for_instance
else:
initial_config = _load_config_from_env()
loader = _load_config_for_instance

transport = HttpTransport(initial_config)
return AgentMemoryClient(
transport,
access_strategy=access_strategy,
tenant=tenant,
config_loader=loader,
)
except AgentMemoryConfigError:
raise
except Exception as exc:
Expand All @@ -61,6 +105,7 @@ def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryC


__all__ = [
"AccessStrategy",
"AgentMemoryClient",
"AgentMemoryConfig",
"AgentMemoryError",
Expand Down
130 changes: 88 additions & 42 deletions src/sap_cloud_sdk/agent_memory/_http_transport.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""HTTP transport for the Agent Memory service.

Handles OAuth2 ``client_credentials`` token acquisition with lazy,
expiry-aware caching. If ``token_url`` is not configured, requests are
sent unauthenticated — expected for local development environments.
expiry-aware caching per tenant subdomain. If ``token_url`` is not configured,
requests are sent unauthenticated — expected for local development environments.
"""

from __future__ import annotations
Expand Down Expand Up @@ -31,38 +31,44 @@
class HttpTransport:
"""Internal HTTP transport for the Agent Memory service.

Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) and
attaches the ``Authorization`` header to every request automatically via
``OAuth2Session``. In no-auth mode (no ``token_url``), a plain
``requests.Session`` is used instead.
Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) per
tenant subdomain and attaches the ``Authorization`` header automatically.
In no-auth mode (no ``token_url``), a plain ``requests.Session`` is used.

Args:
config: Service configuration.
"""

def __init__(self, config: AgentMemoryConfig) -> None:
self._config = config
self._oauth: Optional[OAuth2Session] = None
# Keyed by tenant_subdomain (None = provider token)
self._oauth_cache: dict[Optional[str], tuple[OAuth2Session, datetime]] = {}
self._plain_session: Optional[requests.Session] = None
self._token_expires_at: Optional[datetime] = None

def close(self) -> None:
"""Close the underlying HTTP session(s) and release resources."""
if self._oauth is not None:
self._oauth.close()
self._oauth = None
"""Close all underlying HTTP sessions and release resources."""
for oauth, _ in self._oauth_cache.values():
oauth.close()
self._oauth_cache.clear()
if self._plain_session is not None:
self._plain_session.close()
self._plain_session = None

# ── Public HTTP methods ────────────────────────────────────────────────────

def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
def get(
self,
path: str,
params: Optional[dict[str, Any]] = None,
*,
tenant_subdomain: Optional[str] = None,
) -> dict[str, Any]:
"""Perform a GET request.

Args:
path: API path (appended to ``base_url``).
params: Optional query parameters.
tenant_subdomain: Subscriber tenant subdomain for token derivation.

Returns:
Parsed JSON response body.
Expand All @@ -71,14 +77,23 @@ def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, A
AgentMemoryHttpError: On HTTP errors or network failures.
AgentMemoryNotFoundError: If the server returns 404.
"""
return self._request("GET", path, params=params)
return self._request(
"GET", path, params=params, tenant_subdomain=tenant_subdomain
)

def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
def post(
self,
path: str,
json: Optional[dict[str, Any]] = None,
*,
tenant_subdomain: Optional[str] = None,
) -> dict[str, Any]:
"""Perform a POST request.

Args:
path: API path (appended to ``base_url``).
json: Optional request body dict (serialised to JSON).
tenant_subdomain: Subscriber tenant subdomain for token derivation.

Returns:
Parsed JSON response body. Returns an empty dict for 204 responses.
Expand All @@ -87,14 +102,21 @@ def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, An
AgentMemoryHttpError: On HTTP errors or network failures.
AgentMemoryNotFoundError: If the server returns 404.
"""
return self._request("POST", path, json=json)

def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
return self._request("POST", path, json=json, tenant_subdomain=tenant_subdomain)

def patch(
self,
path: str,
json: Optional[dict[str, Any]] = None,
*,
tenant_subdomain: Optional[str] = None,
) -> dict[str, Any]:
"""Perform a PATCH request.

Args:
path: API path (appended to ``base_url``).
json: Optional request body dict (serialised to JSON).
tenant_subdomain: Subscriber tenant subdomain for token derivation.

Returns:
Parsed JSON response body. Returns an empty dict for 204 responses.
Expand All @@ -103,58 +125,71 @@ def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, A
AgentMemoryHttpError: On HTTP errors or network failures.
AgentMemoryNotFoundError: If the server returns 404.
"""
return self._request("PATCH", path, json=json)
return self._request(
"PATCH", path, json=json, tenant_subdomain=tenant_subdomain
)

def delete(self, path: str) -> None:
def delete(self, path: str, *, tenant_subdomain: Optional[str] = None) -> None:
"""Perform a DELETE request.

Args:
path: API path (appended to ``base_url``).
tenant_subdomain: Subscriber tenant subdomain for token derivation.

Raises:
AgentMemoryHttpError: On HTTP errors or network failures.
AgentMemoryNotFoundError: If the server returns 404.
"""
self._request("DELETE", path)
self._request("DELETE", path, tenant_subdomain=tenant_subdomain)

# ── Internal helpers ───────────────────────────────────────────────────────

def _get_session(self) -> requests.Session:
"""Return a session ready to make requests.
def _get_session(self, tenant_subdomain: Optional[str]) -> requests.Session:
"""Return a session ready to make requests for the given tenant.

In no-auth mode, returns a plain ``requests.Session`` (created once).
In OAuth2 mode, returns an ``OAuth2Session`` with a valid token,
fetching or refreshing the token if needed.
fetching or refreshing per-tenant as needed.
"""
if not self._config.token_url:
if self._plain_session is None:
self._plain_session = requests.Session()
return self._plain_session

if (
self._oauth is not None
and self._token_expires_at is not None
and datetime.now() < self._token_expires_at
):
return self._oauth
cached = self._oauth_cache.get(tenant_subdomain)
if cached is not None:
oauth, expires_at = cached
if datetime.now() < expires_at:
return oauth

self._oauth = self._fetch_token()
return self._oauth
return self._fetch_token(tenant_subdomain)

def _fetch_token(self) -> OAuth2Session:
"""Acquire a new OAuth2 ``client_credentials`` token.
def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session:
"""Acquire a new OAuth2 ``client_credentials`` token for the given tenant.

When ``tenant_subdomain`` is provided and ``config.identityzone`` is set,
derives the subscriber token URL by replacing the provider identityzone
in ``token_url`` with ``tenant_subdomain``.

Returns:
An ``OAuth2Session`` with a valid token attached.

Raises:
AgentMemoryHttpError: If the token endpoint returns an error or is unreachable.
"""
token_url = self._config.token_url
if (
tenant_subdomain is not None
and self._config.identityzone is not None
and token_url is not None
):
token_url = token_url.replace(self._config.identityzone, tenant_subdomain)

try:
client = BackendApplicationClient(client_id=self._config.client_id)
oauth = OAuth2Session(client=client)
token = oauth.fetch_token(
token_url=self._config.token_url,
token_url=token_url,
client_id=self._config.client_id,
client_secret=self._config.client_secret,
timeout=self._config.timeout,
Expand All @@ -163,29 +198,40 @@ def _fetch_token(self) -> OAuth2Session:
raise AgentMemoryHttpError(f"Failed to obtain OAuth2 token: {exc}") from exc

expires_in: int = token.get("expires_in", 3600)
self._token_expires_at = datetime.now() + timedelta(
expires_at = datetime.now() + timedelta(
seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS
)

if self._oauth is not None:
self._oauth.close()
existing = self._oauth_cache.get(tenant_subdomain)
if existing is not None:
existing[0].close()

self._oauth_cache[tenant_subdomain] = (oauth, expires_at)

logger.debug(
"Obtained new Agent Memory OAuth2 token (expires in %ds)", expires_in
"Obtained new Agent Memory OAuth2 token for tenant=%r (expires in %ds)",
tenant_subdomain,
expires_in,
)
return oauth

def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
def _request(
self,
method: str,
path: str,
tenant_subdomain: Optional[str] = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Execute an HTTP request using the appropriate session."""
logger.debug("%s %s", method, path)
logger.debug("%s %s (tenant=%r)", method, path, tenant_subdomain)

url = f"{self._config.base_url}{path}"
if "params" in kwargs:
raw_params: dict[str, Any] = kwargs.pop("params")
if raw_params:
url = f"{url}?{urlencode(raw_params, quote_via=quote)}"

session = self._get_session()
session = self._get_session(tenant_subdomain)
headers = {"Content-Type": "application/json"}

try:
Expand Down
7 changes: 7 additions & 0 deletions src/sap_cloud_sdk/agent_memory/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
from typing import Any, Optional


class AccessStrategy(str, Enum):
"""Access strategy for tenant-scoped Agent Memory operations."""

SUBSCRIBER = "SUBSCRIBER"
PROVIDER = "PROVIDER"


class MessageRole(str, Enum):
"""Role of the message author."""

Expand Down
Loading
Loading