diff --git a/src/anam/client.py b/src/anam/client.py index 4970bc7..d4567f6 100644 --- a/src/anam/client.py +++ b/src/anam/client.py @@ -424,6 +424,15 @@ def session_id(self) -> str | None: """Get the current session ID.""" return self._session_info.session_id if self._session_info else None + @property + def region(self) -> str | None: + """Get the current session region. + + See https://docs.anam.ai for available regions; additional regions may be + introduced over time. Treat unrecognized values as informational. + """ + return self._session_info.region if self._session_info else None + def get_message_history(self) -> list[Message]: """Get the current message history. @@ -782,6 +791,15 @@ def session_id(self) -> str | None: """Get the session ID.""" return self._client.session_id + @property + def region(self) -> str | None: + """Get the session region. + + See https://docs.anam.ai for available regions; additional regions may be + introduced over time. Treat unrecognized values as informational. + """ + return self._client.region + async def __aenter__(self) -> Session: """Enter async context.""" return self diff --git a/src/anam/types.py b/src/anam/types.py index ff37909..f694616 100644 --- a/src/anam/types.py +++ b/src/anam/types.py @@ -210,6 +210,10 @@ class SessionOptions: egress: Optional direct egress to a third-party transport (e.g. Daily). See :class:`EgressOptions`. show_ai_avatar_disclosure: Show Anam's AI avatar disclosure watermark throughout the session. Defaults to Anam's default behavior, which is off. + region: Requested engine region. See https://docs.anam.ai for available + regions; additional regions may be introduced over time. + region_policy: "preferred" permits cross-region capacity failover; "strict" + keeps the session in the requested region. """ enable_session_replay: bool = True @@ -218,6 +222,8 @@ class SessionOptions: video_height: int | None = None egress: EgressOptions | None = None show_ai_avatar_disclosure: bool | None = None + region: str | None = None + region_policy: Literal["preferred", "strict"] | None = None def __post_init__(self) -> None: self._session_replay = SessionReplayOptions( @@ -227,6 +233,13 @@ def __post_init__(self) -> None: raise ValueError('video_quality must be either "high" or "auto"') if (self.video_width is None) != (self.video_height is None): raise ValueError("video_width and video_height must be provided together") + if self.region_policy is not None and self.region_policy not in { + "preferred", + "strict", + }: + raise ValueError('region_policy must be either "preferred" or "strict"') + if self.region_policy == "strict" and self.region is None: + raise ValueError('region_policy="strict" requires region to be set') for name, value in ( ("video_width", self.video_width), ("video_height", self.video_height), @@ -248,6 +261,10 @@ def to_dict(self) -> dict[str, Any]: result["egress"] = self.egress.to_dict() if self.show_ai_avatar_disclosure is not None: result["showAIAvatarDisclosure"] = self.show_ai_avatar_disclosure + if self.region is not None: + result["region"] = self.region + if self.region_policy is not None: + result["regionPolicy"] = self.region_policy return result @@ -354,6 +371,11 @@ class SessionInfo: """Information about an active streaming session. This is returned by the API when starting a session. + + Args: + region: Actual region that served the session. See https://docs.anam.ai + for available regions; additional regions may be introduced over + time. Treat unrecognized values as informational. """ session_id: str @@ -363,6 +385,7 @@ class SessionInfo: heartbeat_interval_seconds: int max_reconnection_attempts: int ice_servers: list[dict[str, Any]] = field(default_factory=list) + region: str | None = None @classmethod def from_api_response(cls, data: dict[str, Any]) -> "SessionInfo": @@ -376,4 +399,5 @@ def from_api_response(cls, data: dict[str, Any]) -> "SessionInfo": heartbeat_interval_seconds=client_config.get("heartbeatIntervalSeconds", 5), max_reconnection_attempts=client_config.get("maxWsReconnectionAttempts", 5), ice_servers=client_config.get("iceServers", []), + region=data.get("region"), ) diff --git a/tests/test_client.py b/tests/test_client.py index 3933e70..0173046 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,7 +2,7 @@ import json import math -from typing import Any +from typing import Any, get_type_hints from unittest.mock import AsyncMock, MagicMock import pytest @@ -21,6 +21,7 @@ SessionOptions, ) from anam.errors import ConfigurationError, SessionError +from anam.types import SessionInfo class TestAnamClientInit: @@ -294,6 +295,47 @@ def test_to_dict_with_ai_avatar_disclosure(self, value: bool) -> None: def test_to_dict_omits_ai_avatar_disclosure_by_default(self) -> None: assert "showAIAvatarDisclosure" not in SessionOptions().to_dict() + def test_to_dict_with_session_region(self) -> None: + options = SessionOptions(region="eu", region_policy="strict") + + assert options.to_dict()["region"] == "eu" + assert options.to_dict()["regionPolicy"] == "strict" + + def test_to_dict_with_future_session_region(self) -> None: + options = SessionOptions(region="mars", region_policy="preferred") + + assert options.to_dict()["region"] == "mars" + assert options.to_dict()["regionPolicy"] == "preferred" + + def test_region_type_accepts_future_values(self) -> None: + assert get_type_hints(SessionOptions)["region"] == str | None + + def test_to_dict_omits_session_region_by_default(self) -> None: + result = SessionOptions().to_dict() + + assert "region" not in result + assert "regionPolicy" not in result + + def test_invalid_region_policy_raises(self) -> None: + with pytest.raises( + ValueError, + match='region_policy must be either "preferred" or "strict"', + ): + SessionOptions(region="eu", region_policy="fallback") # type: ignore[arg-type] + + def test_strict_region_policy_requires_region(self) -> None: + with pytest.raises( + ValueError, + match='region_policy="strict" requires region to be set', + ): + SessionOptions(region_policy="strict") + + def test_strict_region_policy_with_region_is_valid(self) -> None: + options = SessionOptions(region="us", region_policy="strict") + + assert options.region == "us" + assert options.region_policy == "strict" + def test_ai_avatar_disclosure_preserves_positional_egress_argument(self) -> None: egress = EgressOptions( mode="daily", @@ -352,6 +394,71 @@ def test_video_dimensions_must_be_positive_integers( SessionOptions(**kwargs) # type: ignore[arg-type] +class TestSessionInfo: + """Tests for engine-session response parsing.""" + + @staticmethod + def api_response(**overrides: Any) -> dict[str, Any]: + return { + "sessionId": "session-1", + "engineHost": "engine.test", + "engineProtocol": "https", + "signallingEndpoint": "/ws", + "clientConfig": { + "heartbeatIntervalSeconds": 5, + "maxWsReconnectionAttempts": 3, + "iceServers": [], + }, + **overrides, + } + + @pytest.mark.parametrize("region", ["us", "mars"]) + def test_from_api_response_includes_served_region(self, region: str) -> None: + info = SessionInfo.from_api_response(self.api_response(region=region)) + + assert info.region == region + + def test_region_type_accepts_future_values(self) -> None: + assert get_type_hints(SessionInfo)["region"] == str | None + + def test_from_api_response_omits_unreported_region(self) -> None: + info = SessionInfo.from_api_response(self.api_response()) + + assert info.region is None + + def test_region_field_preserves_positional_constructor_compatibility(self) -> None: + info = SessionInfo("session-1", "engine.test", "https", "/ws", 5, 3, []) + + assert info.region is None + + +class TestSessionRegionAccessors: + """Tests for served-region access through the public client API.""" + + @staticmethod + def client_with_response(**overrides: Any) -> AnamClient: + client = AnamClient(api_key="test-key", persona_id="stateful-persona") + client._session_info = SessionInfo.from_api_response( + TestSessionInfo.api_response(**overrides) + ) + return client + + @pytest.mark.parametrize("region", ["us", "mars"]) + def test_accessors_return_served_region(self, region: str) -> None: + client = self.client_with_response(region=region) + session = Session(client) + + assert client.region == region + assert session.region == region + + def test_accessors_return_none_when_region_is_unreported(self) -> None: + client = self.client_with_response() + session = Session(client) + + assert client.region is None + assert session.region is None + + class TestDirectorNoteCue: """Tests for Session.send_director_note_cue."""