Metadata-Version: 2.5
Name: interhumanai
Version: 1.1.0
Summary: First-party Python SDK for the Interhuman API: upload, stream, and realtime social-signal analysis.
Project-URL: Homepage, https://docs.interhuman.ai
Project-URL: Repository, https://github.com/interhumanai/interhuman-api
Project-URL: Changelog, https://github.com/interhumanai/interhuman-api/blob/main/sdk/python/CHANGELOG.md
Author: Interhuman AI
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: analysis,interhuman,sdk,social-signals,video
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic>=2.7.0
Requires-Dist: websockets>=13.0
Description-Content-Type: text/markdown

# Interhuman Python SDK

`interhumanai` is the first-party Python client for the
[Interhuman API](https://docs.interhuman.ai). It wraps authentication, client
tokens, video upload analysis, and the live stream and realtime WebSocket
protocols behind a typed, asyncio-first API — no hand-rolled token exchange,
multipart bodies, or WebSocket envelope parsing.

## Installation

```bash
pip install interhumanai
```

Requires Python 3.10+. Runtime dependencies: `httpx`, `websockets`, `pydantic`.

The SDK is asyncio-first: every network call is a coroutine. Use `asyncio.run()`
in scripts and top-level `await` in notebooks. The live stream and realtime
surfaces are inherently event-driven, so a single async API keeps every surface
consistent (and mirrors the promise-based TypeScript SDK).

## Quickstart

```python
import asyncio
from interhumanai import InterhumanClient

async def main() -> None:
    client = InterhumanClient(key_id="...", key_secret="...")
    result = await client.upload.analyze("meeting.mp4")
    for signal in result.signals:
        print(signal.type.value, signal.start, signal.end)

asyncio.run(main())
```

## Authentication

Two ways to authenticate:

- **API key credentials** (`key_id` + `key_secret`): the client exchanges them
  at `POST /v1/auth` for a short-lived bearer token and refreshes it
  automatically before expiry.
- **Pre-issued token** (`access_token`): a JWT or client token used as-is.

```python
from interhumanai import InterhumanClient, Scope

# Managed credentials (recommended for servers)
client = InterhumanClient(key_id="...", key_secret="...", scopes=[Scope.UPLOAD, Scope.STREAM])

# Pre-issued bearer token
client = InterhumanClient(access_token="eyJ...")
```

The standalone `AuthClient` exposes the token endpoints directly, and
`TokenManager` / `StaticTokenProvider` are available when you need to plug a
custom token source into the lower-level clients.

Scopes name one operation, or one model on the `/v2` routes:

- `Scope.UPLOAD` (`interhumanai.upload`) — `POST /v1/upload/analyze` (Inter-1).
- `Scope.STREAM` (`interhumanai.stream`) — `WS /v1/stream/analyze` (Inter-1).
- `Scope.REALTIME` (`interhumanai.realtime`) — `WS /v0/realtime/analyze`.
- `Scope.UPLOAD_INTER_2` / `Scope.UPLOAD_INTER_2_AUDIO` / `Scope.UPLOAD_INTER_2_DEEP`
  (`interhumanai.upload.<model>`) — `POST /v2/upload/analyze` with that
  `model`; any one of them also reads the account's jobs.
- `Scope.STREAM_INTER_2` / `Scope.STREAM_INTER_2_AUDIO` (`interhumanai.stream.<model>`)
  — `WS /v2/stream/analyze` opened on that model. There is no stream scope
  for `inter-2-deep`, which is an upload-only model.

Each scope grants only its own cell, so request every scope the client needs;
holding one never implies another.

## Client tokens (browser / untrusted clients)

Mint short-lived, capped tokens server-side so untrusted clients never see the
API key:

```python
from interhumanai import AuthClient, Scope

auth = AuthClient()
token = await auth.create_client_token(
    api_key="ih_...",
    scopes=[Scope.STREAM],
    expires_in=300,            # clamped to 60-3600 seconds by the API
    max_concurrent=1,
    max_video_seconds=600,
    allowed_origins=["https://app.example.com"],
)
await auth.revoke_client_token(api_key="ih_...", token=token.access_token)
```

## Upload API

Analyze a complete video file (mp4, avi, mov, mkv, mpeg-ts, or webm; at least
3 seconds, at most 32 MB):

```python
from interhumanai import GoalDimension, IncludeFlag

result = await client.upload.analyze(
    "meeting.mp4",                                        # path, bytes, or file object
    include=[IncludeFlag.CONVERSATION_QUALITY_OVERALL],   # optional sections
    goal_dimensions=[GoalDimension.CLARITY],              # enables feedback
)
```

The typed `AnalysisResult` carries `signals`, `engagement_state`, and — when
requested — `feedback` and `conversation_quality`.

Every v1 `Signal` carries a `modality` naming the analysis modalities that
detected it. When several tracks detect the same signal, every contributing
modality is included, e.g. `["audio", "visual"]`. The Inter-2 (v2) routes send
no `modality`: the model you choose names the evidence.

### Upload jobs with Inter-2 (`POST /v2/upload/analyze`)

The v2 upload route analyzes a file with an Inter-2 model as an **asynchronous
job**: the API validates the file, answers with a job envelope, and analyzes
the file afterwards. Poll the job until it is `completed` or `failed`:

```python
from interhumanai import IncludeFlag, UploadModel

job = await client.upload.submit(
    "call.wav",
    model=UploadModel.INTER_2_AUDIO,
    include=[IncludeFlag.CONVERSATION_QUALITY_OVERALL],   # optional quality scores
)
print(job.job_id, job.status.value)          # queued

job = await client.upload.wait_for_job(job, timeout=300)   # polls every 2 s
if job.status.value == "completed":
    for window in job.result.windows:
        names = ", ".join(signal.type.value for signal in window.signals)
        print(f"{window.start_seconds:5.1f}s {window.engagement_status.value:11} {names}")
    print(job.result.conversation_quality.overall.quality_index)
else:
    print(job.error.error_id, job.error.message)
```

- `submit(file, model=..., wait_seconds=0, include=None)` — pass `wait_seconds`
  to let the API hold the request open for up to that long and return the
  finished job inline when it completes in time; otherwise it returns the
  queued envelope. Pass `include` with the same `IncludeFlag` values `analyze`
  takes to have the result carry `conversation_quality`, an
  `UploadJobConversationQuality` scored over the whole file with `overall`
  and/or `timeline` set for the flags you named; without it the result has no
  quality block.
- `get_job(job_id)` — one status read. Raises `InterhumanAPIError` with
  `error_id` `ih4021` once the job has expired (`job.expires_at`).
- `wait_for_job(job_id, timeout=None, poll_interval=2.0)` — returns the
  terminal envelope, failed jobs included; raises `UploadJobTimeoutError` when
  `timeout` elapses first (the job keeps running).
- `UploadModel.INTER_2` accepts an mp4, mov, avi, mkv, webm or mpeg-ts video
  carrying both a video and an audio track and reads picture and sound
  together. `UploadModel.INTER_2_AUDIO` accepts wav, flac, mp3, m4a, ogg, and
  webm or mp4 with an audio track, and hears the audio alone. Both take at least 3 seconds, at most 32 MB, and no longer than the
  deployment's maximum duration (30 minutes by default), and report one
  `UploadJobWindow` per fixed-length window, each with its span, an
  `engagement_status` and the `signals` read over it. Each signal is an
  `UploadJobSignal`: the v1 `Signal` without `modality`.
  `UploadModel.INTER_2_DEEP` is a valid value the route does not serve yet and
  raises `InterhumanAPIError` (`ih4020`).

## Stream API

One `StreamClient` handles one live session against `WS /v1/stream/analyze`.
Send binary WebM or fragmented-MP4 chunks and consume typed events with
`async for`:

```python
from interhumanai import IncludeFlag, SignalDetectedEvent

async with client.stream() as session:
    await session.wait_for_session_ready()
    await session.update_config(include=[IncludeFlag.CONVERSATION_QUALITY_OVERALL])
    await session.send_video(first_chunk)   # first chunk carries the container header
    await session.request_close()           # graceful drain; close() tears down immediately
    async for event in session:
        if isinstance(event, SignalDetectedEvent):
            print(event.data.signal_type.value, event.data.start)
```

Iteration ends when the connection closes; `session.close_info` then holds the
close code and reason. Event types this SDK version does not know arrive as
`UnknownEvent` instead of failing the session.

### Choosing the model: v1 and v2

`client.stream()` opens `WS /v1/stream/analyze`, analyzed by the Inter-1
model. Pass `api_version="v2"` to open `WS /v2/stream/analyze` and have the
same session analyzed by the Inter-2 model:

```python
async with client.stream(api_version="v2") as session:
    ...
```

Everything else is identical — the `session.ready` limits, session config, the
video you send, the events you receive and their order, and the graceful close
— so the code above works unchanged, except that v2 signal events carry no
`modality` (`event.data.modality` is `None`). Two more things differ. The scope: a v2 session
needs `interhumanai.stream.<model>` for the model it opens on
(`Scope.STREAM_INTER_2` for the default), where v1 needs `Scope.STREAM`. That
scope is not among the client's defaults (`Scope.UPLOAD` and `Scope.STREAM`),
so include it in the `scopes` the client requests alongside the ones you still
use — a token grants exactly what was requested, so the key carrying the scope
is not enough on its own:

```python
client = InterhumanClient(
    key_id=..., key_secret=..., scopes=[Scope.UPLOAD, Scope.STREAM, Scope.STREAM_INTER_2]
)
```

And how the client names itself: errors raised for a v2 session say "Inter-2
stream" where a v1 session says "Stream", so a message names the endpoint the
session actually opened. If a deployment has no Inter-2 backend configured, a
v2 session is refused right after the handshake with an `ErrorEvent`
(`ih1003`) and close code 1013.

### Choosing the Inter-2 model: video or audio

On `api_version="v2"` the `model` argument picks the engine the session opens
on, and the credential must carry the matching `interhumanai.stream.<model>`
scope. Leave it out and the session runs Inter-2 on the video; pass
`model=StreamModel.INTER_2_AUDIO` to have the audio analyzed on its own. An
audio-only recording is then accepted (the video track of a recording that
carries one is dropped).
`session.ready` lists the models the deployment serves that your credential
may select under `supported_session_config_options.model`, and
`session.updated` echoes the one in force. `update_config(model=...)`
switches the model before any media is sent; a switch to a model the
credential lacks is answered with a non-fatal `ErrorEvent` (`ih2003`) and the
session continues on its current model.

```python
from interhumanai import StreamModel

async with client.stream(api_version="v2", model=StreamModel.INTER_2_AUDIO) as session:
    await session.wait_for_session_ready()
    await session.send_video(audio_only_webm_chunk)  # an audio-only recording is fine here
```

With `INTER_2_AUDIO` you can also skip the container and send raw audio:
declare the shape once, then send signed 16-bit mono PCM frames with
`send_audio`. Frames can be any size and cut at any byte boundary, at 16 kHz
or 24 kHz. See [`examples/stream_audio.py`](examples/stream_audio.py).

```python
from interhumanai import RawAudioFormat

await session.update_config(audio_format=RawAudioFormat(sample_rate=16000))
await session.send_audio(pcm_frame)
```

Opening on, or switching to, `INTER_2_AUDIO` on a deployment without an audio
backend is refused with an `ErrorEvent` (`ih1003`) and close code 1013,
exactly as an unconfigured v2 backend refuses the connection. `INTER_2_DEEP`
is an upload model: `StreamClient` rejects it at construction, and the server
refuses it in a settings message (`ih6001`).

## Realtime API

`RealtimeClient` targets `WS /v0/realtime/analyze`, which requires the
`interhumanai.realtime` scope, so include `Scope.REALTIME` in the scopes the
client requests alongside any others you use (a token grants exactly what was
requested, so `[Scope.REALTIME]` alone would close off `upload()` and
`stream()`). It offers multi-track analysis
configuration, client transcripts, and periodic recommendations:

```python
from interhumanai import AnalysisGroup, RealtimeRecommendationFrequency, RealtimeRecommendationGeneratedEvent, Scope, TranscriptSegment

client = InterhumanClient(
    key_id=..., key_secret=..., scopes=[Scope.UPLOAD, Scope.STREAM, Scope.REALTIME]
)

async with client.realtime() as session:
    await session.wait_for_session_ready()
    await session.update_config(
        analysis_groups=[AnalysisGroup.AUDIO, AnalysisGroup.VISUAL],
        realtime_recommendation_frequency=RealtimeRecommendationFrequency.MEDIUM,
        realtime_recommendation_instructions="Coach the presenter.",   # non-empty value enables recommendations
    )
    await session.send_transcript([TranscriptSegment(start=0.0, end=2.0, text="Hi.", speaker=0)])
    async for event in session:
        if isinstance(event, RealtimeRecommendationGeneratedEvent):
            print(event.data.text)
```

The `VISUAL` analysis group reports its negative signal as
`SignalType.TENSION`, while the `AUDIO` group (and Inter-1) reports
`SignalType.FRUSTRATION`. They describe the same state seen versus heard, and
both can be active at once, each with its own signal lifecycle.
`SignalType.TENSION` is realtime only — the upload and stream surfaces never
emit it — so code that branches on `signal_type` should handle both.

## Errors

Three surfaces, mirroring the API:

- `InterhumanAPIError` — raised for non-2xx HTTP responses (with `status`,
  `error_id`, `correlation_id`, `link`) and for transport failures
  (`status == 0`).
- `InterhumanConfigError` — raised for client-side misuse before any network
  call (missing credentials, sending on a closed session, double connect).
- `wait_for_session_ready()` raises `InterhumanError` if the connection closes
  before the session becomes ready. When the server explained the close with an
  `error` envelope first, the message quotes it; otherwise it points at the
  credential or scope, which is the usual cause.
- WebSocket `error` **envelopes** are delivered as `ErrorEvent`s through
  iteration, not raised — fatal ones are followed by the connection closing.

## SDK attribution

Every request the SDK makes tells the API which SDK sent it, so Interhuman can
report SDK adoption and spot clients stuck on an old release:

- HTTP requests carry `X-Interhuman-SDK: python/<version>`.
- Stream and realtime WebSocket handshakes carry the same header **and**
  `ih_sdk=python&ih_sdk_version=<version>` in the URL; the two always agree,
  and if an intermediary makes them disagree the API keeps the header's
  identity. The query pair is what survives an intermediary that does not
  preserve handshake headers. Any query parameters your `base_url` already had
  are kept. (Releases before 0.15.0 sent the same values as
  `sdk`/`sdk_version`; the API accepts both spellings.)

The version comes from the installed package, so it always matches the release
you have. Nothing else is sent — no device, OS, runtime, hostname, application
name, or end-user identifier — and because any caller can send the same values,
the API treats the metadata as a self-declared hint that never affects
authentication, authorization, quotas, or billing. Servers that predate it
ignore it.

`SDK_NAME`, `SDK_HEADER_NAME`, and `sdk_header_value()` are exported if you
want to inspect exactly what is sent.

## Environments

```python
InterhumanClient(key_id=..., key_secret=...)                          # production (default): api.interhuman.ai
InterhumanClient(key_id=..., key_secret=..., environment="staging")   # staging-api.interhuman.ai
InterhumanClient(key_id=..., key_secret=..., base_url="http://localhost:8080")  # local override
```

WebSocket URLs are derived automatically (`https://` → `wss://`).

## Examples

Runnable scripts for every flow live in [`examples/`](examples/): `auth.py`,
`client_tokens.py`, `upload.py`, `upload_job.py`, `stream.py`, and
`realtime.py`.

## Development

The SDK lives in the [interhuman-api](https://github.com/interhumanai/interhuman-api)
repository under `sdk/python/`. Its tests live in `tests/sdk/python/` and run
with the repository's main suite:

```bash
uv run pytest tests/sdk/python
```

### API reference docs

The reference documentation on [docs.interhuman.ai](https://docs.interhuman.ai)
is generated from this package's docstrings and type metadata — do not hand-write
it. Edit the docstrings in `src/interhumanai/`, then regenerate:

```bash
python scripts/build_mintlify_docs.py   # docstrings → Mintlify MDX in docs-dist/mintlify/
```

On every push to `main` that touches the SDK, the `Sync Docs to interhuman-docs`
workflow regenerates this page and opens a PR against the `interhuman-docs`
repository. That workflow keeps a single rolling docs-sync PR open — a later run
updates it in place instead of opening another one.

## Releasing

This package is versioned in **lockstep** with the TypeScript SDK
(`@interhumanai/sdk`): both always carry the same version and release together
from a single workflow. Publishing is automatic and deploy-gated: bump
`__version__` in `src/interhumanai/_version.py` **and** the `version` in
`sdk/typescript/package.json` to the same value (semver), add a `CHANGELOG.md`
entry to each package, and merge to `main`. After the `Deploy` workflow
succeeds, the `SDK release` workflow builds, tests, and publishes both packages
(idempotently per registry), then tags the commit `sdk-v<version>`. See
[`docs/sdk-release.md`](../../docs/sdk-release.md) for details.

## License

Apache-2.0
