Metadata-Version: 2.4
Name: bloonio-voice-relay-client
Version: 0.1.0
Summary: Client SDK for bloonio_voice_relay. Backend integration for the Bloonio voice-assistant PaaS — mint per-call tokens for your app's WebRTC/WS clients, manage assistants, fetch sessions and transcripts. Framework-agnostic (httpx + pydantic), bearer-key auth.
Author: Bloonio
License-Expression: LicenseRef-Proprietary
Project-URL: Repository, https://github.com/Bloonio/bloonio_voice_relay_client
Keywords: bloonio,voice,assistant,webrtc,call-token,transcript
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Communications :: Telephony
Classifier: Operating System :: POSIX
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.6
Requires-Dist: pydantic-settings>=2.2
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Dynamic: license-file

# bloonio_voice_relay_client

Client SDK for `bloonio_voice_relay` — the Bloonio voice-assistant PaaS. Backend
integration for tenants: mint per-call tokens for your app's WebRTC/WS client, manage
voice assistants (`openai` / `gemini` / `sandbox`), and fetch sessions and transcripts.
Framework-agnostic (httpx + pydantic), bearer-key auth only.

## Install

```bash
pip install bloonio-voice-relay-client
```

## Two-minute integration

```bash
# .env
BLOONIO_VOICE_BASE_URL=$BASE_URL
BLOONIO_VOICE_API_KEY=bvr_...
```

Unlike the sibling `wa` / `mail` / `auth` / `chat` relay clients (each keyed on a
`tenant_id` + `tenant_secret` pair), the voice tenant plane accepts exactly one
credential: `Authorization: Bearer bvr_...`. No HMAC signing, no secondary auth mode —
`BLOONIO_VOICE_API_KEY` is the only secret this SDK needs.

> **This SDK is the backend side — read this before writing any integration code.**
> `VoiceRelayClient` / `AsyncVoiceRelayClient` hold your tenant's `bvr_` key and must
> never run in a browser or ship inside a mobile app. The pattern your app's WebRTC/WS
> client actually needs: your server calls `mint_call_token(assistant_id=...)` and
> hands the resulting `call_token` to your app; the app then talks to the relay's
> **public plane** directly (`/api/v1/public/connect/session`, the call-token-gated
> stream socket, `/api/v1/public/fetch/transcript`) using only that token. The `bvr_`
> key itself never leaves your backend.
>
> The token is **session-scoped** — it authorises exactly one session (connect it,
> stream it, read its transcript) and nothing else — and **reusable within its TTL**
> (`CALL_TOKEN_TTL_SECONDS`, 300s by default server-side): it is **not** single-use.
> The TTL bounds how long the token can be used to *start* something (connect the call,
> open the stream socket); reading the transcript of a call that's already connected is
> honoured past that TTL for as long as the session is still live.

## Quickstart

```python
# main.py — construct the client once at startup and register it as the singleton
from fastapi import FastAPI
from bloonio_voice_relay_client import VoiceRelayClient, VoiceRelaySettings, set_voice_client

app = FastAPI()
set_voice_client(VoiceRelayClient(VoiceRelaySettings()))   # reads the BLOONIO_VOICE_* env vars above
```

```python
# anywhere else — a route, a worker task, a management command
from bloonio_voice_relay_client import get_voice_client

voice = get_voice_client()
voice.whoami()   # confirms the key resolves: {"tenant_id": ..., "tenant_name": ...}

# provider="sandbox" is the one provider that costs nothing and calls nothing to try —
# no OpenAI/Gemini credentials needed, zero outbound network calls server-side. `openai`
# and `gemini` are the real providers; swap `provider=` once you're wiring an actual call.
assistant = voice.create_assistant(
    name="Front desk",
    provider="sandbox",
    instructions="You are a friendly assistant for Acme Freight.",
)
print(assistant["assistant_id"], assistant["status"])

# Mint a call token for THIS assistant and hand `call_token` to your app — see
# "This SDK is the backend side" above. Never hand the app `voice` itself.
token = voice.mint_call_token(assistant_id=assistant["assistant_id"])
print(token["call_token"], token["expires_at"])

session = voice.fetch_session(session_id=token["session_id"])
print(session["status"])   # "created" -> "active" -> "ended" | "orphaned"

for turn in voice.fetch_transcript(session_id=token["session_id"]):
    print(turn["role"], turn["text"])
```

**Errors.** Every method raises `VoiceRelayError` on any non-2xx response: `.status_code`,
`.message` (the relay's message, or the raw response text when there's no structured
message to show), and `.body` (the parsed JSON envelope, or `None` when the response
body wasn't JSON at all). `.code` also exists — extracted from the envelope's `data.code`
when present, the same mechanism `bloonio_wa_relay_client` uses — but is always `None`
against the real relay today: no voice tenant-plane route currently populates it, so an
identifier like `QUOTA_EXCEEDED` rides inside `.message` instead
(`"QUOTA_EXCEEDED: monthly minutes exhausted"`), not as a separate field.

```python
from bloonio_voice_relay_client import VoiceRelayError

try:
    voice.mint_call_token(assistant_id=assistant["assistant_id"])
except VoiceRelayError as e:
    print(e.status_code, e.message)
```

Reuse one `VoiceRelayClient` per process — it wraps a single `httpx.Client` — and either
call `voice.close()` when done or use it as a context manager: `with VoiceRelayClient(...)
as voice:`. The async twin, `AsyncVoiceRelayClient`, has full method parity (`await
voice.whoami()`, `async with AsyncVoiceRelayClient(...) as voice:` / `await voice.aclose()`).

## Assistants

```python
for a in voice.list_assistants():   # newest first, no pagination, capped at 200
    print(a["assistant_id"], a["name"], a["status"])

assistant = voice.fetch_assistant(assistant_id="asst_1")
```

`update_assistant` only edits `name` / `instructions` / `voice` / `first_message` /
`language` / `temperature` / `tools` — **`provider`, `pipeline`, `mode` and `model` are
fixed at creation and cannot be changed afterward**; the update endpoint has no fields
for them at all (create a new assistant instead of trying to migrate one in place).
Fields you don't pass are left untouched, not cleared — there's no way to null a field
through this route either:

```python
updated = voice.update_assistant(assistant_id="asst_1", name="New name", temperature=0.5)
```

`delete_assistant` **always returns `None`** — the relay's delete response carries no
`data`, only a confirmation message, so there's nothing to hand back:

```python
voice.delete_assistant(assistant_id="asst_1")   # -> None
```

## Sessions & calls

`create_session` and `mint_call_token` accept the identical `assistant_id` request — the
difference is the response. `create_session` is for when your OWN backend will finish
the call itself (see `connect_session` below); `mint_call_token` (Quickstart, above) is
the one to reach for when you're instead handing the call off to your app's own
WebRTC/WS client over the public plane.

```python
session = voice.create_session(assistant_id="asst_1")

for s in voice.list_sessions():   # newest first, no pagination/filter, capped at 200
    print(s["session_id"], s["status"])
```

`connect_session` applies to `provider="openai"` sessions only (400 otherwise) and,
unlike every other method here, **performs a real, billable server-proxied SDP exchange
with OpenAI the moment it succeeds** — there is no sandbox/dry-run form of this one
call. A `gemini` session instead streams over a websocket (`/relay/stream/session`), out
of this SDK's scope. Only call it once you're intentionally starting a live OpenAI call,
with an SDP offer your own WebRTC stack produced:

```python
result = voice.connect_session(session_id=session["session_id"], sdp_offer="v=0\r\n...")
print(result["attached"], result["sdp_answer"])
```

## Public surface

Every client method returns the relay's response `data` verbatim — a plain `dict` or
`list`, never an instance of the schemas below. The schemas exist so you can
validate/type a response yourself, e.g. `Assistant.model_validate(res)`.

| Symbol | Kind | Notes |
|---|---|---|
| `VoiceRelayClient` | client (sync) | wraps `httpx.Client`; reuse one per process |
| `AsyncVoiceRelayClient` | client (async) | wraps `httpx.AsyncClient`; `async with` / `await .aclose()` |
| `VoiceRelaySettings` | settings | reads `BLOONIO_VOICE_*` env vars (`pydantic-settings`); `base_url` / `api_key` required, `request_timeout_seconds` defaults to `10.0` |
| `VoiceRelayError` | error | raised on any non-2xx; `.status_code` / `.message` / `.body` / `.code` (see "Errors" above) |
| `AssistantProvider` | enum | `openai` \| `gemini` \| `sandbox` |
| `SessionStatus` | enum | `created` → `active` → `ended` \| `orphaned` |
| `TurnRole` | enum | `user` \| `assistant` |
| `AssistantTool` | schema | one entry of `Assistant.tools` — the create-side `hmac_secret` is write-only and never echoed back, so this schema doesn't model it either |
| `Assistant` | schema | `create_assistant()` / `fetch_assistant()` / `list_assistants()` (one row) / `update_assistant()`; `status` is a plain `str`, always `"active"` in practice (deleted rows are never returned again) |
| `CallToken` | schema | `mint_call_token()`'s response shape |
| `Session` | schema | `fetch_session()` / `list_sessions()` (one row) — deliberately narrower than the raw dict; internal bookkeeping fields present after a call connects are ignored, not rejected, if you validate into this schema |
| `ConnectSessionResult` | schema | `connect_session()`'s response shape |
| `TranscriptTurn` | schema | one row of `fetch_transcript()` |
| `set_voice_client` / `get_voice_client` | singleton | app-startup wiring — see "Quickstart" |

Voice ships no contract doc — the three enums above are read directly off
`bloonio_voice_relay`'s own source, not a published spec, and none of them are formal
`enum.Enum` classes server-side. A future value the relay starts returning that isn't
listed here raises `pydantic.ValidationError` out of `.model_validate()` rather than
parsing leniently — client methods return the raw `dict`/`list` regardless of this, so
it only bites if you opt into the schemas above.

## License

Proprietary — Bloonio internal.
