interhumanai 0.4.1__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,181 @@
1
+ """First-party Python SDK for the Interhuman API.
2
+
3
+ Quickstart::
4
+
5
+ import asyncio
6
+ from interhumanai import InterhumanClient
7
+
8
+ async def main() -> None:
9
+ client = InterhumanClient(key_id="...", key_secret="...")
10
+ result = await client.upload.analyze("meeting.mp4")
11
+ for signal in result.signals:
12
+ print(signal.type.value, signal.start, signal.end)
13
+
14
+ asyncio.run(main())
15
+ """
16
+
17
+ from ._session_socket import CloseInfo, SessionSocketClient, UnknownEvent
18
+ from ._version import __version__
19
+ from .auth import (
20
+ DEFAULT_REFRESH_SKEW_SECONDS,
21
+ DEFAULT_SCOPES,
22
+ AuthClient,
23
+ ClientTokenResponse,
24
+ StaticTokenProvider,
25
+ TokenManager,
26
+ TokenProvider,
27
+ TokenResponse,
28
+ )
29
+ from .client import InterhumanClient
30
+ from .config import HTTP_BASE_URLS, Environment, http_to_ws_base_url, resolve_http_base_url
31
+ from .errors import InterhumanAPIError, InterhumanConfigError, InterhumanError
32
+ from .realtime import (
33
+ NO_GUIDANCE,
34
+ RealtimeClient,
35
+ RealtimeEvent,
36
+ RealtimeSessionConfigOptions,
37
+ RealtimeSessionReadyData,
38
+ RealtimeSessionReadyEvent,
39
+ RealtimeSessionUpdatedData,
40
+ RealtimeSessionUpdatedEvent,
41
+ SynthesisGeneratedData,
42
+ SynthesisGeneratedEvent,
43
+ TranscriptGeneratedEvent,
44
+ TranscriptSegment,
45
+ parse_realtime_event,
46
+ )
47
+ from .stream import (
48
+ ConversationQualityUpdatedData,
49
+ ConversationQualityUpdatedEvent,
50
+ CoverageDroppedData,
51
+ CoverageDroppedEvent,
52
+ CoverageRange,
53
+ EngagementUpdatedData,
54
+ EngagementUpdatedEvent,
55
+ ErrorData,
56
+ ErrorEvent,
57
+ FeedbackGeneratedData,
58
+ FeedbackGeneratedEvent,
59
+ SessionClosingData,
60
+ SessionClosingEvent,
61
+ SessionConfigOptions,
62
+ SessionEndedData,
63
+ SessionEndedEvent,
64
+ SessionReadyData,
65
+ SessionReadyEvent,
66
+ SessionUpdatedData,
67
+ SessionUpdatedEvent,
68
+ SignalDetectedData,
69
+ SignalDetectedEvent,
70
+ SignalEndedData,
71
+ SignalEndedEvent,
72
+ SignalUpdatedData,
73
+ SignalUpdatedEvent,
74
+ StreamClient,
75
+ StreamEvent,
76
+ parse_stream_event,
77
+ )
78
+ from .types import (
79
+ AnalysisGroup,
80
+ AnalysisResult,
81
+ ConversationQuality,
82
+ ConversationQualityTimelineEntry,
83
+ ConversationQualityValues,
84
+ EngagementLevel,
85
+ EngagementStateEntry,
86
+ Feedback,
87
+ GoalDimension,
88
+ IncludeFlag,
89
+ InteractionFeedback,
90
+ NoFeedback,
91
+ Probability,
92
+ Scope,
93
+ Signal,
94
+ SignalType,
95
+ SynthesisFrequency,
96
+ )
97
+ from .upload import UploadClient, VideoInput
98
+
99
+ __all__ = [
100
+ "DEFAULT_REFRESH_SKEW_SECONDS",
101
+ "DEFAULT_SCOPES",
102
+ "HTTP_BASE_URLS",
103
+ "NO_GUIDANCE",
104
+ "AnalysisGroup",
105
+ "AnalysisResult",
106
+ "AuthClient",
107
+ "ClientTokenResponse",
108
+ "CloseInfo",
109
+ "ConversationQuality",
110
+ "ConversationQualityTimelineEntry",
111
+ "ConversationQualityUpdatedData",
112
+ "ConversationQualityUpdatedEvent",
113
+ "ConversationQualityValues",
114
+ "CoverageDroppedData",
115
+ "CoverageDroppedEvent",
116
+ "CoverageRange",
117
+ "EngagementLevel",
118
+ "EngagementStateEntry",
119
+ "EngagementUpdatedData",
120
+ "EngagementUpdatedEvent",
121
+ "Environment",
122
+ "ErrorData",
123
+ "ErrorEvent",
124
+ "Feedback",
125
+ "FeedbackGeneratedData",
126
+ "FeedbackGeneratedEvent",
127
+ "GoalDimension",
128
+ "IncludeFlag",
129
+ "InteractionFeedback",
130
+ "InterhumanAPIError",
131
+ "InterhumanClient",
132
+ "InterhumanConfigError",
133
+ "InterhumanError",
134
+ "NoFeedback",
135
+ "Probability",
136
+ "RealtimeClient",
137
+ "RealtimeEvent",
138
+ "RealtimeSessionConfigOptions",
139
+ "RealtimeSessionReadyData",
140
+ "RealtimeSessionReadyEvent",
141
+ "RealtimeSessionUpdatedData",
142
+ "RealtimeSessionUpdatedEvent",
143
+ "Scope",
144
+ "SessionClosingData",
145
+ "SessionClosingEvent",
146
+ "SessionConfigOptions",
147
+ "SessionEndedData",
148
+ "SessionEndedEvent",
149
+ "SessionReadyData",
150
+ "SessionReadyEvent",
151
+ "SessionSocketClient",
152
+ "SessionUpdatedData",
153
+ "SessionUpdatedEvent",
154
+ "Signal",
155
+ "SignalDetectedData",
156
+ "SignalDetectedEvent",
157
+ "SignalEndedData",
158
+ "SignalEndedEvent",
159
+ "SignalType",
160
+ "SignalUpdatedData",
161
+ "SignalUpdatedEvent",
162
+ "StaticTokenProvider",
163
+ "StreamClient",
164
+ "StreamEvent",
165
+ "SynthesisFrequency",
166
+ "SynthesisGeneratedData",
167
+ "SynthesisGeneratedEvent",
168
+ "TokenManager",
169
+ "TokenProvider",
170
+ "TokenResponse",
171
+ "TranscriptGeneratedEvent",
172
+ "TranscriptSegment",
173
+ "UnknownEvent",
174
+ "UploadClient",
175
+ "VideoInput",
176
+ "__version__",
177
+ "http_to_ws_base_url",
178
+ "parse_realtime_event",
179
+ "parse_stream_event",
180
+ "resolve_http_base_url",
181
+ ]
interhumanai/_http.py ADDED
@@ -0,0 +1,73 @@
1
+ """Internal HTTP helpers shared by the SDK's HTTP clients."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from .errors import InterhumanAPIError
8
+
9
+
10
+ def _error_from_response(response: httpx.Response) -> InterhumanAPIError:
11
+ """Build an :class:`InterhumanAPIError` from a non-2xx HTTP response."""
12
+ body: dict[str, Any] | None = None
13
+ try:
14
+ parsed = response.json()
15
+ except ValueError:
16
+ parsed = None
17
+ if isinstance(parsed, dict) and isinstance(parsed.get("error_id"), str):
18
+ body = parsed
19
+ detail = None
20
+ if body is not None and isinstance(body.get("message"), str):
21
+ detail = body["message"]
22
+ error_id = body.get("error_id") if body else None
23
+ message = f"Interhuman API error ({error_id}): " if error_id else "Interhuman API error: "
24
+ message += detail or f"HTTP {response.status_code}"
25
+ return InterhumanAPIError(
26
+ message,
27
+ status=response.status_code,
28
+ error_id=error_id,
29
+ correlation_id=body.get("correlation_id") if body else None,
30
+ link=body.get("link") if body else None,
31
+ body=body,
32
+ )
33
+
34
+
35
+ async def request_json(
36
+ client: httpx.AsyncClient,
37
+ method: str,
38
+ url: str,
39
+ *,
40
+ json: Any | None = None,
41
+ files: Any | None = None,
42
+ data: Any | None = None,
43
+ headers: dict[str, str] | None = None,
44
+ ) -> Any:
45
+ """Send a request and return the parsed JSON body.
46
+
47
+ Raises:
48
+ InterhumanAPIError: For non-2xx responses (with the API's canonical
49
+ error fields when present) and for transport failures (``status=0``).
50
+
51
+ Returns:
52
+ The parsed JSON response body, or ``None`` for empty bodies (e.g. 204).
53
+ """
54
+ try:
55
+ response = await client.request(
56
+ method, url, json=json, files=files, data=data, headers=headers
57
+ )
58
+ except httpx.HTTPError as exc:
59
+ raise InterhumanAPIError(
60
+ f"Interhuman API request failed before a response was received: {exc}",
61
+ status=0,
62
+ ) from exc
63
+ if response.status_code < 200 or response.status_code >= 300:
64
+ raise _error_from_response(response)
65
+ if not response.content:
66
+ return None
67
+ try:
68
+ return response.json()
69
+ except ValueError as exc:
70
+ raise InterhumanAPIError(
71
+ "Interhuman API returned a malformed JSON response.",
72
+ status=response.status_code,
73
+ ) from exc
@@ -0,0 +1,314 @@
1
+ """Shared asyncio WebSocket engine for the stream and real-time clients."""
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any, ClassVar, Generic, TypeVar
6
+
7
+ from pydantic import BaseModel, ValidationError
8
+ from websockets.asyncio.client import connect as ws_connect
9
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake
10
+
11
+ from .auth import TokenProvider
12
+ from .config import Environment, http_to_ws_base_url, resolve_http_base_url
13
+ from .errors import InterhumanConfigError, InterhumanError
14
+
15
+
16
+ class CloseInfo(BaseModel):
17
+ """Close code and reason of a finished WebSocket session."""
18
+
19
+ code: int
20
+ reason: str
21
+
22
+
23
+ class UnknownEvent(BaseModel):
24
+ """A server envelope whose ``type`` this SDK version does not know.
25
+
26
+ Newer API versions may add event types; they are surfaced as-is instead of
27
+ failing the session.
28
+
29
+ Attributes:
30
+ type: The envelope's ``type`` discriminator.
31
+ raw: The full envelope payload as received.
32
+ """
33
+
34
+ type: str
35
+ raw: dict[str, Any]
36
+
37
+
38
+ EventT = TypeVar("EventT")
39
+
40
+ _QUEUE_END = object()
41
+
42
+
43
+ class SessionSocketClient(Generic[EventT]):
44
+ """One live analysis session over a WebSocket.
45
+
46
+ Connect with :meth:`connect` (or ``async with``), send binary video chunks
47
+ with :meth:`send_video`, and consume typed server events by iterating the
48
+ client with ``async for``. Iteration ends when the connection closes;
49
+ :attr:`close_info` then holds the close code and reason.
50
+
51
+ Args:
52
+ token_provider: Source of bearer tokens for authentication.
53
+ environment: Named environment to connect to. Defaults to
54
+ ``production``.
55
+ base_url: Explicit HTTP base URL override; converted to ``ws(s)://``.
56
+ """
57
+
58
+ _endpoint_path: ClassVar[str]
59
+ _display_name: ClassVar[str]
60
+ _scope_hint: ClassVar[str]
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ token_provider: TokenProvider,
66
+ environment: Environment | None = None,
67
+ base_url: str | None = None,
68
+ ) -> None:
69
+ """Initialize the client (see class docstring for arguments)."""
70
+ self._token_provider = token_provider
71
+ self._ws_base_url = http_to_ws_base_url(
72
+ resolve_http_base_url(base_url=base_url, environment=environment)
73
+ )
74
+ self._ws: Any = None
75
+ self._open = False
76
+ self._close_info: CloseInfo | None = None
77
+ self._events: asyncio.Queue[Any] = asyncio.Queue()
78
+ self._session_ready: asyncio.Future[EventT] | None = None
79
+ self._receiver: asyncio.Task[None] | None = None
80
+
81
+ def _parse_event(self, payload: dict[str, Any]) -> EventT:
82
+ """Parse one server envelope into a typed event (subclass hook)."""
83
+ raise NotImplementedError
84
+
85
+ @property
86
+ def url(self) -> str:
87
+ """The WebSocket URL this client connects to."""
88
+ return self._ws_base_url + self._endpoint_path
89
+
90
+ @property
91
+ def is_open(self) -> bool:
92
+ """Whether the connection is currently open."""
93
+ return self._open
94
+
95
+ @property
96
+ def close_info(self) -> CloseInfo | None:
97
+ """Close code and reason once the connection has ended, else ``None``."""
98
+ return self._close_info
99
+
100
+ async def connect(self) -> "SessionSocketClient[EventT]":
101
+ """Open the WebSocket connection and start receiving events.
102
+
103
+ Returns:
104
+ This client, for chaining.
105
+
106
+ Raises:
107
+ InterhumanConfigError: If the client was already connected.
108
+ InterhumanError: If the handshake fails (bad credentials or scope,
109
+ unreachable host).
110
+ """
111
+ if self._ws is not None:
112
+ raise InterhumanConfigError(
113
+ f"{self._display_name} client is already connected; "
114
+ "create a new client for a new session."
115
+ )
116
+ token = await self._token_provider.get_token()
117
+ try:
118
+ self._ws = await ws_connect(
119
+ self.url,
120
+ additional_headers={"Authorization": f"Bearer {token}"},
121
+ )
122
+ except InvalidHandshake as exc:
123
+ raise InterhumanError(
124
+ f"{self._display_name} WebSocket handshake failed: {exc}. {self._scope_hint}"
125
+ ) from exc
126
+ except OSError as exc:
127
+ raise InterhumanError(
128
+ f"{self._display_name} WebSocket connection failed: {exc}"
129
+ ) from exc
130
+ self._open = True
131
+ self._session_ready = asyncio.get_running_loop().create_future()
132
+ self._receiver = asyncio.create_task(self._receive_loop())
133
+ return self
134
+
135
+ async def __aenter__(self) -> "SessionSocketClient[EventT]":
136
+ """Connect on entering an ``async with`` block."""
137
+ return await self.connect()
138
+
139
+ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
140
+ """Close the connection on leaving an ``async with`` block."""
141
+ await self.close()
142
+
143
+ def __aiter__(self) -> "SessionSocketClient[EventT]":
144
+ """Iterate over typed server events until the connection closes."""
145
+ return self
146
+
147
+ async def __anext__(self) -> EventT:
148
+ """Return the next server event.
149
+
150
+ Raises:
151
+ StopAsyncIteration: When the connection has closed and every
152
+ buffered event has been consumed.
153
+ InterhumanError: When the server sent a frame that is not a valid
154
+ JSON envelope. The connection stays open and later events are
155
+ preserved, but the raise exits an ``async for`` loop - start a
156
+ new ``async for`` (or call ``__anext__``) to keep consuming.
157
+ """
158
+ item = await self._events.get()
159
+ if item is _QUEUE_END:
160
+ self._events.put_nowait(_QUEUE_END)
161
+ raise StopAsyncIteration
162
+ if isinstance(item, InterhumanError):
163
+ raise item
164
+ return item
165
+
166
+ async def wait_for_session_ready(self) -> EventT:
167
+ """Wait for the server's ``session.ready`` event.
168
+
169
+ The event is also delivered through iteration; this helper simply
170
+ awaits it (or returns it if it already arrived).
171
+
172
+ Returns:
173
+ The ``session.ready`` event.
174
+
175
+ Raises:
176
+ InterhumanError: If the connection closes before the session
177
+ becomes ready (typically an auth or scope failure).
178
+ """
179
+ if self._session_ready is None:
180
+ raise InterhumanConfigError(
181
+ f"{self._display_name} client is not connected; call connect() first."
182
+ )
183
+ return await asyncio.shield(self._session_ready)
184
+
185
+ async def send_video(self, chunk: bytes | bytearray | memoryview) -> None:
186
+ """Send one binary video chunk.
187
+
188
+ The first chunk must carry the container's init header; later chunks
189
+ are continuation fragments of the same WebM or fragmented-MP4 stream.
190
+ The SDK does not enforce a chunk size; the server rejects chunks above
191
+ its limit (32 MB by default, reported in ``session.ready`` as
192
+ ``max_segment_size_bytes``) with an ``ih6002`` error.
193
+
194
+ Args:
195
+ chunk: The raw video bytes to send.
196
+ """
197
+ await self._send(bytes(chunk))
198
+
199
+ async def request_close(self) -> None:
200
+ """Ask the server to drain in-flight analysis and end the session.
201
+
202
+ The server acknowledges with ``session.closing``, emits any final
203
+ events, sends ``session.ended``, and closes the socket normally. Keep
204
+ iterating to observe the drain; for an immediate teardown use
205
+ :meth:`close` instead.
206
+ """
207
+ await self._send_json({"type": "session.close"})
208
+
209
+ async def close(self, code: int = 1000, reason: str = "") -> None:
210
+ """Close the WebSocket immediately, discarding in-flight analysis.
211
+
212
+ Args:
213
+ code: WebSocket close code to send.
214
+ reason: Optional close reason.
215
+ """
216
+ if self._ws is None:
217
+ return
218
+ await self._ws.close(code, reason)
219
+ if self._receiver is not None:
220
+ await self._receiver
221
+
222
+ async def wait_closed(self) -> CloseInfo:
223
+ """Wait until the connection has fully closed.
224
+
225
+ Returns:
226
+ The session's close code and reason.
227
+ """
228
+ if self._ws is None:
229
+ raise InterhumanConfigError(f"{self._display_name} client was never connected.")
230
+ if self._receiver is not None:
231
+ await asyncio.shield(self._receiver)
232
+ assert self._close_info is not None
233
+ return self._close_info
234
+
235
+ async def _send(self, message: bytes | str) -> None:
236
+ """Send a raw frame, translating closed-connection errors."""
237
+ if self._ws is None:
238
+ raise InterhumanConfigError(
239
+ f"{self._display_name} client is not connected; call connect() first."
240
+ )
241
+ if not self._open:
242
+ raise InterhumanConfigError(
243
+ f"{self._display_name} connection is closed; nothing can be sent."
244
+ )
245
+ try:
246
+ await self._ws.send(message)
247
+ except ConnectionClosed as exc:
248
+ raise InterhumanError(f"{self._display_name} connection closed while sending.") from exc
249
+
250
+ async def _send_json(self, payload: dict[str, Any]) -> None:
251
+ """Serialize ``payload`` and send it as a text frame."""
252
+ await self._send(json.dumps(payload))
253
+
254
+ def _dispatch(self, raw: str | bytes) -> None:
255
+ """Parse one inbound frame and enqueue the resulting event or error."""
256
+ if isinstance(raw, bytes):
257
+ self._events.put_nowait(
258
+ InterhumanError(f"{self._display_name} server sent an unexpected binary frame.")
259
+ )
260
+ return
261
+ try:
262
+ payload = json.loads(raw)
263
+ except ValueError:
264
+ self._events.put_nowait(
265
+ InterhumanError(f"{self._display_name} server sent a malformed JSON frame.")
266
+ )
267
+ return
268
+ if not isinstance(payload, dict) or not isinstance(payload.get("type"), str):
269
+ self._events.put_nowait(
270
+ InterhumanError(
271
+ f"{self._display_name} server sent an envelope without a string 'type'."
272
+ )
273
+ )
274
+ return
275
+ try:
276
+ event = self._parse_event(payload)
277
+ except ValidationError as exc:
278
+ self._events.put_nowait(
279
+ InterhumanError(
280
+ f"{self._display_name} server sent an invalid '{payload['type']}' "
281
+ f"envelope: {exc}"
282
+ )
283
+ )
284
+ return
285
+ ready = self._session_ready
286
+ if payload["type"] == "session.ready" and ready is not None and not ready.done():
287
+ ready.set_result(event)
288
+ self._events.put_nowait(event)
289
+
290
+ async def _receive_loop(self) -> None:
291
+ """Receive frames until the connection closes, then finalize state."""
292
+ assert self._ws is not None
293
+ try:
294
+ async for raw in self._ws:
295
+ self._dispatch(raw)
296
+ except ConnectionClosed:
297
+ pass
298
+ finally:
299
+ self._open = False
300
+ code = self._ws.close_code if self._ws.close_code is not None else 1006
301
+ reason = self._ws.close_reason or ""
302
+ self._close_info = CloseInfo(code=code, reason=reason)
303
+ ready = self._session_ready
304
+ if ready is not None and not ready.done():
305
+ ready.set_exception(
306
+ InterhumanError(
307
+ f"{self._display_name} connection closed before the session became "
308
+ f"ready (code {code}, reason {reason!r}). {self._scope_hint}"
309
+ )
310
+ )
311
+ # Mark the exception as observed so sessions that never await
312
+ # readiness do not log "Future exception was never retrieved".
313
+ ready.exception()
314
+ self._events.put_nowait(_QUEUE_END)
@@ -0,0 +1,3 @@
1
+ """Package version (single source of truth, read by hatchling at build time)."""
2
+
3
+ __version__ = "0.4.1"