Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/anam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/anam/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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),
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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":
Expand All @@ -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"),
)
109 changes: 108 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +21,7 @@
SessionOptions,
)
from anam.errors import ConfigurationError, SessionError
from anam.types import SessionInfo


class TestAnamClientInit:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down
Loading