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.
- saa_livekit_client/__init__.py +52 -0
- saa_livekit_client/_wire.py +84 -0
- saa_livekit_client/api.py +168 -0
- saa_livekit_client/engine.py +469 -0
- saa_livekit_client/factory.py +140 -0
- saa_livekit_client/tokens.py +60 -0
- saa_livekit_client/types.py +130 -0
- saa_livekit_client-0.3.0.dist-info/METADATA +175 -0
- saa_livekit_client-0.3.0.dist-info/RECORD +12 -0
- saa_livekit_client-0.3.0.dist-info/WHEEL +5 -0
- saa_livekit_client-0.3.0.dist-info/licenses/LICENSE +201 -0
- saa_livekit_client-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|