diff --git a/.github/workflows/release-drift.yml b/.github/workflows/release-drift.yml index 9201ffb..bbbc2fd 100644 --- a/.github/workflows/release-drift.yml +++ b/.github/workflows/release-drift.yml @@ -53,3 +53,31 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: python3 scripts/release/check_drift.py --repo-root . + + # The SDK against the published API: refresh the OpenAPI snapshot from the live document, then + # run the contract test. It fails when the document gains an operation that is neither mapped to + # an SDK method nor allowlisted with a reason, or when a mapped method no longer sends the + # operation's verb and path. A failed fetch fails the job too. + spec-drift: + name: SDK requests / published OpenAPI agreement + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install the SDK with its test dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Refresh the OpenAPI snapshot from https://api.wave.online/openapi.json + run: python3 scripts/refresh_openapi_snapshot.py + + - name: Contract test against the refreshed snapshot + run: python -m pytest -q tests/test_contract_coverage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6289c3d..d0b8c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,119 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [2.3.0] - not yet published (tag `v2.3.0` after merge publishes it) + +This pull request bumps the version, so its notes sit under the release heading rather than +`Unreleased`. + +Connectivity release. Every free flow the README documents now succeeds against the live API +with the documented `Authorization: Bearer` key (`scripts/smoke_live.py`). Billed flows reach +their route and raise the server's own payment error. The contract test now checks the request +each mapped method actually sends. + +### Fixed + +- **Realtime** used a host that does not resolve. `publish()`, `presence()` and `history()` now + go through the client to `https://api.wave.online/v1/realtime/channels/{channel}/...`, so they + raise `WaveError` on failure and send `X-Organization-Id`; the channel name is percent-encoded + (`:` stays literal). `connect()` opens `wss://api.wave.online/v1/realtime/connect` (derived + from `base_url`) and sends the key in the `Authorization` header on the upgrade instead of an + `?access_token=` query parameter. A rejected upgrade raises the matching `WaveError` subclass. + `connect()` refuses a `ws://` origin other than localhost with `ValueError`, because the key + would travel unencrypted. +- **Inference** `complete()` posted to a proxy that rejects WAVE API keys. It now sends + `POST /v1/inference/chat/completions` through the client. `models()` reads + `GET /v1/inference/models` instead of requiring a registry URL and key. A 2xx without choices, + or without a model list, raises `WaveError` with code `INVALID_RESPONSE` instead of returning + an empty result. +- **Meter** models failed to parse every live response. `ledger()` returns the single window the + API sends (`org`, `from`, `to`, `channels`, `generated_at`); a channel's `blocked` may be a + count or a reason string; unknown fields are kept; a counter the response leaves out is + `None`, never a default `0`. +- **Billed or broadcast writes added in this release are sent once**: `inference.complete()`, + `realtime.publish()`, `voice.generate()`, `clips.detect()`, `chapters.detect()`, + `chapters.create_chapter()`, `editor.export()`, `podcast.create()` and + `podcast.create_episode()`. A timeout or bare 5xx after the server may have accepted the + request is raised to the caller instead of retried, since a retry could bill a job or + broadcast an event twice. Existing methods keep their retry behavior. +- **Error parsing** kept only the `{"error": {"code", "message"}}` shape. Flat bodies + (`{"error": "...", "code": ..., "message": ...}`), bare `{"error": "..."}` bodies and the x402 + challenge now keep the server's code, message and context instead of becoming + `HTTP_`. +- **Retries** follow the server's `next_action`: a permanent error (for example a 503 whose + directive is `none`, or a 429 whose directive is not a retry) is raised on the first attempt + instead of after three backoffs. A `Retry-After` header (on a 429 or any retryable 5xx) or a + `retry_after` directive sets the wait. A wait over 60 s is raised at once with the full value + on the error, instead of being slept through. A `Retry-After` that is not a finite, non-negative number of seconds is + ignored; before, `nan` or `-1` escaped as a non-`WaveError` exception. +- **Rate limits** raise `RateLimitError` from a WebSocket upgrade too, not a plain `WaveError`. +- **Inference** no longer sends the WAVE API key to `funnel_url`; the argument is ignored. +- `captions.download()` returns the caption text as `content` if the API answers with the file + itself rather than JSON. +- **Paths** moved to the published API operations: `podcast` uses `/v1/podcast/shows` and + `/v1/podcast/shows/{id}/episodes`; `sentiment.analyze_text()` posts to + `/v1/sentiment/analyze`. +- **Mesh** requests carry the required `x-wave-node` header (`client.mesh.node`, or `node=` on + any method, mutations included); a call without one, or with a multi-line node name, raises + `ValueError` before sending. A per-call `node=` always wins, so `node=""` (or a blank name) + raises instead of silently using the client default. Mesh ids are encoded as one path segment, and the mutations + (`add_peer`, `remove_peer`, `create_policy`, `trigger_failover`) are sent once rather than + retried, so a 5xx after the server applied the change cannot fail over twice. +- **Path safety**: every request whose path contains a `.` or `..` segment raises `ValueError` + before it is sent. httpx resolves those segments, so in 2.2.0 `clips.get("../usage")` was sent + to `/v1/usage` with the caller's key. The methods added or changed in this release also + encode an id as exactly one path segment (`/`, `?`, `#` and `%` are percent-encoded; `:` + stays literal). +- Package docstring examples and the PyPI project links point at calls and pages that exist. + +### Added + +- `client.usage.get()`: `GET /v1/usage`, the organization's metered totals. +- `PaymentRequiredError` (HTTP 402; `accepts` and `x402_version` for an x402 challenge, spend-cap + context in `details`) and `RouteNotServedError` (404 for a route no capability serves, or 405). + Both subclass `WaveError`. `WaveError` gains `next_action`, `suggestions` and `doc_url`. +- `pulse.get_overview()` and `pulse.get_top_content()`; `from_`/`to` on + `pulse.get_engagement_metrics()`. +- Methods for published operations the SDK sent to other paths: `clips.detect()`, + `captions.download()`, `voice.generate()`, `editor.export()`, `collab.delete_room()`, + `chapters.list_chapters()`, `chapters.create_chapter()`, `chapters.detect()`. +- `raw=True` on `WaveClient.get()` / `post()` / ... returns the `httpx.Response` for binary + bodies. +- `scripts/smoke_live.py`: runs the documented free flows against the live API. +- `scripts/refresh_openapi_snapshot.py`: regenerates the OpenAPI snapshot the contract test + reads. + +### Deprecated + +These methods and arguments warn with `DeprecationWarning`. All but two call paths the API +does not publish: `clips.detect_highlights()` (use `detect()`), `voice.synthesize()` +(`generate()`), `captions.get_text()` (`download()`), `chapters.get_default_set()` +(`list_chapters()`), `chapters.add_chapter()` (`create_chapter()`), `editor.render()` +(`export()`), `inference.profile()`, and the podcast methods with no published operation +(`get()`, `update()`, `remove()`, `get_episode()`, `publish_episode()`, `get_rss_feed()`, +`get_analytics()`, `distribute()`). The two exceptions: `collab.close_room()` is now an alias of +`delete_room()` and sends the published `DELETE`, and `InferenceAPI(funnel_url=...)` is ignored. + +### Changed + +- `podcast.create()` takes `name` (the API's field) as its first argument; `create_episode()` + requires `audio_url`. Both return `PodcastShow` / `PodcastEpisode` (`Podcast` and `Episode` + remain as aliases). The keyword `podcast_id=` is now `show_id=`. No 2.2.0 podcast call + reached a served route, so no working call changes behavior. +- `MeterLedgerRow` is an alias of `MeterLedger`; `MeterLedger.rows` no longer exists. +- The README lists which namespaces were checked live, which wrap published operations, and + which the API does not route today. + +### Testing + +- `tests/test_contract_coverage.py` calls every mapped method against a mock transport and + asserts the verb and path it sends match the spec operation (the old test only checked that + the method name existed). The snapshot now covers all 255 operations of the published + document. +- `tests/test_errors.py`, `tests/test_realtime.py` and `tests/test_served_routes.py` cover the + error envelopes, the realtime URLs and headers, and parsing of recorded live response bodies + (`tests/fixtures/live_responses.json`, identifiers scrubbed). + ## [2.2.0] - 2026-09-06 ### Added diff --git a/MIGRATING.md b/MIGRATING.md index 95eef1f..c864e83 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -1,3 +1,48 @@ +# Migrating to `wave-sdk` 2.3.0 + +2.3.0 moves methods onto the paths the WAVE API publishes. The old method names in the table +below keep working and emit a `DeprecationWarning`; switch when convenient. The changes listed +after the table can require edits to existing code. + +| Before (2.2.0) | 2.3.0 | +| ----------------------------------------------- | --------------------------------------------------------- | +| `clips.detect_highlights(source_type, source_id)` | `clips.detect(video_id)` (`POST /v1/clips/detect`) | +| `voice.synthesize(text, voice_id)` | `voice.generate(text, voice_id)` (`POST /v1/voice/generate`) | +| `captions.get_text(caption_id)` | `captions.download(job_id, language, format="txt")` (`format` defaults to `None`, which lets the API choose; pass `"txt"` for plain text) | +| `chapters.get_default_set(asset_id)` | `chapters.list_chapters(video_id)` | +| `chapters.add_chapter(set_id, ...)` | `chapters.create_chapter(video_id, ...)` | +| `editor.render(project_id)` | `editor.export(project_id)` | +| `collab.close_room(room_id)` | `collab.delete_room(room_id)` | +| `inference.profile(model_id)` | `inference.models()` | +| `InferenceAPI(client, funnel_url=...)` | `InferenceAPI(client)`: `funnel_url` is now ignored; completions go through `api.wave.online` | + +Changes that can require edits: + +- `podcast.create(title, description, category)` is now `podcast.create(name, description=None, + category=None, ...)`, and `podcast.create_episode()` requires `audio_url=`. Positional calls + keep working; rename a `title=` keyword to `name=` and `podcast_id=` to `show_id=`. The old + `/v1/podcasts` paths were never routed, so no call that worked before changes behavior. The + podcast methods with no published operation (`get`, `update`, `remove`, `get_episode`, + `publish_episode`, `get_rss_feed`, `get_analytics`, `distribute`) now warn. +- An id that is `.` or `..`, or any request path with such a segment, raises `ValueError` + before anything is sent. +- `meter.ledger()` returns one window: read `ledger.channels` instead of `ledger.rows[0].channels`. + A channel counter the response leaves out is `None` (it used to fail validation), so check + for `None` before doing arithmetic on it. +- `realtime.connect()` raises `ValueError` for a `ws://` origin other than localhost (a + `base_url` of `http://...`): the key would travel unencrypted. Use `https://` / `wss://`. +- A 402 now raises `PaymentRequiredError`, and a 404 for a route nothing serves (or a 405) raises + `RouteNotServedError`. Errors whose body the old parser could not read, which surfaced as + `code="HTTP_402"` or `"HTTP_405"`, now carry the server's code and message. Both classes + subclass `WaveError`, so existing `except WaveError` blocks still catch them. +- Retries follow the server. An error whose `next_action` says a retry cannot help (including a + 429 or 5xx) is raised on the first attempt, and a 429 or `retry_after` directive asking for + more than 60 seconds is raised at once with the full wait on the error + (`RateLimitError.retry_after`) instead of being slept through. The billed or broadcast methods + new in 2.3.0 (listed in the CHANGELOG) are never retried automatically. + +--- + # Migrating to `wave-sdk` 2.1.0 Two things changed between the published `2.0.0` releases and `2.1.0`: the diff --git a/README.md b/README.md index 98bb7f7..e263036 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,29 @@ WAVE Online, LLC. ```bash pip install wave-sdk +# with the realtime WebSocket client: +pip install 'wave-sdk[realtime]' ``` +The distribution is `wave-sdk` and the import is `wave_sdk`. No other distribution name is +published for this SDK. + ## Quick start +Every call goes to `https://api.wave.online` with your API key in an +`Authorization: Bearer` header. + ```python +import os + from wave_sdk import Wave -client = Wave(api_key="your-api-key", organization_id="org_123") +client = Wave(api_key=os.environ["WAVE_API_KEY"]) + +# Your organization's metered usage. Nothing has to exist first, so it is the +# quickest check that a key works. +usage = client.usage.get() +print(usage.org, usage.range.days) # Search your organization's indexed media results = client.search.search(query="product launch") @@ -22,127 +37,156 @@ results = client.search.search(query="product launch") # List your org's published pricing tiers (requires the pricing:read scope) manifests = client.pricing.list_manifests() -# Transcribe a recording and auto-generate captions for it -transcription = client.transcribe.create(source_url="https://example.com/clip.mp4") -captions = client.captions.generate(media_id=transcription.id, media_type="video") - # Propose a plan across WAVE products for a plain-English intent (requires # composer:write). A proposal never executes anything. proposal = client.compose.compose("live captions for tomorrow's webinar") print(proposal.stages, proposal.price_rows, proposal.executes) # executes is always False ``` -## All 43 APIs - -### P1 - Core - -| API | Description | -| --------------- | ----------------------- | -| `wave.pipeline` | Live streaming engine | -| `wave.studio` | Multi-camera production | - -### P2 - Enterprise - -| API | Description | -| ------------ | ----------------------- | -| `wave.fleet` | Device fleet management | -| `wave.ghost` | AI auto-directing | -| `wave.mesh` | Multi-region failover | -| `wave.edge` | CDN and edge workers | -| `wave.pulse` | Analytics and BI | -| `wave.prism` | Virtual Device Bridge | -| `wave.zoom` | Zoom integration | - -### P3 - Content & Commerce - -| API | Description | -| ------------------- | ------------------ | -| `wave.clips` | Video clips | -| `wave.editor` | Video editor | -| `wave.voice` | Voice synthesis | -| `wave.phone` | Phone calls | -| `wave.collab` | Collaboration | -| `wave.captions` | Auto-captions | -| `wave.chapters` | Video chapters | -| `wave.studio_ai` | AI assistant | -| `wave.transcribe` | Transcription | -| `wave.sentiment` | Sentiment analysis | -| `wave.search` | Content search | -| `wave.scene` | Scene detection | -| `wave.vault` | Recording storage | -| `wave.marketplace` | Marketplace | -| `wave.connect` | Integrations | -| `wave.distribution` | Social simulcast | -| `wave.desktop` | Desktop Node | -| `wave.signage` | Digital signage | -| `wave.qr` | QR codes | -| `wave.audience` | Polls/Q&A | -| `wave.creator` | Monetization | - -### P4 - Specialized - -| API | Description | -| -------------- | ------------------ | -| `wave.podcast` | Podcast production | -| `wave.slides` | Slides-to-video | -| `wave.usb` | USB relay | - -### Cross-cutting - -| API | Description | -| -------------------- | ----------------------------------------------- | -| `wave.notifications` | User notifications, preferences, delivery | -| `wave.drm` | Digital rights management | -| `wave.realtime` | Live control and event plane (WebSocket) | - -### Agent-native and comms productization (2.1.0) - -| API | Description | -| ------------------ | ------------------------------------------------------------ | -| `wave.transcripts` | The voice-agent transcript (list + read) | -| `wave.mail` | Send, reply, search, transcript email, and SMS | -| `wave.meter` | Read-only usage ledger and rollup (`meter:read`) | -| `wave.pricing` | The seller tier-manifest registry (`pricing:read`/`:write`) | -| `wave.perception` | Agentic live-media `subscribe()` control plane | -| `wave.inference` | One completion endpoint through the measured funnel | - -### Composer (PR4) - -| API | Description | -| -------------- | --------------------------------------------------------------------| -| `wave.compose` | `POST /v1/compose`: propose a plan across products (`composer:write`/`:read`) | - -`wave.compose.compose(intent, ...)` never executes anything — the response's -`executes` field is a literal `False`. `wave.compose.save_flow(proposal)` -saves the proposal as a flow with `createdBy.kind: "wave-composer"`; until a -composer:write console token exists, it never calls the console silently — -it prints (and returns) the exact `curl` a signed-in console session can run: +Media jobs are billed. An organization without a payment method gets a +`PaymentRequiredError` (HTTP 402) whose message says what to add: ```python -proposal = client.compose.compose("live captions for tomorrow's webinar", budget_usd=5) -curl_cmd = client.compose.save_flow(proposal) # prints the curl; does not save it itself +transcription = client.transcribe.create(source_url="https://example.com/clip.mp4") +captions = client.captions.generate(media_id=transcription.id, media_type="video") ``` +`scripts/smoke_live.py` runs the free flows above, and the realtime and inference ones below, +against the live API: `WAVE_API_KEY=... python3 scripts/smoke_live.py`. + +## Namespaces + +`client.` attributes of the `Wave` facade. Every method raises a `WaveError` +subclass carrying the server's own error code and message when a call fails (see +[Error handling](#error-handling)). + +### Checked against the live API in this release + +| Namespace | What it does | +| ------------------ | ---------------------------------------------------------------------------- | +| `client.usage` | `get()`: the organization's metered totals (`GET /v1/usage`) | +| `client.search` | `search()`, `get_analytics()` | +| `client.pricing` | `list_manifests()`: seller tier manifests (`pricing:read`) | +| `client.compose` | `compose()`: propose a plan across products; never executes | +| `client.pulse` | `get_overview()`, `get_top_content()`, `get_engagement_metrics()` | +| `client.meter` | `ledger()`, `rollup()`: usage per comms channel (`meter:read`) | +| `client.realtime` | `presence()`, `history()`, `connect()` over WebSocket; `publish()` needs `realtime:write` | +| `client.inference` | `models()` and `complete()`, OpenAI-compatible, with your WAVE key | +| `client.mesh` | `list_peers()` for a mesh node (set `client.mesh.node` first) | + +### Wrapping published API operations + +The request each of these methods sends is checked against the +[published OpenAPI document](https://api.wave.online/openapi.json) by +`tests/test_contract_coverage.py`. + +| Namespace | Published operations it wraps | +| ------------------- | ------------------------------------------------------------------ | +| `client.clips` | list, create, get, update, remove, `detect()` | +| `client.captions` | list, `generate()`, get, remove, `download()` | +| `client.transcribe` | list, create, get, remove | +| `client.voice` | `list_voices()`, `clone_voice()`, `generate()` | +| `client.editor` | project CRUD, `export()` | +| `client.collab` | rooms: list, create, get, `delete_room()` | +| `client.chapters` | `list_chapters()`, `create_chapter()`, `detect()` for a video | +| `client.studio` | productions: list, create, get | +| `client.phone` | calls: `list_calls()`, `make_call()` | +| `client.sentiment` | `analyze()`, `analyze_text()`, `list()` | +| `client.podcast` | shows and episodes: `list()`, `create()`, `list_episodes()`, `create_episode()` | +| `client.pipeline` | streams: list, create, get, start, stop | + +At the time of this release the API answers `404` for the published streams and podcast-shows +operations and `405` for `GET /v1/sentiment`; those calls raise `RouteNotServedError` until the +API serves them. + +### Other namespaces + +`fleet`, `ghost`, `edge`, `zoom`, `scene`, `vault`, `connect`, `studio_ai`, `perception`, +`transcripts`, and the methods of the namespaces above that are not listed, call endpoints +outside the published API reference. + +`prism`, `marketplace`, `distribution`, `desktop`, `signage`, `qr`, `audience`, `creator`, +`slides`, `usb`, `notifications`, `drm` and `mail` are not routed by the API today: every call +raises `RouteNotServedError` with code `ROUTE_NOT_MAPPED`. They stay in the SDK so existing code +keeps importing. The error's `doc_url` points at the API's capability index. + +## Realtime + +```python +channel = client.realtime.connect("stream:abc") # wss://api.wave.online/v1/realtime/connect +channel.on("caption.cue", lambda data: print(data)) +channel.run() # blocks, dispatching frames + +client.realtime.presence("stream:abc") # {"channel": ..., "members": [...]} +client.realtime.history("stream:abc", limit=20) +``` + +The key travels in the `Authorization` header on the WebSocket upgrade too, never in the URL. +A rejected upgrade raises the same `WaveError` subclass a REST call would. + +## Inference + +```python +models = client.inference.models() # GET /v1/inference/models +reply = client.inference.complete(models[0].id, [{"role": "user", "content": "hi"}], max_tokens=64) +print(reply.content) +``` + +`complete()` sends `POST /v1/inference/chat/completions` and needs the `dispatch:write` scope. + +## Mesh + +Every `/v1/mesh` request names the node it is for in an `x-wave-node` header: + +```python +client.mesh.node = "studio-a" +peers = client.mesh.list_peers() # {"org", "count", "peers": [...]} +peers_b = client.mesh.list_peers(node="studio-b") # any method takes node= for one call +``` + +Every mesh method, mutations included, raises `ValueError` before sending when no node is set. +A per-call `node=` always overrides `client.mesh.node`, so an empty or blank `node=""` raises +rather than falling back to the default node. +The mutations (`add_peer`, `remove_peer`, `create_policy`, `trigger_failover`) are sent once and +never retried automatically. + ## Error handling ```python -from wave_sdk import WaveError, RateLimitError +from wave_sdk import PaymentRequiredError, RateLimitError, RouteNotServedError, WaveError try: client.clips.get("invalid-id") except RateLimitError as e: print(f"Rate limited. Retry after {e.retry_after}s") +except PaymentRequiredError as e: + # A plan spend cap (e.code == "SPEND_CAP_TIER_BLOCKED", context in e.details) or an x402 + # payment challenge (payment options in e.accepts). + print(f"{e.code}: {e.message}") +except RouteNotServedError as e: + print(f"Not served: {e.code} (capability index: {e.doc_url})") except WaveError as e: - print(f"{e.code}: {e.message} ({e.status_code})") + print(f"{e.code}: {e.message} ({e.status_code}, request {e.request_id})") ``` +`WaveError` carries `code`, `message`, `status_code`, `request_id`, `details` (for example +`available_scopes` on a 403), `suggestions`, `doc_url` and `next_action`. The client retries a +failed request only when a retry can help: a 429, a 5xx without a directive, or a +`next_action` of `retry_backoff` / `retry_after`. A permanent error such as an unconfigured +server-side store is raised on the first attempt. + ## Requirements - Python 3.9+ - httpx - pydantic +- `websocket-client` for `realtime.connect()` (the `realtime` extra) + +## Migrating -## Migrating from 2.0.0 +2.3.0 moves several methods onto the paths the API publishes. The old names still work and +emit a `DeprecationWarning`; see +[MIGRATING.md](https://github.com/wave-av/sdk-python/blob/main/MIGRATING.md). If you installed `wave-av-sdk` or `wave-sdk` at `2.0.0`, two names changed: @@ -154,18 +198,13 @@ If you installed `wave-av-sdk` or `wave-sdk` at `2.0.0`, two names changed: +from wave_sdk import Wave ``` -Nothing below the top-level name changed, so replacing the import line is the -whole migration. The old `wave` package collided with the Python standard -library's own `wave` module and was never importable from an installed -`2.0.0` — full detail, the uninstall step, and a bulk find-and-replace are in -[MIGRATING.md](MIGRATING.md). - -Note that the `wave.` names in the tables above are attributes of a client -instance, not module paths: name your client whatever you like -(`client = Wave(...)` in the quick start above), and `client.clips` is the row -the table writes as `wave.clips`. +The old `wave` package collided with the Python standard library's own `wave` module and was +never importable from an installed `2.0.0`; the uninstall step and a bulk find-and-replace are +in [MIGRATING.md](https://github.com/wave-av/sdk-python/blob/main/MIGRATING.md). ## License -Apache-2.0 - WAVE Online, LLC. See [LICENSE](LICENSE) and [NOTICE](NOTICE); the -WAVE marks are not licensed under the Apache grant. +Apache-2.0 - WAVE Online, LLC. See +[LICENSE](https://github.com/wave-av/sdk-python/blob/main/LICENSE) and +[NOTICE](https://github.com/wave-av/sdk-python/blob/main/NOTICE); the WAVE marks are not +licensed under the Apache grant. diff --git a/pyproject.toml b/pyproject.toml index 02efa13..22b74c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "wave-sdk" -version = "2.2.0" -description = "Official WAVE SDK for Python - 43 API modules for streaming, production, analytics, and more" +version = "2.3.0" +description = "Official WAVE SDK for Python - search, usage, pricing, composer, realtime, inference, analytics, and media APIs" readme = "README.md" # Apache-2.0 is the repo's actual license: the LICENSE file is the Apache 2.0 # text and NOTICE carves the WAVE marks out of that grant. This field said "MIT" @@ -87,8 +87,11 @@ dev = [ ] [project.urls] -Homepage = "https://docs.wave.online/sdk/python" -Documentation = "https://docs.wave.online/sdk/python" +# The README on GitHub is the Python SDK's reference page; it is what the published +# quickstart, namespace table and error-handling docs live in. +Homepage = "https://github.com/wave-av/sdk-python#readme" +Documentation = "https://github.com/wave-av/sdk-python#readme" +Changelog = "https://github.com/wave-av/sdk-python/blob/main/CHANGELOG.md" Repository = "https://github.com/wave-av/sdk-python" Issues = "https://github.com/wave-av/sdk-python/issues" diff --git a/scripts/refresh_openapi_snapshot.py b/scripts/refresh_openapi_snapshot.py new file mode 100644 index 0000000..852243b --- /dev/null +++ b/scripts/refresh_openapi_snapshot.py @@ -0,0 +1,66 @@ +"""Regenerate tests/fixtures/openapi_snapshot.json from the live WAVE OpenAPI document. + + python3 scripts/refresh_openapi_snapshot.py # fetch https://api.wave.online/openapi.json + python3 scripts/refresh_openapi_snapshot.py spec.json # or read a local copy + +The snapshot keeps only what the contract test compares: each operation's method, path (relative +to the spec's server URL, which ends in /v1), operationId, and whether it is a product +skill-invocation operation (one that carries ``x-skill-url``). After refreshing, run +``pytest tests/test_contract_coverage.py``: every new operation must be mapped to the SDK method +that sends it, or listed with a reason. +""" +from __future__ import annotations + +import datetime +import json +import sys +import urllib.request +from pathlib import Path + +SOURCE = "https://api.wave.online/openapi.json" +OUT = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "openapi_snapshot.json" +# Every operation verb a Path Item can hold (OpenAPI 3.x), so a new HEAD/OPTIONS/TRACE operation +# reaches the contract test instead of being dropped. The other Path Item keys (summary, +# description, servers, parameters, $ref) are not operations. +METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace") + + +def load(argv: list[str]) -> dict: + if len(argv) > 1: + return json.loads(Path(argv[1]).read_text()) + req = urllib.request.Request(SOURCE, headers={"Accept": "application/json", "User-Agent": "wave-sdk-python-snapshot"}) + with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 - fixed https URL + return json.loads(resp.read()) + + +def main(argv: list[str]) -> int: + spec = load(argv) + servers = [s.get("url", "") for s in spec.get("servers", [])] + ops = [] + for path, item in spec["paths"].items(): + for method, op in item.items(): + if method not in METHODS or not isinstance(op, dict): + continue + ops.append({ + "method": method.upper(), + "path": path, + "operationId": op.get("operationId"), + "skill_invocation": "x-skill-url" in op, + }) + ops.sort(key=lambda o: (o["path"], o["method"])) + snapshot = { + "source": SOURCE, + "servers": servers, + "spec_version": spec.get("info", {}).get("version"), + "fetched": datetime.date.today().isoformat(), + "total_paths": len(spec["paths"]), + "total_ops": len(ops), + "operations": ops, + } + OUT.write_text(json.dumps(snapshot, indent=1) + "\n") + print(f"wrote {OUT}: {len(ops)} operations across {len(spec['paths'])} paths (spec {snapshot['spec_version']})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/smoke_live.py b/scripts/smoke_live.py new file mode 100644 index 0000000..b903f3f --- /dev/null +++ b/scripts/smoke_live.py @@ -0,0 +1,207 @@ +"""Live smoke test: run the SDK's documented flows against the real WAVE API. + + WAVE_API_KEY=... python3 scripts/smoke_live.py + +Every call is a free read, a proposal that never executes, a request the API rejects before any +work is done (an unsupported model name), or one realtime event published to the +``stream:sdk-smoke`` channel (only when the key holds ``realtime:write``), so a run costs nothing. +The script prints one line per check with the HTTP outcome and the request id. It never prints the +key or a response body. + +Each check says what it expects: + +* ``200``: the flow must succeed and parse into the SDK's model; +* ``error CODE``: the API must answer with that error code, parsed into the named WaveError + subclass (this proves the request reached a real route with a working key and that the SDK + surfaces the server's code and message); +* a list: any one of the outcomes it holds. + +A GET to ``/v1/network/surface`` runs first as a control: a route that is known to be served. If it +fails, the key or the network is at fault, and no other result means anything. + +Exit status: 0 when every check matches its expectation, 1 otherwise, 2 when WAVE_API_KEY is unset +or WAVE_BASE_URL is not https:// (http:// is accepted for localhost only). +""" +from __future__ import annotations + +import os +import sys +import time +from typing import Any, Callable +from urllib.parse import urlsplit + +from wave_sdk import PaymentRequiredError, RouteNotServedError, Wave, WaveError + +Check = tuple[str, Callable[[Wave], Any], Any] +_LOOPBACK = frozenset({"localhost", "127.0.0.1", "::1"}) + + +# Status and x-request-id of the most recent HTTP response, so a 200 line carries the same receipt +# an error does and "200" means exactly 200 (not any 2xx). +_LAST_RID: dict[str, str] = {} + + +def _remember_request_id(response: Any) -> None: + _LAST_RID["rid"] = response.headers.get("x-request-id") or "-" + _LAST_RID["status"] = str(response.status_code) + + +def _safe_summary(exc: Exception) -> str: + """Describe a non-WaveError failure without echoing response data: a pydantic + ValidationError's str() includes the offending input value, so only locations and types.""" + errors = getattr(exc, "errors", None) + if callable(errors): + try: + items = errors(include_input=False) + except TypeError: + items = errors() + locs = ", ".join(f"{'.'.join(str(p) for p in e.get('loc', ()))}:{e.get('type')}" for e in items[:5]) + return f"{type(exc).__name__}: {len(items)} error(s) at {locs}" + return type(exc).__name__ + + +def _request_id(obj: Any) -> str: + return getattr(obj, "request_id", None) or "-" + + +def _checks() -> list[Check]: + channel = "stream:sdk-smoke" + return [ + ("control GET /v1/network/surface", lambda w: w.client.get("/v1/network/surface"), 200), + ("usage.get", lambda w: w.usage.get(), 200), + ("search.search", lambda w: w.search.search(query="product launch"), 200), + ("search.get_analytics", lambda w: w.search.get_analytics(), 200), + ("pricing.list_manifests", lambda w: w.pricing.list_manifests(), 200), + ("compose.compose", lambda w: w.compose.compose("live captions for tomorrow's webinar"), 200), + ("pulse.get_overview", lambda w: w.pulse.get_overview(), 200), + ("pulse.get_top_content", lambda w: w.pulse.get_top_content(limit=5), 200), + ("pulse.get_engagement_metrics", lambda w: w.pulse.get_engagement_metrics(), 200), + ("meter.ledger", lambda w: w.meter.ledger(), 200), + ("meter.rollup", lambda w: w.meter.rollup(), 200), + ("realtime.presence", lambda w: w.realtime.presence(channel), 200), + ("realtime.history", lambda w: w.realtime.history(channel, limit=1), 200), + # publish needs realtime:write. A key without it must get the gateway's scope error for + # this exact route (proof the request reached it), never a transport failure. + ( + "realtime.publish", + lambda w: w.realtime.publish(channel, "sdk.smoke", {"ok": True}), + [200, (WaveError, "SCOPE_INSUFFICIENT")], + ), + ("inference.models", lambda w: w.inference.models(), 200), + ( + "inference.complete (unsupported model)", + lambda w: w.inference.complete("sdk-smoke-unsupported-model", [{"role": "user", "content": "hi"}], max_tokens=1), + (WaveError, "model_not_supported"), + ), + ("mesh.list_peers", lambda w: w.mesh.list_peers(node="sdk-smoke"), 200), + ] + + +def _outcome_matches(expected: Any, result: Any, error: WaveError | None) -> bool: + if isinstance(expected, list): # any one of several acceptable outcomes + return any(_outcome_matches(e, result, error) for e in expected) + if expected == 200: + return error is None and _LAST_RID.get("status") == "200" + cls, code = expected + return isinstance(error, cls) and error.code == code + + +def _describe(result: Any, error: WaveError | None, elapsed: float) -> str: + if error is not None: + kind = type(error).__name__ + return f"{kind} {error.status_code} {error.code} rid={_request_id(error)} ({elapsed:.1f}s)" + shape = type(result).__name__ + return f"{_LAST_RID.get('status', '?')} -> {shape} rid={_LAST_RID.get('rid', '-')} ({elapsed:.1f}s)" + + +def _run(wave: Wave, name: str, fn: Callable[[Wave], Any], expected: Any) -> bool: + _LAST_RID.clear() + started = time.monotonic() + result: Any = None + error: WaveError | None = None + try: + result = fn(wave) + except WaveError as exc: + error = exc + except Exception as exc: # a parsing or transport failure is a failed check, not a crash + print(f"FAIL {name}: {_safe_summary(exc)} rid={_LAST_RID.get('rid', '-')}") + return False + ok = _outcome_matches(expected, result, error) + print(f"{'PASS' if ok else 'FAIL'} {name}: {_describe(result, error, time.monotonic() - started)}") + return ok + + +def _websocket_check(wave: Wave) -> bool | None: + """Open and close a realtime WebSocket, if the optional websocket-client package is installed.""" + try: + import websocket # noqa: F401 + except ImportError: + print("SKIP realtime.connect: pip install 'wave-sdk[realtime]' to include it") + return None + started = time.monotonic() + try: + channel = wave.realtime.connect("stream:sdk-smoke") + except WaveError as exc: + print(f"FAIL realtime.connect: {_describe(None, exc, time.monotonic() - started)}") + return False + except Exception as exc: + print(f"FAIL realtime.connect: {_safe_summary(exc)}") + return False + channel.close() + print(f"PASS realtime.connect: upgraded and closed ({time.monotonic() - started:.1f}s)") + return True + + +def _report_known_server_gaps(wave: Wave) -> None: + """Informational: flows whose SDK request matches the published API but that the server does + not answer yet. They are printed, not scored, so a server-side fix shows up here first. Any + WaveError is reported rather than failing the run on purpose: the key, the network and the + parser are already proven by the scored checks, and these routes' answers are server state.""" + probes: list[tuple[str, Callable[[Wave], Any]]] = [ + ("pipeline.list", lambda w: w.pipeline.list()), + ("podcast.list", lambda w: w.podcast.list()), + ("clips.list", lambda w: w.clips.list(limit=1)), + ] + for name, fn in probes: + started = time.monotonic() + try: + fn(wave) + print(f"INFO {name}: {_LAST_RID.get('status', '?')} rid={_LAST_RID.get('rid', '-')} ({time.monotonic() - started:.1f}s)") + except (RouteNotServedError, PaymentRequiredError) as exc: + print(f"INFO {name}: {_describe(None, exc, time.monotonic() - started)}") + except WaveError as exc: # a different answer than the known gap: shown, flagged, not scored + print(f"WARN {name}: {_describe(None, exc, time.monotonic() - started)} (not the known gap)") + + +def main() -> int: + api_key = os.environ.get("WAVE_API_KEY") + if not api_key: + print("WAVE_API_KEY is not set", file=sys.stderr) + return 2 + base_url = os.environ.get("WAVE_BASE_URL", "https://api.wave.online") + parts = urlsplit(base_url) + if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in _LOOPBACK): + print("WAVE_BASE_URL must be https:// (http:// only for localhost): the key is sent to it", file=sys.stderr) + return 2 + wave = Wave(api_key=api_key, base_url=base_url, max_retries=0) + wave.client._client.event_hooks["response"].append(_remember_request_id) + print(f"wave-sdk live smoke against {base_url}") + + checks = _checks() + control_ok = _run(wave, *checks[0]) + if not control_ok: + print("control failed: the key or the network is at fault; stopping", file=sys.stderr) + return 1 + results = [_run(wave, *c) for c in checks[1:]] + ws = _websocket_check(wave) + if ws is not None: + results.append(ws) + _report_known_server_gaps(wave) + + passed = sum(results) + print(f"{passed}/{len(results)} checks passed") + return 0 if all(results) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 7c1e788..efb2f12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,17 @@ """WAVE SDK test configuration.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Callable + +import httpx import pytest +LIVE_RESPONSES = json.loads( + (Path(__file__).parent / "fixtures" / "live_responses.json").read_text() +) + @pytest.fixture def api_key(): @@ -11,3 +22,62 @@ def api_key(): def wave_client(): from wave_sdk import Wave return Wave(api_key="test-api-key", organization_id="org_test") + + +def live(label: str) -> dict[str, Any]: + """A recorded (scrubbed) live response: ``{"status", "path", "body"}``.""" + return LIVE_RESPONSES[label] + + +def live_response(label: str, **headers: str) -> httpx.Response: + entry = live(label) + return httpx.Response(entry["status"], json=entry["body"], headers=headers) + + +class Recorder: + """An httpx transport handler that records every request and answers from a handler.""" + + def __init__(self, handler: Callable[[httpx.Request], httpx.Response] | None = None): + self.requests: list[httpx.Request] = [] + self._handler = handler or (lambda _r: httpx.Response(200, json={})) + + def set(self, handler: Callable[[httpx.Request], httpx.Response]) -> None: + self._handler = handler + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return self._handler(request) + + @property + def last(self) -> httpx.Request: + return self.requests[-1] + + +def attach(client: Any, recorder: Recorder) -> None: + """Point a WaveClient's HTTP pool at ``recorder`` (headers and base URL unchanged).""" + client._client = httpx.Client( + base_url=client.base_url, + transport=httpx.MockTransport(recorder), + headers=client._build_headers(), + ) + + +@pytest.fixture +def recorder() -> Recorder: + return Recorder() + + +@pytest.fixture +def wave(recorder: Recorder, monkeypatch: pytest.MonkeyPatch): + """A Wave facade whose every request goes to ``recorder``. Sleeping is an error, so a test + notices any retry it did not ask for.""" + from wave_sdk import Wave + + w = Wave(api_key="test-api-key", organization_id="org_test") + attach(w.client, recorder) + + def no_sleep(seconds: float) -> None: + raise AssertionError(f"unexpected retry sleep ({seconds}s)") + + monkeypatch.setattr("wave_sdk.client.time.sleep", no_sleep) + return w diff --git a/tests/fixtures/live_responses.json b/tests/fixtures/live_responses.json new file mode 100644 index 0000000..33b17ab --- /dev/null +++ b/tests/fixtures/live_responses.json @@ -0,0 +1,410 @@ +{ + "analytics_overview": { + "body": { + "organizationId": "org_fixture", + "period": { + "from": "2026-09-01", + "to": "2026-09-29" + }, + "summary": { + "activeDays": 0, + "topDimensions": [], + "totalCostUsd": 0, + "totalEvents": null, + "totalQuantity": 0 + } + }, + "path": "/v1/analytics/overview", + "status": 200 + }, + "analytics_top_content": { + "body": { + "organizationId": "org_fixture", + "period": { + "from": "2026-09-01", + "to": "2026-09-29" + }, + "topContent": [] + }, + "path": "/v1/analytics/top-content", + "status": 200 + }, + "err_method_not_allowed_405": { + "body": { + "error": "method not allowed" + }, + "path": "/v1/sentiment", + "status": 405 + }, + "err_missing_node_400": { + "body": { + "error": "missing x-wave-node" + }, + "path": "/v1/mesh/peers", + "status": 400 + }, + "err_product_route_not_found_404": { + "body": { + "error": { + "code": "search_route_not_found", + "message": "no route for /v1/search/quick", + "next_action": { + "reason": "see suggestions for how to resolve this error", + "type": "none" + }, + "request_id": "00000000-0000-4000-8000-000000000000" + } + }, + "path": "/v1/search/quick", + "status": 404 + }, + "err_route_not_found_404": { + "body": { + "error": { + "code": "ROUTE_NOT_FOUND", + "doc_url": "https://gateway.wave.online/.well-known/wave-skills.json", + "message": "No WAVE capability is served at this path.", + "next_action": { + "reason": "no WAVE capability is served at this path; the free capability index is https://gateway.wave.online/.well-known/wave-skills.json", + "type": "none" + }, + "request_id": "00000000-0000-4000-8000-000000000000", + "suggestions": [ + "Check the path for typos.", + "List available capabilities in the free capability index at the doc_url." + ] + } + }, + "path": "/v1/streams", + "status": 404 + }, + "err_route_not_mapped_404": { + "body": { + "error": { + "code": "ROUTE_NOT_MAPPED", + "doc_url": "https://gateway.wave.online/.well-known/wave-skills.json", + "message": "no scope rule for this route (fail-closed)", + "next_action": { + "reason": "this path and method are not part of the WAVE API, so the gateway refuses them fail-closed; every callable route is listed at https://gateway.wave.online/.well-known/wave-skills.json", + "type": "none" + }, + "request_id": "00000000-0000-4000-8000-000000000000", + "suggestions": [ + "Double-check the request path and HTTP method.", + "Every callable route, with the scope it requires, is listed in the free capability index." + ] + } + }, + "path": "/v1/podcasts", + "status": 404 + }, + "err_scope_insufficient_403": { + "body": { + "error": { + "available_scopes": [ + "acp:read", + "analytics:read", + "api-keys:read", + "comms:read", + "dante:read", + "directory:read", + "mesh:read", + "org:read", + "pricing:read", + "receipts:read", + "srt:read", + "streams:read", + "user:read", + "vision:read", + "vision:write", + "streams:write", + "clips:read", + "clips:write", + "recording:read", + "render:read", + "render:write", + "transcribe:read", + "transcribe:write", + "captions:read", + "captions:write", + "voice:read", + "voice:write", + "realtime:read", + "dispatch:read", + "dispatch:write", + "search:read", + "search:write", + "storage:read", + "storage:write", + "organizations:read", + "organizations:write", + "billing:read", + "insights:read", + "meter:read", + "usage:read", + "audit:read", + "acuity:read", + "engine:read", + "gpu:read", + "webhooks:read", + "webhooks:write", + "billing:write", + "sentiment:read", + "sentiment:write", + "podcast:read", + "podcast:write", + "composer:read", + "composer:write" + ], + "code": "SCOPE_INSUFFICIENT", + "doc_url": "https://gateway.wave.online/.well-known/wave-scopes.json", + "message": "requires scope: productions:read", + "next_action": { + "scope": "productions:read", + "type": "acquire_scope" + }, + "request_id": "00000000-0000-4000-8000-000000000000", + "required_scope": "productions:read", + "suggestions": [ + "Issue a key that includes the scope named in `required_scope`, then retry.", + "Every route's required scope is listed in the free scope catalog at the doc_url." + ] + } + }, + "path": "/v1/productions", + "status": 403 + }, + "err_spend_cap_402": { + "body": { + "code": "SPEND_CAP_TIER_BLOCKED", + "dimension": "wave_clip_minutes", + "error": "spend_cap_exceeded", + "message": "Add a payment method to continue — this action exceeds your plan's included allotment.", + "projected_cost_usd": 0, + "reason": "uncarded_costheavy_over", + "remaining_allowance_usd": 0 + }, + "path": "/v1/clips", + "status": 402 + }, + "err_store_unconfigured_503": { + "body": { + "error": { + "code": "COMPOSE_STORE_UNCONFIGURED", + "doc_url": "https://docs.wave.online/status", + "message": "The Composer proposal store is not bound on this deployment. Proposals still compose; re-reading one by id needs the store provisioned. This is server-side, not your request.", + "next_action": { + "reason": "see suggestions for how to resolve this error", + "type": "none" + }, + "request_id": "00000000-0000-4000-8000-000000000000", + "suggestions": [ + "Retry POST /v1/compose to get a fresh proposal.", + "If it persists, check the WAVE status page." + ] + } + }, + "path": "/v1/compose/proposals/prp_aaaaaaaaaaaaaaaaaaaa", + "status": 503 + }, + "err_x402_challenge_402": { + "body": { + "accepts": [ + { + "asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "description": "wave api access", + "extra": { + "quote_token": "fixture-quote-token", + "session_id": "00000000-0000-4000-8000-000000000000", + "settlement": "verified-on-chain-by-gateway", + "terms": "https://wave.online/legal/terms" + }, + "maxAmountRequired": "20000", + "maxTimeoutSeconds": 60, + "mimeType": "application/json", + "network": "base", + "payTo": "0x0000000000000000000000000000000000000000", + "protocol": "x402", + "resource": "/v1/clips", + "scheme": "exact" + } + ], + "error": "payment required", + "error_detail": { + "code": "PAYMENT_REQUIRED", + "doc_url": "https://gateway.wave.online/.well-known/payments.json", + "message": "Payment required. Complete the x402 challenge in `accepts` to proceed.", + "next_action": { + "reason": "complete the x402 payment challenge in the response's accepts[] / root next_action", + "type": "none" + }, + "session_id": "00000000-0000-4000-8000-000000000000", + "suggestions": [ + "Sign the x402 'exact' authorization for accepts[0].maxAmountRequired and retry with the x-payment header.", + "See the WAVE payments-discovery document at the doc_url for the exact-scheme flow and every other protocol this gateway accepts." + ] + }, + "next_action": { + "accepts": [ + { + "asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "description": "wave api access", + "extra": { + "quote_token": "fixture-quote-token", + "session_id": "00000000-0000-4000-8000-000000000000", + "settlement": "verified-on-chain-by-gateway", + "terms": "https://wave.online/legal/terms" + }, + "maxAmountRequired": "20000", + "maxTimeoutSeconds": 60, + "mimeType": "application/json", + "network": "base", + "payTo": "0x0000000000000000000000000000000000000000", + "protocol": "x402", + "resource": "/v1/clips", + "scheme": "exact" + } + ], + "protocol": "x402", + "quote_token": "fixture-quote-token", + "session_id": "00000000-0000-4000-8000-000000000000", + "type": "pay" + }, + "x402Version": 1 + }, + "path": "/v1/clips", + "status": 402 + }, + "inference_models": { + "body": { + "data": [ + { + "id": "model-a", + "object": "model", + "owned_by": "wave" + }, + { + "id": "model-b", + "object": "model", + "owned_by": "wave" + }, + { + "id": "model-c", + "object": "model", + "owned_by": "wave" + } + ], + "object": "list" + }, + "path": "/v1/inference/models", + "status": 200 + }, + "mesh_peers": { + "body": { + "count": 0, + "org": "org_fixture", + "peers": [] + }, + "path": "/v1/mesh/peers", + "status": 200 + }, + "meter_ledger": { + "body": { + "channels": { + "mail": { + "blocked": "mail_audit_not_owner", + "errors": 0, + "ops": 0, + "usdc": "0" + }, + "realtime": { + "minutes": 0 + }, + "sms": { + "blocked": "a2p-unregistered", + "ops": 0 + }, + "storage": { + "bytes": 0 + }, + "voice": { + "minutes": 0, + "usdc": "0" + } + }, + "from": "2026-09-29T00:00:00.000Z", + "generated_at": "2026-09-29T14:13:18.694Z", + "org": "org_fixture", + "to": "2026-09-29T23:59:59.999Z" + }, + "path": "/v1/meter/ledger", + "status": 200 + }, + "meter_rollup": { + "body": { + "from": "2026-09-29T00:00:00.000Z", + "generated_at": "2026-09-29T14:13:19.048Z", + "org": "org_fixture", + "period": "month", + "to": "2026-09-29T23:59:59.999Z", + "totals": { + "mail": { + "blocked": "mail_audit_not_owner", + "errors": 0, + "ops": 0, + "usdc": "0" + }, + "realtime": { + "minutes": 0 + }, + "sms": { + "blocked": "a2p-unregistered", + "ops": 0 + }, + "storage": { + "bytes": 0 + }, + "voice": { + "minutes": 0, + "usdc": "0" + } + } + }, + "path": "/v1/meter/ledger/rollup", + "status": 200 + }, + "realtime_presence": { + "body": { + "channel": "stream:fixture", + "members": [] + }, + "path": "/v1/realtime/channels/stream:fixture/presence", + "status": 200 + }, + "usage": { + "body": { + "billable": { + "agent_seconds_second": 0, + "avsync_checks_check": 0, + "bytes_GB": 0, + "duration_ms:aes67_minute": 0 + }, + "org": "org_fixture", + "range": { + "days": 1, + "from": "20260929", + "to": "20260929" + }, + "totals": { + "bytes": 0, + "duration_ms": 0, + "frames": 0, + "realtime_tokens": 0, + "transcription_seconds": 0, + "wave_search_queries": 0 + } + }, + "path": "/v1/usage", + "status": 200 + } +} diff --git a/tests/fixtures/openapi_snapshot.json b/tests/fixtures/openapi_snapshot.json index 8c34ce5..f7c57b2 100644 --- a/tests/fixtures/openapi_snapshot.json +++ b/tests/fixtures/openapi_snapshot.json @@ -1,383 +1,1542 @@ { - "source": "https://api.wave.online/openapi.json", - "fetched": "2026-09-01", - "info_version": "1.0.0", - "total_ops": 75, - "operations": [ - { - "operationId": "agentAuthDevice", - "method": "POST", - "path": "/agent/auth/device" - }, - { - "operationId": "agentAuthToken", - "method": "POST", - "path": "/agent/auth/token" - }, - { - "operationId": "avDemux", - "method": "POST", - "path": "/av/demux" - }, - { - "operationId": "avRemux", - "method": "POST", - "path": "/av/remux" - }, - { - "operationId": "batchOperations", - "method": "POST", - "path": "/batch" - }, - { - "operationId": "publishBraidAudio", - "method": "POST", - "path": "/braid/publish" - }, - { - "operationId": "stopBraidAudio", - "method": "DELETE", - "path": "/braid/publish/{ns}" - }, - { - "operationId": "listCaptions", - "method": "GET", - "path": "/captions" - }, - { - "operationId": "createCaptionJob", - "method": "POST", - "path": "/captions" - }, - { - "operationId": "deleteCaptionJob", - "method": "DELETE", - "path": "/captions/{jobId}" - }, - { - "operationId": "getCaptionJob", - "method": "GET", - "path": "/captions/{jobId}" - }, - { - "operationId": "downloadCaptions", - "method": "GET", - "path": "/captions/{jobId}/download" - }, - { - "operationId": "listClips", - "method": "GET", - "path": "/clips" - }, - { - "operationId": "createClip", - "method": "POST", - "path": "/clips" - }, - { - "operationId": "detectClips", - "method": "POST", - "path": "/clips/detect" - }, - { - "operationId": "deleteClip", - "method": "DELETE", - "path": "/clips/{clipId}" - }, - { - "operationId": "getClip", - "method": "GET", - "path": "/clips/{clipId}" - }, - { - "operationId": "updateClip", - "method": "PATCH", - "path": "/clips/{clipId}" - }, - { - "operationId": "listCollabRooms", - "method": "GET", - "path": "/collab/rooms" - }, - { - "operationId": "createCollabRoom", - "method": "POST", - "path": "/collab/rooms" - }, - { - "operationId": "deleteCollabRoom", - "method": "DELETE", - "path": "/collab/rooms/{roomId}" - }, - { - "operationId": "getCollabRoom", - "method": "GET", - "path": "/collab/rooms/{roomId}" - }, - { - "operationId": "custodyOperation", - "method": "POST", - "path": "/custody/{op}" - }, - { - "operationId": "listProjects", - "method": "GET", - "path": "/editor/projects" - }, - { - "operationId": "createProject", - "method": "POST", - "path": "/editor/projects" - }, - { - "operationId": "deleteProject", - "method": "DELETE", - "path": "/editor/projects/{projectId}" - }, - { - "operationId": "getProject", - "method": "GET", - "path": "/editor/projects/{projectId}" - }, - { - "operationId": "updateProject", - "method": "PATCH", - "path": "/editor/projects/{projectId}" - }, - { - "operationId": "exportProject", - "method": "POST", - "path": "/editor/projects/{projectId}/export" - }, - { - "operationId": "engineCapabilities", - "method": "GET", - "path": "/engine/capabilities" - }, - { - "operationId": "gpuStatus", - "method": "GET", - "path": "/gpu/infer" - }, - { - "operationId": "gpuInfer", - "method": "POST", - "path": "/gpu/infer" - }, - { - "operationId": "identityResolve", - "method": "GET", - "path": "/identity/resolve" - }, - { - "operationId": null, - "method": "GET", - "path": "/leaderboard" - }, - { - "operationId": "mintMoqPublishToken", - "method": "POST", - "path": "/moq/publish/{ns}/{track}" - }, - { - "operationId": "mintMoqSubscribeToken", - "method": "GET", - "path": "/moq/subscribe/{ns}/{track}" - }, - { - "operationId": "listCalls", - "method": "GET", - "path": "/phone/calls" - }, - { - "operationId": "makeCall", - "method": "POST", - "path": "/phone/calls" - }, - { - "operationId": "listPhoneLines", - "method": "GET", - "path": "/phone/lines" - }, - { - "operationId": "provisionPhoneLine", - "method": "POST", - "path": "/phone/lines" - }, - { - "operationId": null, - "method": "GET", - "path": "/platform" - }, - { - "operationId": "listPodcastShows", - "method": "GET", - "path": "/podcast/shows" - }, - { - "operationId": "createPodcastShow", - "method": "POST", - "path": "/podcast/shows" - }, - { - "operationId": "listPodcastEpisodes", - "method": "GET", - "path": "/podcast/shows/{showId}/episodes" - }, - { - "operationId": "createPodcastEpisode", - "method": "POST", - "path": "/podcast/shows/{showId}/episodes" - }, - { - "operationId": "pricingManifestsList", - "method": "GET", - "path": "/pricing/manifests" - }, - { - "operationId": "pricingManifestsCreate", - "method": "POST", - "path": "/pricing/manifests" - }, - { - "operationId": "realtimeHistory", - "method": "GET", - "path": "/realtime/channels/{channel}/history" - }, - { - "operationId": "realtimePresence", - "method": "GET", - "path": "/realtime/channels/{channel}/presence" - }, - { - "operationId": "realtimePublish", - "method": "POST", - "path": "/realtime/channels/{channel}/publish" - }, - { - "operationId": "realtimeConnect", - "method": "GET", - "path": "/realtime/connect" - }, - { - "operationId": "renderVideo", - "method": "POST", - "path": "/render" - }, - { - "operationId": "renderPoll", - "method": "GET", - "path": "/render/{jobId}" - }, - { - "operationId": "renderEvents", - "method": "GET", - "path": "/render/{jobId}/events" - }, - { - "operationId": "search", - "method": "POST", - "path": "/search" - }, - { - "operationId": "searchAnalytics", - "method": "GET", - "path": "/search/analytics" - }, - { - "operationId": "searchIndex", - "method": "POST", - "path": "/search/index" - }, - { - "operationId": "searchDelete", - "method": "DELETE", - "path": "/search/index/{id}" - }, - { - "operationId": "listSentimentAnalyses", - "method": "GET", - "path": "/sentiment" - }, - { - "operationId": "createSentimentAnalysis", - "method": "POST", - "path": "/sentiment" - }, - { - "operationId": "analyzeText", - "method": "POST", - "path": "/sentiment/analyze" - }, - { - "operationId": "listEnhancements", - "method": "GET", - "path": "/studio-ai/enhancements" - }, - { - "operationId": "createEnhancement", - "method": "POST", - "path": "/studio-ai/enhancements" - }, - { - "operationId": "previewEnhancement", - "method": "POST", - "path": "/studio-ai/preview" - }, - { - "operationId": "listTranscriptions", - "method": "GET", - "path": "/transcribe" - }, - { - "operationId": "createTranscription", - "method": "POST", - "path": "/transcribe" - }, - { - "operationId": "deleteTranscription", - "method": "DELETE", - "path": "/transcribe/{transcriptionId}" - }, - { - "operationId": "getTranscription", - "method": "GET", - "path": "/transcribe/{transcriptionId}" - }, - { - "operationId": null, - "method": "GET", - "path": "/usage" - }, - { - "operationId": "listChapters", - "method": "GET", - "path": "/videos/{videoId}/chapters" - }, - { - "operationId": "createChapter", - "method": "POST", - "path": "/videos/{videoId}/chapters" - }, - { - "operationId": "detectChapters", - "method": "POST", - "path": "/videos/{videoId}/chapters/detect" - }, - { - "operationId": "cloneVoice", - "method": "POST", - "path": "/voice/clone" - }, - { - "operationId": "generateSpeech", - "method": "POST", - "path": "/voice/generate" - }, - { - "operationId": "listVoices", - "method": "GET", - "path": "/voice/voices" - } - ] + "source": "https://api.wave.online/openapi.json", + "servers": [ + "https://api.wave.online/v1" + ], + "spec_version": "1.1.0", + "fetched": "2026-09-30", + "total_paths": 230, + "total_ops": 255, + "operations": [ + { + "method": "POST", + "path": "/accessibility-studio", + "operationId": "accessibilityStudio", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/acp", + "operationId": "acp", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/acuity", + "operationId": "acuity", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/aegis", + "operationId": "aegis", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/aes67", + "operationId": "aes67", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/agent/auth/device", + "operationId": "agentAuthDevice", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/agent/auth/token", + "operationId": "agentAuthToken", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/agentic-media", + "operationId": "agenticMedia", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/agents", + "operationId": "agents", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/ai", + "operationId": "ai", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/analytics", + "operationId": "analytics", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/analytics/engagement", + "operationId": "getAnalyticsEngagement", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/analytics/overview", + "operationId": "getAnalyticsOverview", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/analytics/top-content", + "operationId": "getAnalyticsTopContent", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/api-gateway", + "operationId": "apiGateway", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/archive", + "operationId": "archive", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/argus", + "operationId": "argus", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/audience-engagement", + "operationId": "audienceEngagement", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/audio-mastering", + "operationId": "audioMastering", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/auth", + "operationId": "auth", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/autopilot", + "operationId": "autopilot", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/av/demux", + "operationId": "avDemux", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/av/remux", + "operationId": "avRemux", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/batch", + "operationId": "batchOperations", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/behavioral-intelligence", + "operationId": "behavioralIntelligence", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/benchmark", + "operationId": "benchmark", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/billing", + "operationId": "getBilling", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/billing/usage", + "operationId": "getBillingUsage", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/bmd", + "operationId": "bmd", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/braid/publish", + "operationId": "publishBraidAudio", + "skill_invocation": false + }, + { + "method": "DELETE", + "path": "/braid/publish/{ns}", + "operationId": "stopBraidAudio", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/bridge", + "operationId": "bridge", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/broadcast", + "operationId": "broadcast", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/camera-control", + "operationId": "cameraControl", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/cameras", + "operationId": "listCameras", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/cameras", + "operationId": "registerCamera", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/cameras/{cameraId}/control", + "operationId": "controlCamera", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/campus", + "operationId": "campus", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/captions", + "operationId": "listCaptions", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/captions", + "operationId": "createCaptionJob", + "skill_invocation": false + }, + { + "method": "DELETE", + "path": "/captions/{jobId}", + "operationId": "deleteCaptionJob", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/captions/{jobId}", + "operationId": "getCaptionJob", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/captions/{jobId}/download", + "operationId": "downloadCaptions", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/challenge", + "operationId": "challenge", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/chapters", + "operationId": "chapters", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/ci", + "operationId": "ci", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/clips", + "operationId": "listClips", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/clips", + "operationId": "createClip", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/clips/detect", + "operationId": "detectClips", + "skill_invocation": false + }, + { + "method": "DELETE", + "path": "/clips/{clipId}", + "operationId": "deleteClip", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/clips/{clipId}", + "operationId": "getClip", + "skill_invocation": false + }, + { + "method": "PATCH", + "path": "/clips/{clipId}", + "operationId": "updateClip", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/cloud-switcher", + "operationId": "cloudSwitcher", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/collab/rooms", + "operationId": "listCollabRooms", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/collab/rooms", + "operationId": "createCollabRoom", + "skill_invocation": false + }, + { + "method": "DELETE", + "path": "/collab/rooms/{roomId}", + "operationId": "deleteCollabRoom", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/collab/rooms/{roomId}", + "operationId": "getCollabRoom", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/companion", + "operationId": "companion", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/competitive-intel", + "operationId": "competitiveIntel", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/compliance", + "operationId": "compliance", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/compose", + "operationId": "createComposeProposal", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/compose/proposals/{proposalId}", + "operationId": "getComposeProposal", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/compose/run", + "operationId": "runComposeProposal", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/connect", + "operationId": "connect", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/cookie-consent", + "operationId": "cookieConsent", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/cost", + "operationId": "cost", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/creator", + "operationId": "creator", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/creator-economy", + "operationId": "creatorEconomy", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/creator-storefront", + "operationId": "creatorStorefront", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/crest", + "operationId": "crest", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/cro", + "operationId": "cro", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/custody/{op}", + "operationId": "custodyOperation", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/dante", + "operationId": "dante", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/data-exchange", + "operationId": "dataExchange", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/decode", + "operationId": "decode", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/director", + "operationId": "director", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/discovery", + "operationId": "discovery", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/dispatch", + "operationId": "dispatch", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/dmca", + "operationId": "dmca", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/dsar", + "operationId": "dsar", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/dub", + "operationId": "dub", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/echo", + "operationId": "echo", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/edge", + "operationId": "edge", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/editor/projects", + "operationId": "listProjects", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/editor/projects", + "operationId": "createProject", + "skill_invocation": false + }, + { + "method": "DELETE", + "path": "/editor/projects/{projectId}", + "operationId": "deleteProject", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/editor/projects/{projectId}", + "operationId": "getProject", + "skill_invocation": false + }, + { + "method": "PATCH", + "path": "/editor/projects/{projectId}", + "operationId": "updateProject", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/editor/projects/{projectId}/export", + "operationId": "exportProject", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/embeddings", + "operationId": "embeddings", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/encode", + "operationId": "encode", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/engagement", + "operationId": "engagement", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/engine/capabilities", + "operationId": "engineCapabilities", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/enhance", + "operationId": "enhance", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/example", + "operationId": "example", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/experiments", + "operationId": "experiments", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/fleet", + "operationId": "fleet", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/forecast", + "operationId": "forecast", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/geo", + "operationId": "geo", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/ghost-producer", + "operationId": "ghostProducer", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/gpu/infer", + "operationId": "gpuStatus", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/gpu/infer", + "operationId": "gpuInfer", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/graphics-engine", + "operationId": "graphicsEngine", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/identity/resolve", + "operationId": "identityResolve", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/integrations", + "operationId": "integrations", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/intel", + "operationId": "intel", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/leaderboard", + "operationId": null, + "skill_invocation": false + }, + { + "method": "POST", + "path": "/listen", + "operationId": "listen", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/live", + "operationId": "live", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/live-annotation", + "operationId": "liveAnnotation", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/live-commerce", + "operationId": "liveCommerce", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/live/caption", + "operationId": "transcribeLiveAudio", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/live/pipeline", + "operationId": "startLivePipeline", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/local-ai", + "operationId": "localAi", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/me", + "operationId": "me", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/memory", + "operationId": "memory", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/mesh", + "operationId": "mesh", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/mlvc", + "operationId": "mlvc", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/mobile-producer", + "operationId": "mobileProducer", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/moderate", + "operationId": "moderateContent", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/monetization", + "operationId": "monetization", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/monitoring", + "operationId": "monitoring", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/moq/publish/{ns}/{track}", + "operationId": "mintMoqPublishToken", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/moq/subscribe/{ns}/{track}", + "operationId": "mintMoqSubscribeToken", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/mpp", + "operationId": "mpp", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/mux", + "operationId": "mux", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/mxl", + "operationId": "mxl", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/ndi", + "operationId": "ndi", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/nvr", + "operationId": "nvr", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/omt", + "operationId": "omt", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/ops", + "operationId": "ops", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/orbit", + "operationId": "orbit", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/organizations", + "operationId": "organizations", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/outliers", + "operationId": "outliers", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/payments", + "operationId": "payments", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/perception", + "operationId": "perception", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/phone/calls", + "operationId": "listCalls", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/phone/calls", + "operationId": "makeCall", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/phone/lines", + "operationId": "listPhoneLines", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/phone/lines", + "operationId": "provisionPhoneLine", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/pipelines", + "operationId": "pipelines", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/platform", + "operationId": null, + "skill_invocation": false + }, + { + "method": "GET", + "path": "/podcast/shows", + "operationId": "listPodcastShows", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/podcast/shows", + "operationId": "createPodcastShow", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/podcast/shows/{showId}/episodes", + "operationId": "listPodcastEpisodes", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/podcast/shows/{showId}/episodes", + "operationId": "createPodcastEpisode", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/preferences", + "operationId": "preferences", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/presence", + "operationId": "presence", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/pricing/manifests", + "operationId": "pricingManifestsList", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/pricing/manifests", + "operationId": "pricingManifestsCreate", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/privy", + "operationId": "privy", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/production", + "operationId": "production", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/production-graph", + "operationId": "productionGraph", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/productions", + "operationId": "listProductions", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/productions", + "operationId": "createProduction", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/productions/{productionId}", + "operationId": "getProduction", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/productions/{productionId}/camera", + "operationId": "switchProductionCamera", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/productions/{productionId}/overlay", + "operationId": "setProductionOverlay", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/products", + "operationId": "products", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/pulse", + "operationId": "pulse", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/qr-system", + "operationId": "qrSystem", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/quality-scorecard", + "operationId": "qualityScorecard", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/radar", + "operationId": "radar", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/rate-limit", + "operationId": "rateLimit", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/realtime/channels/{channel}/history", + "operationId": "realtimeHistory", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/realtime/channels/{channel}/presence", + "operationId": "realtimePresence", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/realtime/channels/{channel}/publish", + "operationId": "realtimePublish", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/realtime/connect", + "operationId": "realtimeConnect", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/recommend", + "operationId": "recommend", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/remotion", + "operationId": "remotion", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/render", + "operationId": "renderVideo", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/render/{jobId}", + "operationId": "renderPoll", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/render/{jobId}/events", + "operationId": "renderEvents", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/renders", + "operationId": "renders", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/replay", + "operationId": "replay", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/replay-engine", + "operationId": "replayEngine", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/review", + "operationId": "review", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/rist", + "operationId": "rist", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/router", + "operationId": "router", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/routes", + "operationId": "routes", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/rtmp", + "operationId": "rtmp", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/runtime", + "operationId": "runtime", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/sandbox", + "operationId": "sandbox", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/scene", + "operationId": "scene", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/search", + "operationId": "search", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/search/analytics", + "operationId": "searchAnalytics", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/search/index", + "operationId": "searchIndex", + "skill_invocation": false + }, + { + "method": "DELETE", + "path": "/search/index/{id}", + "operationId": "searchDelete", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/sentiment", + "operationId": "listSentimentAnalyses", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/sentiment", + "operationId": "createSentimentAnalysis", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/sentiment/analyze", + "operationId": "analyzeText", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/signal", + "operationId": "signal", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/signal-generator", + "operationId": "signalGenerator", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/signal-verifier", + "operationId": "signalVerifier", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/slides-to-video", + "operationId": "slidesToVideo", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/social-distribution", + "operationId": "socialDistribution", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/sports-data", + "operationId": "sportsData", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/srt", + "operationId": "srt", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/st2110", + "operationId": "st2110", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/stream", + "operationId": "stream", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/stream-router", + "operationId": "streamRouter", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/streamdeck", + "operationId": "streamdeck", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/streaming", + "operationId": "streaming", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/streams", + "operationId": null, + "skill_invocation": false + }, + { + "method": "POST", + "path": "/streams", + "operationId": null, + "skill_invocation": false + }, + { + "method": "GET", + "path": "/streams/{streamId}", + "operationId": "getStream", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/streams/{streamId}/analytics", + "operationId": "getStreamAnalytics", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/streams/{streamId}/highlights", + "operationId": "listStreamHighlights", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/streams/{streamId}/highlights", + "operationId": "markStreamHighlight", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/streams/{streamId}/start", + "operationId": "startStream", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/streams/{streamId}/status", + "operationId": "getStreamStatus", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/streams/{streamId}/stop", + "operationId": "stopStream", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/studio", + "operationId": "studio", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/studio-ai/enhancements", + "operationId": "listEnhancements", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/studio-ai/enhancements", + "operationId": "createEnhancement", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/studio-ai/preview", + "operationId": "previewEnhancement", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/studio-automation", + "operationId": "studioAutomation", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/switcher", + "operationId": "switcher", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/tempo", + "operationId": "tempo", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/transcode", + "operationId": "transcode", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/transcribe", + "operationId": "listTranscriptions", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/transcribe", + "operationId": "createTranscription", + "skill_invocation": false + }, + { + "method": "DELETE", + "path": "/transcribe/{transcriptionId}", + "operationId": "deleteTranscription", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/transcribe/{transcriptionId}", + "operationId": "getTranscription", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/twilio", + "operationId": "twilio", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/unsubscribe", + "operationId": "unsubscribe", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/usage", + "operationId": null, + "skill_invocation": false + }, + { + "method": "POST", + "path": "/usb-relay", + "operationId": "usbRelay", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/vault", + "operationId": "vault", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/video-gen", + "operationId": "videoGen", + "skill_invocation": true + }, + { + "method": "GET", + "path": "/videos/{videoId}/chapters", + "operationId": "listChapters", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/videos/{videoId}/chapters", + "operationId": "createChapter", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/videos/{videoId}/chapters/detect", + "operationId": "detectChapters", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/viewer", + "operationId": "viewer", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/virtual-studio", + "operationId": "virtualStudio", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/vision", + "operationId": "vision", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/visual-programming", + "operationId": "visualProgramming", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/visual-qa", + "operationId": "visualQa", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/vod", + "operationId": "vod", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/voice/clone", + "operationId": "cloneVoice", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/voice/generate", + "operationId": "generateSpeech", + "skill_invocation": false + }, + { + "method": "GET", + "path": "/voice/voices", + "operationId": "listVoices", + "skill_invocation": false + }, + { + "method": "POST", + "path": "/volumetric", + "operationId": "volumetric", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/wave-console", + "operationId": "waveConsole", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/wave-node", + "operationId": "waveNode", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/wave-sdk", + "operationId": "waveSdk", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/wave-tokens", + "operationId": "waveTokens", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/webrtc", + "operationId": "webrtc", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/whep", + "operationId": "whep", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/whip", + "operationId": "whip", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/workflow-engine", + "operationId": "workflowEngine", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/x402", + "operationId": "x402", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/zero-trust-vault", + "operationId": "zeroTrustVault", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/zoom", + "operationId": "zoom", + "skill_invocation": true + }, + { + "method": "POST", + "path": "/zoom-integration", + "operationId": "zoomIntegration", + "skill_invocation": true + } + ] } diff --git a/tests/test_contract_coverage.py b/tests/test_contract_coverage.py index 2e793fb..46736b8 100644 --- a/tests/test_contract_coverage.py +++ b/tests/test_contract_coverage.py @@ -1,163 +1,283 @@ -"""Contract test: every operation in the live WAVE OpenAPI spec has a Python -SDK method, or is explicitly allowlisted with a justification. - -The spec snapshot (tests/fixtures/openapi_snapshot.json) is a point-in-time -capture of https://api.wave.online/openapi.json (75 ops across 54 paths, -fetched 2026-09-01). Re-fetch and regenerate the snapshot when the live spec -grows; this test fails loud if new operations appear unmapped and unallowed. - -Two allowlist classes, both justified inline: - - "unwrapped": a genuinely new backend surface neither the TypeScript nor - the Python SDK wraps yet (verified by grep against @wave-av/sdk - origin/main). Not part of this pass's TS-namespace-parity scope. - - "drift": the namespace exists in both SDKs, but the concrete method the - spec describes was never implemented on either side (pre-existing drift, - predates this task). +"""Contract test: every operation in the published WAVE OpenAPI document is either sent by a Python +SDK method, checked on the wire, or listed with a reason. + +The snapshot (tests/fixtures/openapi_snapshot.json) is regenerated from +https://api.wave.online/openapi.json by ``python3 scripts/refresh_openapi_snapshot.py``. Paths in +it are relative to the spec's server URL, which ends in ``/v1``. + +For every mapped operation the test calls the SDK method against an ``httpx.MockTransport`` and +asserts that the request it actually sends has the operation's HTTP verb and matches its templated +path. A method name that exists but sends a different path fails here; earlier versions of this +test only checked that the method name existed, which let 19 of 52 mapped methods drift. + +Operations carrying ``x-skill-url`` are the single-call product invocation surface +(``POST /v1/``, one per product); they are counted, not mapped one by one. """ from __future__ import annotations import json +import re +import sys +import types +import warnings from pathlib import Path +from typing import Any + +import pydantic +import pytest + +from tests.conftest import Recorder, attach SNAPSHOT = json.loads((Path(__file__).parent / "fixtures" / "openapi_snapshot.json").read_text()) +OPERATIONS: list[dict[str, Any]] = SNAPSHOT["operations"] -def _op_key(op: dict) -> str: +def _op_key(op: dict[str, Any]) -> str: return op["operationId"] or f"{op['method']} {op['path']}" -# operationId (or "METHOD /path" when the spec omits an operationId) -> (namespace, method) -MAPPING: dict[str, tuple[str, str]] = { - "listCaptions": ("captions", "list"), - "createCaptionJob": ("captions", "generate"), - "getCaptionJob": ("captions", "get"), - "deleteCaptionJob": ("captions", "remove"), - "downloadCaptions": ("captions", "get_text"), - "listClips": ("clips", "list"), - "createClip": ("clips", "create"), - "detectClips": ("clips", "detect_highlights"), - "getClip": ("clips", "get"), - "updateClip": ("clips", "update"), - "deleteClip": ("clips", "remove"), - "listCollabRooms": ("collab", "list_rooms"), - "createCollabRoom": ("collab", "create_room"), - "getCollabRoom": ("collab", "get_room"), - "deleteCollabRoom": ("collab", "close_room"), - "listProjects": ("editor", "list_projects"), - "createProject": ("editor", "create_project"), - "getProject": ("editor", "get_project"), - "updateProject": ("editor", "update_project"), - "deleteProject": ("editor", "remove_project"), - "exportProject": ("editor", "render"), - "listCalls": ("phone", "list_calls"), - "makeCall": ("phone", "make_call"), - "listPhoneLines": ("phone", "list_numbers"), - "provisionPhoneLine": ("phone", "purchase_number"), - "listPodcastShows": ("podcast", "list"), - "createPodcastShow": ("podcast", "create"), - "listPodcastEpisodes": ("podcast", "list_episodes"), - "createPodcastEpisode": ("podcast", "create_episode"), - "pricingManifestsList": ("pricing", "list_manifests"), - "pricingManifestsCreate": ("pricing", "create_manifest"), - "realtimeHistory": ("realtime", "history"), - "realtimePresence": ("realtime", "presence"), - "realtimePublish": ("realtime", "publish"), - "realtimeConnect": ("realtime", "connect"), - "search": ("search", "search"), - "searchAnalytics": ("search", "get_analytics"), - "searchIndex": ("search", "index_media"), - "searchDelete": ("search", "remove_from_index"), - "listSentimentAnalyses": ("sentiment", "list"), - "createSentimentAnalysis": ("sentiment", "analyze"), - "analyzeText": ("sentiment", "analyze_text"), - "listTranscriptions": ("transcribe", "list"), - "createTranscription": ("transcribe", "create"), - "getTranscription": ("transcribe", "get"), - "deleteTranscription": ("transcribe", "remove"), - "listChapters": ("chapters", "get_default_set"), - "createChapter": ("chapters", "add_chapter"), - "detectChapters": ("chapters", "generate"), - "cloneVoice": ("voice", "clone_voice"), - "generateSpeech": ("voice", "synthesize"), - "listVoices": ("voice", "list_voices"), +SPEC = {_op_key(op): op for op in OPERATIONS} +SKILL_OPS = [op for op in OPERATIONS if op["skill_invocation"]] +API_OPS = {_op_key(op): op for op in OPERATIONS if not op["skill_invocation"]} + +_MANIFEST = { + "slug": "demo", + "title": "Demo", + "tiers": [{"id": "base", "name": "Base", "price_usdc_micro": "10000", "rail": "x402", "billing": "per_op", "features": []}], +} + +# operation key -> (namespace, method, positional args, keyword args) +MAPPING: dict[str, tuple[str, str, tuple[Any, ...], dict[str, Any]]] = { + "getAnalyticsEngagement": ("pulse", "get_engagement_metrics", (), {}), + "getAnalyticsOverview": ("pulse", "get_overview", (), {}), + "getAnalyticsTopContent": ("pulse", "get_top_content", (), {}), + "listCaptions": ("captions", "list", (), {}), + "createCaptionJob": ("captions", "generate", ("media_1",), {}), + "getCaptionJob": ("captions", "get", ("job_1",), {}), + "deleteCaptionJob": ("captions", "remove", ("job_1",), {}), + "downloadCaptions": ("captions", "download", ("job_1", "en"), {"format": "srt"}), + "listClips": ("clips", "list", (), {}), + "createClip": ("clips", "create", ("Best moment", {"type": "stream", "id": "s1"}), {}), + "detectClips": ("clips", "detect", ("video_1",), {}), + "getClip": ("clips", "get", ("clip_1",), {}), + "updateClip": ("clips", "update", ("clip_1",), {"title": "t"}), + "deleteClip": ("clips", "remove", ("clip_1",), {}), + "listCollabRooms": ("collab", "list_rooms", (), {}), + "createCollabRoom": ("collab", "create_room", ("Room",), {}), + "getCollabRoom": ("collab", "get_room", ("room_1",), {}), + "deleteCollabRoom": ("collab", "delete_room", ("room_1",), {}), + "createComposeProposal": ("compose", "compose", ("live captions for a webinar",), {}), + "getComposeProposal": ("compose", "get_proposal", ("prp_1",), {}), + "listProjects": ("editor", "list_projects", (), {}), + "createProject": ("editor", "create_project", ("Cut",), {}), + "getProject": ("editor", "get_project", ("prj_1",), {}), + "updateProject": ("editor", "update_project", ("prj_1",), {"title": "t"}), + "deleteProject": ("editor", "remove_project", ("prj_1",), {}), + "exportProject": ("editor", "export", ("prj_1",), {"format": "mp4"}), + "listCalls": ("phone", "list_calls", (), {}), + "makeCall": ("phone", "make_call", ("+15550100", "+15550101"), {}), + "listPodcastShows": ("podcast", "list", (), {}), + "createPodcastShow": ("podcast", "create", ("Show",), {}), + "listPodcastEpisodes": ("podcast", "list_episodes", ("show_1",), {}), + "createPodcastEpisode": ("podcast", "create_episode", ("show_1", "Ep 1"), {"audio_url": "https://example.com/a.mp3"}), + "pricingManifestsList": ("pricing", "list_manifests", (), {}), + "pricingManifestsCreate": ("pricing", "create_manifest", (_MANIFEST,), {}), + "listProductions": ("studio", "list", (), {}), + "createProduction": ("studio", "create", ("Show",), {}), + "getProduction": ("studio", "get", ("prod_1",), {}), + "realtimeHistory": ("realtime", "history", ("stream:abc",), {}), + "realtimePresence": ("realtime", "presence", ("stream:abc",), {}), + "realtimePublish": ("realtime", "publish", ("stream:abc", "note"), {}), + "realtimeConnect": ("realtime", "connect", ("stream:abc",), {}), + "search": ("search", "search", ("product launch",), {}), + "searchAnalytics": ("search", "get_analytics", (), {}), + "searchIndex": ("search", "index_media", ("media_1",), {}), + "searchDelete": ("search", "remove_from_index", ("media_1",), {}), + "listSentimentAnalyses": ("sentiment", "list", (), {}), + "createSentimentAnalysis": ("sentiment", "analyze", ("asset_1",), {}), + "analyzeText": ("sentiment", "analyze_text", ("great show",), {}), + "GET /streams": ("pipeline", "list", (), {}), + "POST /streams": ("pipeline", "create", ("Main",), {}), + "getStream": ("pipeline", "get", ("str_1",), {}), + "startStream": ("pipeline", "start", ("str_1",), {}), + "stopStream": ("pipeline", "stop", ("str_1",), {}), + "listTranscriptions": ("transcribe", "list", (), {}), + "createTranscription": ("transcribe", "create", ("https://example.com/a.mp3",), {}), + "getTranscription": ("transcribe", "get", ("tr_1",), {}), + "deleteTranscription": ("transcribe", "remove", ("tr_1",), {}), + "GET /usage": ("usage", "get", (), {}), + "listChapters": ("chapters", "list_chapters", ("video_1",), {}), + "createChapter": ("chapters", "create_chapter", ("video_1", "Intro", 0, 30), {}), + "detectChapters": ("chapters", "detect", ("video_1",), {}), + "cloneVoice": ("voice", "clone_voice", ("Narrator", ["https://example.com/s.wav"]), {}), + "generateSpeech": ("voice", "generate", ("hello", "voice_1"), {}), + "listVoices": ("voice", "list_voices", (), {}), } -# operationId (or "METHOD /path") -> justification. Every spec op not in MAPPING must be here. +_UNWRAPPED = "not wrapped by the Python SDK yet; callable with client.client.request(...)" + +# operation key -> reason it has no mapped SDK method. Every API operation not in MAPPING must be +# listed here with a reason of at least 20 characters. ALLOWLIST: dict[str, str] = { - "agentAuthDevice": "TS implements the RFC 8628 ceremony as standalone module functions " - "(startAgentCeremony et al in agent-auth.ts), not a Wave facade namespace — out of " - "scope for this namespace-parity pass.", - "agentAuthToken": "see agentAuthDevice — same standalone TS ceremony surface.", - "avDemux": "unwrapped by either SDK (verified: no av/demux reference in @wave-av/sdk " - "origin/main src/*.ts) — new backend surface, not part of TS-namespace parity.", - "avRemux": "unwrapped by either SDK — see avDemux.", - "batchOperations": "the top-level /batch op is unwrapped; qr.ts and sentiment.ts call " - "their own namespaced .../batch sub-resource endpoints, not this generic op.", - "publishBraidAudio": "unwrapped by either SDK (verified: no braid/publish reference in " - "@wave-av/sdk origin/main) — new backend surface.", - "stopBraidAudio": "unwrapped by either SDK — see publishBraidAudio.", - "custodyOperation": "TS wraps this as a standalone CustodyClient (custody.ts), not a " - "Wave facade namespace — out of scope for this namespace-parity pass.", - "engineCapabilities": "unwrapped by either SDK — new backend surface.", - "gpuInfer": "unwrapped by either SDK — new backend surface.", - "gpuStatus": "unwrapped by either SDK — see gpuInfer.", - "identityResolve": "unwrapped by either SDK — new backend surface.", - "GET /leaderboard": "unwrapped by either SDK; the spec omits an operationId for this op.", - "mintMoqPublishToken": "unwrapped by either SDK — new backend surface (Media over QUIC).", - "mintMoqSubscribeToken": "unwrapped by either SDK — see mintMoqPublishToken.", - "GET /platform": "unwrapped by either SDK; the spec omits an operationId for this op.", - "renderVideo": "the standalone render service is listed as phase=\"planned\" in TS " - "products.ts's catalog, not implemented as an SDK call by either SDK.", - "renderPoll": "unwrapped by either SDK — see renderVideo.", - "renderEvents": "unwrapped by either SDK — see renderVideo.", - "GET /usage": "unwrapped top-level org-usage op; inference.ts and prompter.ts only call " - "their own namespaced usage sub-paths, not this endpoint. Spec omits an operationId.", - "listEnhancements": "studio-ai namespace exists in both SDKs, but neither implements the " - "literal enhancements CRUD the spec describes — pre-existing drift, predates this task.", - "createEnhancement": "see listEnhancements — same pre-existing studio-ai drift.", - "previewEnhancement": "see listEnhancements — same pre-existing studio-ai drift.", + "agentAuthDevice": "the device-authorization ceremony for agents (RFC 8628); " + _UNWRAPPED, + "agentAuthToken": "the token half of the agent device-authorization ceremony; " + _UNWRAPPED, + "avDemux": _UNWRAPPED, + "avRemux": _UNWRAPPED, + "batchOperations": "generic batch envelope; " + _UNWRAPPED, + "getBilling": _UNWRAPPED, + "getBillingUsage": "billing view of usage; client.usage.get() wraps GET /v1/usage. " + _UNWRAPPED, + "publishBraidAudio": _UNWRAPPED, + "stopBraidAudio": _UNWRAPPED, + "listCameras": _UNWRAPPED, + "registerCamera": _UNWRAPPED, + "controlCamera": _UNWRAPPED, + "runComposeProposal": "executes a proposal (billed work); ComposeAPI is deliberately " + "propose-only and never executes. " + _UNWRAPPED, + "custodyOperation": _UNWRAPPED, + "engineCapabilities": _UNWRAPPED, + "gpuStatus": _UNWRAPPED, + "gpuInfer": _UNWRAPPED, + "identityResolve": _UNWRAPPED, + "GET /leaderboard": "public leaderboard (no operationId in the spec); " + _UNWRAPPED, + "transcribeLiveAudio": _UNWRAPPED, + "startLivePipeline": _UNWRAPPED, + "moderateContent": _UNWRAPPED, + "mintMoqPublishToken": "Media over QUIC publish token; " + _UNWRAPPED, + "mintMoqSubscribeToken": "Media over QUIC subscribe token; " + _UNWRAPPED, + "listPhoneLines": "the phone API serves numbers under /v1/phone/numbers, which PhoneAPI " + "wraps; the published /phone/lines path is not the served one, so no method sends it", + "provisionPhoneLine": "see listPhoneLines; PhoneAPI.purchase_number sends POST " + "/v1/phone/numbers, the served provisioning route", + "GET /platform": "platform descriptor (no operationId in the spec); " + _UNWRAPPED, + "switchProductionCamera": _UNWRAPPED, + "setProductionOverlay": _UNWRAPPED, + "renderVideo": "standalone render service; " + _UNWRAPPED, + "renderPoll": "standalone render service; " + _UNWRAPPED, + "renderEvents": "standalone render service; " + _UNWRAPPED, + "getStreamAnalytics": _UNWRAPPED, + "listStreamHighlights": _UNWRAPPED, + "markStreamHighlight": _UNWRAPPED, + "getStreamStatus": _UNWRAPPED, + "listEnhancements": "StudioAIAPI predates the enhancements resource; " + _UNWRAPPED, + "createEnhancement": "StudioAIAPI predates the enhancements resource; " + _UNWRAPPED, + "previewEnhancement": "StudioAIAPI predates the enhancements resource; " + _UNWRAPPED, } +def _path_regex(template: str) -> re.Pattern[str]: + return re.compile("^/v1" + re.sub(r"\{[^}]+\}", "[^/]+", template) + "$") + + +class _FakeSocket: + def recv(self) -> str: + return "" + + def close(self) -> None: + pass + + +def _send(monkeypatch: pytest.MonkeyPatch, namespace: str, method: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[str, str]: + """Call one SDK method and return the (verb, path) of the request it sent.""" + from wave_sdk import Wave + + recorder = Recorder() + wave = Wave(api_key="test-api-key", organization_id="org_test") + attach(wave.client, recorder) + monkeypatch.setattr("wave_sdk.client.time.sleep", lambda _s: None) + + upgrades: list[str] = [] + fake_ws = types.ModuleType("websocket") + fake_ws.create_connection = lambda url, header=None, **_k: upgrades.append(url) or _FakeSocket() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "websocket", fake_ws) + + call = getattr(getattr(wave, namespace), method) + if namespace == "pricing" and method == "create_manifest": + from wave_sdk.pricing import PricingManifest + + args = (PricingManifest(**args[0]),) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + try: + call(*args, **kwargs) + except pydantic.ValidationError as exc: + # The mock answers `{}`, and a typed response model rejects that after the request was + # sent. That is the only exception tolerated, and only once a request went out: any + # other exception (a TypeError or AttributeError in response handling, a WaveError) + # propagates and fails the test. + if not recorder.requests and not upgrades: + raise AssertionError(f"{namespace}.{method} raised before sending: {exc!r}") from exc + + if upgrades: + from urllib.parse import urlsplit + + return "GET", urlsplit(upgrades[-1]).path + assert len(recorder.requests) == 1, f"{namespace}.{method} sent {len(recorder.requests)} requests" + req = recorder.last + return req.method, req.url.path + + +def test_send_fails_when_the_method_raises_before_sending(monkeypatch: pytest.MonkeyPatch): + """The harness itself must not turn a method that never reaches the wire into a pass.""" + def broken(self: Any, *_a: Any, **_k: Any) -> None: + raise TypeError("bad argument handling") + + monkeypatch.setattr("wave_sdk.usage.UsageAPI.get", broken) + with pytest.raises(TypeError, match="bad argument handling"): + _send(monkeypatch, "usage", "get", (), {}) + + +def test_send_fails_when_response_handling_breaks_after_sending(monkeypatch: pytest.MonkeyPatch): + """Only a response model rejecting the mock's `{}` is tolerated after the send. A bug in + response handling (here an AttributeError) must fail the contract test, not pass it.""" + def sends_then_breaks(self: Any, *_a: Any, **_k: Any) -> None: + self._client.get("/v1/usage") + raise AttributeError("response handling bug") + + monkeypatch.setattr("wave_sdk.usage.UsageAPI.get", sends_then_breaks) + with pytest.raises(AttributeError, match="response handling bug"): + _send(monkeypatch, "usage", "get", (), {}) + + +def test_send_tolerates_only_a_model_rejecting_the_empty_mock_body(monkeypatch: pytest.MonkeyPatch): + """The real GET /v1/usage call parses `{}` into UsageReport, which rejects it after sending; + the harness still returns the request it sent.""" + assert _send(monkeypatch, "usage", "get", (), {}) == ("GET", "/v1/usage") + + def test_snapshot_is_sane(): - """Guard against an empty/corrupt fixture silently passing everything.""" - assert SNAPSHOT["total_ops"] == len(SNAPSHOT["operations"]) == 75 + """Guard against an empty or truncated fixture silently passing everything.""" + assert SNAPSHOT["total_ops"] == len(OPERATIONS) >= 250 + assert SNAPSHOT["servers"] and SNAPSHOT["servers"][0].rstrip("/").endswith("/v1") + keys = [_op_key(op) for op in OPERATIONS] + assert len(keys) == len(set(keys)), "duplicate operation keys in the snapshot" + + +def test_skill_invocation_ops_are_single_product_posts(): + shapes = {(op["method"], op["path"].count("/")) for op in SKILL_OPS} + assert shapes == {("POST", 1)}, f"unexpected skill-invocation op shapes: {shapes}" + assert len(SKILL_OPS) >= 100 -def test_every_op_is_mapped_or_allowlisted(): - keys = [_op_key(op) for op in SNAPSHOT["operations"]] - unmapped = [k for k in keys if k not in MAPPING and k not in ALLOWLIST] +def test_every_api_op_is_mapped_or_allowlisted(): + unmapped = sorted(k for k in API_OPS if k not in MAPPING and k not in ALLOWLIST) assert not unmapped, ( - f"{len(unmapped)} spec operation(s) have no Python method and no allowlist " - f"justification: {unmapped}" + f"{len(unmapped)} spec operation(s) have no Python method and no allowlist reason: {unmapped}" ) - # Every allowlist entry must carry a real justification (not a stub). stubs = [k for k, v in ALLOWLIST.items() if len(v) < 20] - assert not stubs, f"allowlist entries missing a real justification: {stubs}" + assert not stubs, f"allowlist entries missing a real reason: {stubs}" + assert not set(MAPPING) & set(ALLOWLIST), "an operation is both mapped and allowlisted" def test_no_stale_mapping_or_allowlist_entries(): - keys = {_op_key(op) for op in SNAPSHOT["operations"]} - stale_mapped = set(MAPPING) - keys - stale_allowed = set(ALLOWLIST) - keys - assert not stale_mapped, f"MAPPING references ops no longer in the spec: {stale_mapped}" - assert not stale_allowed, f"ALLOWLIST references ops no longer in the spec: {stale_allowed}" + stale = (set(MAPPING) | set(ALLOWLIST)) - set(API_OPS) + assert not stale, f"entries reference operations no longer in the spec: {sorted(stale)}" -def test_mapped_methods_exist_on_wave(): - from wave_sdk import Wave - w = Wave(api_key="test-key") - missing = [] - for op_id, (namespace, method) in MAPPING.items(): - ns = getattr(w, namespace, None) - if ns is None or not hasattr(ns, method): - missing.append(f"{op_id} -> wave.{namespace}.{method}") - assert not missing, f"mapped spec operations missing their Python method: {missing}" - - -def test_mapping_and_allowlist_cover_every_op_exactly_once(): - keys = [_op_key(op) for op in SNAPSHOT["operations"]] - assert len(keys) == len(set(keys)), "duplicate operation keys in the snapshot" - covered = set(MAPPING) | set(ALLOWLIST) - assert covered == set(keys) - assert len(MAPPING) + len(ALLOWLIST) == SNAPSHOT["total_ops"] +@pytest.mark.parametrize("op_key", sorted(MAPPING)) +def test_mapped_method_sends_the_spec_operation(op_key: str, monkeypatch: pytest.MonkeyPatch): + namespace, method, args, kwargs = MAPPING[op_key] + op = SPEC[op_key] + verb, path = _send(monkeypatch, namespace, method, args, kwargs) + expected = f"{op['method']} /v1{op['path']}" + assert verb == op["method"], f"wave.{namespace}.{method} sent {verb} {path}; spec is {expected}" + assert _path_regex(op["path"]).match(path), f"wave.{namespace}.{method} sent {verb} {path}; spec is {expected}" diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..23fc248 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,289 @@ +"""Error parsing and retry behaviour, driven by response bodies recorded from the live API. + +Every body in tests/fixtures/live_responses.json was returned by https://api.wave.online (ids +scrubbed). The API uses three error shapes, and before 2.3.0 the client only understood the +first, so the other two surfaced as a bare ``HTTP 402: Payment Required``: + +* the normalized envelope ``{"error": {"code", "message", ...}}``; +* a flat body ``{"error": "", "code", "message", ...}`` (spend-cap refusal), or just + ``{"error": ""}``; +* the x402 payment challenge ``{"x402Version", "error": "payment required", "accepts": [...]}``. +""" +from __future__ import annotations + +import httpx +import pytest + +from tests.conftest import live, live_response +from wave_sdk import PaymentRequiredError, RateLimitError, RouteNotServedError, WaveError +from wave_sdk.errors import error_from_response + + +def parse(label: str, **headers: str) -> WaveError: + return error_from_response(live_response(label, **headers)) + + +# --- the three shapes -------------------------------------------------------------------------- + + +def test_spend_cap_refusal_keeps_the_server_code_message_and_context(): + err = parse("err_spend_cap_402") + assert isinstance(err, PaymentRequiredError) + assert err.status_code == 402 + assert err.code == "SPEND_CAP_TIER_BLOCKED" + assert err.message.startswith("Add a payment method to continue") + assert err.details is not None + assert err.details["projected_cost_usd"] == 0 + assert err.details["dimension"] == "wave_clip_minutes" + assert err.details["error"] == "spend_cap_exceeded" + assert err.accepts == [] + assert err.retryable is False + + +def test_x402_challenge_exposes_the_payment_options(): + err = parse("err_x402_challenge_402") + assert isinstance(err, PaymentRequiredError) + assert err.code == "PAYMENT_REQUIRED" + assert "x402" in err.message + assert err.x402_version == 1 + assert err.accepts and err.accepts[0]["scheme"] == "exact" + assert err.accepts[0]["resource"] == "/v1/clips" + assert err.next_action is not None and err.next_action["type"] == "pay" + assert err.retryable is False + + +def test_bare_reason_string_becomes_the_message(): + err = parse("err_missing_node_400") + assert type(err) is WaveError + assert err.code == "HTTP_400" + assert err.message == "missing x-wave-node" + + +def test_method_not_allowed_is_a_route_not_served_error(): + err = parse("err_method_not_allowed_405") + assert isinstance(err, RouteNotServedError) + assert err.status_code == 405 + assert err.message == "method not allowed" + + +@pytest.mark.parametrize( + "label, code", + [ + ("err_route_not_mapped_404", "ROUTE_NOT_MAPPED"), + ("err_route_not_found_404", "ROUTE_NOT_FOUND"), + ("err_product_route_not_found_404", "search_route_not_found"), + ], +) +def test_missing_routes_are_route_not_served_errors(label, code): + err = parse(label) + assert isinstance(err, RouteNotServedError) + assert err.code == code + assert err.retryable is False + + +def test_route_not_served_carries_the_capability_index_url(): + err = parse("err_route_not_mapped_404") + assert err.doc_url and err.doc_url.endswith("/.well-known/wave-skills.json") + assert err.suggestions and len(err.suggestions) >= 1 + + +def test_a_missing_resource_is_not_a_route_error(): + response = httpx.Response(404, json={"error": {"code": "CLIP_NOT_FOUND", "message": "no clip"}}) + err = error_from_response(response) + assert type(err) is WaveError + assert err.code == "CLIP_NOT_FOUND" + + +def test_scope_error_keeps_required_and_available_scopes(): + err = parse("err_scope_insufficient_403") + assert err.code == "SCOPE_INSUFFICIENT" + assert err.details is not None + assert "required_scope" in err.details + assert isinstance(err.details["available_scopes"], list) + + +def test_request_id_prefers_the_header_then_the_body(): + assert parse("err_route_not_found_404", **{"x-request-id": "req-header"}).request_id == "req-header" + assert parse("err_route_not_found_404").request_id == live("err_route_not_found_404")["body"]["error"]["request_id"] + + +def test_non_json_body_falls_back_to_the_status(): + err = error_from_response(httpx.Response(502, text="bad gateway")) + assert err.code == "HTTP_502" + assert err.message.startswith("HTTP 502") + assert err.retryable is True + + +def test_str_names_the_code(): + assert str(parse("err_spend_cap_402")).startswith("WaveError(SPEND_CAP_TIER_BLOCKED)") + + +# --- retry decisions follow the server's next_action -------------------------------------------- + + +def test_permanent_503_is_not_retried(wave, recorder): + """COMPOSE_STORE_UNCONFIGURED says next_action none: one request, no backoff sleeps.""" + recorder.set(lambda _r: live_response("err_store_unconfigured_503")) + with pytest.raises(WaveError) as exc: + wave.compose.get_proposal("prp_aaaaaaaaaaaaaaaaaaaa") + assert exc.value.code == "COMPOSE_STORE_UNCONFIGURED" + assert exc.value.retryable is False + assert len(recorder.requests) == 1 + + +def test_retry_backoff_directive_is_retried(wave, recorder, monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr("wave_sdk.client.time.sleep", sleeps.append) + upstream = {"error": {"code": "UPSTREAM_ERROR", "message": "upstream failed", "next_action": {"type": "retry_backoff"}}} + replies = iter([httpx.Response(500, json=upstream), httpx.Response(200, json={"org": "o", "range": {"from": "20260901", "to": "20260901", "days": 1}, "totals": {}})]) + recorder.set(lambda _r: next(replies)) + report = wave.usage.get() + assert report.org == "o" + assert len(recorder.requests) == 2 and len(sleeps) == 1 + + +def test_retry_after_directive_sets_the_delay(wave, recorder, monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr("wave_sdk.client.time.sleep", sleeps.append) + busy = {"error": {"code": "BUSY", "message": "try later", "next_action": {"type": "retry_after", "seconds": 7}}} + replies = iter([httpx.Response(503, json=busy), httpx.Response(200, json={"ok": True})]) + recorder.set(lambda _r: next(replies)) + assert wave.client.get("/v1/anything") == {"ok": True} + assert sleeps == [7.0] + + +def test_rate_limit_error_keeps_the_server_message(wave, recorder, monkeypatch): + monkeypatch.setattr("wave_sdk.client.time.sleep", lambda _s: None) + body = {"error": {"code": "RATE_LIMITED", "message": "slow down", "next_action": {"type": "retry_after", "seconds": 2}}} + recorder.set(lambda _r: httpx.Response(429, json=body, headers={"retry-after": "3"})) + with pytest.raises(RateLimitError) as exc: + wave.client.get("/v1/anything") + assert exc.value.message == "slow down" + assert exc.value.retry_after == 3.0 + assert len(recorder.requests) == wave.client.max_retries + 1 + + +def test_a_429_is_a_rate_limit_error_wherever_it_is_parsed(): + """The parser, not the REST loop, picks the class, so a WebSocket upgrade gets it too.""" + err = error_from_response(httpx.Response(429, json={"error": "slow down"}, headers={"retry-after": "4"})) + assert isinstance(err, RateLimitError) + assert err.message == "slow down" + assert err.retry_after == 4.0 + bare = error_from_response(httpx.Response(429)) + assert isinstance(bare, RateLimitError) and bare.message == "Rate limit exceeded" and bare.retry_after == 1.0 + + +def test_a_permanent_429_is_not_retried(wave, recorder): + """A 429 whose directive is not a retry (here: a plan limit) is raised on the first attempt.""" + body = {"error": {"code": "PLAN_LIMIT", "message": "monthly cap reached", "next_action": {"type": "upgrade_plan"}}} + recorder.set(lambda _r: httpx.Response(429, json=body, headers={"retry-after": "1"})) + with pytest.raises(RateLimitError) as exc: + wave.client.get("/v1/anything") + assert exc.value.retryable is False + assert len(recorder.requests) == 1 + + +def test_a_long_retry_after_is_raised_not_slept_through(wave, recorder): + """Retry-After: 86400 used to become time.sleep(86400). It is raised at once with the full wait.""" + recorder.set(lambda _r: httpx.Response(429, json={"error": {"code": "RATE_LIMITED", "message": "m"}}, headers={"retry-after": "86400"})) + with pytest.raises(RateLimitError) as exc: + wave.client.get("/v1/anything") + assert exc.value.retry_after == 86400.0 + assert len(recorder.requests) == 1 + + +def test_a_long_retry_after_directive_is_raised_not_slept_through(wave, recorder): + busy = {"error": {"code": "BUSY", "message": "m", "next_action": {"type": "retry_after", "seconds": 3600}}} + recorder.set(lambda _r: httpx.Response(503, json=busy)) + with pytest.raises(WaveError) as exc: + wave.client.get("/v1/anything") + assert exc.value.retry_after_hint == 3600.0 + assert len(recorder.requests) == 1 + + +@pytest.mark.parametrize("header", ["nan", "inf", "-1", "Wed, 21 Oct 2026 07:28:00 GMT", "soon"]) +def test_an_unusable_retry_after_header_falls_back(wave, recorder, monkeypatch, header): + """nan / inf / negative values reached time.sleep() and escaped as ValueError or OverflowError.""" + sleeps: list[float] = [] + monkeypatch.setattr("wave_sdk.client.time.sleep", sleeps.append) + replies = iter([httpx.Response(429, headers={"retry-after": header}), httpx.Response(200, json={"ok": True})]) + recorder.set(lambda _r: next(replies)) + assert wave.client.get("/v1/anything") == {"ok": True} + assert sleeps == [1.0] + + +def test_a_retryable_503_follows_its_retry_after_header(wave, recorder, monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr("wave_sdk.client.time.sleep", sleeps.append) + replies = iter([httpx.Response(503, headers={"retry-after": "2"}), httpx.Response(200, json={"ok": True})]) + recorder.set(lambda _r: next(replies)) + assert wave.client.get("/v1/anything") == {"ok": True} + assert sleeps == [2.0] + + +def test_request_id_inside_error_detail_is_kept(): + body = {"error": "payment required", "error_detail": {"code": "PAYMENT_REQUIRED", "message": "m", "request_id": "req-in-detail"}} + assert error_from_response(httpx.Response(402, json=body)).request_id == "req-in-detail" + + +def test_a_retry_hint_over_the_cap_is_raised_with_the_full_wait(wave, recorder): + """61 s is just over the 60 s the client will sleep through: raised at once, not capped.""" + recorder.set(lambda _r: httpx.Response(503, headers={"retry-after": "61"})) + with pytest.raises(WaveError) as exc: + wave.client.get("/v1/anything") + assert exc.value.status_code == 503 + assert len(recorder.requests) == 1 + + +# --- raw responses and billed calls ------------------------------------------------------------- + + +def test_raw_returns_the_response_for_json_and_bytes(wave, recorder): + recorder.set(lambda _r: httpx.Response(200, content=b"WEBVTT\n", headers={"content-type": "text/vtt"})) + resp = wave.client.get("/v1/anything", raw=True) + assert isinstance(resp, httpx.Response) and resp.content == b"WEBVTT\n" + assert wave.client.get("/v1/anything") is None # the JSON path has no body to decode + recorder.set(lambda _r: httpx.Response(200, json={"ok": True})) + assert wave.client.get("/v1/anything", raw=True).json() == {"ok": True} + + +def test_raw_still_raises_the_parsed_error(wave, recorder): + recorder.set(lambda _r: live_response("err_spend_cap_402")) + with pytest.raises(PaymentRequiredError) as exc: + wave.client.get("/v1/anything", raw=True) + assert exc.value.code == "SPEND_CAP_TIER_BLOCKED" + + +def test_inference_complete_raises_the_gateway_error_once(wave, recorder): + """A refused completion raises the gateway's error; it is sent once (billed per token).""" + refused = {"error": {"code": "SCOPE_INSUFFICIENT", "message": "needs dispatch:write"}} + recorder.set(lambda _r: httpx.Response(403, json=refused)) + with pytest.raises(WaveError) as exc: + wave.inference.complete("qwen2.5:3b", [{"role": "user", "content": "hi"}], max_tokens=1) + assert exc.value.code == "SCOPE_INSUFFICIENT" and exc.value.status_code == 403 + assert [(r.method, r.url.path) for r in recorder.requests] == [("POST", "/v1/inference/chat/completions")] + + +def test_a_non_finite_directive_is_ignored(): + err = error_from_response(httpx.Response(503, content=b'{"error": {"code": "BUSY", "message": "m", "next_action": {"type": "retry_after", "seconds": Infinity}}}', headers={"content-type": "application/json"})) + assert err.retry_after_hint is None + + +# --- the README's error-handling example -------------------------------------------------------- + + +def test_readme_example_surfaces_the_spend_cap_message(wave, recorder): + recorder.set(lambda _r: live_response("err_spend_cap_402")) + with pytest.raises(PaymentRequiredError) as exc: + wave.clips.get("invalid-id") + assert exc.value.code == "SPEND_CAP_TIER_BLOCKED" + assert "payment method" in exc.value.message + assert len(recorder.requests) == 1 + + +def test_error_classes_are_importable_from_the_client_module_too(): + from wave_sdk import client + + assert client.WaveError is WaveError + assert issubclass(client.PaymentRequiredError, WaveError) + assert issubclass(client.RouteNotServedError, WaveError) diff --git a/tests/test_mesh.py b/tests/test_mesh.py new file mode 100644 index 0000000..1b636de --- /dev/null +++ b/tests/test_mesh.py @@ -0,0 +1,138 @@ +"""MeshAPI: every method, reads and mutations alike, builds the x-wave-node header before it sends +anything, and the mutations are sent once.""" +from __future__ import annotations + +import httpx +import pytest + +from tests.conftest import live_response +from wave_sdk.errors import WaveError + + +def sent(recorder) -> tuple[str, str]: + req = recorder.last + return req.method, req.url.raw_path.decode() + + +def test_mesh_requires_a_node_before_sending(wave, recorder): + with pytest.raises(ValueError, match="x-wave-node"): + wave.mesh.list_peers() + assert recorder.requests == [] + + +def test_mesh_sends_the_node_header(wave, recorder): + recorder.set(lambda _r: live_response("mesh_peers")) + wave.mesh.node = "studio-a" + peers = wave.mesh.list_peers() + assert sent(recorder) == ("GET", "/v1/mesh/peers") + assert recorder.last.headers["x-wave-node"] == "studio-a" + assert peers == {"count": 0, "org": "org_fixture", "peers": []} + wave.mesh.list_peers(node="studio-b") + assert recorder.last.headers["x-wave-node"] == "studio-b" + + +# Every MeshAPI method, mutations included, with the (verb, path) it sends. +MESH_CALLS = [ + ("list_peers", (), ("GET", "/v1/mesh/peers")), + ("list_regions", (), ("GET", "/v1/mesh/regions")), + ("get_region", ("r1",), ("GET", "/v1/mesh/regions/r1")), + ("get_region_health", ("r1",), ("GET", "/v1/mesh/regions/r1/health")), + ("add_peer", ("r1", "https://peer.example"), ("POST", "/v1/mesh/regions/r1/peers")), + ("remove_peer", ("p1",), ("DELETE", "/v1/mesh/peers/p1")), + ("create_policy", ("p", "priority", "r1", ["r2"]), ("POST", "/v1/mesh/policies")), + ("list_policies", (), ("GET", "/v1/mesh/policies")), + ("trigger_failover", ("pol1", "r2"), ("POST", "/v1/mesh/policies/pol1/failover")), + ("get_replication_status", (), ("GET", "/v1/mesh/replication")), + ("get_topology", (), ("GET", "/v1/mesh/topology")), +] +MESH_MUTATIONS = {"add_peer", "remove_peer", "create_policy", "trigger_failover"} + + +def test_mesh_calls_cover_every_public_method(wave): + public = {m for m in dir(wave.mesh) if not m.startswith("_") and callable(getattr(wave.mesh, m))} + assert public == {name for name, _args, _route in MESH_CALLS} + + +@pytest.mark.parametrize("method, args, route", MESH_CALLS, ids=[c[0] for c in MESH_CALLS]) +def test_every_mesh_method_refuses_to_send_without_a_node(wave, recorder, method, args, route): + with pytest.raises(ValueError, match="x-wave-node"): + getattr(wave.mesh, method)(*args) + assert recorder.requests == [] + + +@pytest.mark.parametrize("method, args, route", MESH_CALLS, ids=[c[0] for c in MESH_CALLS]) +def test_every_mesh_method_sends_the_node_header(wave, recorder, method, args, route): + """Reads and mutations alike carry x-wave-node: the client default, or a per-call override.""" + ok = { + "create_policy": {"id": "pol1", "organization_id": "o", "name": "p", "strategy": "priority", "primary_region": "r1", "fallback_regions": ["r2"], "created_at": "t", "updated_at": "t"}, + "get_region": {"id": "r1", "name": "n", "provider": "p", "location": "l", "status": "up", "latency_ms": 1, "capacity_percent": 1.0, "stream_count": 0, "viewer_count": 0, "is_primary": True, "created_at": "t", "updated_at": "t"}, + "get_replication_status": [], + }.get(method, {}) + recorder.set(lambda _r: httpx.Response(200, json=ok)) + wave.mesh.node = "studio-a" + getattr(wave.mesh, method)(*args) + assert sent(recorder) == route + assert recorder.last.headers["x-wave-node"] == "studio-a" + getattr(wave.mesh, method)(*args, node="studio-b") + assert recorder.last.headers["x-wave-node"] == "studio-b" + + +@pytest.mark.parametrize("method", sorted(MESH_MUTATIONS)) +def test_mesh_mutations_are_sent_once(wave, recorder, monkeypatch, method): + """A 503 after the server applied the change must not add a second peer or fail over twice.""" + monkeypatch.setattr("wave_sdk.client.time.sleep", lambda _s: None) + recorder.set(lambda _r: httpx.Response(503, json={"error": {"code": "UPSTREAM_ERROR", "message": "m"}})) + args = next(a for name, a, _route in MESH_CALLS if name == method) + with pytest.raises(WaveError): + getattr(wave.mesh, method)(*args, node="studio-a") + assert len(recorder.requests) == 1 + + +def test_mesh_reads_are_still_retried(wave, recorder, monkeypatch): + monkeypatch.setattr("wave_sdk.client.time.sleep", lambda _s: None) + replies = iter([httpx.Response(503, json={"error": {"code": "UPSTREAM_ERROR", "message": "m"}}), live_response("mesh_peers")]) + recorder.set(lambda _r: next(replies)) + assert wave.mesh.list_peers(node="studio-a")["peers"] == [] + assert len(recorder.requests) == 2 + + +@pytest.mark.parametrize("method, args, route", MESH_CALLS, ids=[c[0] for c in MESH_CALLS]) +@pytest.mark.parametrize("override", ["", " "], ids=["empty", "blank"]) +def test_an_empty_override_never_falls_back_to_the_default_node(wave, recorder, method, args, route, override): + """``node=""`` with a client default set must raise, not quietly target the default node: a + multi-node caller whose config value came back empty would otherwise read from, or mutate, + the wrong node.""" + recorder.set(lambda _r: httpx.Response(200, json={})) + wave.mesh.node = "studio-a" + with pytest.raises(ValueError, match="x-wave-node"): + getattr(wave.mesh, method)(*args, node=override) + assert recorder.requests == [] + + +def test_node_none_means_use_the_default(wave, recorder): + recorder.set(lambda _r: live_response("mesh_peers")) + wave.mesh.node = "studio-a" + wave.mesh.list_peers(node=None) + assert recorder.last.headers["x-wave-node"] == "studio-a" + + +def test_a_blank_default_node_is_rejected(wave, recorder): + wave.mesh.node = " " + with pytest.raises(ValueError, match="x-wave-node"): + wave.mesh.list_peers() + assert recorder.requests == [] + + +@pytest.mark.parametrize("node", ["studio-a\r\nx-evil: 1", "studio\n"]) +def test_mesh_rejects_a_multi_line_node_name(wave, recorder, node): + with pytest.raises(ValueError, match="single-line"): + wave.mesh.list_peers(node=node) + assert recorder.requests == [] + + +def test_mesh_ids_stay_one_path_segment(wave, recorder): + recorder.set(lambda _r: httpx.Response(200, json={})) + wave.mesh.remove_peer("a/b?c", node="studio-a") + assert sent(recorder) == ("DELETE", "/v1/mesh/peers/a%2Fb%3Fc") + with pytest.raises(ValueError): + wave.mesh.trigger_failover("..", "r2", node="studio-a") diff --git a/tests/test_parity_apis.py b/tests/test_parity_apis.py index e7f64b4..d2f9843 100644 --- a/tests/test_parity_apis.py +++ b/tests/test_parity_apis.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock -import httpx import pytest from wave_sdk.inference import InferenceAPI @@ -112,16 +111,16 @@ def _channels(): def test_meter_ledger(mock_client): + # The ledger is one metering window with per-channel counters (not a list of rows). mock_client.get.return_value = { - "rows": [{"org": "acme", "from": "2026-08-01", "to": "2026-08-31", "channels": _channels()}], + "org": "acme", "from": "2026-08-01", "to": "2026-08-31", "channels": _channels(), "generated_at": "2026-09-01T00:00:00Z", } api = MeterAPI(mock_client) result = api.ledger(channel="mail") mock_client.get.assert_called_once_with("/v1/meter/ledger", params={"channel": "mail"}) - assert len(result.rows) == 1 - assert result.rows[0].from_ == "2026-08-01" - assert result.rows[0].channels.mail.ops == 10 + assert result.from_ == "2026-08-01" + assert result.channels.mail.ops == 10 def test_meter_rollup(mock_client): @@ -212,60 +211,84 @@ def test_perception_unsubscribe(mock_client): # InferenceAPI # --------------------------------------------------------------------------- -def test_inference_complete(monkeypatch): - class FakeClient: - api_key = "test-key" - - def fake_post(url, headers=None, json=None, timeout=None): - assert url == "https://inference.wave.online/v1/chat/completions" - assert headers["authorization"] == "Bearer test-key" - return httpx.Response(200, json={ - "model": "claude-haiku", "choices": [{"message": {"content": "hi there"}}], - "usage": {"cost": 0.0001, "total_tokens": 12}, - }) - - monkeypatch.setattr("wave_sdk.inference.httpx.post", fake_post) - api = InferenceAPI(FakeClient()) - result = api.complete("claude-haiku", [{"role": "user", "content": "hi"}]) +def test_inference_complete_goes_through_the_gateway(mock_client): + mock_client.post.return_value = { + "model": "claude-haiku", "choices": [{"message": {"content": "hi there"}}], + "usage": {"cost": 0.0001, "total_tokens": 12}, + } + api = InferenceAPI(mock_client) + result = api.complete("claude-haiku", [{"role": "user", "content": "hi"}], max_tokens=5) + mock_client.post.assert_called_once_with( + "/v1/inference/chat/completions", + json={"model": "claude-haiku", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}, + timeout=120.0, + no_retry=True, + ) assert result.model == "claude-haiku" assert result.content == "hi there" assert result.total_tokens == 12 -def test_inference_complete_raises_on_error(monkeypatch): - class FakeClient: - api_key = "test-key" +@pytest.mark.parametrize("reply", [None, "text", [], {}, {"choices": []}, {"choices": ["x"]}]) +def test_inference_complete_rejects_a_reply_without_choices(mock_client, reply): + """A 2xx without choices used to become an empty, apparently successful completion.""" + from wave_sdk.client import WaveError - def fake_post(url, headers=None, json=None, timeout=None): - return httpx.Response(500, text="funnel down") + mock_client.post.return_value = reply + with pytest.raises(WaveError) as exc: + InferenceAPI(mock_client).complete("m", [{"role": "user", "content": "hi"}]) + assert exc.value.code == "INVALID_RESPONSE" - monkeypatch.setattr("wave_sdk.inference.httpx.post", fake_post) - api = InferenceAPI(FakeClient()) + +@pytest.mark.parametrize("reply", [None, [], {"data": None}, {"data": "x"}, {"data": [{"id": "a"}, "b"]}]) +def test_inference_models_rejects_a_reply_without_a_model_list(mock_client, reply): from wave_sdk.client import WaveError - with pytest.raises(WaveError): - api.complete("claude-haiku", [{"role": "user", "content": "hi"}]) + mock_client.get.return_value = reply + with pytest.raises(WaveError) as exc: + InferenceAPI(mock_client).models() + assert exc.value.code == "INVALID_RESPONSE" -def test_inference_models_requires_registry(): - class FakeClient: - api_key = "test-key" - api = InferenceAPI(FakeClient()) - from wave_sdk.client import WaveError - with pytest.raises(WaveError, match="registry_url"): - api.models() +def test_inference_complete_never_leaves_the_gateway(monkeypatch, mock_client): + """The default path must not post to any other host with the WAVE key.""" + def boom(*_a, **_k): + raise AssertionError("complete() made a direct httpx.post call") + + monkeypatch.setattr("wave_sdk.inference.httpx.post", boom) + mock_client.post.return_value = {"choices": [{"message": {"content": "x"}}]} + InferenceAPI(mock_client).complete("m", [{"role": "user", "content": "hi"}]) + + +def test_inference_funnel_url_is_deprecated_and_never_receives_the_key(monkeypatch, mock_client): + """funnel_url used to get the WAVE key in an Authorization header; it is now ignored.""" + def boom(*_a, **_k): + raise AssertionError("the WAVE key was sent to funnel_url") + + monkeypatch.setattr("wave_sdk.inference.httpx.post", boom) + with pytest.warns(DeprecationWarning, match="funnel_url"): + api = InferenceAPI(mock_client, funnel_url="http://funnel.example.com") + mock_client.post.return_value = {"choices": [{"message": {"content": "ok"}}]} + assert api.complete("m", [{"role": "user", "content": "hi"}]).content == "ok" + assert mock_client.post.call_args.args[0] == "/v1/inference/chat/completions" -def test_inference_models_with_registry(monkeypatch): +def test_inference_models_reads_the_gateway_list(mock_client): + mock_client.get.return_value = { + "object": "list", + "data": [{"id": "model-a", "object": "model", "owned_by": "wave"}, {"id": "model-b", "object": "model"}], + } + models = InferenceAPI(mock_client).models() + mock_client.get.assert_called_once_with("/v1/inference/models") + assert [m.id for m in models] == ["model-a", "model-b"] + assert models[0].owned_by == "wave" + + +def test_inference_profile_requires_registry_and_is_deprecated(): class FakeClient: api_key = "test-key" - def fake_get(url, headers=None, timeout=None): - assert url.startswith("https://registry.example.com/rest/v1/models") - return httpx.Response(200, json=[{"id": "m1", "rail": "openai", "cost_input_per_m": 1.0, "cost_output_per_m": 2.0}]) - - monkeypatch.setattr("wave_sdk.inference.httpx.get", fake_get) - api = InferenceAPI(FakeClient(), registry_url="https://registry.example.com", registry_key="k") - models = api.models() - assert models[0].id == "m1" - assert models[0].input_per_m == 1.0 + api = InferenceAPI(FakeClient()) + from wave_sdk.client import WaveError + with pytest.warns(DeprecationWarning), pytest.raises(WaveError, match="registry_url"): + api.profile("m1") diff --git a/tests/test_realtime.py b/tests/test_realtime.py new file mode 100644 index 0000000..8e88701 --- /dev/null +++ b/tests/test_realtime.py @@ -0,0 +1,195 @@ +"""Realtime: the REST calls and the WebSocket upgrade target the API host's published operations, +carry the org header, and never put the API key in a URL. + +Published operations (api.wave.online, prefix /v1): + realtimePublish POST /realtime/channels/{channel}/publish + realtimePresence GET /realtime/channels/{channel}/presence + realtimeHistory GET /realtime/channels/{channel}/history + realtimeConnect GET /realtime/connect (WebSocket upgrade) +""" +from __future__ import annotations + +import sys +import types +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest + +from tests.conftest import live_response +from wave_sdk import PaymentRequiredError, RouteNotServedError, WaveError +from wave_sdk.client import WaveClient +from wave_sdk.realtime import RealtimeAPI, _channel_path + +KEY = "test-realtime-key" + + +class _FakeSocket: + def __init__(self, url: str, header: list[str] | None = None) -> None: + self.url = url + self.header = header or [] + + +@pytest.fixture +def captured_ws(monkeypatch): + """Stub the optional ``websocket-client`` dependency and capture the upgrade it would do.""" + calls: list[_FakeSocket] = [] + + def create_connection(url: str, header: list[str] | None = None, **_kw: object) -> _FakeSocket: + sock = _FakeSocket(url, header) + calls.append(sock) + return sock + + module = types.ModuleType("websocket") + module.create_connection = create_connection # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "websocket", module) + return calls + + +# --- REST --------------------------------------------------------------------------------------- + + +def test_presence_uses_the_published_path_on_the_api_host(wave, recorder): + recorder.set(lambda _r: live_response("realtime_presence")) + body = wave.realtime.presence("stream:fixture") + req = recorder.last + assert req.method == "GET" + assert req.url.host == "api.wave.online" + assert req.url.raw_path == b"/v1/realtime/channels/stream:fixture/presence" + assert body == {"channel": "stream:fixture", "members": []} + + +def test_history_and_publish_paths(wave, recorder): + recorder.set(lambda _r: httpx.Response(200, json={"ok": True})) + wave.realtime.history("probe", limit=5) + assert recorder.last.url.raw_path == b"/v1/realtime/channels/probe/history?limit=5" + wave.realtime.publish("probe", "sdk.test", {"n": 1}) + req = recorder.last + assert req.method == "POST" + assert req.url.raw_path == b"/v1/realtime/channels/probe/publish" + assert req.content == b'{"event":"sdk.test","data":{"n":1}}' + + +def test_rest_calls_carry_auth_and_org_headers(wave, recorder): + wave.realtime.presence("probe") + assert recorder.last.headers["authorization"] == "Bearer test-api-key" + assert recorder.last.headers["x-organization-id"] == "org_test" + + +def test_channel_is_one_encoded_path_segment(wave, recorder): + wave.realtime.presence("a/../b?x=1#f") + assert recorder.last.url.raw_path == b"/v1/realtime/channels/a%2F..%2Fb%3Fx%3D1%23f/presence" + + +def test_channel_colon_stays_literal(): + # The API answers 404 for stream%3Aabc and 200 for stream:abc. + assert _channel_path("stream:abc") == "stream:abc" + + +def test_rest_errors_raise_instead_of_returning_the_error_body(wave, recorder): + recorder.set(lambda _r: live_response("err_scope_insufficient_403")) + with pytest.raises(WaveError) as exc: + wave.realtime.publish("probe", "evt") + assert exc.value.code == "SCOPE_INSUFFICIENT" + recorder.set(lambda _r: live_response("err_route_not_found_404")) + with pytest.raises(RouteNotServedError): + wave.realtime.history("probe") + + +# --- WebSocket ---------------------------------------------------------------------------------- + + +def _api(**client_kwargs) -> RealtimeAPI: + return RealtimeAPI(WaveClient(api_key=KEY, **client_kwargs)) + + +def test_connect_targets_the_published_operation(captured_ws): + _api().connect("stream:abc") + url = urlsplit(captured_ws[0].url) + assert (url.scheme, url.netloc, url.path) == ("wss", "api.wave.online", "/v1/realtime/connect") + assert parse_qs(url.query) == {"channel": ["stream:abc"]} + + +def test_connect_follows_a_custom_base_url(captured_ws): + _api(base_url="http://localhost:8787").connect("c") + assert captured_ws[0].url.startswith("ws://localhost:8787/v1/realtime/connect?") + + +def test_api_key_travels_only_in_the_upgrade_header(captured_ws): + _api().connect("stream:abc") + assert KEY not in captured_ws[0].url + assert "access_token" not in captured_ws[0].url + assert f"Authorization: Bearer {KEY}" in captured_ws[0].header + + +def test_upgrade_carries_the_organization(captured_ws): + _api(organization_id="org_123").connect("stream:abc") + assert "X-Organization-Id: org_123" in captured_ws[0].header + _api().connect("stream:abc") + assert not any(h.startswith("X-Organization-Id") for h in captured_ws[1].header) + + +def test_channel_and_as_cannot_inject_query_parameters(captured_ws): + _api().connect("stream:abc&as=victim", as_="user&admin=1") + query = parse_qs(urlsplit(captured_ws[0].url).query) + assert query == {"channel": ["stream:abc&as=victim"], "as": ["user&admin=1"]} + + +def test_rejected_upgrade_raises_the_same_error_as_rest(monkeypatch): + challenge = live_response("err_x402_challenge_402") + + # Stands in for websocket-client's WebSocketBadStatusException. + class BadStatusError(Exception): + status_code = 402 + resp_body = challenge.content + resp_headers = {"content-type": "application/json"} + + def create_connection(url, header=None, **_kw): + raise BadStatusError("Handshake status 402") + + module = types.ModuleType("websocket") + module.create_connection = create_connection # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "websocket", module) + with pytest.raises(PaymentRequiredError) as exc: + _api().connect("stream:abc") + assert exc.value.accepts and exc.value.accepts[0]["resource"] == "/v1/clips" + + +@pytest.mark.parametrize("base_url", ["http://api.example.com", "http://10.0.0.5:8787"]) +def test_key_is_never_sent_over_cleartext_websocket(captured_ws, base_url): + """ws:// carries the Authorization header in the clear; only a loopback dev server may use it.""" + with pytest.raises(ValueError, match="wss://"): + _api(base_url=base_url).connect("c") + assert captured_ws == [] + + +def test_rate_limited_upgrade_raises_rate_limit_error(monkeypatch): + """A 429 on the upgrade is the same RateLimitError (with retry_after) a REST call raises.""" + from wave_sdk import RateLimitError + + class BadStatusError(Exception): + status_code = 429 + resp_body = b'{"error": {"code": "RATE_LIMITED", "message": "too many sockets"}}' + resp_headers = {"content-type": "application/json", "retry-after": "5"} + + def create_connection(url, header=None, **_kw): + raise BadStatusError("Handshake status 429") + + module = types.ModuleType("websocket") + module.create_connection = create_connection # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "websocket", module) + with pytest.raises(RateLimitError) as exc: + _api().connect("stream:abc") + assert exc.value.retry_after == 5.0 + assert exc.value.message == "too many sockets" + + +def test_network_failure_on_upgrade_is_not_swallowed(monkeypatch): + def create_connection(url, header=None, **_kw): + raise OSError("connection refused") + + module = types.ModuleType("websocket") + module.create_connection = create_connection # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "websocket", module) + with pytest.raises(OSError): + _api().connect("stream:abc") diff --git a/tests/test_sdk_exports.py b/tests/test_sdk_exports.py index 40608bd..584925c 100644 --- a/tests/test_sdk_exports.py +++ b/tests/test_sdk_exports.py @@ -64,10 +64,18 @@ def test_all_modules_import(): def test_wave_client_import(): """Core client classes should import.""" - from wave_sdk import RateLimitError, WaveClient, WaveError + from wave_sdk import ( + PaymentRequiredError, + RateLimitError, + RouteNotServedError, + WaveClient, + WaveError, + ) assert callable(WaveClient) assert issubclass(WaveError, Exception) assert issubclass(RateLimitError, WaveError) + assert issubclass(PaymentRequiredError, WaveError) + assert issubclass(RouteNotServedError, WaveError) def test_wave_client_requires_api_key(): @@ -139,13 +147,13 @@ def test_wave_convenience_class(): def test_api_count(): - """Wave class should have exactly 43 API bindings (+ client) — the 42 - TS-parity namespaces plus PR4's `compose` (a mirror of `@wave-av/sdk`'s - own PR4-SDK addition, tracked separately in wave-av/sdk).""" + """Wave class should have exactly 44 API bindings (+ client): the 42 + TS-parity namespaces, `compose` (POST /v1/compose), and `usage` + (GET /v1/usage, added in 2.3.0).""" from wave_sdk import Wave w = Wave(api_key="test-key") api_attrs = [a for a in dir(w) if not a.startswith('_') and a != 'client'] - assert len(api_attrs) == 43, f"Expected 43 APIs, got {len(api_attrs)}: {api_attrs}" + assert len(api_attrs) == 44, f"Expected 44 APIs, got {len(api_attrs)}: {api_attrs}" def test_pipeline_has_methods(): @@ -173,9 +181,9 @@ def test_studio_has_methods(): def test_version(): - """SDK version should be 2.2.0.""" + """SDK version should be 2.3.0.""" import wave_sdk - assert wave_sdk.__version__ == "2.2.0" + assert wave_sdk.__version__ == "2.3.0" def test_all_exports(): diff --git a/tests/test_served_routes.py b/tests/test_served_routes.py new file mode 100644 index 0000000..3ef8ef9 --- /dev/null +++ b/tests/test_served_routes.py @@ -0,0 +1,329 @@ +"""The requests each changed method actually sends, checked at the HTTP transport, and the live +response bodies (tests/fixtures/live_responses.json, recorded from https://api.wave.online with +ids scrubbed) parsing into the SDK's models.""" +from __future__ import annotations + +import json +from datetime import date + +import httpx +import pytest + +from tests.conftest import live, live_response +from wave_sdk.meter import MeterLedger, MeterRollup + + +def sent(recorder) -> tuple[str, str]: + req = recorder.last + return req.method, req.url.raw_path.decode() + + +def body(recorder) -> dict: + return json.loads(recorder.last.content) + + +# --- usage -------------------------------------------------------------------------------------- + + +def test_usage_get_parses_the_live_body(wave, recorder): + recorder.set(lambda _r: live_response("usage")) + report = wave.usage.get() + assert sent(recorder) == ("GET", "/v1/usage") + assert report.org == "org_fixture" + assert report.range.days >= 1 + assert all(isinstance(v, float) for v in report.totals.values()) + + +def test_usage_window_accepts_dates_and_strings(wave, recorder): + recorder.set(lambda _r: live_response("usage")) + wave.usage.get(from_=date(2026, 9, 1), to="2026-09-28") + assert sent(recorder) == ("GET", "/v1/usage?from=20260901&to=20260928") + + +# --- pulse (analytics) --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method, label, path", + [ + ("get_overview", "analytics_overview", "/v1/analytics/overview"), + ("get_top_content", "analytics_top_content", "/v1/analytics/top-content"), + ], +) +def test_pulse_published_reads(wave, recorder, method, label, path): + recorder.set(lambda _r: live_response(label)) + result = getattr(wave.pulse, method)() + assert sent(recorder) == ("GET", path) + assert result["organizationId"] == "org_fixture" + + +def test_pulse_window_and_limit(wave, recorder): + wave.pulse.get_top_content(from_="2026-09-01T00:00:00Z", limit=5) + assert sent(recorder) == ("GET", "/v1/analytics/top-content?from=2026-09-01T00%3A00%3A00Z&limit=5") + wave.pulse.get_engagement_metrics() + assert sent(recorder) == ("GET", "/v1/analytics/engagement") + + +# --- meter --------------------------------------------------------------------------------------- + + +def test_meter_ledger_parses_the_live_window(wave, recorder): + recorder.set(lambda _r: live_response("meter_ledger")) + ledger = wave.meter.ledger() + assert isinstance(ledger, MeterLedger) + assert sent(recorder) == ("GET", "/v1/meter/ledger") + assert ledger.org == "org_fixture" + assert ledger.channels.sms is not None and ledger.channels.sms.blocked == "a2p-unregistered" + assert ledger.channels.mail is not None and ledger.channels.mail.blocked == "mail_audit_not_owner" + + +def test_meter_rollup_parses_the_live_totals(wave, recorder): + recorder.set(lambda _r: live_response("meter_rollup")) + rollup = wave.meter.rollup() + assert isinstance(rollup, MeterRollup) + assert rollup.period == "month" + assert rollup.totals.sms is not None and isinstance(rollup.totals.sms.blocked, str) + + +def test_meter_accepts_counts_and_unknown_fields(): + ledger = MeterLedger.model_validate({ + "org": "o", "from": "a", "to": "b", "generated_at": "c", + "channels": {"sms": {"ops": 2, "blocked": 1}, "fax": {"pages": 3}}, + }) + assert ledger.channels.sms is not None and ledger.channels.sms.blocked == 1 + + +def test_meter_missing_counters_are_none_not_zero(): + """A counter the response leaves out must not read as zero usage.""" + ledger = MeterLedger.model_validate({ + "org": "o", "from": "a", "to": "b", "generated_at": "c", + "channels": {"voice": {"usdc": "0.25"}, "mail": {"ops": 4}}, + }) + assert ledger.channels.voice is not None and ledger.channels.voice.minutes is None + assert ledger.channels.mail is not None and ledger.channels.mail.usdc is None + assert ledger.channels.storage is None + + +# --- inference ----------------------------------------------------------------------------------- + + +def test_inference_models_through_the_gateway(wave, recorder): + recorder.set(lambda _r: live_response("inference_models")) + models = wave.inference.models() + assert sent(recorder) == ("GET", "/v1/inference/models") + assert [m.id for m in models] == ["model-a", "model-b", "model-c"] + + +def test_inference_complete_through_the_gateway(wave, recorder): + recorder.set(lambda _r: httpx.Response(200, json={"model": "m", "choices": [{"message": {"content": "ok"}}], "usage": {"total_tokens": 3}})) + result = wave.inference.complete("m", [{"role": "user", "content": "hi"}], max_tokens=1) + assert sent(recorder) == ("POST", "/v1/inference/chat/completions") + assert recorder.last.headers["authorization"] == "Bearer test-api-key" + assert result.content == "ok" + + +# --- path corrections ------------------------------------------------------------------------------ + + +def test_sentiment_analyze_text_uses_the_published_path(wave, recorder): + wave.sentiment.analyze_text("great show", include_emotions=True) + assert sent(recorder) == ("POST", "/v1/sentiment/analyze") + assert body(recorder) == {"text": "great show", "includeEmotions": True} + + +def test_clips_detect(wave, recorder): + wave.clips.detect("vid_1", max_clips=3) + assert sent(recorder) == ("POST", "/v1/clips/detect") + assert body(recorder) == {"videoId": "vid_1", "maxClips": 3} + + +def test_voice_generate_returns_json_or_audio(wave, recorder): + recorder.set(lambda _r: httpx.Response(200, json={"id": "gen_1"})) + assert wave.voice.generate("hello", "voice_1", output_format="mp3_44100_128") == {"id": "gen_1"} + assert sent(recorder) == ("POST", "/v1/voice/generate") + assert body(recorder) == {"text": "hello", "voiceId": "voice_1", "outputFormat": "mp3_44100_128"} + recorder.set(lambda _r: httpx.Response(200, content=b"ID3audio", headers={"content-type": "audio/mpeg"})) + assert wave.voice.generate("hello", "voice_1") == b"ID3audio" + + +def test_captions_download(wave, recorder): + recorder.set(lambda _r: httpx.Response(200, json={"url": "https://cdn.example.com/c.vtt"})) + assert wave.captions.download("job_1", language="en", format="vtt") == {"url": "https://cdn.example.com/c.vtt"} + assert sent(recorder) == ("GET", "/v1/captions/job_1/download?language=en&format=vtt") + + +def test_captions_download_keeps_a_file_body(wave, recorder): + """If the API answers with the caption file itself, its text is returned, not dropped.""" + vtt = "WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n" + recorder.set(lambda _r: httpx.Response(200, text=vtt, headers={"content-type": "text/vtt; charset=utf-8"})) + result = wave.captions.download("job_1", language="en", format="vtt") + assert result == {"content": vtt, "content_type": "text/vtt; charset=utf-8"} + + +def test_chapters_per_video_operations(wave, recorder): + wave.chapters.list_chapters("rec:1") + assert sent(recorder) == ("GET", "/v1/videos/rec:1/chapters") + wave.chapters.create_chapter("rec:1", "Intro", 0, 30.5) + assert sent(recorder) == ("POST", "/v1/videos/rec:1/chapters") + assert body(recorder) == {"title": "Intro", "startTime": 0, "endTime": 30.5} + wave.chapters.detect("rec:1", max_chapters=4) + assert sent(recorder) == ("POST", "/v1/videos/rec:1/chapters/detect") + assert body(recorder) == {"maxChapters": 4} + + +def test_chapters_detect_sends_the_published_request_body(wave, recorder): + """ChapterDetectRequest uses camelCase keys; an argument left as None is not sent.""" + wave.chapters.detect("rec:1", min_duration=45.0, max_chapters=6, include_descriptions=False, include_thumbnails=True) + assert body(recorder) == {"minDuration": 45.0, "maxChapters": 6, "includeDescriptions": False, "includeThumbnails": True} + wave.chapters.detect("rec:1", include_thumbnails=False) + assert body(recorder) == {"includeThumbnails": False} + wave.chapters.detect("rec:1") + assert body(recorder) == {} + + +def test_editor_export(wave, recorder): + wave.editor.export("prj_1", format="mp4", quality="high") + assert sent(recorder) == ("POST", "/v1/editor/projects/prj_1/export") + assert body(recorder) == {"format": "mp4", "quality": "high"} + + +def test_collab_delete_room_and_deprecated_alias(wave, recorder): + recorder.set(lambda _r: httpx.Response(204)) + wave.collab.delete_room("room_1") + assert sent(recorder) == ("DELETE", "/v1/collab/rooms/room_1") + with pytest.warns(DeprecationWarning): + wave.collab.close_room("room_2") + assert sent(recorder) == ("DELETE", "/v1/collab/rooms/room_2") + + +def test_podcast_uses_the_published_show_paths(wave, recorder): + wave.podcast.list(page=2) + assert sent(recorder) == ("GET", "/v1/podcast/shows?page=2") + recorder.set(lambda _r: httpx.Response(201, json={"id": "show_1", "name": "My Show"})) + show = wave.podcast.create("My Show", category="tech") + assert sent(recorder) == ("POST", "/v1/podcast/shows") + assert body(recorder) == {"name": "My Show", "category": "tech"} + assert show.id == "show_1" + wave.podcast.list_episodes("show_1") + assert sent(recorder) == ("GET", "/v1/podcast/shows/show_1/episodes") + recorder.set(lambda _r: httpx.Response(201, json={"id": "ep_1"})) + wave.podcast.create_episode("show_1", "Ep 1", audio_url="https://example.com/a.mp3") + assert sent(recorder) == ("POST", "/v1/podcast/shows/show_1/episodes") + assert body(recorder) == {"title": "Ep 1", "audioUrl": "https://example.com/a.mp3"} + + +@pytest.mark.parametrize( + "call", + [ + lambda w: w.clips.detect_highlights("stream", "s1"), + lambda w: w.voice.synthesize("t", "v"), + lambda w: w.captions.get_text("c1"), + lambda w: w.chapters.get_default_set("a1"), + lambda w: w.chapters.add_chapter("s1", "t", 0, 1), + lambda w: w.editor.render("p1"), + lambda w: w.podcast.get("show_1"), + lambda w: w.podcast.update("show_1", name="n"), + lambda w: w.podcast.remove("show_1"), + lambda w: w.podcast.get_episode("ep_1"), + lambda w: w.podcast.publish_episode("ep_1"), + lambda w: w.podcast.get_rss_feed("show_1"), + lambda w: w.podcast.get_analytics("show_1"), + lambda w: w.podcast.distribute("show_1", ["spotify"]), + ], +) +def test_methods_on_unpublished_paths_warn(wave, recorder, call): + from wave_sdk import RouteNotServedError + + recorder.set(lambda _r: live_response("err_route_not_found_404")) + with pytest.warns(DeprecationWarning), pytest.raises(RouteNotServedError): + call(wave) + + +@pytest.mark.parametrize( + "call", + [ + lambda w: w.inference.complete("m", [{"role": "user", "content": "hi"}]), + lambda w: w.realtime.publish("stream:abc", "note", {"x": 1}), + lambda w: w.voice.generate("hello", "voice_1"), + lambda w: w.clips.detect("video_1"), + lambda w: w.chapters.detect("video_1"), + lambda w: w.chapters.create_chapter("video_1", "Intro", 0, 1), + lambda w: w.editor.export("prj_1"), + lambda w: w.podcast.create("Show"), + lambda w: w.podcast.create_episode("show_1", "Ep", audio_url="https://example.com/a.mp3"), + ], +) +def test_billed_or_broadcast_writes_are_sent_once(wave, recorder, call): + """A bare 5xx after the server may have accepted the request is raised, not retried: a retry + could bill a job twice or broadcast an event twice. (The fixture fails on any retry sleep.)""" + from wave_sdk import WaveError + + recorder.set(lambda _r: httpx.Response(502, text="bad gateway")) + with pytest.raises(WaveError) as exc: + call(wave) + assert exc.value.status_code == 502 + assert len(recorder.requests) == 1 + + +def test_ids_stay_one_path_segment(wave, recorder): + """An id carrying '/', '?' or '#' must not move the request to another route.""" + recorder.set(lambda _r: httpx.Response(204)) + wave.captions.download("job/1?x#y", language="en") + assert sent(recorder) == ("GET", "/v1/captions/job%2F1%3Fx%23y/download?language=en") + wave.editor.export("prj/1") + assert sent(recorder) == ("POST", "/v1/editor/projects/prj%2F1/export") + wave.collab.delete_room("room/1") + assert sent(recorder) == ("DELETE", "/v1/collab/rooms/room%2F1") + wave.podcast.list_episodes("show/1") + assert sent(recorder) == ("GET", "/v1/podcast/shows/show%2F1/episodes") + + +_ID_CALLS = [ + lambda w, i: w.chapters.list_chapters(i), + lambda w, i: w.editor.export(i), + lambda w, i: w.collab.delete_room(i), + lambda w, i: w.realtime.presence(i), + lambda w, i: w.podcast.list_episodes(i), +] + + +@pytest.mark.parametrize("bad_id", ["..", ".", ""]) +@pytest.mark.parametrize("call", _ID_CALLS) +def test_ids_that_are_dot_segments_are_refused(wave, recorder, call, bad_id): + """``/v1/videos/../chapters`` resolves to ``/v1/chapters``: a dot-segment id would send the + caller's key to a different route, so it is refused before anything is sent.""" + with pytest.raises(ValueError): + call(wave, bad_id) + assert recorder.requests == [] + + +@pytest.mark.parametrize("call", _ID_CALLS) +def test_percent_encoded_dots_stay_one_opaque_segment(wave, recorder, call): + """``%`` is encoded, so ``%2E%2E`` is sent as ``%252E%252E``: literal text, not a parent.""" + recorder.set(lambda _r: httpx.Response(204)) + call(wave, "%2E%2E") + assert "/%252E%252E" in recorder.last.url.raw_path.decode() + + +@pytest.mark.parametrize("path", ["/v1/clips/..", "/v1/clips/../usage", "/v1/clips/./x", "/v1/clips/%2e%2e?x=1"]) +def test_any_request_path_with_a_dot_segment_is_refused(wave, recorder, path): + """Methods that interpolate ids without ``path_segment`` are covered by the client-wide check.""" + with pytest.raises(ValueError, match="dot segment"): + wave.client.get(path) + assert recorder.requests == [] + + +def test_an_unencoded_dot_segment_id_is_refused_by_the_client(wave, recorder): + with pytest.raises(ValueError, match="dot segment"): + wave.clips.get("..") + assert recorder.requests == [] + + +def test_live_fixture_file_has_no_unscrubbed_ids(): + import re + from pathlib import Path + + text = (Path(__file__).parent / "fixtures" / "live_responses.json").read_text() + uuids = set(re.findall(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", text)) + assert uuids <= {"00000000-0000-4000-8000-000000000000"} + assert live("usage")["body"]["org"] == "org_fixture" diff --git a/wave_sdk/__init__.py b/wave_sdk/__init__.py index a2a50c5..97db94e 100644 --- a/wave_sdk/__init__.py +++ b/wave_sdk/__init__.py @@ -7,15 +7,15 @@ Example: >>> from wave_sdk import Wave >>> client = Wave(api_key="your-api-key") - >>> streams = client.pipeline.list() - >>> clips = client.clips.list() + >>> usage = client.usage.get() + >>> results = client.search.search(query="product launch") """ from __future__ import annotations from wave_sdk.audience import AudienceAPI from wave_sdk.captions import CaptionsAPI from wave_sdk.chapters import ChaptersAPI -from wave_sdk.client import RateLimitError, WaveClient, WaveError, __version__ +from wave_sdk.client import WaveClient, __version__ # Existing P3 modules from wave_sdk.clips import ClipsAPI @@ -28,6 +28,7 @@ from wave_sdk.drm import DrmAPI from wave_sdk.edge import EdgeAPI from wave_sdk.editor import EditorAPI +from wave_sdk.errors import PaymentRequiredError, RateLimitError, RouteNotServedError, WaveError # P2 modules from wave_sdk.fleet import FleetAPI @@ -62,6 +63,7 @@ from wave_sdk.studio_ai import StudioAIAPI from wave_sdk.transcribe import TranscribeAPI from wave_sdk.transcripts import TranscriptAPI +from wave_sdk.usage import UsageAPI from wave_sdk.usb import UsbAPI # P3 new modules @@ -77,6 +79,8 @@ "WaveClient", "WaveError", "RateLimitError", + "PaymentRequiredError", + "RouteNotServedError", "RealtimeAPI", "RealtimeChannel", # Existing P3 @@ -99,6 +103,8 @@ "TranscriptAPI", "MailAPI", "MeterAPI", "PricingAPI", "PerceptionAPI", "InferenceAPI", # PR4: the Composer rendering (POST /v1/compose) "ComposeAPI", + # GET /v1/usage + "UsageAPI", # x402 agent payments "sign_exact_authorization", "encode_exact_payment_header", ] @@ -111,9 +117,9 @@ class Wave: Example: >>> from wave_sdk import Wave >>> client = Wave(api_key="your-api-key", organization_id="org_123") - >>> streams = client.pipeline.list() - >>> clips = client.clips.list() - >>> client.prism.discover_sources() + >>> usage = client.usage.get() + >>> results = client.search.search(query="product launch") + >>> manifests = client.pricing.list_manifests() """ def __init__( @@ -201,3 +207,6 @@ def __init__( # Composer — POST /v1/compose: propose a plan across products for a # plain-English intent (PR4). Never executes; a proposal is a plan. self.compose = ComposeAPI(self.client) + + # Usage - GET /v1/usage: the org's own metered totals (the quickest key check) + self.usage = UsageAPI(self.client) diff --git a/wave_sdk/captions.py b/wave_sdk/captions.py index d5dd7f5..b880beb 100644 --- a/wave_sdk/captions.py +++ b/wave_sdk/captions.py @@ -2,11 +2,13 @@ from __future__ import annotations import time -from typing import Any +import warnings +from typing import Any, Literal +import httpx from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment class CaptionTrack(BaseModel): @@ -33,7 +35,22 @@ def update_cue(self, caption_id: str, cue_id: str, **kwargs: Any) -> CaptionCue: def remove_cue(self, caption_id: str, cue_id: str) -> None: self._client.delete(f"{self._base}/{caption_id}/cues/{cue_id}") def bulk_update_cues(self, caption_id: str, cues: list[dict]) -> list[CaptionCue]: return [CaptionCue(**c) for c in self._client.put(f"{self._base}/{caption_id}/cues", json={"cues": cues})] def export_format(self, caption_id: str, format: str = "srt") -> dict: return self._client.get(f"{self._base}/{caption_id}/export", params={"format": format}) - def get_text(self, caption_id: str, include_speakers: bool = False) -> dict: return self._client.get(f"{self._base}/{caption_id}/text", params={"include_speakers": include_speakers}) + def download(self, job_id: str, language: str, format: Literal["srt", "vtt", "txt", "json"] | None = None) -> dict[str, Any]: + """Download a finished caption track. ``GET /v1/captions/{jobId}/download``. + + Returns ``{"url": ..., "content": ...}``: a download URL and/or the caption text inline. + The published response is JSON; if the API answers with the caption file itself (for + example ``text/vtt``), its text is returned as ``content``, with ``content_type`` set. + """ + response: httpx.Response = self._client.get(f"{self._base}/{path_segment(job_id)}/download", params={"language": language, "format": format}, raw=True) + if response.headers.get("content-type", "").startswith("application/json"): + result: dict[str, Any] = response.json() + return result + return {"content": response.text, "content_type": response.headers.get("content-type")} + def get_text(self, caption_id: str, include_speakers: bool = False) -> dict: + """Deprecated: calls ``/v1/captions/{id}/text``, which the API does not serve. Use :meth:`download`.""" + warnings.warn("captions.get_text() calls a route the API does not serve; use captions.download(job_id, language, format='txt')", DeprecationWarning, stacklevel=2) + return self._client.get(f"{self._base}/{caption_id}/text", params={"include_speakers": include_speakers}) def detect_language(self, media_id: str) -> dict: return self._client.post(f"{self._base}/detect-language", json={"media_id": media_id}) def get_supported_languages(self) -> list[dict]: return self._client.get(f"{self._base}/languages") def get_for_media(self, media_id: str, media_type: str = "video") -> list[CaptionTrack]: return [CaptionTrack(**t) for t in self._client.get(f"{self._base}/media/{media_id}", params={"media_type": media_type})] diff --git a/wave_sdk/chapters.py b/wave_sdk/chapters.py index 7824a81..c3bf17f 100644 --- a/wave_sdk/chapters.py +++ b/wave_sdk/chapters.py @@ -1,12 +1,23 @@ -"""WAVE SDK - Chapters API. Auto-generate and manage video chapters.""" +"""WAVE SDK - Chapters API. Auto-generate and manage video chapters. + +A video's chapters live under ``/v1/videos/{videoId}/chapters``: :meth:`ChaptersAPI.list_chapters`, +:meth:`ChaptersAPI.create_chapter` and :meth:`ChaptersAPI.detect`. The API publishes no operation +for polling a detection job; read the result with :meth:`ChaptersAPI.list_chapters`. +""" from __future__ import annotations import time +import warnings from typing import Any from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment + + +def _video_path(video_id: str) -> str: + # The video id is one path segment; encode anything that could leave it. + return f"/v1/videos/{path_segment(video_id)}/chapters" class Chapter(BaseModel): @@ -24,8 +35,31 @@ def list_sets(self, **params: Any) -> dict: return self._client.get(self._base, def update_set(self, set_id: str, **kwargs: Any) -> ChapterSet: return ChapterSet(**self._client.patch(f"{self._base}/{set_id}", json=kwargs)) def remove_set(self, set_id: str) -> None: self._client.delete(f"{self._base}/{set_id}") def duplicate_set(self, set_id: str) -> ChapterSet: return ChapterSet(**self._client.post(f"{self._base}/{set_id}/duplicate")) - def get_default_set(self, asset_id: str) -> ChapterSet: return ChapterSet(**self._client.get(f"{self._base}/default/{asset_id}")) - def add_chapter(self, set_id: str, title: str, start_time: float, end_time: float, **kwargs: Any) -> Chapter: return Chapter(**self._client.post(f"{self._base}/{set_id}/chapters", json={"title": title, "start_time": start_time, "end_time": end_time, **kwargs})) + # --- Per-video chapters: /v1/videos/{videoId}/chapters* ------------------------------------- + def list_chapters(self, video_id: str) -> dict[str, Any]: + """A video's chapters. ``GET /v1/videos/{videoId}/chapters`` -> ``{"chapters": [...]}``.""" + result: dict[str, Any] = self._client.get(_video_path(video_id)) + return result + def create_chapter(self, video_id: str, title: str, start_time: float, end_time: float, description: str | None = None) -> dict[str, Any]: + """Add one chapter to a video. ``POST /v1/videos/{videoId}/chapters``.""" + body = {"title": title, "startTime": start_time, "endTime": end_time, "description": description} + # Sent once: a retry after the API created the chapter would create a duplicate. + result: dict[str, Any] = self._client.post(_video_path(video_id), json={k: v for k, v in body.items() if v is not None}, no_retry=True) + return result + def detect(self, video_id: str, min_duration: float | None = None, max_chapters: int | None = None, include_descriptions: bool | None = None, include_thumbnails: bool | None = None) -> dict[str, Any]: + """Start AI chapter detection. ``POST /v1/videos/{videoId}/chapters/detect`` -> a detection job (HTTP 202).""" + body = {"minDuration": min_duration, "maxChapters": max_chapters, "includeDescriptions": include_descriptions, "includeThumbnails": include_thumbnails} + # Sent once (billed): a retry after the API accepted the job would start a second one. + result: dict[str, Any] = self._client.post(f"{_video_path(video_id)}/detect", json={k: v for k, v in body.items() if v is not None}, no_retry=True) + return result + def get_default_set(self, asset_id: str) -> ChapterSet: + """Deprecated: calls ``/v1/chapters/default/{id}``, which the API does not serve. Use :meth:`list_chapters`.""" + warnings.warn("chapters.get_default_set() calls a route the API does not serve; use chapters.list_chapters(video_id)", DeprecationWarning, stacklevel=2) + return ChapterSet(**self._client.get(f"{self._base}/default/{asset_id}")) + def add_chapter(self, set_id: str, title: str, start_time: float, end_time: float, **kwargs: Any) -> Chapter: + """Deprecated: calls ``/v1/chapters/{set}/chapters``, which the API does not serve. Use :meth:`create_chapter`.""" + warnings.warn("chapters.add_chapter() calls a route the API does not serve; use chapters.create_chapter(video_id, ...)", DeprecationWarning, stacklevel=2) + return Chapter(**self._client.post(f"{self._base}/{set_id}/chapters", json={"title": title, "start_time": start_time, "end_time": end_time, **kwargs})) def get_chapter(self, set_id: str, chapter_id: str) -> Chapter: return Chapter(**self._client.get(f"{self._base}/{set_id}/chapters/{chapter_id}")) def update_chapter(self, set_id: str, chapter_id: str, **kwargs: Any) -> Chapter: return Chapter(**self._client.patch(f"{self._base}/{set_id}/chapters/{chapter_id}", json=kwargs)) def remove_chapter(self, set_id: str, chapter_id: str) -> None: self._client.delete(f"{self._base}/{set_id}/chapters/{chapter_id}") diff --git a/wave_sdk/client.py b/wave_sdk/client.py index a6b090d..b5621b9 100644 --- a/wave_sdk/client.py +++ b/wave_sdk/client.py @@ -7,63 +7,84 @@ from __future__ import annotations import logging +import random import time from typing import Any, Generic, TypeVar +from urllib.parse import quote import httpx from pydantic import BaseModel +# Error types live in wave_sdk.errors; they are re-exported here so existing +# `from wave_sdk.client import WaveError` imports keep working. +from wave_sdk.errors import ( + PaymentRequiredError, + RateLimitError, + RouteNotServedError, + WaveError, + error_from_response, + retry_after_header, +) + +__all__ = [ + "PaginatedResponse", + "PaymentRequiredError", + "RateLimitError", + "RouteNotServedError", + "WaveClient", + "WaveError", + "__version__", + "error_from_response", +] + logger = logging.getLogger("wave_sdk") # Single source of truth for the SDK version, used both in the package's # public __version__ (re-exported from wave_sdk/__init__.py) and here in the # default User-Agent header, so the two can never drift. -__version__ = "2.2.0" +__version__ = "2.3.0" T = TypeVar("T") +# Longest server-requested wait the client sleeps through before retrying on its own. A longer +# request is raised to the caller at once (with the full wait on the error) instead of sleeping. +_MAX_SERVER_RETRY_AFTER = 60.0 -class WaveError(Exception): - """WAVE API error.""" +# Path segments a URL resolver treats as "this directory" or "the parent directory" (RFC 3986 +# section 5.2.4; a WHATWG URL parser also decodes ``%2e``). httpx and the server both collapse +# them, so ``/v1/videos/../chapters`` would reach ``/v1/chapters``: an id equal to one of these +# would move the request to another route with the caller's key. +_DOT_SEGMENTS = frozenset({".", "..", "%2e", "%2e%2e", ".%2e", "%2e."}) - def __init__( - self, - message: str, - code: str, - status_code: int, - request_id: str | None = None, - details: dict[str, Any] | None = None, - ): - super().__init__(message) - self.message = message - self.code = code - self.status_code = status_code - self.request_id = request_id - self.details = details - self.retryable = self._is_retryable(status_code, code) - def _is_retryable(self, status_code: int, code: str) -> bool: - if status_code == 429: - return True - if 500 <= status_code < 600: - return True - return code in ("TIMEOUT", "NETWORK_ERROR", "SERVICE_UNAVAILABLE") +def path_segment(value: str | int) -> str: + """Encode an id as exactly one URL path segment. - def __str__(self) -> str: - return f"WaveError({self.code}): {self.message}" + ``/``, ``?``, ``#`` and ``%`` are percent-encoded (``:`` stays literal, as in + ``stream:abc``), so the id cannot add segments, a query or a fragment. An empty id, or one + that is a dot segment (``.``, ``..``), raises ``ValueError`` before anything is sent. + """ + if value is None or isinstance(value, bool) or value == "": + raise ValueError("WAVE SDK: an id used in a URL path must be a non-empty string") + encoded = quote(str(value), safe=":") + if encoded.lower() in _DOT_SEGMENTS: + raise ValueError(f"WAVE SDK: {value!r} is not a valid id: it is a relative path segment") + return encoded -class RateLimitError(WaveError): - """Rate limit exceeded error.""" +def _check_path(path: str) -> None: + """Refuse a request path with a dot segment, whichever method built it. - def __init__( - self, - message: str, - retry_after: float, - request_id: str | None = None, - ): - super().__init__(message, "RATE_LIMITED", 429, request_id) - self.retry_after = retry_after + Most namespaces interpolate ids into paths directly; this check is what keeps an id such as + ``..`` from redirecting any of them to a different route. + """ + route = path.split("?", 1)[0].split("#", 1)[0] + for segment in route.split("/"): + if segment.lower() in _DOT_SEGMENTS: + raise ValueError( + f"WAVE SDK: refusing to send {path!r}: the dot segment {segment!r} would resolve " + "to a different route (check the ids passed to this method)" + ) class PaginatedResponse(BaseModel, Generic[T]): @@ -83,7 +104,7 @@ class WaveClient: Example: >>> client = WaveClient(api_key="your-api-key") - >>> response = client.get("/v1/clips") + >>> usage = client.get("/v1/usage") """ def __init__( @@ -192,9 +213,16 @@ def _request( json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, no_retry: bool = False, + raw: bool = False, **kwargs: Any, ) -> Any: - """Make an API request with retry logic.""" + """Make an API request with retry logic. + + Returns the decoded JSON body, or ``None`` for a non-JSON success. With ``raw=True`` the + successful ``httpx.Response`` itself is returned, for routes that answer with bytes (audio, + caption files); errors still raise :class:`WaveError` exactly as on the JSON path. + """ + _check_path(path) # Filter out None params if params: params = {k: v for k, v in params.items() if v is not None} @@ -215,30 +243,19 @@ def _request( **kwargs, ) - # Handle rate limiting - if response.status_code == 429: - retry_after = self._parse_retry_after(response) - if attempt < max_retries: - logger.warning(f"Rate limited. Retrying in {retry_after}s") - time.sleep(retry_after) - continue - raise RateLimitError( - "Rate limit exceeded", - retry_after, - response.headers.get("x-request-id"), - ) - - # Handle errors + # Handle errors (a 429 parses to RateLimitError) if not response.is_success: error = self._parse_error(response) - if error.retryable and attempt < max_retries: - delay = self._calculate_backoff(attempt) - logger.warning(f"Request failed. Retrying in {delay}s") + delay = self._retry_delay(error, attempt, response) + if delay is not None and attempt < max_retries: + logger.warning(f"Request failed ({error.code}). Retrying in {delay}s") time.sleep(delay) continue raise error # Parse response + if raw: + return response if response.headers.get("content-type", "").startswith("application/json"): return response.json() return None @@ -261,34 +278,30 @@ def _request( raise WaveError("Request failed after retries", "UNKNOWN_ERROR", 0) def _parse_error(self, response: httpx.Response) -> WaveError: - """Parse error response.""" - request_id = response.headers.get("x-request-id") - try: - body = response.json() - return WaveError( - body.get("error", {}).get("message", f"HTTP {response.status_code}"), - body.get("error", {}).get("code", f"HTTP_{response.status_code}"), - response.status_code, - request_id or body.get("request_id"), - body.get("error", {}).get("details"), - ) - except Exception: - return WaveError( - f"HTTP {response.status_code}: {response.reason_phrase}", - f"HTTP_{response.status_code}", - response.status_code, - request_id, - ) - - def _parse_retry_after(self, response: httpx.Response) -> float: - """Parse Retry-After header.""" - retry_after = response.headers.get("retry-after") - if not retry_after: - return 1.0 - try: - return float(retry_after) - except ValueError: - return 1.0 + """Parse an error response into the most specific WaveError subclass.""" + return error_from_response(response) + + def _retry_delay( + self, error: WaveError, attempt: int, response: httpx.Response | None = None + ) -> float | None: + """Seconds to sleep before retrying ``error``, or None to raise it now. + + The wait is the server's: ``RateLimitError.retry_after`` for a 429; otherwise a usable + ``Retry-After`` header, then a ``retry_after`` directive, then exponential backoff. None + when the server's ``next_action`` says a retry cannot help, and when the server asked + for a longer wait than ``_MAX_SERVER_RETRY_AFTER`` (sleeping a capped time and retrying + early would only be refused again; the caller gets the full wait on the error). + """ + if not error.retryable: + return None + if isinstance(error, RateLimitError): + wait: float | None = error.retry_after + else: + header = retry_after_header(response) if response is not None else None + wait = header if header is not None else error.retry_after_hint + if wait is None: + return self._calculate_backoff(attempt) + return wait if wait <= _MAX_SERVER_RETRY_AFTER else None def _calculate_backoff(self, attempt: int) -> float: """Calculate exponential backoff delay.""" @@ -296,8 +309,6 @@ def _calculate_backoff(self, attempt: int) -> float: max_delay = 30.0 delay = min(base_delay * (2**attempt), max_delay) # Add jitter - import random - return delay + random.random() * delay * 0.25 def close(self) -> None: diff --git a/wave_sdk/clips.py b/wave_sdk/clips.py index 6668f36..1defc5f 100644 --- a/wave_sdk/clips.py +++ b/wave_sdk/clips.py @@ -7,6 +7,8 @@ from __future__ import annotations import time +import warnings +from collections.abc import Sequence from typing import Any, Literal from pydantic import BaseModel @@ -79,7 +81,7 @@ class ClipsAPI: Clips API client. Example: - >>> from wave import Wave + >>> from wave_sdk import Wave >>> wave = Wave(api_key="your-api-key") >>> clip = wave.clips.create( ... title="Best Moment", @@ -255,6 +257,34 @@ def list_exports( return self._client.get(f"{self._base_path}/{clip_id}/exports", params=params) + def detect( + self, + video_id: str, + min_duration: float | None = None, + max_duration: float | None = None, + categories: Sequence[str] | None = None, + sensitivity: float | None = None, + max_clips: int | None = None, + ) -> dict[str, Any]: + """Start AI clip detection on a video. ``POST /v1/clips/detect``. + + Returns the detection job (``{"id", "status", "progress", "createdAt"}``, HTTP 202). + """ + body = { + "videoId": video_id, + "minDuration": min_duration, + "maxDuration": max_duration, + "categories": list(categories) if categories is not None else None, + "sensitivity": sensitivity, + "maxClips": max_clips, + } + # Sent once (billed): a retry after the API accepted the job would start a second one. + result: dict[str, Any] = self._client.post( + f"{self._base_path}/detect", json={k: v for k, v in body.items() if v is not None}, + no_retry=True, + ) + return result + def detect_highlights( self, source_type: Literal["stream", "recording"], @@ -263,7 +293,13 @@ def detect_highlights( min_score: float | None = None, max_results: int | None = None, ) -> list[ClipHighlight]: - """Detect highlights in source content.""" + """Deprecated: calls ``/v1/clips/highlights/detect``, which the API does not serve. + Use :meth:`detect` (``POST /v1/clips/detect``).""" + warnings.warn( + "clips.detect_highlights() calls a route the API does not serve; use clips.detect(video_id)", + DeprecationWarning, + stacklevel=2, + ) data: dict[str, Any] = { "source_type": source_type, "source_id": source_id, diff --git a/wave_sdk/collab.py b/wave_sdk/collab.py index 38dc36f..a6a6179 100644 --- a/wave_sdk/collab.py +++ b/wave_sdk/collab.py @@ -1,11 +1,12 @@ """WAVE SDK - Collab API. Real-time collaboration rooms, participants, comments, and annotations.""" from __future__ import annotations +import warnings from typing import Any from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment class CollabRoom(BaseModel): @@ -26,7 +27,14 @@ def create_room(self, name: str, **kwargs: Any) -> CollabRoom: return CollabRoom def get_room(self, room_id: str) -> CollabRoom: return CollabRoom(**self._client.get(f"{self._base}/rooms/{room_id}")) def update_room(self, room_id: str, **kwargs: Any) -> CollabRoom: return CollabRoom(**self._client.patch(f"{self._base}/rooms/{room_id}", json=kwargs)) def list_rooms(self, **params: Any) -> dict: return self._client.get(f"{self._base}/rooms", params={k: v for k, v in params.items() if v is not None}) - def close_room(self, room_id: str) -> None: self._client.post(f"{self._base}/rooms/{room_id}/close") + def delete_room(self, room_id: str) -> None: + """Close and delete a room. ``DELETE /v1/collab/rooms/{roomId}``.""" + self._client.delete(f"{self._base}/rooms/{path_segment(room_id)}") + def close_room(self, room_id: str) -> None: + """Deprecated alias of :meth:`delete_room` (it used to send ``POST .../close``, which is not a + published API operation).""" + warnings.warn("collab.close_room() is deprecated; use collab.delete_room(room_id)", DeprecationWarning, stacklevel=2) + self.delete_room(room_id) def archive_room(self, room_id: str) -> None: self._client.post(f"{self._base}/rooms/{room_id}/archive") def get_join_token(self, room_id: str) -> dict: return self._client.post(f"{self._base}/rooms/{room_id}/join-token") def invite(self, room_id: str, user_ids: list[str], role: str = "viewer") -> None: self._client.post(f"{self._base}/rooms/{room_id}/invite", json={"user_ids": user_ids, "role": role}) diff --git a/wave_sdk/editor.py b/wave_sdk/editor.py index 069a4c5..5dc7fd8 100644 --- a/wave_sdk/editor.py +++ b/wave_sdk/editor.py @@ -2,11 +2,12 @@ from __future__ import annotations import time -from typing import Any +import warnings +from typing import Any, Literal from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment class EditorProject(BaseModel): @@ -45,7 +46,16 @@ def update_effect(self, project_id: str, effect_id: str, **kwargs: Any) -> dict: def remove_effect(self, project_id: str, effect_id: str) -> None: self._client.delete(f"{self._base}/{project_id}/effects/{effect_id}") def get_preview_frame(self, project_id: str, time_seconds: float) -> dict: return self._client.get(f"{self._base}/{project_id}/preview/frame", params={"time": time_seconds}) def get_preview_segment(self, project_id: str, start: float, end: float) -> dict: return self._client.get(f"{self._base}/{project_id}/preview/segment", params={"start": start, "end": end}) - def render(self, project_id: str, **kwargs: Any) -> RenderJob: return RenderJob(**self._client.post(f"{self._base}/{project_id}/render", json=kwargs if kwargs else None)) + def export(self, project_id: str, format: Literal["mp4", "webm", "mov"] | None = None, resolution: str | None = None, quality: Literal["low", "medium", "high", "ultra"] | None = None) -> dict[str, Any]: + """Render a project to a file. ``POST /v1/editor/projects/{projectId}/export`` -> an export job (HTTP 202).""" + body = {"format": format, "resolution": resolution, "quality": quality} + # Sent once (billed): a retry after the API accepted the export would render it twice. + result: dict[str, Any] = self._client.post(f"{self._base}/{path_segment(project_id)}/export", json={k: v for k, v in body.items() if v is not None}, no_retry=True) + return result + def render(self, project_id: str, **kwargs: Any) -> RenderJob: + """Deprecated: calls ``.../render``; the published API operation is ``.../export``. Use :meth:`export`.""" + warnings.warn("editor.render() calls .../render, which is not a published API operation; use editor.export(project_id)", DeprecationWarning, stacklevel=2) + return RenderJob(**self._client.post(f"{self._base}/{project_id}/render", json=kwargs if kwargs else None)) def get_render_job(self, job_id: str) -> RenderJob: return RenderJob(**self._client.get(f"/v1/editor/renders/{job_id}")) def list_render_jobs(self, project_id: str, **params: Any) -> dict: return self._client.get(f"{self._base}/{project_id}/renders", params={k: v for k, v in params.items() if v is not None}) def cancel_render_job(self, job_id: str) -> None: self._client.post(f"/v1/editor/renders/{job_id}/cancel") diff --git a/wave_sdk/errors.py b/wave_sdk/errors.py new file mode 100644 index 0000000..9c02fe2 --- /dev/null +++ b/wave_sdk/errors.py @@ -0,0 +1,293 @@ +"""WAVE SDK - error types and the parser that maps every WAVE error body onto them. + +``wave_sdk.client`` re-exports everything here, so ``from wave_sdk.client import WaveError`` +keeps working. +""" +from __future__ import annotations + +import math +from typing import Any + +import httpx + +# The gateway attaches a machine-readable `next_action` to its errors. Only these two verbs mean +# "the same request can succeed later"; every other verb (`none`, `pay`, `upgrade_plan`, +# `acquire_scope`, `authenticate`) names a condition a retry cannot clear. +_RETRY_ACTIONS = frozenset({"retry_backoff", "retry_after"}) + +# Codes the gateway uses when no WAVE capability answers a (method, path) pair. +_ROUTE_NOT_SERVED_CODES = frozenset({"ROUTE_NOT_MAPPED", "ROUTE_NOT_FOUND"}) + +# Keys of the normalized error object that map onto WaveError attributes rather than `details`. +_ERROR_OBJECT_KEYS = frozenset( + {"code", "message", "details", "request_id", "next_action", "suggestions", "doc_url"} +) + +# Top-level keys of a flat error body that are not caller-facing context. +_FLAT_BODY_SKIP = frozenset( + {"code", "message", "next_action", "suggestions", "doc_url", "request_id", + "accepts", "x402Version", "error_detail"} +) + + +class WaveError(Exception): + """WAVE API error. + + ``code`` and ``message`` come from the response body whenever the server sent them; + ``details`` carries any structured context (for example ``available_scopes`` on a 403). + ``next_action`` is the gateway's machine-readable directive (``{"type": "retry_backoff"}``, + ``{"type": "acquire_scope", "scope": ...}``, ...), and ``retryable`` follows it when present. + """ + + def __init__( + self, + message: str, + code: str, + status_code: int, + request_id: str | None = None, + details: dict[str, Any] | None = None, + *, + next_action: dict[str, Any] | None = None, + suggestions: list[str] | None = None, + doc_url: str | None = None, + ): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.request_id = request_id + self.details = details + self.next_action = next_action + self.suggestions = suggestions + self.doc_url = doc_url + self.retryable = self._is_retryable(status_code, code) + + def _is_retryable(self, status_code: int, code: str) -> bool: + action = self.next_action.get("type") if isinstance(self.next_action, dict) else None + if isinstance(action, str): + # The server said whether a retry can help; believe it over the status code, so a + # permanent 503 (for example an unbound store) is not retried three times. + return action in _RETRY_ACTIONS + if status_code == 429: + return True + if 500 <= status_code < 600: + return True + return code in ("TIMEOUT", "NETWORK_ERROR", "SERVICE_UNAVAILABLE") + + @property + def retry_after_hint(self) -> float | None: + """Seconds to wait from a ``{"type": "retry_after", "seconds": N}`` directive, if any.""" + return _retry_after_directive(self.next_action) + + def __str__(self) -> str: + return f"WaveError({self.code}): {self.message}" + + +def _wait_seconds(value: Any) -> float | None: + """A usable wait: a finite, non-negative number (bools, NaN and infinities are rejected).""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) and value >= 0 else None + + +def _retry_after_directive(next_action: Any) -> float | None: + if not isinstance(next_action, dict) or next_action.get("type") != "retry_after": + return None + return _wait_seconds(next_action.get("seconds")) + + +def retry_after_header(response: httpx.Response) -> float | None: + """The ``Retry-After`` header in seconds, or None when it is absent or is not a finite, + non-negative number (an HTTP date, ``nan``, ``-1``).""" + header = response.headers.get("retry-after") + if not header: + return None + try: + return _wait_seconds(float(header)) + except ValueError: + return None + + +def retry_after_seconds(response: httpx.Response, next_action: Any = None) -> float: + """How long the server asked the caller to wait before retrying. + + A usable ``Retry-After`` header wins, then the body's ``retry_after`` directive, then 1 s. + The value is not capped here; the client decides whether a wait is too long to retry on its + own. + """ + header = retry_after_header(response) + if header is not None: + return header + directive = _retry_after_directive(next_action) + return directive if directive is not None else 1.0 + + +class RateLimitError(WaveError): + """HTTP 429. ``retry_after`` is the wait the server asked for, in seconds.""" + + def __init__( + self, + message: str, + retry_after: float, + request_id: str | None = None, + **kwargs: Any, + ): + super().__init__(message, "RATE_LIMITED", 429, request_id, **kwargs) + self.retry_after = retry_after + + +class PaymentRequiredError(WaveError): + """HTTP 402: the request was not served because it needs payment. + + Two bodies produce this error: + + * an x402 payment challenge (no key, or an unrecognized key, on a paid route): ``accepts`` + holds the payment options, ``x402_version`` the protocol version, and ``next_action`` the + server's ``pay`` directive; + * a plan spend-cap refusal (code ``SPEND_CAP_TIER_BLOCKED``): ``details`` carries the + server's ``dimension``, ``projected_cost_usd``, ``remaining_allowance_usd`` and ``reason``. + """ + + def __init__( + self, + message: str, + code: str, + request_id: str | None = None, + details: dict[str, Any] | None = None, + *, + accepts: list[dict[str, Any]] | None = None, + x402_version: int | None = None, + **kwargs: Any, + ): + super().__init__(message, code, 402, request_id, details, **kwargs) + self.accepts: list[dict[str, Any]] = accepts or [] + self.x402_version = x402_version + + +class RouteNotServedError(WaveError): + """No WAVE capability serves this method and path. + + Raised for a 404 whose code says the route itself is missing (``ROUTE_NOT_MAPPED`` when the + gateway has no rule for the path, ``ROUTE_NOT_FOUND`` when nothing is behind a mapped + prefix, or a product's own ``*_ROUTE_NOT_FOUND``), and for a 405 (the path exists, but not + for this method). It is never raised for a missing resource such as an unknown clip id. + ``doc_url`` points at the free capability index when the server names one. + """ + + +def _as_str_list(value: Any) -> list[str] | None: + if isinstance(value, list) and all(isinstance(v, str) for v in value): + return list(value) + return None + + +def _as_dict(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def _is_route_not_served(status_code: int, code: str) -> bool: + if status_code == 405: + return True + return status_code == 404 and ( + code in _ROUTE_NOT_SERVED_CODES or code.upper().endswith("_ROUTE_NOT_FOUND") + ) + + +def _from_envelope(err: dict[str, Any], fields: dict[str, Any]) -> None: + """The normalized envelope: ``{"error": {"code", "message", ...}}``.""" + fields["code"] = str(err.get("code") or fields["code"]) + fields["message"] = str(err.get("message") or fields["message"]) + details = _as_dict(err.get("details")) + # Extra members of the error object (required_scope, available_scopes, ...) are context + # the caller can act on, so they are kept rather than dropped. + extra = {k: v for k, v in err.items() if k not in _ERROR_OBJECT_KEYS} + fields["details"] = {**extra, **(details or {})} if extra else details + fields["next_action"] = _as_dict(err.get("next_action")) + fields["suggestions"] = _as_str_list(err.get("suggestions")) + fields["doc_url"] = err.get("doc_url") if isinstance(err.get("doc_url"), str) else None + if isinstance(err.get("request_id"), str): + fields["request_id"] = err["request_id"] + + +def _from_flat_body(body: dict[str, Any], fields: dict[str, Any]) -> None: + """Flat bodies: ``error`` is a short reason string (or absent). The normalized object, when + there is one, sits under ``error_detail`` (the x402 challenge) or at the top level.""" + raw_error = body.get("error") + detail = _as_dict(body.get("error_detail")) or {} + reason = raw_error if isinstance(raw_error, str) else None + fields["code"] = str(detail.get("code") or body.get("code") or fields["code"]) + fields["message"] = str(detail.get("message") or body.get("message") or reason or fields["message"]) + fields["next_action"] = _as_dict(body.get("next_action")) or _as_dict(detail.get("next_action")) + fields["suggestions"] = _as_str_list(detail.get("suggestions") or body.get("suggestions")) + url = detail.get("doc_url") or body.get("doc_url") + fields["doc_url"] = url if isinstance(url, str) else None + if isinstance(body.get("accepts"), list): + fields["accepts"] = [a for a in body["accepts"] if isinstance(a, dict)] + if isinstance(body.get("x402Version"), int): + fields["x402_version"] = body["x402Version"] + if not fields["request_id"] and isinstance(detail.get("request_id"), str): + fields["request_id"] = detail["request_id"] + extra = {k: v for k, v in body.items() if k not in _FLAT_BODY_SKIP} + detail_extra = {k: v for k, v in detail.items() if k not in _ERROR_OBJECT_KEYS} + merged = {**detail_extra, **extra, **(_as_dict(detail.get("details")) or {})} + fields["details"] = merged or None + + +def error_from_response(response: httpx.Response) -> WaveError: + """Build the most specific WaveError for a non-2xx response. + + Understands every error body the WAVE API returns: + + * the normalized envelope ``{"error": {"code", "message", "details", "next_action", ...}}``; + * a flat body ``{"error": "", "code": ..., "message": ..., }`` + (for example a spend-cap refusal), and the bare ``{"error": ""}`` form; + * the x402 challenge ``{"x402Version", "error": "payment required", "accepts": [...], + "error_detail": {...}, "next_action": {...}}``. + + Anything else (a non-JSON body) falls back to code ``HTTP_``. + """ + status = response.status_code + fallback_message = f"HTTP {status}: {response.reason_phrase}" + fields: dict[str, Any] = { + "code": f"HTTP_{status}", + "message": fallback_message, + "details": None, "next_action": None, "suggestions": None, "doc_url": None, + "request_id": None, "accepts": None, "x402_version": None, + } + try: + body = response.json() + except ValueError: # not JSON (JSONDecodeError), or not text (UnicodeDecodeError) + body = None + + if isinstance(body, dict): + if isinstance(body.get("request_id"), str): + fields["request_id"] = body["request_id"] + if isinstance(body.get("error"), dict): + _from_envelope(body["error"], fields) + else: + _from_flat_body(body, fields) + + request_id = response.headers.get("x-request-id") or fields["request_id"] + message, code, details = fields["message"], fields["code"], fields["details"] + common: dict[str, Any] = { + "next_action": fields["next_action"], + "suggestions": fields["suggestions"], + "doc_url": fields["doc_url"], + } + if status == 429: + # One class for a 429 wherever it comes from (a REST call or a WebSocket upgrade). + return RateLimitError( + message if message != fallback_message else "Rate limit exceeded", + retry_after_seconds(response, fields["next_action"]), + request_id, + details=details, + **common, + ) + if status == 402: + return PaymentRequiredError( + message, code, request_id, details, + accepts=fields["accepts"], x402_version=fields["x402_version"], **common, + ) + if _is_route_not_served(status, code): + return RouteNotServedError(message, code, status, request_id, details, **common) + return WaveError(message, code, status, request_id, details, **common) diff --git a/wave_sdk/inference.py b/wave_sdk/inference.py index ad2d576..9d9b129 100644 --- a/wave_sdk/inference.py +++ b/wave_sdk/inference.py @@ -1,24 +1,29 @@ -"""WAVE SDK - Inference API (the funnel rendering). One OpenAI-compatible -completion endpoint fronting the WAVE model registry - measured routing, -automatic failover, per-token metering. The SDK forwards the API key; auth, -budgets, guardrails, and spend tracking are enforced by the funnel plane -(inference.wave.online). - -The routing decision is measured: every model carries a floor-to-ceiling -transition profile in the registry. `profile()` returns it alongside live -usage. Reading the registry directly (`models`, `profile`) requires the -caller to supply the registry's own read endpoint and key - the WAVE API key -alone is not a registry credential. +"""WAVE SDK - Inference API. One OpenAI-compatible completion endpoint fronting the WAVE model +registry: measured routing, automatic failover, per-token metering. + +Both calls go through the API gateway with your WAVE API key, like every other namespace: + +* ``complete()`` -> ``POST /v1/inference/chat/completions`` (scope ``dispatch:write``); +* ``models()`` -> ``GET /v1/inference/models`` (an OpenAI-style model list). + +``profile()`` reads the model registry directly and needs the registry's own endpoint and key +(``registry_url`` / ``registry_key``); the WAVE API key is not a registry credential. It is +deprecated and will be removed in a future major release. """ from __future__ import annotations +import warnings from typing import Any, Literal +from urllib.parse import quote import httpx -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from wave_sdk.client import WaveClient, WaveError +_COMPLETIONS_PATH = "/v1/inference/chat/completions" +_MODELS_PATH = "/v1/inference/models" + class InferenceMessage(BaseModel): role: Literal["system", "user", "assistant", "tool"] @@ -33,10 +38,16 @@ class InferenceResult(BaseModel): class InferenceModel(BaseModel): + """One entry of ``GET /v1/inference/models``. ``rail`` and the per-token prices are only filled + by the deprecated registry read; the gateway list carries ``id``, ``object`` and ``owned_by``.""" + + model_config = ConfigDict(extra="allow") id: str - rail: str - input_per_m: float | None - output_per_m: float | None + object: str | None = None + owned_by: str | None = None + rail: str | None = None + input_per_m: float | None = None + output_per_m: float | None = None class ModelTransition(BaseModel): @@ -65,29 +76,41 @@ class ModelProfile(BaseModel): class InferenceAPI: - """Inference API - one completion call through the measured funnel, plus - registry reads (model catalog, measured profile).""" + """Inference API - one completion call through the gateway, plus the model list. + + ``funnel_url`` is deprecated and ignored. It used to send completions, with the WAVE API key, + straight to a proxy that does not accept WAVE API keys. Every completion now goes through the + client's own host (``https://api.wave.online`` by default), so the key is never sent anywhere + else. + """ def __init__(self, client: WaveClient, funnel_url: str | None = None, registry_url: str | None = None, registry_key: str | None = None): self._client = client - self._funnel_url = (funnel_url or "https://inference.wave.online").rstrip("/") + if funnel_url is not None: + warnings.warn( + "InferenceAPI(funnel_url=...) is deprecated and ignored: completions go through the " + f"API gateway ({_COMPLETIONS_PATH}), which accepts your WAVE API key.", + DeprecationWarning, + stacklevel=2, + ) self._registry_url = (registry_url or "").rstrip("/") self._registry_key = registry_key or "" def complete(self, model: str, messages: list[InferenceMessage | dict[str, Any]], max_tokens: int = 1024) -> InferenceResult: - """One completion through the measured funnel. Raises WaveError on HTTP errors.""" + """One completion. ``POST /v1/inference/chat/completions``; raises WaveError on HTTP errors.""" msgs = [m.model_dump() if isinstance(m, InferenceMessage) else m for m in messages] - response = httpx.post( - f"{self._funnel_url}/v1/chat/completions", - headers={"content-type": "application/json", "authorization": f"Bearer {self._client.api_key}"}, - json={"model": model, "messages": msgs, "max_tokens": max_tokens}, - timeout=120.0, - ) - if not response.is_success: - raise WaveError(f"inference {response.status_code}: {response.text[:300]}", "INFERENCE_ERROR", response.status_code) - data = response.json() + body = {"model": model, "messages": msgs, "max_tokens": max_tokens} + # Sent once: a completion is billed per token, and a retry after the gateway accepted it + # (a timeout, a bare 5xx) would bill it twice. The caller decides whether to retry. + data = self._client.post(_COMPLETIONS_PATH, json=body, timeout=120.0, no_retry=True) + choices = data.get("choices") if isinstance(data, dict) else None + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + raise WaveError( + f"inference: {_COMPLETIONS_PATH} answered 2xx without any choices", + "INVALID_RESPONSE", + 200, + ) usage = data.get("usage") or {} - choices = data.get("choices") or [{}] return InferenceResult( model=data.get("model") or model, content=(choices[0].get("message") or {}).get("content") or "", @@ -96,18 +119,34 @@ def complete(self, model: str, messages: list[InferenceMessage | dict[str, Any]] ) def models(self) -> list[InferenceModel]: - """Models admitted to the registry with their per-token pricing.""" - rows = self._registry_get("/rest/v1/models?select=id,rail,cost_input_per_m,cost_output_per_m&limit=1000") - return [InferenceModel(id=r["id"], rail=r["rail"], input_per_m=r.get("cost_input_per_m"), output_per_m=r.get("cost_output_per_m")) for r in rows] + """Models the gateway will dispatch to. ``GET /v1/inference/models``.""" + data = self._client.get(_MODELS_PATH) + rows = data.get("data") if isinstance(data, dict) else None + if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows): + raise WaveError( + f"inference: {_MODELS_PATH} answered 2xx without a model list", + "INVALID_RESPONSE", + 200, + ) + return [InferenceModel(**r) for r in rows] def profile(self, model_id: str) -> ModelProfile: - """A model's measured profile: the transition signature + pricing + live usage.""" - rows = self._registry_get(f"/rest/v1/models?select=*&id=eq.{model_id}") + """Deprecated. A model's measured profile read straight from the registry; requires + ``registry_url`` and ``registry_key``.""" + warnings.warn( + "InferenceAPI.profile() reads the model registry directly and is deprecated; " + "use models() for the gateway's model list.", + DeprecationWarning, + stacklevel=2, + ) + # Encoded so a model id cannot add filters of its own to the registry query. + mid = quote(model_id, safe="") + rows = self._registry_get(f"/rest/v1/models?select=*&id=eq.{mid}") if not rows: raise WaveError(f"model {model_id}: NOT ADMITTED", "MODEL_NOT_FOUND", 404) row = rows[0] health = row.get("health") or {} - usage = self._registry_get(f"/rest/v1/usage_logs?select=cost,latency_ms&model_id=eq.{model_id}&limit=1000") + usage = self._registry_get(f"/rest/v1/usage_logs?select=cost,latency_ms&model_id=eq.{mid}&limit=1000") latencies = [float(u["latency_ms"]) for u in usage if u.get("latency_ms") is not None and float(u["latency_ms"]) > 0] return ModelProfile( id=row["id"], @@ -124,7 +163,7 @@ def profile(self, model_id: str) -> ModelProfile: def _registry_get(self, path: str) -> list[dict[str, Any]]: if not self._registry_url: - raise WaveError("InferenceAPI: registry_url is required for models()/profile()", "REGISTRY_UNCONFIGURED", 0) + raise WaveError("InferenceAPI: registry_url is required for profile()", "REGISTRY_UNCONFIGURED", 0) response = httpx.get(f"{self._registry_url}{path}", headers={"apikey": self._registry_key}, timeout=20.0) if not response.is_success: raise WaveError(f"registry {response.status_code}: {response.text[:200]}", "REGISTRY_ERROR", response.status_code) diff --git a/wave_sdk/mesh.py b/wave_sdk/mesh.py index 221a5a7..049fb7a 100644 --- a/wave_sdk/mesh.py +++ b/wave_sdk/mesh.py @@ -1,11 +1,36 @@ -"""WAVE SDK - Mesh API. Multi-region infrastructure failover.""" +"""WAVE SDK - Mesh API. The WAVE mesh overlay's control plane at ``/v1/mesh``. + +Every ``/v1/mesh`` request must carry an ``x-wave-node`` header: the name of the node the call +is made for (a target selector the server scopes to your organization, not a credential). Set it +once with ``client.mesh.node = "studio-a"`` (or ``MeshAPI(client, node=...)``), or pass ``node=`` +to any method for one call. Every method, reads and mutations alike, builds the header before it +sends anything: a call without a node raises ``ValueError`` locally instead of a 400 from the +server, and so does a node name containing a line break. A per-call ``node=`` always wins over the +client default, so an empty or blank ``node=""`` raises rather than silently using the default. + +``list_peers()`` (``GET /v1/mesh/peers``) is served. The region / policy / replication / +topology methods predate the current mesh API and are answered 404 by it today; they raise +:class:`~wave_sdk.client.WaveError` with the server's code. + +The mutations (``add_peer``, ``remove_peer``, ``create_policy``, ``trigger_failover``) are sent +once: a retry after the server applied the change would add a second peer or policy, or fail +over twice. +""" from __future__ import annotations from typing import Any from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment + +NODE_HEADER = "x-wave-node" + +_seg = path_segment + + +def _clean(params: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in params.items() if v is not None} class MeshRegion(BaseModel): @@ -15,15 +40,74 @@ class FailoverPolicy(BaseModel): id: str; organization_id: str; name: str; strategy: str; primary_region: str; fallback_regions: list[str]; auto_failback: bool = True; created_at: str; updated_at: str class MeshAPI: - def __init__(self, client: WaveClient): self._client = client; self._base = "/v1/mesh" - def list_regions(self, **params: Any) -> dict: return self._client.get(f"{self._base}/regions", params={k: v for k, v in params.items() if v is not None}) - def get_region(self, region_id: str) -> MeshRegion: return MeshRegion(**self._client.get(f"{self._base}/regions/{region_id}")) - def get_region_health(self, region_id: str) -> dict: return self._client.get(f"{self._base}/regions/{region_id}/health") - def list_peers(self, region_id: str | None = None) -> list[dict]: path = f"{self._base}/regions/{region_id}/peers" if region_id else f"{self._base}/peers"; return self._client.get(path) - def add_peer(self, region_id: str, endpoint: str) -> dict: return self._client.post(f"{self._base}/regions/{region_id}/peers", json={"endpoint": endpoint}) - def remove_peer(self, peer_id: str) -> None: self._client.delete(f"{self._base}/peers/{peer_id}") - def create_policy(self, name: str, strategy: str, primary_region: str, fallback_regions: list[str], **kwargs: Any) -> FailoverPolicy: return FailoverPolicy(**self._client.post(f"{self._base}/policies", json={"name": name, "strategy": strategy, "primary_region": primary_region, "fallback_regions": fallback_regions, **kwargs})) - def list_policies(self, **params: Any) -> dict: return self._client.get(f"{self._base}/policies", params={k: v for k, v in params.items() if v is not None}) - def trigger_failover(self, policy_id: str, target_region: str) -> dict: return self._client.post(f"{self._base}/policies/{policy_id}/failover", json={"target_region": target_region}) - def get_replication_status(self) -> list[dict]: return self._client.get(f"{self._base}/replication") - def get_topology(self) -> dict: return self._client.get(f"{self._base}/topology") + def __init__(self, client: WaveClient, node: str | None = None): + self._client = client + self._base = "/v1/mesh" + self.node = node + + def _headers(self, node: str | None = None) -> dict[str, str]: + # Fall back to the client default only when no override was passed. An explicit override + # that is empty (an unset config value, say) is an error, not a request to use whatever + # node the client happens to default to: that would read from or mutate the wrong node. + name = self.node if node is None else node + if name is None or (isinstance(name, str) and not name.strip()): + raise ValueError( + "WAVE mesh: every /v1/mesh request needs a non-empty x-wave-node target; " + "set client.mesh.node = '' or pass node=''" + ) + if not isinstance(name, str) or "\r" in name or "\n" in name: + raise ValueError("WAVE mesh: a node name must be a single-line string") + return {NODE_HEADER: name} + + def _get(self, path: str, params: dict[str, Any] | None = None, node: str | None = None) -> Any: + return self._client.get(path, params=params, headers=self._headers(node)) + + def _post(self, path: str, json: dict[str, Any] | None = None, node: str | None = None) -> Any: + return self._client.post(path, json=json, headers=self._headers(node), no_retry=True) + + def _delete(self, path: str, node: str | None = None) -> Any: + return self._client.delete(path, headers=self._headers(node), no_retry=True) + + def list_peers(self, region_id: str | None = None, node: str | None = None) -> dict[str, Any]: + """``GET /v1/mesh/peers``: the org's mesh peers (``{"org", "count", "peers": [...]}``).""" + path = f"{self._base}/regions/{_seg(region_id)}/peers" if region_id else f"{self._base}/peers" + result: dict[str, Any] = self._get(path, node=node) + return result + + def list_regions(self, *, node: str | None = None, **params: Any) -> dict[str, Any]: + result: dict[str, Any] = self._get(f"{self._base}/regions", params=_clean(params), node=node) + return result + + def get_region(self, region_id: str, *, node: str | None = None) -> MeshRegion: + return MeshRegion(**self._get(f"{self._base}/regions/{_seg(region_id)}", node=node)) + + def get_region_health(self, region_id: str, *, node: str | None = None) -> dict[str, Any]: + result: dict[str, Any] = self._get(f"{self._base}/regions/{_seg(region_id)}/health", node=node) + return result + + def add_peer(self, region_id: str, endpoint: str, *, node: str | None = None) -> dict[str, Any]: + result: dict[str, Any] = self._post(f"{self._base}/regions/{_seg(region_id)}/peers", json={"endpoint": endpoint}, node=node) + return result + + def remove_peer(self, peer_id: str, *, node: str | None = None) -> None: + self._delete(f"{self._base}/peers/{_seg(peer_id)}", node=node) + + def create_policy(self, name: str, strategy: str, primary_region: str, fallback_regions: list[str], *, node: str | None = None, **kwargs: Any) -> FailoverPolicy: + body = {"name": name, "strategy": strategy, "primary_region": primary_region, "fallback_regions": fallback_regions, **kwargs} + return FailoverPolicy(**self._post(f"{self._base}/policies", json=body, node=node)) + + def list_policies(self, *, node: str | None = None, **params: Any) -> dict[str, Any]: + result: dict[str, Any] = self._get(f"{self._base}/policies", params=_clean(params), node=node) + return result + + def trigger_failover(self, policy_id: str, target_region: str, *, node: str | None = None) -> dict[str, Any]: + result: dict[str, Any] = self._post(f"{self._base}/policies/{_seg(policy_id)}/failover", json={"target_region": target_region}, node=node) + return result + + def get_replication_status(self, *, node: str | None = None) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = self._get(f"{self._base}/replication", node=node) + return result + + def get_topology(self, *, node: str | None = None) -> dict[str, Any]: + result: dict[str, Any] = self._get(f"{self._base}/topology", node=node) + return result diff --git a/wave_sdk/meter.py b/wave_sdk/meter.py index 7f41cd9..51705f7 100644 --- a/wave_sdk/meter.py +++ b/wave_sdk/meter.py @@ -1,66 +1,83 @@ -"""WAVE SDK - Meter API. Read-only metering surface: the ledger (per-window -rows) and rollup (aggregated totals) for the comms productization planes. +"""WAVE SDK - Meter API. Read-only metering surface for the comms productization planes: the +ledger (one metering window with per-channel counters) and the rollup (aggregated totals). Requires scope `meter:read`. Auth, scope, and entitlement are enforced server-side; the SDK only forwards the API key. + +Every model here accepts fields the SDK does not know yet (``extra="allow"``), and a channel's +``blocked`` is either a count or a reason string (for example ``"a2p-unregistered"``), because +that is what the API returns. """ from __future__ import annotations -from typing import Literal +from typing import Literal, Union from pydantic import BaseModel, ConfigDict, Field from wave_sdk.client import WaveClient +# `blocked` is a count on some channels and a reason string on others. +Blocked = Union[int, str, None] +# Amounts arrive as decimal strings ("0.05"); accept a bare number too. +Amount = Union[str, float] + + +class _MeterModel(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + -class MeterMailChannel(BaseModel): - ops: int; usdc: str; errors: int +# A counter the response leaves out is None, never 0: a missing value must not read as "no usage". -class MeterVoiceChannel(BaseModel): - minutes: float; usdc: str +class MeterMailChannel(_MeterModel): + ops: int | None = None; usdc: Amount | None = None; errors: int | None = None; blocked: Blocked = None -class MeterSmsChannel(BaseModel): - ops: int; blocked: int +class MeterVoiceChannel(_MeterModel): + minutes: float | None = None; usdc: Amount | None = None -class MeterRealtimeChannel(BaseModel): - minutes: float +class MeterSmsChannel(_MeterModel): + ops: int | None = None; blocked: Blocked = None -class MeterStorageChannel(BaseModel): - bytes: int +class MeterRealtimeChannel(_MeterModel): + minutes: float | None = None -class MeterChannels(BaseModel): - mail: MeterMailChannel; voice: MeterVoiceChannel; sms: MeterSmsChannel - realtime: MeterRealtimeChannel; storage: MeterStorageChannel +class MeterStorageChannel(_MeterModel): + bytes: int | None = None -class MeterLedgerRow(BaseModel): - model_config = ConfigDict(populate_by_name=True) +class MeterChannels(_MeterModel): + mail: MeterMailChannel | None = None; voice: MeterVoiceChannel | None = None + sms: MeterSmsChannel | None = None; realtime: MeterRealtimeChannel | None = None + storage: MeterStorageChannel | None = None + + +class MeterLedger(_MeterModel): + """``GET /v1/meter/ledger``: one metering window for the org.""" org: str from_: str = Field(alias="from") to: str channels: MeterChannels + generated_at: str -class MeterLedger(BaseModel): - rows: list[MeterLedgerRow] - generated_at: str +# Kept so existing imports resolve; the ledger is a single window, not a list of rows. +MeterLedgerRow = MeterLedger -class MeterRollupTotals(BaseModel): - mail: MeterMailChannel; voice: MeterVoiceChannel; sms: MeterSmsChannel - realtime: MeterRealtimeChannel; storage: MeterStorageChannel +class MeterRollupTotals(MeterChannels): + pass -class MeterRollup(BaseModel): - model_config = ConfigDict(populate_by_name=True) +class MeterRollup(_MeterModel): + """``GET /v1/meter/ledger/rollup``: totals aggregated over ``period``.""" org: str from_: str = Field(alias="from") to: str + period: str | None = None totals: MeterRollupTotals generated_at: str @@ -73,7 +90,7 @@ def __init__(self, client: WaveClient): self._base = "/v1/meter" def ledger(self, from_: str | None = None, to: str | None = None, channel: Literal["mail", "voice", "sms", "realtime", "storage"] | None = None) -> MeterLedger: - """Fetch ledger rows for the given time window and optional channel filter.""" + """Fetch the ledger window, optionally bounded by ``from_``/``to`` and filtered by channel.""" params = {"from": from_, "to": to, "channel": channel} return MeterLedger(**self._client.get(f"{self._base}/ledger", params={k: v for k, v in params.items() if v is not None})) diff --git a/wave_sdk/podcast.py b/wave_sdk/podcast.py index 9e25b56..822bc7c 100644 --- a/wave_sdk/podcast.py +++ b/wave_sdk/podcast.py @@ -1,30 +1,132 @@ -"""WAVE SDK - Podcast API. Podcast production, episodes, and distribution.""" +"""WAVE SDK - Podcast API. Podcast shows and episodes. + +Paths follow the published API operations: ``/v1/podcast/shows`` (``listPodcastShows``, +``createPodcastShow``) and ``/v1/podcast/shows/{showId}/episodes`` (``listPodcastEpisodes``, +``createPodcastEpisode``). The remaining methods (get/update/remove a show, RSS, analytics, +distribution, a single episode) have no published API operation: they keep the ``/v1/podcast`` +prefix the gateway maps, warn with ``DeprecationWarning`` on every call, and raise +``RouteNotServedError`` until the API publishes them. + +2.2.0 keyword names changed with the paths: ``create(title=...)`` is ``create(name=...)`` and +``podcast_id=`` is ``show_id=``. No 2.2.0 podcast call reached a served route (``/v1/podcasts`` +is not routed), so no working call changes behavior. +""" from __future__ import annotations +import warnings from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict + +from wave_sdk.client import WaveClient, path_segment + +_SHOWS = "/v1/podcast/shows" +_EPISODES = "/v1/podcast/episodes" +_seg = path_segment + + +def _clean(d: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in d.items() if v is not None} + + +def _unpublished(method: str, route: str) -> None: + warnings.warn( + f"podcast.{method}() calls {route}, which the WAVE API does not publish; it raises " + "RouteNotServedError until the API adds that operation, and may be removed", + DeprecationWarning, + stacklevel=3, + ) + + +class PodcastShow(BaseModel): + """``PodcastShow`` from the API reference; unknown fields are kept.""" + + model_config = ConfigDict(extra="allow") + id: str + name: str | None = None + description: str | None = None + coverUrl: str | None = None # noqa: N815 - wire name + rssUrl: str | None = None # noqa: N815 - wire name + category: str | None = None + language: str | None = None + + +class PodcastEpisode(BaseModel): + """``PodcastEpisode`` from the API reference; unknown fields are kept.""" -from wave_sdk.client import WaveClient + model_config = ConfigDict(extra="allow") + id: str + showId: str | None = None # noqa: N815 - wire name + title: str | None = None + description: str | None = None + audioUrl: str | None = None # noqa: N815 - wire name + duration: float | None = None + episodeNumber: int | None = None # noqa: N815 - wire name -class Podcast(BaseModel): - id: str; organization_id: str; title: str; description: str; category: str; language: str = "en"; episode_count: int = 0; subscriber_count: int = 0; rss_url: str | None = None; created_at: str; updated_at: str +# Names used by earlier releases. +Podcast = PodcastShow +Episode = PodcastEpisode -class Episode(BaseModel): - id: str; podcast_id: str; title: str; description: str; status: str; audio_url: str | None = None; duration_seconds: float = 0; published_at: str | None = None; created_at: str; updated_at: str class PodcastAPI: - def __init__(self, client: WaveClient): self._client = client; self._base = "/v1/podcasts" - def create(self, title: str, description: str, category: str, **kwargs: Any) -> Podcast: return Podcast(**self._client.post(self._base, json={"title": title, "description": description, "category": category, **kwargs})) - def get(self, podcast_id: str) -> Podcast: return Podcast(**self._client.get(f"{self._base}/{podcast_id}")) - def update(self, podcast_id: str, **kwargs: Any) -> Podcast: return Podcast(**self._client.patch(f"{self._base}/{podcast_id}", json=kwargs)) - def remove(self, podcast_id: str) -> None: self._client.delete(f"{self._base}/{podcast_id}") - def list(self, **params: Any) -> dict: return self._client.get(self._base, params={k: v for k, v in params.items() if v is not None}) - def create_episode(self, podcast_id: str, title: str, description: str, **kwargs: Any) -> Episode: return Episode(**self._client.post(f"{self._base}/{podcast_id}/episodes", json={"title": title, "description": description, **kwargs})) - def get_episode(self, episode_id: str) -> Episode: return Episode(**self._client.get(f"/v1/episodes/{episode_id}")) - def publish_episode(self, episode_id: str) -> Episode: return Episode(**self._client.post(f"/v1/episodes/{episode_id}/publish")) - def list_episodes(self, podcast_id: str, **params: Any) -> dict: return self._client.get(f"{self._base}/{podcast_id}/episodes", params={k: v for k, v in params.items() if v is not None}) - def get_rss_feed(self, podcast_id: str) -> dict: return self._client.get(f"{self._base}/{podcast_id}/rss") - def get_analytics(self, podcast_id: str, **params: Any) -> dict: return self._client.get(f"{self._base}/{podcast_id}/analytics", params={k: v for k, v in params.items() if v is not None}) - def distribute(self, podcast_id: str, targets: list[str]) -> list[dict]: return self._client.post(f"{self._base}/{podcast_id}/distribute", json={"targets": targets}) + def __init__(self, client: WaveClient): + self._client = client + self._base = _SHOWS + + def list(self, **params: Any) -> dict: + """``GET /v1/podcast/shows`` (paginated: ``page``, ``perPage``).""" + result: dict = self._client.get(_SHOWS, params=_clean(params)) + return result + + def create(self, name: str, description: str | None = None, category: str | None = None, *, language: str | None = None, explicit: bool | None = None, cover_url: str | None = None, **kwargs: Any) -> PodcastShow: + """``POST /v1/podcast/shows``.""" + body = {"name": name, "description": description, "category": category, "language": language, "explicit": explicit, "coverUrl": cover_url, **kwargs} + # Sent once: a retry after the API created the show would create a duplicate. + return PodcastShow(**self._client.post(_SHOWS, json=_clean(body), no_retry=True)) + + def list_episodes(self, show_id: str, **params: Any) -> dict: + """``GET /v1/podcast/shows/{showId}/episodes`` (paginated: ``page``, ``perPage``).""" + result: dict = self._client.get(f"{_SHOWS}/{_seg(show_id)}/episodes", params=_clean(params)) + return result + + def create_episode(self, show_id: str, title: str, description: str | None = None, *, audio_url: str, episode_number: int | None = None, season_number: int | None = None, published_at: str | None = None, **kwargs: Any) -> PodcastEpisode: + """``POST /v1/podcast/shows/{showId}/episodes``. ``audio_url`` is required by the API.""" + body = {"title": title, "description": description, "audioUrl": audio_url, "episodeNumber": episode_number, "seasonNumber": season_number, "publishedAt": published_at, **kwargs} + # Sent once: a retry after the API created the episode would create a duplicate. + return PodcastEpisode(**self._client.post(f"{_SHOWS}/{_seg(show_id)}/episodes", json=_clean(body), no_retry=True)) + + # --- No published API operation: each warns, and the API answers RouteNotServedError today. -- + def get(self, show_id: str) -> PodcastShow: + _unpublished("get", "GET /v1/podcast/shows/{id}") + return PodcastShow(**self._client.get(f"{_SHOWS}/{_seg(show_id)}")) + + def update(self, show_id: str, **kwargs: Any) -> PodcastShow: + _unpublished("update", "PATCH /v1/podcast/shows/{id}") + return PodcastShow(**self._client.patch(f"{_SHOWS}/{_seg(show_id)}", json=kwargs)) + + def remove(self, show_id: str) -> None: + _unpublished("remove", "DELETE /v1/podcast/shows/{id}") + self._client.delete(f"{_SHOWS}/{_seg(show_id)}") + + def get_episode(self, episode_id: str) -> PodcastEpisode: + _unpublished("get_episode", "GET /v1/podcast/episodes/{id}") + return PodcastEpisode(**self._client.get(f"{_EPISODES}/{_seg(episode_id)}")) + + def publish_episode(self, episode_id: str) -> PodcastEpisode: + _unpublished("publish_episode", "POST /v1/podcast/episodes/{id}/publish") + return PodcastEpisode(**self._client.post(f"{_EPISODES}/{_seg(episode_id)}/publish")) + + def get_rss_feed(self, show_id: str) -> dict: + _unpublished("get_rss_feed", "GET /v1/podcast/shows/{id}/rss") + result: dict = self._client.get(f"{_SHOWS}/{_seg(show_id)}/rss") + return result + + def get_analytics(self, show_id: str, **params: Any) -> dict: + _unpublished("get_analytics", "GET /v1/podcast/shows/{id}/analytics") + result: dict = self._client.get(f"{_SHOWS}/{_seg(show_id)}/analytics", params=_clean(params)) + return result + + def distribute(self, show_id: str, targets: list[str]) -> list[dict]: + _unpublished("distribute", "POST /v1/podcast/shows/{id}/distribute") + return self._client.post(f"{_SHOWS}/{_seg(show_id)}/distribute", json={"targets": targets}) # type: ignore[no-any-return] diff --git a/wave_sdk/pulse.py b/wave_sdk/pulse.py index 34f538d..3533db3 100644 --- a/wave_sdk/pulse.py +++ b/wave_sdk/pulse.py @@ -1,4 +1,13 @@ -"""WAVE SDK - Pulse Analytics API. Streaming analytics and BI.""" +"""WAVE SDK - Pulse Analytics API. Streaming analytics and BI. + +The API serves three account-level analytics reads (scope ``analytics:read``): +``get_overview()`` (``GET /v1/analytics/overview``), ``get_top_content()`` +(``GET /v1/analytics/top-content``) and ``get_engagement_metrics()`` +(``GET /v1/analytics/engagement``). ``from_``/``to`` are ISO-8601 date-times. + +The other methods predate the current analytics API and are answered 404 by it today; they raise +:class:`~wave_sdk.client.RouteNotServedError`. +""" from __future__ import annotations from typing import Any @@ -6,12 +15,27 @@ from wave_sdk.client import WaveClient +def _window(from_: str | None, to: str | None, **extra: Any) -> dict[str, Any]: + return {k: v for k, v in {"from": from_, "to": to, **extra}.items() if v is not None} + + class PulseAPI: def __init__(self, client: WaveClient): self._client = client; self._base = "/v1/analytics" + def get_overview(self, from_: str | None = None, to: str | None = None) -> dict: + """``GET /v1/analytics/overview``: account-level totals for the window.""" + result: dict = self._client.get(f"{self._base}/overview", params=_window(from_, to)) + return result + def get_top_content(self, from_: str | None = None, to: str | None = None, limit: int | None = None) -> dict: + """``GET /v1/analytics/top-content``: top content by usage (``limit`` 1-100).""" + result: dict = self._client.get(f"{self._base}/top-content", params=_window(from_, to, limit=limit)) + return result + def get_engagement_metrics(self, from_: str | None = None, to: str | None = None, **params: Any) -> dict: + """``GET /v1/analytics/engagement``: account-wide engagement for the window.""" + result: dict = self._client.get(f"{self._base}/engagement", params=_window(from_, to, **params)) + return result def get_stream_analytics(self, stream_id: str, **params: Any) -> dict: return self._client.get(f"{self._base}/streams/{stream_id}", params={k: v for k, v in params.items() if v is not None}) def get_viewer_analytics(self, **params: Any) -> dict: return self._client.get(f"{self._base}/viewers", params={k: v for k, v in params.items() if v is not None}) def get_quality_metrics(self, **params: Any) -> dict: return self._client.get(f"{self._base}/quality", params={k: v for k, v in params.items() if v is not None}) - def get_engagement_metrics(self, **params: Any) -> dict: return self._client.get(f"{self._base}/engagement", params={k: v for k, v in params.items() if v is not None}) def get_revenue_metrics(self, **params: Any) -> dict: return self._client.get(f"{self._base}/revenue", params={k: v for k, v in params.items() if v is not None}) def get_timeseries(self, metric: str, **params: Any) -> list[dict]: return self._client.get(f"{self._base}/timeseries/{metric}", params={k: v for k, v in params.items() if v is not None}) def create_report(self, name: str, type: str, time_range: str, format: str = "json") -> dict: return self._client.post(f"{self._base}/reports", json={"name": name, "type": type, "time_range": time_range, "format": format}) diff --git a/wave_sdk/realtime.py b/wave_sdk/realtime.py index 363b1ca..341ae77 100644 --- a/wave_sdk/realtime.py +++ b/wave_sdk/realtime.py @@ -1,12 +1,21 @@ """WAVE SDK - Realtime API. -The WAVE Realtime control & event plane (realtime.wave.online): presence, pub/sub broadcast, and the -streaming-event bus the WAVE AI products push into. Subscribe once to a channel and receive live -transcription / captions / sentiment / clip / stream events with no polling. +The WAVE Realtime control & event plane: presence, pub/sub broadcast, and the streaming-event bus +the WAVE AI products push into. Subscribe once to a channel and receive live transcription / +captions / sentiment / clip / stream events with no polling. + +Everything goes through the API host (``https://api.wave.online``): + +* REST: ``POST /v1/realtime/channels/{channel}/publish``, ``GET .../presence``, ``GET .../history`` + (sent through :class:`~wave_sdk.client.WaveClient`, so they carry ``X-Organization-Id`` and + raise :class:`~wave_sdk.client.WaveError` on failure like every other namespace); +* WebSocket: ``wss://api.wave.online/v1/realtime/connect?channel=...``. WebSocket support uses the optional ``websocket-client`` package: ``pip install 'wave-sdk[realtime]'``. -Auth, scope, entitlement, and metering are enforced server-side (the gateway, via realtime's /v1/verify -federation) — the SDK only forwards your API key. +Credentials travel only in the ``Authorization`` header, on the REST calls and on the WebSocket +upgrade alike. ``websocket-client`` sets arbitrary upgrade headers, so the browser constraint that +forces a ``?access_token=`` query parameter does not apply here, and the SDK never puts the key in +a URL (a URL is recorded by every proxy and edge log that sees the request line). """ from __future__ import annotations @@ -14,22 +23,71 @@ import json from collections.abc import Iterator from typing import Any, Callable +from urllib.parse import urlencode, urlsplit import httpx -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, WaveError, __version__, error_from_response, path_segment -_DEFAULT_WS = "wss://realtime.wave.online" +_DEFAULT_WS = "wss://api.wave.online" +_CONNECT_PATH = "/v1/realtime/connect" +_CHANNELS_PATH = "/v1/realtime/channels" -def _http_origin(ws_url: str) -> str: - """Derive the https REST origin from a (ws/wss) base URL.""" - base = ws_url.rstrip("/") - if base.startswith("ws"): - return "http" + base[2:] +def _ws_origin(http_url: str) -> str: + """Derive the ws(s) origin from an http(s) base URL (``https://api.wave.online`` -> ``wss://...``).""" + base = http_url.rstrip("/") + if base.startswith("https://"): + return "wss://" + base[len("https://"):] + if base.startswith("http://"): + return "ws://" + base[len("http://"):] return base +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def _require_tls(ws_base: str) -> None: + """The upgrade carries the API key, so it must be encrypted: ``wss://``, or ``ws://`` to a + loopback host only (a local development server).""" + parts = urlsplit(ws_base) + if parts.scheme == "wss": + return + if parts.scheme == "ws" and (parts.hostname or "") in _LOOPBACK_HOSTS: + return + raise ValueError( + f"WAVE realtime: refusing to send the API key to {parts.scheme or '?'}://{parts.hostname or ws_base}; " + "use a wss:// origin (ws:// is allowed only for localhost)" + ) + + +def _channel_path(channel: str) -> str: + """Percent-encode a channel for use as a single REST path segment. + + ``:`` stays literal because WAVE channel names are ``stream:abc`` shaped (the API answers + 404 for ``stream%3Aabc``); everything else that could leave the segment (``/``, ``?``, ``#``, + ``&``) is encoded, and an empty or ``.``/``..`` channel raises ``ValueError``. + """ + return path_segment(channel) + + +def _handshake_error(exc: Exception) -> WaveError | None: + """Turn a rejected WebSocket upgrade (websocket-client's WebSocketBadStatusException) into the + same WaveError subclass the REST path raises for that status and body.""" + status = getattr(exc, "status_code", None) + if not isinstance(status, int): + return None + body = getattr(exc, "resp_body", None) or b"" + if isinstance(body, str): + body = body.encode() + headers = getattr(exc, "resp_headers", None) or {} + try: + response = httpx.Response(status, content=body, headers=dict(headers)) + except Exception: # pragma: no cover - defensive: malformed handshake headers + response = httpx.Response(status, content=body) + return error_from_response(response) + + class RealtimeChannel: """One subscribed channel over a WebSocket. @@ -40,7 +98,14 @@ class RealtimeChannel: ch.run() # blocks, dispatching frames """ - def __init__(self, channel: str, api_key: str, ws_base: str = _DEFAULT_WS, as_: str | None = None): + def __init__( + self, + channel: str, + api_key: str, + ws_base: str = _DEFAULT_WS, + as_: str | None = None, + organization_id: str | None = None, + ): try: import websocket # websocket-client (optional dep) except ImportError as e: # pragma: no cover - import guard @@ -48,14 +113,29 @@ def __init__(self, channel: str, api_key: str, ws_base: str = _DEFAULT_WS, as_: "WAVE realtime requires the 'websocket-client' package: pip install 'wave-sdk[realtime]'" ) from e self.channel = channel - # Browser/SDK clients can't set headers on the WS upgrade → key travels as a query param (wss). - url = f"{ws_base.rstrip('/')}/v1/connect?channel={channel}&access_token={api_key}" + + # Every value is urlencoded: a channel containing '&' or '#' would otherwise inject or + # truncate query parameters on the upgrade. + params: dict[str, str] = {"channel": channel} if as_: - url += f"&as={as_}" - self._ws = websocket.create_connection(url) + params["as"] = as_ + + headers = [f"Authorization: Bearer {api_key}", f"User-Agent: wave-sdk-python/{__version__}"] + if organization_id: + headers.append(f"X-Organization-Id: {organization_id}") + + _require_tls(ws_base) + url = f"{ws_base.rstrip('/')}{_CONNECT_PATH}?{urlencode(params)}" + try: + self._ws = websocket.create_connection(url, header=headers) + except Exception as e: + error = _handshake_error(e) + if error is not None: + raise error from e + raise self._handlers: dict[str, list[Callable[[Any], None]]] = {} - def __iter__(self) -> Iterator[dict]: + def __iter__(self) -> Iterator[dict[str, Any]]: try: while True: raw = self._ws.recv() @@ -97,35 +177,46 @@ def close(self) -> None: class RealtimeAPI: """Realtime entry point. ``wave.realtime.connect('stream:abc')`` for WS; ``publish/presence/history`` - are one-shot REST calls for producers that don't hold a socket.""" + are one-shot REST calls for producers that don't hold a socket. - def __init__(self, client: WaveClient, url: str = _DEFAULT_WS): + ``url`` overrides the WebSocket origin (default: the client's ``base_url`` with ``https`` + swapped for ``wss``); the connect path ``/v1/realtime/connect`` is appended to it. + """ + + def __init__(self, client: WaveClient, url: str | None = None): + self._client = client self._api_key = client.api_key - self._ws_base = url.rstrip("/") - self._http_base = _http_origin(self._ws_base) + # Multi-tenant isolation: WaveClient stamps X-Organization-Id on every REST call, so the + # WebSocket upgrade carries it too. + self._organization_id = client.organization_id + self._ws_base = (url or _ws_origin(client.base_url)).rstrip("/") def connect(self, channel: str, as_: str | None = None) -> RealtimeChannel: - return RealtimeChannel(channel, self._api_key, self._ws_base, as_) - - def _headers(self) -> dict[str, str]: - return {"Authorization": f"Bearer {self._api_key}", "content-type": "application/json"} + """Open ``wss://…/v1/realtime/connect?channel=…``. A rejected upgrade raises the same + :class:`~wave_sdk.client.WaveError` subclass a REST call would for that status and body.""" + return RealtimeChannel( + channel, self._api_key, self._ws_base, as_, organization_id=self._organization_id + ) - def publish(self, channel: str, event: str, data: Any = None) -> dict: - r = httpx.post( - f"{self._http_base}/v1/channels/{channel}/publish", - headers=self._headers(), + def publish(self, channel: str, event: str, data: Any = None) -> dict[str, Any]: + """``POST /v1/realtime/channels/{channel}/publish`` (scope ``realtime:write``).""" + # Sent once: a publish the API accepted before a timeout or bare 5xx would otherwise be + # broadcast twice. The caller decides whether to retry. + result: dict[str, Any] = self._client.post( + f"{_CHANNELS_PATH}/{_channel_path(channel)}/publish", json={"event": event, "data": data}, + no_retry=True, ) - return r.json() + return result - def presence(self, channel: str) -> dict: - r = httpx.get(f"{self._http_base}/v1/channels/{channel}/presence", headers=self._headers()) - return r.json() + def presence(self, channel: str) -> dict[str, Any]: + """``GET /v1/realtime/channels/{channel}/presence``.""" + result: dict[str, Any] = self._client.get(f"{_CHANNELS_PATH}/{_channel_path(channel)}/presence") + return result - def history(self, channel: str, limit: int = 50) -> dict: - r = httpx.get( - f"{self._http_base}/v1/channels/{channel}/history", - headers=self._headers(), - params={"limit": limit}, + def history(self, channel: str, limit: int = 50) -> dict[str, Any]: + """``GET /v1/realtime/channels/{channel}/history`` (the API caps ``limit`` at 50).""" + result: dict[str, Any] = self._client.get( + f"{_CHANNELS_PATH}/{_channel_path(channel)}/history", params={"limit": limit} ) - return r.json() + return result diff --git a/wave_sdk/sentiment.py b/wave_sdk/sentiment.py index 6df80ab..ba781ad 100644 --- a/wave_sdk/sentiment.py +++ b/wave_sdk/sentiment.py @@ -15,7 +15,11 @@ class SentimentAnalysis(BaseModel): class SentimentAPI: def __init__(self, client: WaveClient): self._client = client; self._base = "/v1/sentiment" def analyze(self, asset_id: str, **kwargs: Any) -> SentimentAnalysis: return SentimentAnalysis(**self._client.post(self._base, json={"asset_id": asset_id, **kwargs})) - def analyze_text(self, text: str, **kwargs: Any) -> dict: return self._client.post(f"{self._base}/text", json={"text": text, **kwargs}) + def analyze_text(self, text: str, include_emotions: bool | None = None, include_topics: bool | None = None, **kwargs: Any) -> dict: + """``POST /v1/sentiment/analyze``: score a piece of text directly.""" + body = {"text": text, "includeEmotions": include_emotions, "includeTopics": include_topics, **kwargs} + result: dict = self._client.post(f"{self._base}/analyze", json={k: v for k, v in body.items() if v is not None}) + return result def get(self, analysis_id: str) -> SentimentAnalysis: return SentimentAnalysis(**self._client.get(f"{self._base}/{analysis_id}")) def list(self, **params: Any) -> dict: return self._client.get(self._base, params={k: v for k, v in params.items() if v is not None}) def remove(self, analysis_id: str) -> None: self._client.delete(f"{self._base}/{analysis_id}") diff --git a/wave_sdk/usage.py b/wave_sdk/usage.py new file mode 100644 index 0000000..67943e4 --- /dev/null +++ b/wave_sdk/usage.py @@ -0,0 +1,57 @@ +"""WAVE SDK - Usage API. ``GET /v1/usage``: your organization's own metered totals per dimension. + +It needs no resource to exist first, so it is the quickest end-to-end check that a key works:: + + report = client.usage.get() + print(report.org, report.range.days, report.totals.get("wave_search_queries")) + +``from_`` and ``to`` are days, as ``YYYYMMDD`` strings or ``datetime.date`` values; both default +to today (UTC) on the server. +""" +from __future__ import annotations + +from datetime import date +from typing import Union + +from pydantic import BaseModel, ConfigDict, Field + +from wave_sdk.client import WaveClient + +Day = Union[str, date] + + +class UsageRange(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + from_: str = Field(alias="from") + to: str + days: int + + +class UsageReport(BaseModel): + """``totals`` holds raw counters per dimension; ``billable`` the same dimensions in billed units.""" + + model_config = ConfigDict(extra="allow") + org: str + range: UsageRange + # A counter the server has no value for arrives as null; one missing counter must not make + # the whole report unreadable (the meter models failed that way in 2.2.0). + totals: dict[str, float | None] + billable: dict[str, float | None] | None = None + + +def _day(value: Day | None) -> str | None: + if value is None: + return None + if isinstance(value, date): + return value.strftime("%Y%m%d") + return value.replace("-", "") + + +class UsageAPI: + def __init__(self, client: WaveClient): + self._client = client + + def get(self, from_: Day | None = None, to: Day | None = None) -> UsageReport: + """``GET /v1/usage?from=YYYYMMDD&to=YYYYMMDD``.""" + params = {"from": _day(from_), "to": _day(to)} + return UsageReport(**self._client.get("/v1/usage", params={k: v for k, v in params.items() if v is not None})) diff --git a/wave_sdk/voice.py b/wave_sdk/voice.py index fa7d02d..ba14886 100644 --- a/wave_sdk/voice.py +++ b/wave_sdk/voice.py @@ -2,8 +2,10 @@ from __future__ import annotations import time +import warnings from typing import Any +import httpx from pydantic import BaseModel from wave_sdk.client import WaveClient @@ -26,7 +28,41 @@ def remove_voice(self, voice_id: str) -> None: self._client.delete(f"{self._base def get_voice_settings(self, voice_id: str) -> dict: return self._client.get(f"{self._base}/voices/{voice_id}/settings") def update_voice_settings(self, voice_id: str, **kwargs: Any) -> dict: return self._client.patch(f"{self._base}/voices/{voice_id}/settings", json=kwargs) def get_supported_languages(self) -> list[dict]: return self._client.get(f"{self._base}/languages") - def synthesize(self, text: str, voice_id: str, format: str = "mp3", **kwargs: Any) -> SynthesisResult: return SynthesisResult(**self._client.post(f"{self._base}/synthesize", json={"text": text, "voice_id": voice_id, "format": format, **kwargs})) + def generate( + self, + text: str, + voice_id: str, + *, + model: str | None = None, + output_format: str | None = None, + stability: float | None = None, + similarity_boost: float | None = None, + style: float | None = None, + timestamps: bool | None = None, + ) -> dict[str, Any] | bytes: + """Text to speech. ``POST /v1/voice/generate``. + + Returns the JSON body (a generation record, or inline audio as base64) when the API answers + ``application/json``, and the raw audio bytes when it answers with audio. + """ + body = { + "text": text, "voiceId": voice_id, "model": model, "outputFormat": output_format, + "stability": stability, "similarityBoost": similarity_boost, "style": style, + "timestamps": timestamps, + } + # Sent once (billed): a retry after the API accepted the request would bill it twice. + response: httpx.Response = self._client.post( + f"{self._base}/generate", json={k: v for k, v in body.items() if v is not None}, + raw=True, no_retry=True, + ) + if response.headers.get("content-type", "").startswith("application/json"): + result: dict[str, Any] = response.json() + return result + return response.content + def synthesize(self, text: str, voice_id: str, format: str = "mp3", **kwargs: Any) -> SynthesisResult: + """Deprecated: calls ``/v1/voice/synthesize``, which the API does not serve. Use :meth:`generate`.""" + warnings.warn("voice.synthesize() calls a route the API does not serve; use voice.generate(text, voice_id)", DeprecationWarning, stacklevel=2) + return SynthesisResult(**self._client.post(f"{self._base}/synthesize", json={"text": text, "voice_id": voice_id, "format": format, **kwargs})) def synthesize_stream(self, text: str, voice_id: str) -> dict: return self._client.post(f"{self._base}/synthesize/stream", json={"text": text, "voice_id": voice_id}) def get_synthesis(self, synthesis_id: str) -> SynthesisResult: return SynthesisResult(**self._client.get(f"{self._base}/syntheses/{synthesis_id}")) def list_syntheses(self, **params: Any) -> dict: return self._client.get(f"{self._base}/syntheses", params={k: v for k, v in params.items() if v is not None})