saa-pipecat-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_pipecat_client/__init__.py +52 -0
- saa_pipecat_client/_wire.py +84 -0
- saa_pipecat_client/api.py +165 -0
- saa_pipecat_client/engine.py +576 -0
- saa_pipecat_client/factory.py +138 -0
- saa_pipecat_client/tokens.py +111 -0
- saa_pipecat_client/types.py +130 -0
- saa_pipecat_client-0.3.0.dist-info/METADATA +218 -0
- saa_pipecat_client-0.3.0.dist-info/RECORD +12 -0
- saa_pipecat_client-0.3.0.dist-info/WHEEL +5 -0
- saa_pipecat_client-0.3.0.dist-info/licenses/LICENSE +201 -0
- saa_pipecat_client-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""saa-pipecat-client — consume saa events inside a Pipecat / Daily voice agent.
|
|
2
|
+
|
|
3
|
+
Public exports:
|
|
4
|
+
|
|
5
|
+
AttentionEngine - listens to the hidden hosted bot's Daily
|
|
6
|
+
app-message events and fires typed callbacks.
|
|
7
|
+
start_attention_session - summons the hosted bot into your Daily room.
|
|
8
|
+
attention_agent_token - issues the hidden-participant Daily meeting
|
|
9
|
+
token the hosted bot uses to connect.
|
|
10
|
+
build_attention_runner - factory that composes the above into a
|
|
11
|
+
ready-to-go `run(...)` coroutine.
|
|
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, AttentionStartupError, DATA_TOPIC
|
|
23
|
+
from .factory import build_attention_runner
|
|
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", "AttentionStartupError", "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_runner",
|
|
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 over the Daily app-message channel.
|
|
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,165 @@
|
|
|
1
|
+
"""REST client for the saa hosted bridge.
|
|
2
|
+
|
|
3
|
+
Wraps `POST /v1/sessions/daily` (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 Daily session;
|
|
7
|
+
the actual saa bot joins their room asynchronously and starts
|
|
8
|
+
publishing events on the `"saa"` app-message 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_pipecat_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 Daily `user_name` the hosted bot joined under —
|
|
41
|
+
use it when constructing `AttentionEngine` and when sending upstream
|
|
42
|
+
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 shutdown hook) won't raise on the second call. The underlying
|
|
46
|
+
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 bot 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
|
+
room_url: str,
|
|
91
|
+
agent_token: str,
|
|
92
|
+
participant_identity: str,
|
|
93
|
+
attention_config: dict[str, Any] | None = None,
|
|
94
|
+
api_base: str = DEFAULT_API_BASE,
|
|
95
|
+
timeout: float = 30.0,
|
|
96
|
+
) -> SessionHandle:
|
|
97
|
+
"""Summon the saa hosted bot into the customer's Daily room.
|
|
98
|
+
|
|
99
|
+
The call returns once the hosted bridge accepts the request (HTTP 200);
|
|
100
|
+
the hidden bot joins the room asynchronously shortly after. Wait on
|
|
101
|
+
`AttentionEngine.is_ready` (or the `"started"` event) before relying
|
|
102
|
+
on inference output.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
api_key: SAA_API_KEY — customer's saa API key.
|
|
106
|
+
room_url: Customer's Daily room URL (e.g.
|
|
107
|
+
https://their-org.daily.co/sess-xyz). Must be
|
|
108
|
+
publicly reachable from our cloud.
|
|
109
|
+
agent_token: Hidden-participant Daily meeting token, issued
|
|
110
|
+
via `attention_agent_token(...)`.
|
|
111
|
+
participant_identity: `user_name` of the human user whose tracks the
|
|
112
|
+
bot should analyze.
|
|
113
|
+
attention_config: Optional config overrides (vetted subset only).
|
|
114
|
+
See https://attentionlabs.ai/docs/integrations/pipecat
|
|
115
|
+
for the public field list. Unknown fields are silently ignored.
|
|
116
|
+
api_base: Override the API base URL (testing / private envs).
|
|
117
|
+
timeout: HTTP timeout for the POST call.
|
|
118
|
+
"""
|
|
119
|
+
api_base = api_base.rstrip("/")
|
|
120
|
+
|
|
121
|
+
body: dict[str, Any] = {
|
|
122
|
+
"room_url": room_url,
|
|
123
|
+
"agent_token": agent_token,
|
|
124
|
+
"participant_identity": participant_identity,
|
|
125
|
+
}
|
|
126
|
+
if attention_config:
|
|
127
|
+
body["attention_config"] = attention_config
|
|
128
|
+
|
|
129
|
+
# Keep the httpx client open for the lifetime of the handle so
|
|
130
|
+
# subsequent .stop() / .status() calls reuse the same connection pool.
|
|
131
|
+
client = httpx.AsyncClient(timeout=timeout)
|
|
132
|
+
try:
|
|
133
|
+
resp = await client.post(
|
|
134
|
+
f"{api_base}/v1/sessions/daily",
|
|
135
|
+
json=body,
|
|
136
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
137
|
+
)
|
|
138
|
+
except Exception:
|
|
139
|
+
await client.aclose()
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
if resp.status_code >= 400:
|
|
143
|
+
body_text = _safe_json(resp)
|
|
144
|
+
await client.aclose()
|
|
145
|
+
raise AttentionAPIError(resp.status_code, body_text)
|
|
146
|
+
|
|
147
|
+
data = resp.json()
|
|
148
|
+
logger.info(
|
|
149
|
+
"started attention session %s (agent_identity=%s)",
|
|
150
|
+
data.get("session_id"), data.get("agent_identity"),
|
|
151
|
+
)
|
|
152
|
+
return SessionHandle(
|
|
153
|
+
session_id=data["session_id"],
|
|
154
|
+
agent_identity=data["agent_identity"],
|
|
155
|
+
_api_base=api_base,
|
|
156
|
+
_api_key=api_key,
|
|
157
|
+
_client=client,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _safe_json(resp: httpx.Response) -> Any:
|
|
162
|
+
try:
|
|
163
|
+
return resp.json()
|
|
164
|
+
except Exception:
|
|
165
|
+
return resp.text
|