saa-livekit-client 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ """saa-livekit-client — consume saa events inside a LiveKit voice agent.
2
+
3
+ Public exports:
4
+
5
+ AttentionEngine - listens to the hidden hosted agent's data
6
+ channel events and fires typed callbacks.
7
+ start_attention_session - summons the hosted agent into your room.
8
+ attention_agent_token - issues the hidden-participant token the
9
+ hosted agent uses to connect.
10
+ build_attention_entrypoint - factory that composes the above into a
11
+ ready-to-go entrypoint(ctx) function.
12
+
13
+ Event types:
14
+ PredictionEvent, VADEvent, TurnReadyEvent, TurnFrame,
15
+ InterruptEvent, InterjectionEvent, ErrorEvent
16
+ """
17
+ from .api import (
18
+ AttentionAPIError,
19
+ SessionHandle,
20
+ start_attention_session,
21
+ )
22
+ from .engine import AttentionEngine, DATA_TOPIC
23
+ from .factory import build_attention_entrypoint
24
+ from .tokens import DEFAULT_AGENT_IDENTITY, attention_agent_token
25
+ from .types import (
26
+ ErrorEvent,
27
+ InterjectionEvent,
28
+ InterruptEvent,
29
+ PredictionEvent,
30
+ TurnFrame,
31
+ TurnReadyEvent,
32
+ VADEvent,
33
+ )
34
+
35
+
36
+ __version__ = "0.3.0"
37
+
38
+ __all__ = [
39
+ # Engine
40
+ "AttentionEngine", "DATA_TOPIC",
41
+ # REST client
42
+ "start_attention_session", "SessionHandle", "AttentionAPIError",
43
+ # Tokens
44
+ "attention_agent_token", "DEFAULT_AGENT_IDENTITY",
45
+ # Factory
46
+ "build_attention_entrypoint",
47
+ # Event types
48
+ "PredictionEvent", "VADEvent", "TurnReadyEvent", "TurnFrame",
49
+ "InterruptEvent", "InterjectionEvent", "ErrorEvent",
50
+ # Version
51
+ "__version__",
52
+ ]
@@ -0,0 +1,84 @@
1
+ """Binary turn payload codec (parser side).
2
+
3
+ Wire format (`application/x-saa-turn`), little-endian throughout:
4
+
5
+ [4-byte uint32: pcm_byte_len]
6
+ [pcm_byte_len bytes: int16 LE PCM @ 16 kHz mono]
7
+ [for each frame:
8
+ [4-byte float32: ts_offset_s]
9
+ [4-byte uint32: jpeg_byte_len]
10
+ [jpeg_byte_len bytes: raw JPEG]
11
+ ]
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import struct
16
+ from dataclasses import dataclass
17
+
18
+ from .types import TurnFrame
19
+
20
+
21
+ _HEADER = struct.Struct("<I") # pcm_byte_len
22
+ _FRAME_HEADER = struct.Struct("<fI") # ts_offset_s, jpeg_byte_len
23
+
24
+
25
+ class TurnPayloadError(ValueError):
26
+ """Raised when the binary buffer does not match the wire format."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ParsedTurnPayload:
31
+ pcm16: bytes
32
+ frames: list[TurnFrame]
33
+
34
+
35
+ def parse_turn_payload(buf: bytes) -> ParsedTurnPayload:
36
+ """Decode a turn payload received via LiveKit byte stream.
37
+
38
+ Raises TurnPayloadError on truncation or invalid length fields.
39
+ """
40
+ if len(buf) < _HEADER.size:
41
+ raise TurnPayloadError(
42
+ f"buffer too short for header: {len(buf)} < {_HEADER.size}"
43
+ )
44
+
45
+ (pcm_len,) = _HEADER.unpack_from(buf, 0)
46
+ pcm_start = _HEADER.size
47
+ pcm_end = pcm_start + pcm_len
48
+ if pcm_end > len(buf):
49
+ raise TurnPayloadError(
50
+ f"pcm length {pcm_len} exceeds buffer ({len(buf) - pcm_start} remaining)"
51
+ )
52
+
53
+ pcm16 = bytes(buf[pcm_start:pcm_end])
54
+
55
+ frames: list[TurnFrame] = []
56
+ cursor = pcm_end
57
+ while cursor < len(buf):
58
+ if cursor + _FRAME_HEADER.size > len(buf):
59
+ raise TurnPayloadError(
60
+ f"truncated frame header at offset {cursor} "
61
+ f"({len(buf) - cursor} bytes remaining, need {_FRAME_HEADER.size})"
62
+ )
63
+ ts_offset_s, jpeg_len = _FRAME_HEADER.unpack_from(buf, cursor)
64
+ cursor += _FRAME_HEADER.size
65
+ if cursor + jpeg_len > len(buf):
66
+ raise TurnPayloadError(
67
+ f"truncated frame JPEG at offset {cursor} "
68
+ f"(need {jpeg_len}, have {len(buf) - cursor})"
69
+ )
70
+ frames.append(TurnFrame(
71
+ ts_offset_s=float(ts_offset_s),
72
+ jpeg_bytes=bytes(buf[cursor:cursor + jpeg_len]),
73
+ ))
74
+ cursor += jpeg_len
75
+
76
+ return ParsedTurnPayload(pcm16=pcm16, frames=frames)
77
+
78
+
79
+ def encode_turn_payload(pcm16: bytes, frames: list[TurnFrame]) -> bytes:
80
+ parts: list[bytes] = [_HEADER.pack(len(pcm16)), pcm16]
81
+ for f in frames:
82
+ parts.append(_FRAME_HEADER.pack(f.ts_offset_s, len(f.jpeg_bytes)))
83
+ parts.append(f.jpeg_bytes)
84
+ return b"".join(parts)
@@ -0,0 +1,168 @@
1
+ """REST client for the saa hosted bridge.
2
+
3
+ Wraps `POST /v1/sessions/livekit` (and the GET / DELETE companions) into a
4
+ single `start_attention_session(...)` call returning a `SessionHandle`.
5
+
6
+ The consumer only needs to call this once per LiveKit session;
7
+ the actual saa agent joins their room asynchronously and starts
8
+ publishing events on the `"saa"` topic shortly after.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+
19
+ logger = logging.getLogger("saa_livekit_client.api")
20
+
21
+ DEFAULT_API_BASE = "https://broker.attentionlabs.ai"
22
+
23
+
24
+ class AttentionAPIError(RuntimeError):
25
+ """Raised when the hosted bridge rejects the request or returns a non-2xx
26
+ response. `.status_code` exposes the HTTP code; `.body` carries the
27
+ server's JSON error payload (or raw text if non-JSON).
28
+ """
29
+
30
+ def __init__(self, status_code: int, body: Any, message: str | None = None):
31
+ super().__init__(message or f"hosted bridge error: {status_code} {body!r}")
32
+ self.status_code = status_code
33
+ self.body = body
34
+
35
+
36
+ @dataclass
37
+ class SessionHandle:
38
+ """Reference to an active hosted-bridge session.
39
+
40
+ `agent_identity` is the LiveKit participant identity the hosted agent
41
+ joined under — use it when constructing `AttentionEngine` and when
42
+ sending upstream actions so they're scoped to the right participant.
43
+
44
+ `stop()` is idempotent: calling it twice (e.g. via an explicit cleanup
45
+ AND a `ctx.add_shutdown_callback` registration) won't raise on the
46
+ second call. The underlying httpx client is closed exactly once.
47
+ """
48
+
49
+ session_id: str
50
+ agent_identity: str
51
+ _api_base: str
52
+ _api_key: str
53
+ _client: httpx.AsyncClient
54
+ _closed: bool = field(default=False, repr=False)
55
+
56
+ async def stop(self) -> None:
57
+ """Signal the hosted agent to disconnect and tear down the session."""
58
+ if self._closed:
59
+ return
60
+ self._closed = True
61
+ try:
62
+ resp = await self._client.delete(
63
+ f"{self._api_base}/v1/sessions/{self.session_id}",
64
+ headers={"Authorization": f"Bearer {self._api_key}"},
65
+ )
66
+ if resp.status_code >= 400 and resp.status_code != 404:
67
+ # 404 = already gone, treat as success
68
+ raise AttentionAPIError(resp.status_code, _safe_json(resp))
69
+ finally:
70
+ await self._client.aclose()
71
+
72
+ async def status(self) -> dict[str, Any]:
73
+ """Fetch current session status (uptime, last_prediction, error_count)."""
74
+ if self._closed:
75
+ raise AttentionAPIError(
76
+ 0, None, "SessionHandle.status() called after stop()",
77
+ )
78
+ resp = await self._client.get(
79
+ f"{self._api_base}/v1/sessions/{self.session_id}",
80
+ headers={"Authorization": f"Bearer {self._api_key}"},
81
+ )
82
+ if resp.status_code >= 400:
83
+ raise AttentionAPIError(resp.status_code, _safe_json(resp))
84
+ return resp.json()
85
+
86
+
87
+ async def start_attention_session(
88
+ *,
89
+ api_key: str,
90
+ livekit_url: str,
91
+ agent_token: str,
92
+ room_name: str,
93
+ participant_identity: str,
94
+ attention_config: dict[str, Any] | None = None,
95
+ api_base: str = DEFAULT_API_BASE,
96
+ timeout: float = 30.0,
97
+ ) -> SessionHandle:
98
+ """Summon the saa hosted agent into the customer's LiveKit room.
99
+
100
+ The call returns once the hosted bridge accepts the request (HTTP 200);
101
+ the hidden agent participant joins the room asynchronously shortly after.
102
+ Wait on `AttentionEngine.is_ready` (or the `"started"` event) before
103
+ relying on inference output.
104
+
105
+ Args:
106
+ api_key: SAA_API_KEY — customer's saa API key.
107
+ livekit_url: Customer's LiveKit URL (e.g. wss://x.livekit.cloud).
108
+ Must be publicly reachable from our cloud.
109
+ agent_token: Hidden-participant JWT, issued via
110
+ `attention_agent_token(...)`.
111
+ room_name: Room name the agent should join. Must match the
112
+ room scope on `agent_token`.
113
+ participant_identity: Identity of the human user whose tracks the
114
+ agent should analyze.
115
+ attention_config: Optional config overrides (vetted subset only).
116
+ See https://attentionlabs.ai/docs/livekit for the
117
+ public field list. Unknown fields are silently ignored.
118
+ api_base: Override the API base URL (testing / private envs).
119
+ timeout: HTTP timeout for the POST call.
120
+ """
121
+ api_base = api_base.rstrip("/")
122
+
123
+ body: dict[str, Any] = {
124
+ "livekit_url": livekit_url,
125
+ "agent_token": agent_token,
126
+ "room_name": room_name,
127
+ "participant_identity": participant_identity,
128
+ }
129
+ if attention_config:
130
+ body["attention_config"] = attention_config
131
+
132
+ # Keep the httpx client open for the lifetime of the handle so
133
+ # subsequent .stop() / .status() calls reuse the same connection pool.
134
+ client = httpx.AsyncClient(timeout=timeout)
135
+ try:
136
+ resp = await client.post(
137
+ f"{api_base}/v1/sessions/livekit",
138
+ json=body,
139
+ headers={"Authorization": f"Bearer {api_key}"},
140
+ )
141
+ except Exception:
142
+ await client.aclose()
143
+ raise
144
+
145
+ if resp.status_code >= 400:
146
+ body_text = _safe_json(resp)
147
+ await client.aclose()
148
+ raise AttentionAPIError(resp.status_code, body_text)
149
+
150
+ data = resp.json()
151
+ logger.info(
152
+ "started attention session %s (agent_identity=%s)",
153
+ data.get("session_id"), data.get("agent_identity"),
154
+ )
155
+ return SessionHandle(
156
+ session_id=data["session_id"],
157
+ agent_identity=data["agent_identity"],
158
+ _api_base=api_base,
159
+ _api_key=api_key,
160
+ _client=client,
161
+ )
162
+
163
+
164
+ def _safe_json(resp: httpx.Response) -> Any:
165
+ try:
166
+ return resp.json()
167
+ except Exception:
168
+ return resp.text