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
44 changes: 44 additions & 0 deletions .github/workflows/auto-approve.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Auto approve low risk and hot fix PRs

on:
pull_request:
types: [labeled, synchronize, opened, reopened]

permissions: {}

jobs:
auto-approve:
runs-on: ubuntu-latest

steps:
- id: app-token
if: contains(github.event.pull_request.labels.*.name, 'hot-fix') || contains(github.event.pull_request.labels.*.name, 'low-risk')
uses: actions/create-github-app-token@v2
with:
app-id: '1095332'
private-key: ${{ secrets.ANAM_PUSH_BOT_PRIVATE_KEY }}
Comment thread
bc-anam marked this conversation as resolved.

- uses: actions/github-script@v7
if: contains(github.event.pull_request.labels.*.name, 'hot-fix') || contains(github.event.pull_request.labels.*.name, 'low-risk')
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (reviews.some((review) => review.state === 'APPROVED')) {
core.info('PR is already approved');
return;
}
const labels = context.payload.pull_request.labels
.map((label) => label.name)
.filter((label) => ['hot-fix', 'low-risk'].includes(label));
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
event: 'APPROVE',
body: `Auto-approved with label(s): ${labels.join(', ')}`,
});
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ async def main():
asyncio.run(main())
```

### Pre-minted session tokens

Use a pre-minted session token when another trusted service has already
snapshotted the persona and session configuration:

```python
client = AnamClient(session_token="your-session-token")

async with client.connect() as session:
await session.talk("This text is sent directly to TTS.")
```

The token and its server-side snapshot are authoritative. Do not also pass an
API key, persona configuration, client label, routing override, or session
options that you expect to override that snapshot.

## Features

- 🎥 **Real-time Audio/Video streaming** - Receive synchronized audio/video frames from the avatar (as PyAV AudioFrame/VideoFrame objects)
Expand Down
50 changes: 31 additions & 19 deletions src/anam/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@
class CoreApiClient:
"""Internal client for Anam REST API.

Starts sessions using the direct API-key path.
Starts sessions using either direct API-key auth or a pre-minted token.
"""

def __init__(
self,
api_key: str,
api_key: str | None = None,
options: ClientOptions | None = None,
*,
session_token: str | None = None,
):
self._api_key = api_key
self._session_token = session_token
self._options = options or ClientOptions()
self._base_url = self._options.api_base_url
self._api_version = self._options.api_version
Expand All @@ -40,13 +43,14 @@ def _api_url(self) -> str:

async def start_session(
self,
persona_config: PersonaConfig,
persona_config: PersonaConfig | None,
session_options: SessionOptions,
) -> SessionInfo:
"""Start a new streaming session using direct API-key auth.
"""Start a new streaming session.

Args:
persona_config: The persona configuration.
persona_config: The persona configuration for API-key auth. The
server-side token snapshot is used for session-token auth.
session_options: Additional session options.

Returns:
Expand All @@ -58,23 +62,31 @@ async def start_session(
AnamError: For any other unexpected server response.
"""
url = f"{self._api_url}/engine/session"
authorization = self._session_token or self._api_key
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._api_key}",
"Authorization": f"Bearer {authorization}",
}
client_label = self._options.client_label or "python-sdk"
body: dict[str, Any] = {
"clientLabel": client_label,
"personaConfig": persona_config.to_dict(),
"sessionOptions": session_options.to_dict(),
"clientMetadata": CLIENT_METADATA,
}
# Engine routing overrides (e.g. pin to a specific pod / devspace /
# preview). Only sent when set; omitted entirely for production.
if self._options.environment:
body["environment"] = self._options.environment

logger.debug("Starting session at %s (direct API-key auth)", url)
body: dict[str, Any] = {"clientMetadata": CLIENT_METADATA}
auth_mode = "session-token" if self._session_token else "direct API-key"

if not self._session_token:
if persona_config is None:
raise SessionError("Persona configuration not found")
client_label = self._options.client_label or "python-sdk"
body.update(
{
"clientLabel": client_label,
"personaConfig": persona_config.to_dict(),
"sessionOptions": session_options.to_dict(),
}
)
# Engine routing overrides (e.g. pin to a specific pod / devspace /
# preview). Only sent when set; omitted entirely for production.
if self._options.environment:
body["environment"] = self._options.environment

logger.debug("Starting session at %s (%s auth)", url, auth_mode)

async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=body) as response:
Expand Down
82 changes: 62 additions & 20 deletions src/anam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,29 @@ class AnamClient:

def __init__(
self,
api_key: str,
api_key: str | None = None,
persona_id: str | None = None,
persona_config: PersonaConfig | None = None,
options: ClientOptions | None = None,
*,
session_token: str | None = None,
):
"""Initialize the Anam client.

You must provide either `persona_id` for a simple setup, or `persona_config`
for full configuration control. `persona_config` takes precedence over `persona_id`.
Authenticate with either an API key or a pre-minted session token.

API-key authentication requires either `persona_id` for a simple setup
or `persona_config` for full configuration control. A session token
already contains the server-side persona and session snapshot, so it
must not be combined with either persona argument.

Args:
api_key: Your Anam API key.
api_key: Your Anam API key. Mutually exclusive with `session_token`.
persona_id: ID of the persona to use (simple setup).
persona_config: Full persona configuration (advanced setup).
options: Additional client options.
session_token: A pre-minted Anam session token. Mutually exclusive
with `api_key` and persona configuration.

Raises:
ConfigurationError: If configuration is invalid.
Expand All @@ -108,25 +116,56 @@ def __init__(
),
)
```
"""
# Validate configuration
if not api_key:
raise ConfigurationError("api_key is required")

if not persona_id and not persona_config:
raise ConfigurationError("Either persona_id or persona config must be provided")

if persona_id and persona_config:
raise ConfigurationError("Provide either persona_id or persona config, not both")
Pre-minted session token:
```python
client = AnamClient(session_token="your-session-token")
```
"""
# Before direct API-key session starts were introduced, the first
# positional argument was commonly a pre-minted session token. API keys
# never contain dots, while Anam session JWTs do, so retain that legacy
# form while recommending the explicit session_token keyword.
if (
api_key
and "." in api_key
and not session_token
and not persona_id
and not persona_config
):
session_token = api_key
api_key = None

has_api_key = bool(api_key)
has_session_token = bool(session_token)
if has_api_key == has_session_token:
raise ConfigurationError("Provide exactly one of api_key or session_token")

if has_session_token:
if persona_id or persona_config:
raise ConfigurationError(
"session_token cannot be combined with persona_id or persona_config"
)
else:
if not persona_id and not persona_config:
raise ConfigurationError(
"Either persona_id or persona config must be provided with api_key"
)
if persona_id and persona_config:
raise ConfigurationError("Provide either persona_id or persona config, not both")

self._api_key = api_key
self._session_token = session_token
self._options = options or ClientOptions()

# Create persona config
self._persona_config: PersonaConfig | None
if persona_config:
self._persona_config = persona_config
elif persona_id:
self._persona_config = PersonaConfig(persona_id=persona_id)
else:
self._persona_config = PersonaConfig(persona_id=persona_id) # type: ignore
self._persona_config = None
Comment thread
bc-anam marked this conversation as resolved.

# Event callbacks
self._event_callbacks: dict[AnamEvent, list[EventCallback]] = {
Expand Down Expand Up @@ -242,6 +281,7 @@ async def connect_async(self, session_options: SessionOptions = SessionOptions()
# Create API client and start session
self._api_client = CoreApiClient(
api_key=self._api_key,
session_token=self._session_token,
options=self._options,
)

Expand Down Expand Up @@ -533,12 +573,14 @@ async def send_message(self, content: str) -> None:
Raises:
SessionError: If not connected or if LLM is not available.
"""
# Validate that LLM is available for processing messages
persona_config = self._get_persona_config()

# Check a persona and LLM are consuming the text messages
if persona_config.persona_id is None and (
persona_config.llm_id == "CUSTOMER_CLIENT_V1" or persona_config.llm_id is None
# A pre-minted token owns its persona/brain snapshot server-side, so
# there is intentionally no local PersonaConfig to inspect. API-key
# sessions retain the useful warning for obviously unhandled input.
persona_config = None if self._client._session_token else self._get_persona_config()
if (
persona_config
and persona_config.persona_id is None
and (persona_config.llm_id == "CUSTOMER_CLIENT_V1" or persona_config.llm_id is None)
):
logger.warning(
"Persona ID and LLM ID are not set, messages will not be processed by the backend."
Expand Down
73 changes: 69 additions & 4 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,44 @@
class TestAnamClientInit:
"""Tests for AnamClient initialization."""

def test_requires_api_key(self) -> None:
"""Test that api_key is required."""
with pytest.raises(ConfigurationError, match="api_key is required"):
def test_requires_one_authentication_mode(self) -> None:
"""Test that one credential is required."""
with pytest.raises(ConfigurationError, match="exactly one"):
AnamClient(api_key="", persona_id="test-persona")

def test_cannot_provide_both_authentication_modes(self) -> None:
with pytest.raises(ConfigurationError, match="exactly one"):
AnamClient(
api_key="test-key",
session_token="header.payload.signature",
persona_id="test-persona",
)

def test_requires_persona(self) -> None:
"""Test that either persona_id or persona is required."""
with pytest.raises(ConfigurationError, match="Either persona_id or persona"):
AnamClient(api_key="test-key")

def test_session_token_does_not_accept_persona_configuration(self) -> None:
with pytest.raises(ConfigurationError, match="cannot be combined"):
AnamClient(
session_token="header.payload.signature",
persona_id="test-persona",
)

def test_init_with_session_token(self) -> None:
client = AnamClient(session_token="header.payload.signature")

assert client._api_key is None
assert client._session_token == "header.payload.signature"
assert client._persona_config is None

def test_legacy_positional_session_token_is_supported(self) -> None:
client = AnamClient("header.payload.signature")

assert client._api_key is None
assert client._session_token == "header.payload.signature"

def test_cannot_provide_both_persona_options(self) -> None:
"""Test that you can't provide both persona_id and persona_config."""
with pytest.raises(ConfigurationError, match="not both"):
Expand Down Expand Up @@ -90,7 +118,7 @@ def test_environment_defaults_to_none(self) -> None:


class TestCoreApiClientSessionBody:
"""The session request body carries engine routing overrides."""
"""The session request body matches the selected authentication mode."""

@staticmethod
def _fake_session(captured: dict):
Expand Down Expand Up @@ -121,6 +149,8 @@ async def __aexit__(self, *_: object) -> bool:
return False

def post(self, url: str, headers=None, json=None): # noqa: A002
captured["url"] = url
captured["headers"] = headers
captured["body"] = json
return _Resp()

Expand Down Expand Up @@ -151,6 +181,28 @@ async def test_environment_is_omitted_when_unset(self, monkeypatch: pytest.Monke

assert "environment" not in captured["body"]

@pytest.mark.asyncio
async def test_pre_minted_session_token_uses_snapshot_only(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from anam._api import CLIENT_METADATA, CoreApiClient

captured: dict[str, Any] = {}
monkeypatch.setattr("aiohttp.ClientSession", self._fake_session(captured))

token = "header.payload.signature"
client = CoreApiClient(
session_token=token,
options=ClientOptions(
client_label="must-not-override-token",
environment={"podName": "must-not-override-token"},
),
)
await client.start_session(None, SessionOptions(video_quality="auto"))

assert captured["headers"]["Authorization"] == f"Bearer {token}"
assert captured["body"] == {"clientMetadata": CLIENT_METADATA}


class TestAnamClientEvents:
"""Tests for event handling."""
Expand Down Expand Up @@ -191,6 +243,19 @@ async def handler() -> None:
assert handler not in client._event_callbacks[AnamEvent.CONNECTION_ESTABLISHED]


class TestSessionMessages:
@pytest.mark.asyncio
async def test_pre_minted_token_can_send_message_without_local_persona(self) -> None:
client = AnamClient(session_token="header.payload.signature")
client._streaming_client = MagicMock()
client._streaming_client._data_channel_open = True

session = Session(client)
await session.send_message("Hello")

client._streaming_client.send_user_message.assert_called_once_with("Hello")


class TestAnamClientDataMessages:
"""Tests for data channel message handling."""

Expand Down
Loading
Loading