From 42d25d6b7e854cc0cc939555b040f63d4218f316 Mon Sep 17 00:00:00 2001 From: Anam AI Date: Tue, 4 Aug 2026 12:24:21 +0000 Subject: [PATCH 1/4] feat: add session region controls Serialize engine region preferences and expose the region reported by successful session starts without changing positional SessionInfo construction. T19 --- src/anam/types.py | 11 +++++++++++ tests/test_client.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/anam/types.py b/src/anam/types.py index ff37909..e840706 100644 --- a/src/anam/types.py +++ b/src/anam/types.py @@ -210,6 +210,9 @@ 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. Supported values are "eu" and "us". + region_policy: "preferred" permits cross-region capacity failover; "strict" + keeps the session in the requested region. """ enable_session_replay: bool = True @@ -218,6 +221,8 @@ class SessionOptions: video_height: int | None = None egress: EgressOptions | None = None show_ai_avatar_disclosure: bool | None = None + region: Literal["eu", "us"] | None = None + region_policy: Literal["preferred", "strict"] | None = None def __post_init__(self) -> None: self._session_replay = SessionReplayOptions( @@ -248,6 +253,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 @@ -363,6 +372,7 @@ class SessionInfo: heartbeat_interval_seconds: int max_reconnection_attempts: int ice_servers: list[dict[str, Any]] = field(default_factory=list) + region: Literal["eu", "us"] | None = None @classmethod def from_api_response(cls, data: dict[str, Any]) -> "SessionInfo": @@ -376,4 +386,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..1731e6e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -21,6 +21,7 @@ SessionOptions, ) from anam.errors import ConfigurationError, SessionError +from anam.types import SessionInfo class TestAnamClientInit: @@ -294,6 +295,18 @@ 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_omits_session_region_by_default(self) -> None: + result = SessionOptions().to_dict() + + assert "region" not in result + assert "regionPolicy" not in result + def test_ai_avatar_disclosure_preserves_positional_egress_argument(self) -> None: egress = EgressOptions( mode="daily", @@ -352,6 +365,40 @@ 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, + } + + def test_from_api_response_includes_served_region(self) -> None: + info = SessionInfo.from_api_response(self.api_response(region="us")) + + assert info.region == "us" + + 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 TestDirectorNoteCue: """Tests for Session.send_director_note_cue.""" From 12d462a4d18d3dba627b459572cbed2f06a39dd6 Mon Sep 17 00:00:00 2001 From: Anam AI Date: Tue, 4 Aug 2026 13:04:36 +0000 Subject: [PATCH 2/4] fix: complete session region controls Expose served regions on clients and sessions, and reject strict region policies without an explicit region. --- src/anam/client.py | 10 ++++++++++ src/anam/types.py | 2 ++ tests/test_client.py | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/anam/client.py b/src/anam/client.py index 4970bc7..6155b73 100644 --- a/src/anam/client.py +++ b/src/anam/client.py @@ -424,6 +424,11 @@ 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.""" + 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 +787,11 @@ 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.""" + 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 e840706..ada773b 100644 --- a/src/anam/types.py +++ b/src/anam/types.py @@ -232,6 +232,8 @@ 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 == "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), diff --git a/tests/test_client.py b/tests/test_client.py index 1731e6e..219c495 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -307,6 +307,19 @@ def test_to_dict_omits_session_region_by_default(self) -> None: assert "region" not in result assert "regionPolicy" not in result + 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", @@ -399,6 +412,32 @@ def test_region_field_preserves_positional_constructor_compatibility(self) -> No 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 + + def test_accessors_return_served_region(self) -> None: + client = self.client_with_response(region="us") + session = Session(client) + + assert client.region == "us" + assert session.region == "us" + + 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.""" From 3b45253cd092b8e22f69297fba12778d6f117973 Mon Sep 17 00:00:00 2001 From: Anam AI Date: Tue, 4 Aug 2026 20:32:47 +0000 Subject: [PATCH 3/4] fix: accept future session regions Keep region policies closed while allowing request and served-region values introduced after this SDK release. --- src/anam/client.py | 12 ++++++++++-- src/anam/types.py | 12 +++++++++--- tests/test_client.py | 30 ++++++++++++++++++++++-------- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/anam/client.py b/src/anam/client.py index 6155b73..8be3862 100644 --- a/src/anam/client.py +++ b/src/anam/client.py @@ -426,7 +426,11 @@ def session_id(self) -> str | None: @property def region(self) -> str | None: - """Get the current session region.""" + """Get the current session region. + + Known values today are "eu" and "us"; 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]: @@ -789,7 +793,11 @@ def session_id(self) -> str | None: @property def region(self) -> str | None: - """Get the session region.""" + """Get the session region. + + Known values today are "eu" and "us"; additional regions may be introduced + over time. Treat unrecognized values as informational. + """ return self._client.region async def __aenter__(self) -> Session: diff --git a/src/anam/types.py b/src/anam/types.py index ada773b..36ccbd3 100644 --- a/src/anam/types.py +++ b/src/anam/types.py @@ -210,7 +210,8 @@ 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. Supported values are "eu" and "us". + region: Requested engine region. Known values today are "eu" and "us"; + additional regions may be introduced over time. region_policy: "preferred" permits cross-region capacity failover; "strict" keeps the session in the requested region. """ @@ -221,7 +222,7 @@ class SessionOptions: video_height: int | None = None egress: EgressOptions | None = None show_ai_avatar_disclosure: bool | None = None - region: Literal["eu", "us"] | None = None + region: str | None = None region_policy: Literal["preferred", "strict"] | None = None def __post_init__(self) -> None: @@ -365,6 +366,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. Known values today are "eu" + and "us"; additional regions may be introduced over time. Treat + unrecognized values as informational. """ session_id: str @@ -374,7 +380,7 @@ class SessionInfo: heartbeat_interval_seconds: int max_reconnection_attempts: int ice_servers: list[dict[str, Any]] = field(default_factory=list) - region: Literal["eu", "us"] | None = None + region: str | None = None @classmethod def from_api_response(cls, data: dict[str, Any]) -> "SessionInfo": diff --git a/tests/test_client.py b/tests/test_client.py index 219c495..573b1c4 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 @@ -301,6 +301,15 @@ def test_to_dict_with_session_region(self) -> None: 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="ap", region_policy="preferred") + + assert options.to_dict()["region"] == "ap" + 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() @@ -396,10 +405,14 @@ def api_response(**overrides: Any) -> dict[str, Any]: **overrides, } - def test_from_api_response_includes_served_region(self) -> None: - info = SessionInfo.from_api_response(self.api_response(region="us")) + @pytest.mark.parametrize("region", ["us", "ap"]) + 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 - assert info.region == "us" + 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()) @@ -423,12 +436,13 @@ def client_with_response(**overrides: Any) -> AnamClient: ) return client - def test_accessors_return_served_region(self) -> None: - client = self.client_with_response(region="us") + @pytest.mark.parametrize("region", ["us", "ap"]) + def test_accessors_return_served_region(self, region: str) -> None: + client = self.client_with_response(region=region) session = Session(client) - assert client.region == "us" - assert session.region == "us" + assert client.region == region + assert session.region == region def test_accessors_return_none_when_region_is_unreported(self) -> None: client = self.client_with_response() From b8440e4d24edab489589bbc9214099fcbb8b6ad7 Mon Sep 17 00:00:00 2001 From: Anam AI Date: Thu, 6 Aug 2026 13:32:01 +0000 Subject: [PATCH 4/4] Address review: validate region_policy, docs links, fictional test region - Runtime-validate region_policy like video_quality (cubic review) - Docstrings point to public docs instead of enumerating regions (seb) - Replace "ap" with clearly fictional "mars" in future-region tests (seb) Co-Authored-By: Claude Fable 5 --- src/anam/client.py | 8 ++++---- src/anam/types.py | 15 ++++++++++----- tests/test_client.py | 15 +++++++++++---- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/anam/client.py b/src/anam/client.py index 8be3862..d4567f6 100644 --- a/src/anam/client.py +++ b/src/anam/client.py @@ -428,8 +428,8 @@ def session_id(self) -> str | None: def region(self) -> str | None: """Get the current session region. - Known values today are "eu" and "us"; additional regions may be introduced - over time. Treat unrecognized values as informational. + 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 @@ -795,8 +795,8 @@ def session_id(self) -> str | None: def region(self) -> str | None: """Get the session region. - Known values today are "eu" and "us"; additional regions may be introduced - over time. Treat unrecognized values as informational. + See https://docs.anam.ai for available regions; additional regions may be + introduced over time. Treat unrecognized values as informational. """ return self._client.region diff --git a/src/anam/types.py b/src/anam/types.py index 36ccbd3..f694616 100644 --- a/src/anam/types.py +++ b/src/anam/types.py @@ -210,8 +210,8 @@ 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. Known values today are "eu" and "us"; - additional regions may be introduced over time. + 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. """ @@ -233,6 +233,11 @@ 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 ( @@ -368,9 +373,9 @@ class SessionInfo: This is returned by the API when starting a session. Args: - region: Actual region that served the session. Known values today are "eu" - and "us"; additional regions may be introduced over time. Treat - unrecognized values as informational. + 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 diff --git a/tests/test_client.py b/tests/test_client.py index 573b1c4..0173046 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -302,9 +302,9 @@ def test_to_dict_with_session_region(self) -> None: assert options.to_dict()["regionPolicy"] == "strict" def test_to_dict_with_future_session_region(self) -> None: - options = SessionOptions(region="ap", region_policy="preferred") + options = SessionOptions(region="mars", region_policy="preferred") - assert options.to_dict()["region"] == "ap" + assert options.to_dict()["region"] == "mars" assert options.to_dict()["regionPolicy"] == "preferred" def test_region_type_accepts_future_values(self) -> None: @@ -316,6 +316,13 @@ def test_to_dict_omits_session_region_by_default(self) -> None: 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, @@ -405,7 +412,7 @@ def api_response(**overrides: Any) -> dict[str, Any]: **overrides, } - @pytest.mark.parametrize("region", ["us", "ap"]) + @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)) @@ -436,7 +443,7 @@ def client_with_response(**overrides: Any) -> AnamClient: ) return client - @pytest.mark.parametrize("region", ["us", "ap"]) + @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)