diff --git a/.github/workflows/auto-approve.yaml b/.github/workflows/auto-approve.yaml new file mode 100644 index 0000000..957d77f --- /dev/null +++ b/.github/workflows/auto-approve.yaml @@ -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 }} + + - 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(', ')}`, + }); diff --git a/README.md b/README.md index bc36574..767fdd7 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/anam/_api.py b/src/anam/_api.py index df9afc3..e330f00 100644 --- a/src/anam/_api.py +++ b/src/anam/_api.py @@ -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 @@ -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: @@ -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: diff --git a/src/anam/client.py b/src/anam/client.py index 4970bc7..82a2d2c 100644 --- a/src/anam/client.py +++ b/src/anam/client.py @@ -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. @@ -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 # Event callbacks self._event_callbacks: dict[AnamEvent, list[EventCallback]] = { @@ -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, ) @@ -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." diff --git a/tests/test_client.py b/tests/test_client.py index 313b39d..c9f6569 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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"): @@ -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): @@ -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() @@ -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.""" @@ -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.""" diff --git a/uv.lock b/uv.lock index 66bc0cd..6f7910d 100644 --- a/uv.lock +++ b/uv.lock @@ -188,7 +188,7 @@ wheels = [ [[package]] name = "anam" -version = "0.6.0" +version = "0.9.0a1" source = { editable = "." } dependencies = [ { name = "aiohttp" },