From cb9e50887d896b704d9ebd1e5442222694deb8b0 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 04:07:29 -0400 Subject: [PATCH 1/8] fix: send every SDK call through the API gateway and keep the server's error Realtime REST and WebSocket used realtime.wave.online, which does not resolve. They now go to api.wave.online at /v1/realtime/channels/{c}/... and /v1/realtime/connect, through WaveClient (org header, WaveError on failure, channel percent-encoded) and with the key only in the Authorization header on the upgrade, never in the URL. inference.complete() posted to a LiteLLM proxy that rejects WAVE keys; it now sends POST /v1/inference/chat/completions through the client, and models() reads GET /v1/inference/models. The error parser dropped the code and message of every body whose error member is a string (spend-cap 402, x402 challenge, 405, 400). A new wave_sdk.errors module parses all three envelopes and adds PaymentRequiredError and RouteNotServedError. Retries follow the server next_action, so a permanent 503 is raised on the first attempt. Meter models match the live window (blocked may be a reason string). Podcast, sentiment.analyze_text, clips, captions, chapters, editor, collab and voice gain methods on the published paths; the old names warn. mesh sends x-wave-node. usage.get() wraps GET /v1/usage and pulse gains get_overview() and get_top_content(). Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 70 +++++ tests/fixtures/live_responses.json | 410 +++++++++++++++++++++++++++++ tests/test_errors.py | 183 +++++++++++++ tests/test_parity_apis.py | 85 +++--- tests/test_realtime.py | 166 ++++++++++++ tests/test_sdk_exports.py | 18 +- tests/test_served_routes.py | 245 +++++++++++++++++ wave_sdk/__init__.py | 21 +- wave_sdk/captions.py | 16 +- wave_sdk/chapters.py | 43 ++- wave_sdk/client.py | 141 +++++----- wave_sdk/clips.py | 38 ++- wave_sdk/collab.py | 11 +- wave_sdk/editor.py | 14 +- wave_sdk/errors.py | 246 +++++++++++++++++ wave_sdk/inference.py | 102 ++++--- wave_sdk/mesh.py | 62 ++++- wave_sdk/meter.py | 68 +++-- wave_sdk/podcast.py | 102 +++++-- wave_sdk/pulse.py | 28 +- wave_sdk/realtime.py | 151 ++++++++--- wave_sdk/sentiment.py | 6 +- wave_sdk/usage.py | 57 ++++ wave_sdk/voice.py | 36 ++- 24 files changed, 2043 insertions(+), 276 deletions(-) create mode 100644 tests/fixtures/live_responses.json create mode 100644 tests/test_errors.py create mode 100644 tests/test_realtime.py create mode 100644 tests/test_served_routes.py create mode 100644 wave_sdk/errors.py create mode 100644 wave_sdk/usage.py 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/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..77fed0d --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,183 @@ +"""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 + + +# --- 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_parity_apis.py b/tests/test_parity_apis.py index e7f64b4..0164353 100644 --- a/tests/test_parity_apis.py +++ b/tests/test_parity_apis.py @@ -112,16 +112,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 +212,65 @@ 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, + ) assert result.model == "claude-haiku" assert result.content == "hi there" assert result.total_tokens == 12 -def test_inference_complete_raises_on_error(monkeypatch): +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_legacy_funnel_url_is_deprecated(monkeypatch): class FakeClient: api_key = "test-key" def fake_post(url, headers=None, json=None, timeout=None): - return httpx.Response(500, text="funnel down") + assert url == "https://funnel.example.com/v1/chat/completions" + return httpx.Response(500, text="down") monkeypatch.setattr("wave_sdk.inference.httpx.post", fake_post) - api = InferenceAPI(FakeClient()) + with pytest.warns(DeprecationWarning, match="funnel_url"): + api = InferenceAPI(FakeClient(), funnel_url="https://funnel.example.com") from wave_sdk.client import WaveError with pytest.raises(WaveError): api.complete("claude-haiku", [{"role": "user", "content": "hi"}]) -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_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_models_with_registry(monkeypatch): +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..29086cf --- /dev/null +++ b/tests/test_realtime.py @@ -0,0 +1,166 @@ +"""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" + + +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..0806bce 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(): diff --git a/tests/test_served_routes.py b/tests/test_served_routes.py new file mode 100644 index 0000000..3482c72 --- /dev/null +++ b/tests/test_served_routes.py @@ -0,0 +1,245 @@ +"""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 + + +# --- 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" + + +# --- mesh ---------------------------------------------------------------------------------------- + + +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" + + +# --- 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): + wave.captions.download("job_1", language="en", format="vtt") + assert sent(recorder) == ("GET", "/v1/captions/job_1/download?language=en&format=vtt") + + +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") + wave.chapters.get_detection_job("rec:1", "job/1") + assert sent(recorder) == ("GET", "/v1/videos/rec:1/chapters/detect/job%2F1") + + +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"), + ], +) +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) + + +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") + + +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..83e18ec 100644 --- a/wave_sdk/captions.py +++ b/wave_sdk/captions.py @@ -2,7 +2,9 @@ from __future__ import annotations import time -from typing import Any +import warnings +from typing import Any, Literal +from urllib.parse import quote from pydantic import BaseModel @@ -33,7 +35,17 @@ 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. + """ + result: dict[str, Any] = self._client.get(f"{self._base}/{quote(job_id, safe=':')}/download", params={"language": language, "format": format}) + return result + 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..4aff35e 100644 --- a/wave_sdk/chapters.py +++ b/wave_sdk/chapters.py @@ -1,14 +1,26 @@ -"""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`, :meth:`ChaptersAPI.detect` and +:meth:`ChaptersAPI.get_detection_job`. +""" from __future__ import annotations import time +import warnings from typing import Any +from urllib.parse import quote from pydantic import BaseModel from wave_sdk.client import WaveClient +def _video_path(video_id: str) -> str: + # The video id is one path segment; encode anything that could leave it. + return f"/v1/videos/{quote(video_id, safe=':')}/chapters" + + class Chapter(BaseModel): id: str; title: str; start_time: float; end_time: float; thumbnail_url: str | None = None; description: str | None = None @@ -24,8 +36,33 @@ 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} + 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}) + 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} + 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}) + return result + def get_detection_job(self, video_id: str, job_id: str) -> dict[str, Any]: + """Poll a detection job. ``GET /v1/videos/{videoId}/chapters/detect/{jobId}``.""" + result: dict[str, Any] = self._client.get(f"{_video_path(video_id)}/detect/{quote(job_id, safe='')}") + 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..fde7b36 100644 --- a/wave_sdk/client.py +++ b/wave_sdk/client.py @@ -7,12 +7,34 @@ from __future__ import annotations import logging +import random import time from typing import Any, Generic, TypeVar 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, +) + +__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 @@ -22,48 +44,8 @@ T = TypeVar("T") - -class WaveError(Exception): - """WAVE API error.""" - - 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 __str__(self) -> str: - return f"WaveError({self.code}): {self.message}" - - -class RateLimitError(WaveError): - """Rate limit exceeded error.""" - - 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 +# Longest server-suggested wait the client honours before retrying on its own. +_MAX_SERVER_RETRY_AFTER = 60.0 class PaginatedResponse(BaseModel, Generic[T]): @@ -83,7 +65,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 +174,15 @@ 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. + """ # Filter out None params if params: params = {k: v for k, v in params.items() if v is not None} @@ -217,28 +205,40 @@ def _request( # Handle rate limiting if response.status_code == 429: - retry_after = self._parse_retry_after(response) + parsed = self._parse_error(response) + retry_after = self._parse_retry_after(response, parsed) if attempt < max_retries: logger.warning(f"Rate limited. Retrying in {retry_after}s") time.sleep(retry_after) continue raise RateLimitError( - "Rate limit exceeded", + parsed.message if parsed.code != "HTTP_429" else "Rate limit exceeded", retry_after, - response.headers.get("x-request-id"), + parsed.request_id, + details=parsed.details, + next_action=parsed.next_action, + suggestions=parsed.suggestions, + doc_url=parsed.doc_url, ) # Handle errors 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") + hint = error.retry_after_hint + delay = ( + min(hint, _MAX_SERVER_RETRY_AFTER) + if hint is not None + else self._calculate_backoff(attempt) + ) + 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 +261,21 @@ 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.""" + """Parse an error response into the most specific WaveError subclass.""" + return error_from_response(response) + + def _parse_retry_after(self, response: httpx.Response, error: WaveError | None = None) -> float: + """Seconds to wait: the Retry-After header, else the body's retry_after directive, else 1s.""" retry_after = response.headers.get("retry-after") - if not retry_after: - return 1.0 - try: - return float(retry_after) - except ValueError: - return 1.0 + if retry_after: + try: + return float(retry_after) + except ValueError: + pass + hint = error.retry_after_hint if error is not None else None + if hint is not None: + return min(hint, _MAX_SERVER_RETRY_AFTER) + return 1.0 def _calculate_backoff(self, attempt: int) -> float: """Calculate exponential backoff delay.""" @@ -296,8 +283,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..8148a65 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,32 @@ 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, + } + 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} + ) + return result + def detect_highlights( self, source_type: Literal["stream", "recording"], @@ -263,7 +291,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..4e42bb1 100644 --- a/wave_sdk/collab.py +++ b/wave_sdk/collab.py @@ -1,7 +1,9 @@ """WAVE SDK - Collab API. Real-time collaboration rooms, participants, comments, and annotations.""" from __future__ import annotations +import warnings from typing import Any +from urllib.parse import quote from pydantic import BaseModel @@ -26,7 +28,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/{quote(room_id, safe=':')}") + 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..e5c9851 100644 --- a/wave_sdk/editor.py +++ b/wave_sdk/editor.py @@ -2,7 +2,9 @@ from __future__ import annotations import time -from typing import Any +import warnings +from typing import Any, Literal +from urllib.parse import quote from pydantic import BaseModel @@ -45,7 +47,15 @@ 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} + result: dict[str, Any] = self._client.post(f"{self._base}/{quote(project_id, safe=':')}/export", json={k: v for k, v in body.items() if v is not None}) + 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..7f715c3 --- /dev/null +++ b/wave_sdk/errors.py @@ -0,0 +1,246 @@ +"""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 + +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.""" + if not isinstance(self.next_action, dict) or self.next_action.get("type") != "retry_after": + return None + seconds = self.next_action.get("seconds") + if isinstance(seconds, (int, float)) and not isinstance(seconds, bool) and seconds >= 0: + return float(seconds) + return None + + def __str__(self) -> str: + return f"WaveError({self.code}): {self.message}" + + +class RateLimitError(WaveError): + """Rate limit exceeded error.""" + + 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"] + 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 + fields: dict[str, Any] = { + "code": f"HTTP_{status}", + "message": f"HTTP {status}: {response.reason_phrase}", + "details": None, "next_action": None, "suggestions": None, "doc_url": None, + "request_id": None, "accepts": None, "x402_version": None, + } + try: + body = response.json() + except Exception: + 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 == 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..cdbf3da 100644 --- a/wave_sdk/inference.py +++ b/wave_sdk/inference.py @@ -1,24 +1,28 @@ -"""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 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 +37,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,27 +75,34 @@ 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: it posts completions straight to a proxy that does not accept + WAVE API keys. Leave it unset to go through ``https://api.wave.online``. + """ 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: completions go through the API gateway " + f"({_COMPLETIONS_PATH}), which accepts your WAVE API key. Leave funnel_url unset.", + DeprecationWarning, + stacklevel=2, + ) + self._funnel_url = funnel_url.rstrip("/") if funnel_url else None 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} + if self._funnel_url: + data = self._legacy_funnel_post(body) + else: + data = self._client.post(_COMPLETIONS_PATH, json=body, timeout=120.0) + data = data if isinstance(data, dict) else {} usage = data.get("usage") or {} choices = data.get("choices") or [{}] return InferenceResult( @@ -96,12 +113,20 @@ 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 data + return [InferenceModel(**r) for r in (rows or []) if isinstance(r, dict)] def profile(self, model_id: str) -> ModelProfile: - """A model's measured profile: the transition signature + pricing + live usage.""" + """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, + ) rows = self._registry_get(f"/rest/v1/models?select=*&id=eq.{model_id}") if not rows: raise WaveError(f"model {model_id}: NOT ADMITTED", "MODEL_NOT_FOUND", 404) @@ -122,9 +147,20 @@ def profile(self, model_id: str) -> ModelProfile: ), ) + def _legacy_funnel_post(self, body: dict[str, Any]) -> Any: + response = httpx.post( + f"{self._funnel_url}/v1/chat/completions", + headers={"content-type": "application/json", "authorization": f"Bearer {self._client.api_key}"}, + json=body, + timeout=120.0, + ) + if not response.is_success: + raise WaveError(f"inference {response.status_code}: {response.text[:300]}", "INFERENCE_ERROR", response.status_code) + return response.json() + 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..b92ba73 100644 --- a/wave_sdk/mesh.py +++ b/wave_sdk/mesh.py @@ -1,4 +1,15 @@ -"""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 :meth:`MeshAPI.list_peers`. A call without a node raises ``ValueError`` locally +instead of a 400 from the server. + +``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. +""" from __future__ import annotations from typing import Any @@ -7,6 +18,8 @@ from wave_sdk.client import WaveClient +NODE_HEADER = "x-wave-node" + class MeshRegion(BaseModel): id: str; name: str; provider: str; location: str; status: str; latency_ms: int; capacity_percent: float; stream_count: int; viewer_count: int; is_primary: bool; created_at: str; updated_at: str @@ -15,15 +28,38 @@ 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]: + name = node or self.node + if not name: + raise ValueError( + "WAVE mesh: every /v1/mesh request needs an x-wave-node target; " + "set client.mesh.node = '' or pass node=''" + ) + 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) -> Any: + return self._client.post(path, json=json, headers=self._headers()) + + def list_peers(self, region_id: str | None = None, node: str | None = None) -> dict: + """``GET /v1/mesh/peers``: the org's mesh peers (``{"org", "count", "peers": [...]}``).""" + path = f"{self._base}/regions/{region_id}/peers" if region_id else f"{self._base}/peers" + result: dict = self._get(path, node=node) + return result + def list_regions(self, **params: Any) -> dict: return self._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._get(f"{self._base}/regions/{region_id}")) + def get_region_health(self, region_id: str) -> dict: return self._get(f"{self._base}/regions/{region_id}/health") + def add_peer(self, region_id: str, endpoint: str) -> dict: return self._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}", headers=self._headers()) + def create_policy(self, name: str, strategy: str, primary_region: str, fallback_regions: list[str], **kwargs: Any) -> FailoverPolicy: return FailoverPolicy(**self._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._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._post(f"{self._base}/policies/{policy_id}/failover", json={"target_region": target_region}) + def get_replication_status(self) -> list[dict]: return self._get(f"{self._base}/replication") + def get_topology(self) -> dict: return self._get(f"{self._base}/topology") diff --git a/wave_sdk/meter.py b/wave_sdk/meter.py index 7f41cd9..d4fe3a4 100644 --- a/wave_sdk/meter.py +++ b/wave_sdk/meter.py @@ -1,66 +1,80 @@ -"""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 +class MeterMailChannel(_MeterModel): + ops: int = 0; usdc: Amount = "0"; errors: int = 0; blocked: Blocked = None -class MeterVoiceChannel(BaseModel): - minutes: float; usdc: str +class MeterVoiceChannel(_MeterModel): + minutes: float = 0; usdc: Amount = "0" -class MeterSmsChannel(BaseModel): - ops: int; blocked: int +class MeterSmsChannel(_MeterModel): + ops: int = 0; blocked: Blocked = None -class MeterRealtimeChannel(BaseModel): - minutes: float +class MeterRealtimeChannel(_MeterModel): + minutes: float = 0 -class MeterStorageChannel(BaseModel): - bytes: int +class MeterStorageChannel(_MeterModel): + bytes: int = 0 -class MeterChannels(BaseModel): - mail: MeterMailChannel; voice: MeterVoiceChannel; sms: MeterSmsChannel - realtime: MeterRealtimeChannel; storage: MeterStorageChannel +class MeterChannels(_MeterModel): + mail: MeterMailChannel | None = None; voice: MeterVoiceChannel | None = None + sms: MeterSmsChannel | None = None; realtime: MeterRealtimeChannel | None = None + storage: MeterStorageChannel | None = None -class MeterLedgerRow(BaseModel): - model_config = ConfigDict(populate_by_name=True) + +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 +87,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..2a8d93f 100644 --- a/wave_sdk/podcast.py +++ b/wave_sdk/podcast.py @@ -1,30 +1,94 @@ -"""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 yet and use the same +``/v1/podcast`` prefix. +""" from __future__ import annotations from typing import Any +from urllib.parse import quote -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from wave_sdk.client import WaveClient +_SHOWS = "/v1/podcast/shows" +_EPISODES = "/v1/podcast/episodes" + + +def _seg(value: str) -> str: + return quote(value, safe=":") + + +def _clean(d: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in d.items() if v is not None} + + +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 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 +class PodcastEpisode(BaseModel): + """``PodcastEpisode`` from the API reference; unknown fields are kept.""" + + 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 + + +# 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} + return PodcastShow(**self._client.post(_SHOWS, json=_clean(body))) + + 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} + return PodcastEpisode(**self._client.post(f"{_SHOWS}/{_seg(show_id)}/episodes", json=_clean(body))) + + # No published API operation for the methods below yet. + def get(self, show_id: str) -> PodcastShow: return PodcastShow(**self._client.get(f"{_SHOWS}/{_seg(show_id)}")) + def update(self, show_id: str, **kwargs: Any) -> PodcastShow: return PodcastShow(**self._client.patch(f"{_SHOWS}/{_seg(show_id)}", json=kwargs)) + def remove(self, show_id: str) -> None: self._client.delete(f"{_SHOWS}/{_seg(show_id)}") + def get_episode(self, episode_id: str) -> PodcastEpisode: return PodcastEpisode(**self._client.get(f"{_EPISODES}/{_seg(episode_id)}")) + def publish_episode(self, episode_id: str) -> PodcastEpisode: return PodcastEpisode(**self._client.post(f"{_EPISODES}/{_seg(episode_id)}/publish")) + def get_rss_feed(self, show_id: str) -> dict: return self._client.get(f"{_SHOWS}/{_seg(show_id)}/rss") + def get_analytics(self, show_id: str, **params: Any) -> dict: return self._client.get(f"{_SHOWS}/{_seg(show_id)}/analytics", params=_clean(params)) + def distribute(self, show_id: str, targets: list[str]) -> list[dict]: return self._client.post(f"{_SHOWS}/{_seg(show_id)}/distribute", json={"targets": targets}) 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..8319b45 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,54 @@ import json from collections.abc import Iterator from typing import Any, Callable +from urllib.parse import quote, urlencode import httpx -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, WaveError, __version__, error_from_response -_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 +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. + """ + return quote(channel, safe=":") + + +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 +81,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 +96,28 @@ 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}") + + 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 +159,42 @@ 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(), - json={"event": event, "data": data}, + def publish(self, channel: str, event: str, data: Any = None) -> dict[str, Any]: + """``POST /v1/realtime/channels/{channel}/publish`` (scope ``realtime:write``).""" + result: dict[str, Any] = self._client.post( + f"{_CHANNELS_PATH}/{_channel_path(channel)}/publish", json={"event": event, "data": data} ) - 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..3ca9347 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,39 @@ 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, + } + 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 + ) + 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}) From cda5a7aefd1792f1a0b9e7111ea13743d870f0d1 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 04:09:01 -0400 Subject: [PATCH 2/8] test(contract): check the verb and path each mapped method sends against the live spec tests/test_contract_coverage.py only asserted that each mapped method name existed, so 19 of 52 mapped methods drifted to other paths while it stayed green. It now calls every mapped method against an httpx.MockTransport and asserts the recorded request has the spec operation's verb and templated path. Every API operation is either mapped or allowlisted with a reason. The snapshot is regenerated from https://api.wave.online/openapi.json (spec 1.1.0: 255 operations, 230 paths; it held 75) by the new scripts/refresh_openapi_snapshot.py. release-drift.yml gains a spec-drift job that refreshes it from the live document and runs the contract test on push to main and daily. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-drift.yml | 28 + scripts/refresh_openapi_snapshot.py | 63 + tests/fixtures/openapi_snapshot.json | 1921 +++++++++++++++++++++----- tests/test_contract_coverage.py | 360 +++-- 4 files changed, 1855 insertions(+), 517 deletions(-) create mode 100644 scripts/refresh_openapi_snapshot.py 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/scripts/refresh_openapi_snapshot.py b/scripts/refresh_openapi_snapshot.py new file mode 100644 index 0000000..85b2f30 --- /dev/null +++ b/scripts/refresh_openapi_snapshot.py @@ -0,0 +1,63 @@ +"""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" +METHODS = ("get", "post", "put", "patch", "delete") + + +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/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..dd24301 100644 --- a/tests/test_contract_coverage.py +++ b/tests/test_contract_coverage.py @@ -1,163 +1,251 @@ -"""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 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": []}], } -# operationId (or "METHOD /path") -> justification. Every spec op not in MAPPING must be here. -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.", +# 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", (), {}), } +_UNWRAPPED = "not wrapped by the Python SDK yet; callable with client.client.request(...)" -def test_snapshot_is_sane(): - """Guard against an empty/corrupt fixture silently passing everything.""" - assert SNAPSHOT["total_ops"] == len(SNAPSHOT["operations"]) == 75 +# 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": "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 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] - assert not unmapped, ( - f"{len(unmapped)} spec operation(s) have no Python method and no allowlist " - f"justification: {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}" +def _path_regex(template: str) -> re.Pattern[str]: + return re.compile("^/v1" + re.sub(r"\{[^}]+\}", "[^/]+", template) + "$") -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}" +class _FakeSocket: + def recv(self) -> str: + return "" + def close(self) -> None: + pass -def test_mapped_methods_exist_on_wave(): + +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 - 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}" + 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) -def test_mapping_and_allowlist_cover_every_op_exactly_once(): - keys = [_op_key(op) for op in SNAPSHOT["operations"]] + 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 (ValueError, TypeError, KeyError, AttributeError) as exc: + # The mock answers `{}`; a response model may reject it after the request was sent. + 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_snapshot_is_sane(): + """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" - covered = set(MAPPING) | set(ALLOWLIST) - assert covered == set(keys) - assert len(MAPPING) + len(ALLOWLIST) == SNAPSHOT["total_ops"] + + +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_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 reason: {unmapped}" + ) + stubs = [k for k, v in ALLOWLIST.items() if len(v) < 20] + 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(): + stale = (set(MAPPING) | set(ALLOWLIST)) - set(API_OPS) + assert not stale, f"entries reference operations no longer in the spec: {sorted(stale)}" + + +@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}" From e3a3b4ca2025492ffa7105b829fef5394f330448 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 04:10:19 -0400 Subject: [PATCH 3/8] release: wave-sdk 2.3.0 Bump pyproject.toml and wave_sdk.client.__version__ to 2.3.0. README: the quickstart starts with usage.get(), and the namespace table says which namespaces were checked live, which wrap published operations, and which the gateway does not route today (they raise RouteNotServedError with code ROUTE_NOT_MAPPED). Error handling covers PaymentRequiredError and RouteNotServedError. The MIGRATING.md link is absolute so it resolves on PyPI. pyproject project_urls point at the GitHub README instead of docs.wave.online/sdk/python, which redirects to a page with no Python content. CHANGELOG and MIGRATING describe the 2.3.0 changes and the deprecated names. scripts/smoke_live.py runs the documented free flows against the live API with WAVE_API_KEY and exits non-zero on any mismatch. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 81 +++++++++++++ MIGRATING.md | 31 +++++ README.md | 246 +++++++++++++++++++++----------------- pyproject.toml | 11 +- scripts/smoke_live.py | 180 ++++++++++++++++++++++++++++ tests/test_sdk_exports.py | 4 +- wave_sdk/client.py | 2 +- 7 files changed, 441 insertions(+), 114 deletions(-) create mode 100644 scripts/smoke_live.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6289c3d..ff59527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,87 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [2.3.0] + +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. +- **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. +- **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. +- **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`) is raised on the first attempt instead of after three backoffs, and a + `retry_after` directive sets the wait (capped at 60 s). +- **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=`); a + call without one raises `ValueError` before sending. +- 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()`, + `chapters.get_detection_job()`. +- `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 call paths the API does not publish and warn with `DeprecationWarning`: +`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()`), +`collab.close_room()` (now an alias of `delete_room()`), `inference.profile()` and +`InferenceAPI(funnel_url=...)`. + +### 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). +- `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..b7f2ae1 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -1,3 +1,34 @@ +# Migrating to `wave-sdk` 2.3.0 + +2.3.0 moves methods onto the paths the WAVE API publishes. Old names keep working and emit a +`DeprecationWarning`, so nothing breaks on upgrade; switch when convenient. + +| 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")` | +| `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)`: completions go through `api.wave.online` | + +Three changes can affect existing code: + +- `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=`. The old `/v1/podcasts` paths were never + routed, so no call that worked before changes behavior. +- `meter.ledger()` returns one window: read `ledger.channels` instead of `ledger.rows[0].channels`. +- 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. + +--- + # 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..7307c23 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,149 @@ 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 +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 -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 +client.mesh.node = "studio-a" +peers = client.mesh.list_peers() # {"org", "count", "peers": [...]} ``` ## 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 +191,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/smoke_live.py b/scripts/smoke_live.py new file mode 100644 index 0000000..8af3d4d --- /dev/null +++ b/scripts/smoke_live.py @@ -0,0 +1,180 @@ +"""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. +""" +from __future__ import annotations + +import os +import sys +import time +from typing import Any, Callable + +from wave_sdk import PaymentRequiredError, RouteNotServedError, Wave, WaveError + +Check = tuple[str, Callable[[Wave], Any], Any] + + +# x-request-id of the most recent HTTP response, so a 200 line carries the same receipt an error does. +_LAST_RID: dict[str, str] = {} + + +def _remember_request_id(response: Any) -> None: + _LAST_RID["rid"] = response.headers.get("x-request-id") or "-" + + +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 + 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"200 -> {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}: {type(exc).__name__}: {str(exc)[:200]}") + 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: {type(exc).__name__}: {str(exc)[:200]}") + 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.""" + 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}: 200 ({time.monotonic() - started:.1f}s)") + except (RouteNotServedError, PaymentRequiredError, WaveError) as exc: + print(f"INFO {name}: {_describe(None, exc, time.monotonic() - started)}") + + +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") + 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/test_sdk_exports.py b/tests/test_sdk_exports.py index 0806bce..584925c 100644 --- a/tests/test_sdk_exports.py +++ b/tests/test_sdk_exports.py @@ -181,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/wave_sdk/client.py b/wave_sdk/client.py index fde7b36..85606d9 100644 --- a/wave_sdk/client.py +++ b/wave_sdk/client.py @@ -40,7 +40,7 @@ # 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") From bf9f4aa1885fefe854f962a6a41f29675a0b405e Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 04:38:13 -0400 Subject: [PATCH 4/8] fix: bound server-requested waits, keep 429 class on upgrades, stop sending the key to funnel_url Review follow-ups on this branch: - A Retry-After header was passed to time.sleep() unchecked: 86400 slept a day, and nan/inf/-1 escaped as ValueError/OverflowError. Waits must be finite and non-negative. A wait over 60 s is raised at once with the full value on the error instead of being slept through. - A 429 whose next_action is not a retry verb is no longer retried. - error_from_response() builds RateLimitError for every 429, so a rate-limited WebSocket upgrade raises the same class as REST. - InferenceAPI(funnel_url=...) is ignored with a DeprecationWarning. It used to send the WAVE key to that host, which rejects WAVE keys anyway. The deprecated profile() percent-encodes the model id in its registry filter. - captions.download() returns the text of a non-JSON caption file as content instead of None. - MIGRATING no longer claims nothing breaks on upgrade. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++++++-- MIGRATING.md | 13 ++++++--- tests/test_errors.py | 54 +++++++++++++++++++++++++++++++++++ tests/test_parity_apis.py | 22 ++++++-------- tests/test_realtime.py | 21 ++++++++++++++ tests/test_served_routes.py | 11 ++++++- wave_sdk/captions.py | 10 +++++-- wave_sdk/client.py | 57 ++++++++++++------------------------- wave_sdk/errors.py | 57 +++++++++++++++++++++++++++++++------ wave_sdk/inference.py | 34 ++++++++-------------- 10 files changed, 199 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff59527..895feca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,15 @@ each mapped method actually sends. 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`) is raised on the first attempt instead of after three backoffs, and a - `retry_after` directive sets the wait (capped at 60 s). + 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 or `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`. @@ -66,7 +73,7 @@ These methods call paths the API does not publish and warn with `DeprecationWarn `captions.get_text()` (`download()`), `chapters.get_default_set()` (`list_chapters()`), `chapters.add_chapter()` (`create_chapter()`), `editor.render()` (`export()`), `collab.close_room()` (now an alias of `delete_room()`), `inference.profile()` and -`InferenceAPI(funnel_url=...)`. +`InferenceAPI(funnel_url=...)` (ignored). ### Changed diff --git a/MIGRATING.md b/MIGRATING.md index b7f2ae1..1983de3 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -1,7 +1,8 @@ # Migrating to `wave-sdk` 2.3.0 -2.3.0 moves methods onto the paths the WAVE API publishes. Old names keep working and emit a -`DeprecationWarning`, so nothing breaks on upgrade; switch when convenient. +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 | | ----------------------------------------------- | --------------------------------------------------------- | @@ -13,9 +14,9 @@ | `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)`: completions go through `api.wave.online` | +| `InferenceAPI(client, funnel_url=...)` | `InferenceAPI(client)`: `funnel_url` is now ignored; completions go through `api.wave.online` | -Three changes can affect existing code: +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 @@ -26,6 +27,10 @@ Three changes can affect existing code: `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. --- diff --git a/tests/test_errors.py b/tests/test_errors.py index 77fed0d..2e35cb2 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -163,6 +163,60 @@ def test_rate_limit_error_keeps_the_server_message(wave, recorder, monkeypatch): 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_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 -------------------------------------------------------- diff --git a/tests/test_parity_apis.py b/tests/test_parity_apis.py index 0164353..ce24e2c 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 @@ -239,20 +238,17 @@ def boom(*_a, **_k): InferenceAPI(mock_client).complete("m", [{"role": "user", "content": "hi"}]) -def test_inference_legacy_funnel_url_is_deprecated(monkeypatch): - class FakeClient: - api_key = "test-key" - - def fake_post(url, headers=None, json=None, timeout=None): - assert url == "https://funnel.example.com/v1/chat/completions" - return httpx.Response(500, text="down") +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", fake_post) + monkeypatch.setattr("wave_sdk.inference.httpx.post", boom) with pytest.warns(DeprecationWarning, match="funnel_url"): - api = InferenceAPI(FakeClient(), funnel_url="https://funnel.example.com") - from wave_sdk.client import WaveError - with pytest.raises(WaveError): - api.complete("claude-haiku", [{"role": "user", "content": "hi"}]) + 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_reads_the_gateway_list(mock_client): diff --git a/tests/test_realtime.py b/tests/test_realtime.py index 29086cf..fd39ac7 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -155,6 +155,27 @@ def create_connection(url, header=None, **_kw): assert exc.value.accepts and exc.value.accepts[0]["resource"] == "/v1/clips" +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") diff --git a/tests/test_served_routes.py b/tests/test_served_routes.py index 3482c72..1d1c26c 100644 --- a/tests/test_served_routes.py +++ b/tests/test_served_routes.py @@ -156,10 +156,19 @@ def test_voice_generate_returns_json_or_audio(wave, recorder): def test_captions_download(wave, recorder): - wave.captions.download("job_1", language="en", format="vtt") + 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") diff --git a/wave_sdk/captions.py b/wave_sdk/captions.py index 83e18ec..bfe276d 100644 --- a/wave_sdk/captions.py +++ b/wave_sdk/captions.py @@ -6,6 +6,7 @@ from typing import Any, Literal from urllib.parse import quote +import httpx from pydantic import BaseModel from wave_sdk.client import WaveClient @@ -39,9 +40,14 @@ def download(self, job_id: str, language: str, format: Literal["srt", "vtt", "tx """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. """ - result: dict[str, Any] = self._client.get(f"{self._base}/{quote(job_id, safe=':')}/download", params={"language": language, "format": format}) - return result + response: httpx.Response = self._client.get(f"{self._base}/{quote(job_id, safe=':')}/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) diff --git a/wave_sdk/client.py b/wave_sdk/client.py index 85606d9..0cb87f8 100644 --- a/wave_sdk/client.py +++ b/wave_sdk/client.py @@ -44,7 +44,8 @@ T = TypeVar("T") -# Longest server-suggested wait the client honours before retrying on its own. +# 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 @@ -203,34 +204,11 @@ def _request( **kwargs, ) - # Handle rate limiting - if response.status_code == 429: - parsed = self._parse_error(response) - retry_after = self._parse_retry_after(response, parsed) - if attempt < max_retries: - logger.warning(f"Rate limited. Retrying in {retry_after}s") - time.sleep(retry_after) - continue - raise RateLimitError( - parsed.message if parsed.code != "HTTP_429" else "Rate limit exceeded", - retry_after, - parsed.request_id, - details=parsed.details, - next_action=parsed.next_action, - suggestions=parsed.suggestions, - doc_url=parsed.doc_url, - ) - - # 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: - hint = error.retry_after_hint - delay = ( - min(hint, _MAX_SERVER_RETRY_AFTER) - if hint is not None - else self._calculate_backoff(attempt) - ) + delay = self._retry_delay(error, attempt) + if delay is not None and attempt < max_retries: logger.warning(f"Request failed ({error.code}). Retrying in {delay}s") time.sleep(delay) continue @@ -264,18 +242,19 @@ def _parse_error(self, response: httpx.Response) -> WaveError: """Parse an error response into the most specific WaveError subclass.""" return error_from_response(response) - def _parse_retry_after(self, response: httpx.Response, error: WaveError | None = None) -> float: - """Seconds to wait: the Retry-After header, else the body's retry_after directive, else 1s.""" - retry_after = response.headers.get("retry-after") - if retry_after: - try: - return float(retry_after) - except ValueError: - pass - hint = error.retry_after_hint if error is not None else None - if hint is not None: - return min(hint, _MAX_SERVER_RETRY_AFTER) - return 1.0 + def _retry_delay(self, error: WaveError, attempt: int) -> float | None: + """Seconds to sleep before retrying ``error``, or None to raise it now. + + 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 + wait = error.retry_after if isinstance(error, RateLimitError) 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.""" diff --git a/wave_sdk/errors.py b/wave_sdk/errors.py index 7f715c3..108694a 100644 --- a/wave_sdk/errors.py +++ b/wave_sdk/errors.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import math from typing import Any import httpx @@ -76,19 +77,47 @@ def _is_retryable(self, status_code: int, code: str) -> bool: @property def retry_after_hint(self) -> float | None: """Seconds to wait from a ``{"type": "retry_after", "seconds": N}`` directive, if any.""" - if not isinstance(self.next_action, dict) or self.next_action.get("type") != "retry_after": - return None - seconds = self.next_action.get("seconds") - if isinstance(seconds, (int, float)) and not isinstance(seconds, bool) and seconds >= 0: - return float(seconds) - return None + 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_seconds(response: httpx.Response, next_action: Any = None) -> float: + """How long the server asked the caller to wait before retrying. + + A numeric ``Retry-After`` header wins, then the body's ``retry_after`` directive, then 1 s. A + header that is not a finite, non-negative number of seconds (an HTTP date, ``nan``, ``-1``) + is ignored. The value is not capped here; the client decides whether a wait is too long to + retry on its own. + """ + header = response.headers.get("retry-after") + if header: + try: + parsed = _wait_seconds(float(header)) + except ValueError: + parsed = None + if parsed is not None: + return parsed + directive = _retry_after_directive(next_action) + return directive if directive is not None else 1.0 + + class RateLimitError(WaveError): - """Rate limit exceeded error.""" + """HTTP 429. ``retry_after`` is the wait the server asked for, in seconds.""" def __init__( self, @@ -210,15 +239,16 @@ def error_from_response(response: httpx.Response) -> WaveError: 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": f"HTTP {status}: {response.reason_phrase}", + "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 Exception: + except ValueError: # not JSON (JSONDecodeError), or not text (UnicodeDecodeError) body = None if isinstance(body, dict): @@ -236,6 +266,15 @@ def error_from_response(response: httpx.Response) -> WaveError: "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, diff --git a/wave_sdk/inference.py b/wave_sdk/inference.py index cdbf3da..98e24ad 100644 --- a/wave_sdk/inference.py +++ b/wave_sdk/inference.py @@ -14,6 +14,7 @@ import warnings from typing import Any, Literal +from urllib.parse import quote import httpx from pydantic import BaseModel, ConfigDict @@ -77,20 +78,21 @@ class ModelProfile(BaseModel): class InferenceAPI: """Inference API - one completion call through the gateway, plus the model list. - ``funnel_url`` is deprecated: it posts completions straight to a proxy that does not accept - WAVE API keys. Leave it unset to go through ``https://api.wave.online``. + ``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 if funnel_url is not None: warnings.warn( - "InferenceAPI(funnel_url=...) is deprecated: completions go through the API gateway " - f"({_COMPLETIONS_PATH}), which accepts your WAVE API key. Leave funnel_url unset.", + "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._funnel_url = funnel_url.rstrip("/") if funnel_url else None self._registry_url = (registry_url or "").rstrip("/") self._registry_key = registry_key or "" @@ -98,10 +100,7 @@ def complete(self, model: str, messages: list[InferenceMessage | dict[str, Any]] """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] body = {"model": model, "messages": msgs, "max_tokens": max_tokens} - if self._funnel_url: - data = self._legacy_funnel_post(body) - else: - data = self._client.post(_COMPLETIONS_PATH, json=body, timeout=120.0) + data = self._client.post(_COMPLETIONS_PATH, json=body, timeout=120.0) data = data if isinstance(data, dict) else {} usage = data.get("usage") or {} choices = data.get("choices") or [{}] @@ -127,12 +126,14 @@ def profile(self, model_id: str) -> ModelProfile: DeprecationWarning, stacklevel=2, ) - rows = self._registry_get(f"/rest/v1/models?select=*&id=eq.{model_id}") + # 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"], @@ -147,17 +148,6 @@ def profile(self, model_id: str) -> ModelProfile: ), ) - def _legacy_funnel_post(self, body: dict[str, Any]) -> Any: - response = httpx.post( - f"{self._funnel_url}/v1/chat/completions", - headers={"content-type": "application/json", "authorization": f"Bearer {self._client.api_key}"}, - json=body, - timeout=120.0, - ) - if not response.is_success: - raise WaveError(f"inference {response.status_code}: {response.text[:300]}", "INFERENCE_ERROR", response.status_code) - return response.json() - def _registry_get(self, path: str) -> list[dict[str, Any]]: if not self._registry_url: raise WaveError("InferenceAPI: registry_url is required for profile()", "REGISTRY_UNCONFIGURED", 0) From ba6d8cbaeaa5a509a92cb1461f44194c15950aec Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 13:03:18 -0400 Subject: [PATCH 5/8] fix: refuse dot-segment paths, send billed writes once, reject malformed 2xx Addresses the review findings on #61. - Path safety: WaveClient refuses any request path with a . or .. segment (httpx 0.28.1 resolves /v1/clips/../usage to /v1/usage). path_segment() encodes an id as one segment and is used by the methods this release adds. - Billed or broadcast writes new in 2.3.0 (inference.complete, realtime.publish, voice.generate, clips.detect, chapters.detect/create_chapter, editor.export, podcast.create/create_episode) are sent once: a 5xx after acceptance is raised, not retried. - inference.complete()/models() raise INVALID_RESPONSE on a 2xx without choices or a model list; meter counters the response omits are None, not 0. - Retry-After is honored on retryable 5xx too; error_detail.request_id is kept. - realtime.connect() refuses ws:// to a non-loopback host (the key is in the upgrade headers); smoke_live.py refuses a non-https WAVE_BASE_URL and never prints response values. - chapters.get_detection_job() is removed (no published operation); the eight podcast methods without a published operation warn. - Tests: dot segments, detect() body, raw=True, complete() failure path, send-once writes. Docs: CHANGELOG, MIGRATING. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 51 ++++++++++----- MIGRATING.md | 17 +++-- scripts/refresh_openapi_snapshot.py | 5 +- scripts/smoke_live.py | 45 ++++++++++--- tests/test_errors.py | 52 +++++++++++++++ tests/test_parity_apis.py | 22 +++++++ tests/test_realtime.py | 8 +++ tests/test_served_routes.py | 99 ++++++++++++++++++++++++++++- wave_sdk/captions.py | 5 +- wave_sdk/chapters.py | 19 +++--- wave_sdk/client.py | 59 +++++++++++++++-- wave_sdk/clips.py | 4 +- wave_sdk/collab.py | 5 +- wave_sdk/editor.py | 6 +- wave_sdk/errors.py | 32 ++++++---- wave_sdk/inference.py | 23 +++++-- wave_sdk/meter.py | 13 ++-- wave_sdk/podcast.py | 78 +++++++++++++++++------ wave_sdk/realtime.py | 32 ++++++++-- wave_sdk/voice.py | 4 +- 20 files changed, 473 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 895feca..40c5a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] -## [2.3.0] +## [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 @@ -21,21 +24,32 @@ each mapped method actually sends. (`:` 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. + `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. + 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 or `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 + 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. @@ -46,6 +60,11 @@ each mapped method actually sends. `/v1/sentiment/analyze`. - **Mesh** requests carry the required `x-wave-node` header (`client.mesh.node` or `node=`); a call without one raises `ValueError` before sending. +- **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 @@ -58,8 +77,7 @@ each mapped method actually sends. `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()`, - `chapters.get_detection_job()`. + `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. @@ -68,18 +86,21 @@ each mapped method actually sends. ### Deprecated -These methods call paths the API does not publish and warn with `DeprecationWarning`: -`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()`), -`collab.close_room()` (now an alias of `delete_room()`), `inference.profile()` and -`InferenceAPI(funnel_url=...)` (ignored). +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). + 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. diff --git a/MIGRATING.md b/MIGRATING.md index 1983de3..c864e83 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -8,7 +8,7 @@ after the table can require edits to existing code. | ----------------------------------------------- | --------------------------------------------------------- | | `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")` | +| `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)` | @@ -20,9 +20,17 @@ 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=`. The old `/v1/podcasts` paths were never - routed, so no call that worked before changes behavior. + 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 @@ -30,7 +38,8 @@ Changes that can require edits: - 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. + (`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. --- diff --git a/scripts/refresh_openapi_snapshot.py b/scripts/refresh_openapi_snapshot.py index 85b2f30..852243b 100644 --- a/scripts/refresh_openapi_snapshot.py +++ b/scripts/refresh_openapi_snapshot.py @@ -19,7 +19,10 @@ SOURCE = "https://api.wave.online/openapi.json" OUT = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "openapi_snapshot.json" -METHODS = ("get", "post", "put", "patch", "delete") +# 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: diff --git a/scripts/smoke_live.py b/scripts/smoke_live.py index 8af3d4d..b903f3f 100644 --- a/scripts/smoke_live.py +++ b/scripts/smoke_live.py @@ -19,7 +19,8 @@ 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. +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 @@ -27,18 +28,36 @@ 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"}) -# x-request-id of the most recent HTTP response, so a 200 line carries the same receipt an error does. +# 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: @@ -82,7 +101,7 @@ def _outcome_matches(expected: Any, result: Any, error: WaveError | None) -> boo 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 + return error is None and _LAST_RID.get("status") == "200" cls, code = expected return isinstance(error, cls) and error.code == code @@ -92,7 +111,7 @@ def _describe(result: Any, error: WaveError | None, elapsed: float) -> str: 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"200 -> {shape} rid={_LAST_RID.get('rid', '-')} ({elapsed:.1f}s)" + 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: @@ -105,7 +124,7 @@ def _run(wave: Wave, name: str, fn: Callable[[Wave], Any], expected: Any) -> boo 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}: {type(exc).__name__}: {str(exc)[:200]}") + 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)}") @@ -126,7 +145,7 @@ def _websocket_check(wave: Wave) -> bool | None: print(f"FAIL realtime.connect: {_describe(None, exc, time.monotonic() - started)}") return False except Exception as exc: - print(f"FAIL realtime.connect: {type(exc).__name__}: {str(exc)[:200]}") + 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)") @@ -135,7 +154,9 @@ def _websocket_check(wave: Wave) -> bool | None: 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.""" + 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()), @@ -145,9 +166,11 @@ def _report_known_server_gaps(wave: Wave) -> None: started = time.monotonic() try: fn(wave) - print(f"INFO {name}: 200 ({time.monotonic() - started:.1f}s)") - except (RouteNotServedError, PaymentRequiredError, WaveError) as exc: + 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: @@ -156,6 +179,10 @@ def main() -> int: 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}") diff --git a/tests/test_errors.py b/tests/test_errors.py index 2e35cb2..23fc248 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -212,6 +212,58 @@ def test_an_unusable_retry_after_header_falls_back(wave, recorder, monkeypatch, 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 diff --git a/tests/test_parity_apis.py b/tests/test_parity_apis.py index ce24e2c..d2f9843 100644 --- a/tests/test_parity_apis.py +++ b/tests/test_parity_apis.py @@ -222,12 +222,34 @@ def test_inference_complete_goes_through_the_gateway(mock_client): "/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 +@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 + + 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" + + +@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 + + 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_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): diff --git a/tests/test_realtime.py b/tests/test_realtime.py index fd39ac7..8e88701 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -155,6 +155,14 @@ def create_connection(url, header=None, **_kw): 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 diff --git a/tests/test_served_routes.py b/tests/test_served_routes.py index 1d1c26c..4da8f3e 100644 --- a/tests/test_served_routes.py +++ b/tests/test_served_routes.py @@ -93,6 +93,17 @@ def test_meter_accepts_counts_and_unknown_fields(): 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 ----------------------------------------------------------------------------------- @@ -177,8 +188,17 @@ def test_chapters_per_video_operations(wave, recorder): 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") - wave.chapters.get_detection_job("rec:1", "job/1") - assert sent(recorder) == ("GET", "/v1/videos/rec:1/chapters/detect/job%2F1") + 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): @@ -221,6 +241,14 @@ def test_podcast_uses_the_published_show_paths(wave, recorder): 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): @@ -231,6 +259,32 @@ def test_methods_on_unpublished_paths_warn(wave, recorder, call): 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)) @@ -244,6 +298,47 @@ def test_ids_stay_one_path_segment(wave, recorder): 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 diff --git a/wave_sdk/captions.py b/wave_sdk/captions.py index bfe276d..b880beb 100644 --- a/wave_sdk/captions.py +++ b/wave_sdk/captions.py @@ -4,12 +4,11 @@ import time import warnings from typing import Any, Literal -from urllib.parse import quote import httpx from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment class CaptionTrack(BaseModel): @@ -43,7 +42,7 @@ def download(self, job_id: str, language: str, format: Literal["srt", "vtt", "tx 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}/{quote(job_id, safe=':')}/download", params={"language": language, "format": format}, raw=True) + 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 diff --git a/wave_sdk/chapters.py b/wave_sdk/chapters.py index 4aff35e..c3bf17f 100644 --- a/wave_sdk/chapters.py +++ b/wave_sdk/chapters.py @@ -1,24 +1,23 @@ """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`, :meth:`ChaptersAPI.detect` and -:meth:`ChaptersAPI.get_detection_job`. +: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 urllib.parse import quote 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/{quote(video_id, safe=':')}/chapters" + return f"/v1/videos/{path_segment(video_id)}/chapters" class Chapter(BaseModel): @@ -44,16 +43,14 @@ def list_chapters(self, video_id: str) -> dict[str, Any]: 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} - 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}) + # 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} - 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}) - return result - def get_detection_job(self, video_id: str, job_id: str) -> dict[str, Any]: - """Poll a detection job. ``GET /v1/videos/{videoId}/chapters/detect/{jobId}``.""" - result: dict[str, Any] = self._client.get(f"{_video_path(video_id)}/detect/{quote(job_id, safe='')}") + # 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`.""" diff --git a/wave_sdk/client.py b/wave_sdk/client.py index 0cb87f8..b5621b9 100644 --- a/wave_sdk/client.py +++ b/wave_sdk/client.py @@ -10,6 +10,7 @@ import random import time from typing import Any, Generic, TypeVar +from urllib.parse import quote import httpx from pydantic import BaseModel @@ -22,6 +23,7 @@ RouteNotServedError, WaveError, error_from_response, + retry_after_header, ) __all__ = [ @@ -48,6 +50,42 @@ # request is raised to the caller at once (with the full wait on the error) instead of sleeping. _MAX_SERVER_RETRY_AFTER = 60.0 +# 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 path_segment(value: str | int) -> str: + """Encode an id as exactly one URL path segment. + + ``/``, ``?``, ``#`` 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 + + +def _check_path(path: str) -> None: + """Refuse a request path with a dot segment, whichever method built it. + + 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]): """Standard paginated response.""" @@ -184,6 +222,7 @@ def _request( 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} @@ -207,7 +246,7 @@ def _request( # Handle errors (a 429 parses to RateLimitError) if not response.is_success: error = self._parse_error(response) - delay = self._retry_delay(error, attempt) + 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) @@ -242,16 +281,24 @@ def _parse_error(self, response: httpx.Response) -> WaveError: """Parse an error response into the most specific WaveError subclass.""" return error_from_response(response) - def _retry_delay(self, error: WaveError, attempt: int) -> float | None: + 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. - 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). + 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 - wait = error.retry_after if isinstance(error, RateLimitError) else error.retry_after_hint + 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 diff --git a/wave_sdk/clips.py b/wave_sdk/clips.py index 8148a65..1defc5f 100644 --- a/wave_sdk/clips.py +++ b/wave_sdk/clips.py @@ -278,8 +278,10 @@ def detect( "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} + f"{self._base_path}/detect", json={k: v for k, v in body.items() if v is not None}, + no_retry=True, ) return result diff --git a/wave_sdk/collab.py b/wave_sdk/collab.py index 4e42bb1..a6a6179 100644 --- a/wave_sdk/collab.py +++ b/wave_sdk/collab.py @@ -3,11 +3,10 @@ import warnings from typing import Any -from urllib.parse import quote from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment class CollabRoom(BaseModel): @@ -30,7 +29,7 @@ def update_room(self, room_id: str, **kwargs: Any) -> CollabRoom: return CollabR 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 delete_room(self, room_id: str) -> None: """Close and delete a room. ``DELETE /v1/collab/rooms/{roomId}``.""" - self._client.delete(f"{self._base}/rooms/{quote(room_id, safe=':')}") + 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).""" diff --git a/wave_sdk/editor.py b/wave_sdk/editor.py index e5c9851..5dc7fd8 100644 --- a/wave_sdk/editor.py +++ b/wave_sdk/editor.py @@ -4,11 +4,10 @@ import time import warnings from typing import Any, Literal -from urllib.parse import quote from pydantic import BaseModel -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment class EditorProject(BaseModel): @@ -50,7 +49,8 @@ def get_preview_segment(self, project_id: str, start: float, end: float) -> dict 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} - result: dict[str, Any] = self._client.post(f"{self._base}/{quote(project_id, safe=':')}/export", json={k: v for k, v in body.items() if v is not None}) + # 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`.""" diff --git a/wave_sdk/errors.py b/wave_sdk/errors.py index 108694a..9c02fe2 100644 --- a/wave_sdk/errors.py +++ b/wave_sdk/errors.py @@ -96,22 +96,28 @@ def _retry_after_directive(next_action: Any) -> float | 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 numeric ``Retry-After`` header wins, then the body's ``retry_after`` directive, then 1 s. A - header that is not a finite, non-negative number of seconds (an HTTP date, ``nan``, ``-1``) - is ignored. The value is not capped here; the client decides whether a wait is too long to - retry on its own. + 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 = response.headers.get("retry-after") - if header: - try: - parsed = _wait_seconds(float(header)) - except ValueError: - parsed = None - if parsed is not None: - return parsed + 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 @@ -219,6 +225,8 @@ def _from_flat_body(body: dict[str, Any], fields: dict[str, Any]) -> None: 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 {})} diff --git a/wave_sdk/inference.py b/wave_sdk/inference.py index 98e24ad..9d9b129 100644 --- a/wave_sdk/inference.py +++ b/wave_sdk/inference.py @@ -100,10 +100,17 @@ def complete(self, model: str, messages: list[InferenceMessage | dict[str, Any]] """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] body = {"model": model, "messages": msgs, "max_tokens": max_tokens} - data = self._client.post(_COMPLETIONS_PATH, json=body, timeout=120.0) - data = data if isinstance(data, dict) else {} + # 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 "", @@ -114,8 +121,14 @@ def complete(self, model: str, messages: list[InferenceMessage | dict[str, Any]] def models(self) -> list[InferenceModel]: """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 data - return [InferenceModel(**r) for r in (rows or []) if isinstance(r, dict)] + 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: """Deprecated. A model's measured profile read straight from the registry; requires diff --git a/wave_sdk/meter.py b/wave_sdk/meter.py index d4fe3a4..51705f7 100644 --- a/wave_sdk/meter.py +++ b/wave_sdk/meter.py @@ -26,24 +26,27 @@ class _MeterModel(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) +# A counter the response leaves out is None, never 0: a missing value must not read as "no usage". + + class MeterMailChannel(_MeterModel): - ops: int = 0; usdc: Amount = "0"; errors: int = 0; blocked: Blocked = None + ops: int | None = None; usdc: Amount | None = None; errors: int | None = None; blocked: Blocked = None class MeterVoiceChannel(_MeterModel): - minutes: float = 0; usdc: Amount = "0" + minutes: float | None = None; usdc: Amount | None = None class MeterSmsChannel(_MeterModel): - ops: int = 0; blocked: Blocked = None + ops: int | None = None; blocked: Blocked = None class MeterRealtimeChannel(_MeterModel): - minutes: float = 0 + minutes: float | None = None class MeterStorageChannel(_MeterModel): - bytes: int = 0 + bytes: int | None = None class MeterChannels(_MeterModel): diff --git a/wave_sdk/podcast.py b/wave_sdk/podcast.py index 2a8d93f..822bc7c 100644 --- a/wave_sdk/podcast.py +++ b/wave_sdk/podcast.py @@ -3,30 +3,41 @@ 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 yet and use the same -``/v1/podcast`` prefix. +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 urllib.parse import quote from pydantic import BaseModel, ConfigDict -from wave_sdk.client import WaveClient +from wave_sdk.client import WaveClient, path_segment _SHOWS = "/v1/podcast/shows" _EPISODES = "/v1/podcast/episodes" - - -def _seg(value: str) -> str: - return quote(value, safe=":") +_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.""" @@ -71,7 +82,8 @@ def list(self, **params: Any) -> dict: 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} - return PodcastShow(**self._client.post(_SHOWS, json=_clean(body))) + # 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``).""" @@ -81,14 +93,40 @@ def list_episodes(self, show_id: str, **params: Any) -> dict: 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} - return PodcastEpisode(**self._client.post(f"{_SHOWS}/{_seg(show_id)}/episodes", json=_clean(body))) - - # No published API operation for the methods below yet. - def get(self, show_id: str) -> PodcastShow: return PodcastShow(**self._client.get(f"{_SHOWS}/{_seg(show_id)}")) - def update(self, show_id: str, **kwargs: Any) -> PodcastShow: return PodcastShow(**self._client.patch(f"{_SHOWS}/{_seg(show_id)}", json=kwargs)) - def remove(self, show_id: str) -> None: self._client.delete(f"{_SHOWS}/{_seg(show_id)}") - def get_episode(self, episode_id: str) -> PodcastEpisode: return PodcastEpisode(**self._client.get(f"{_EPISODES}/{_seg(episode_id)}")) - def publish_episode(self, episode_id: str) -> PodcastEpisode: return PodcastEpisode(**self._client.post(f"{_EPISODES}/{_seg(episode_id)}/publish")) - def get_rss_feed(self, show_id: str) -> dict: return self._client.get(f"{_SHOWS}/{_seg(show_id)}/rss") - def get_analytics(self, show_id: str, **params: Any) -> dict: return self._client.get(f"{_SHOWS}/{_seg(show_id)}/analytics", params=_clean(params)) - def distribute(self, show_id: str, targets: list[str]) -> list[dict]: return self._client.post(f"{_SHOWS}/{_seg(show_id)}/distribute", json={"targets": targets}) + # 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/realtime.py b/wave_sdk/realtime.py index 8319b45..341ae77 100644 --- a/wave_sdk/realtime.py +++ b/wave_sdk/realtime.py @@ -23,11 +23,11 @@ import json from collections.abc import Iterator from typing import Any, Callable -from urllib.parse import quote, urlencode +from urllib.parse import urlencode, urlsplit import httpx -from wave_sdk.client import WaveClient, WaveError, __version__, error_from_response +from wave_sdk.client import WaveClient, WaveError, __version__, error_from_response, path_segment _DEFAULT_WS = "wss://api.wave.online" _CONNECT_PATH = "/v1/realtime/connect" @@ -44,14 +44,31 @@ def _ws_origin(http_url: str) -> str: 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. + ``&``) is encoded, and an empty or ``.``/``..`` channel raises ``ValueError``. """ - return quote(channel, safe=":") + return path_segment(channel) def _handshake_error(exc: Exception) -> WaveError | None: @@ -107,6 +124,7 @@ def __init__( 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) @@ -182,8 +200,12 @@ def connect(self, channel: str, as_: str | None = None) -> RealtimeChannel: 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} + f"{_CHANNELS_PATH}/{_channel_path(channel)}/publish", + json={"event": event, "data": data}, + no_retry=True, ) return result diff --git a/wave_sdk/voice.py b/wave_sdk/voice.py index 3ca9347..ba14886 100644 --- a/wave_sdk/voice.py +++ b/wave_sdk/voice.py @@ -50,8 +50,10 @@ def generate( "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 + 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() From 42e4e978ea3a2fae3c39905b8341d7239fa97bca Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 16:55:32 -0400 Subject: [PATCH 6/8] fix(mesh): per-call node on every method, send mutations once, encode ids WAVE BugBot flagged add_peer, create_policy, trigger_failover and remove_peer as sending without the x-wave-node header. They already built it through _headers(), which raises before sending, but nothing pinned that and only list_peers took a per-call node. Now: - every MeshAPI method, mutations included, takes node= for one call; - a multi-line node name raises ValueError before anything is sent; - ids go through path_segment, so "a/b" or ".." cannot move the route; - add_peer, remove_peer, create_policy and trigger_failover are sent once (no_retry), so a 5xx after the server applied the change cannot add a second peer or fail over twice. Reads keep their retries. tests/test_mesh.py (moved out of test_served_routes.py, which was near the file-size gate) checks all 11 public methods: each refuses to send without a node, each sends the client default and a per-call override, and each mutation sends exactly one request on a 503. Live: scripts/smoke_live.py 17/17 against https://api.wave.online, mesh.list_peers(node=...) 200 rid b4c17551-b0f7-49e1-bfde-d39e2077a7b1. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++- README.md | 5 ++ tests/test_mesh.py | 111 ++++++++++++++++++++++++++++++++++++ tests/test_served_routes.py | 20 ------- wave_sdk/mesh.py | 82 ++++++++++++++++++++------ 5 files changed, 184 insertions(+), 41 deletions(-) create mode 100644 tests/test_mesh.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c5a71..a055822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,8 +58,11 @@ each mapped method actually sends. - **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=`); a - call without one raises `ValueError` before sending. +- **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. 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 diff --git a/README.md b/README.md index 7307c23..457d79c 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,13 @@ 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. +The mutations (`add_peer`, `remove_peer`, `create_policy`, `trigger_failover`) are sent once and +never retried automatically. + ## Error handling ```python diff --git a/tests/test_mesh.py b/tests/test_mesh.py new file mode 100644 index 0000000..789703b --- /dev/null +++ b/tests/test_mesh.py @@ -0,0 +1,111 @@ +"""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("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_served_routes.py b/tests/test_served_routes.py index 4da8f3e..3ef8ef9 100644 --- a/tests/test_served_routes.py +++ b/tests/test_served_routes.py @@ -122,26 +122,6 @@ def test_inference_complete_through_the_gateway(wave, recorder): assert result.content == "ok" -# --- mesh ---------------------------------------------------------------------------------------- - - -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" - - # --- path corrections ------------------------------------------------------------------------------ diff --git a/wave_sdk/mesh.py b/wave_sdk/mesh.py index b92ba73..f54ee87 100644 --- a/wave_sdk/mesh.py +++ b/wave_sdk/mesh.py @@ -2,13 +2,18 @@ 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 :meth:`MeshAPI.list_peers`. A call without a node raises ``ValueError`` locally -instead of a 400 from the server. +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. ``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 @@ -16,10 +21,16 @@ 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): id: str; name: str; provider: str; location: str; status: str; latency_ms: int; capacity_percent: float; stream_count: int; viewer_count: int; is_primary: bool; created_at: str; updated_at: str @@ -40,26 +51,59 @@ def _headers(self, node: str | None = None) -> dict[str, str]: "WAVE mesh: every /v1/mesh request needs an 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) -> Any: - return self._client.post(path, json=json, headers=self._headers()) + 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: + 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/{region_id}/peers" if region_id else f"{self._base}/peers" - result: dict = self._get(path, node=node) + 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 - def list_regions(self, **params: Any) -> dict: return self._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._get(f"{self._base}/regions/{region_id}")) - def get_region_health(self, region_id: str) -> dict: return self._get(f"{self._base}/regions/{region_id}/health") - def add_peer(self, region_id: str, endpoint: str) -> dict: return self._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}", headers=self._headers()) - def create_policy(self, name: str, strategy: str, primary_region: str, fallback_regions: list[str], **kwargs: Any) -> FailoverPolicy: return FailoverPolicy(**self._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._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._post(f"{self._base}/policies/{policy_id}/failover", json={"target_region": target_region}) - def get_replication_status(self) -> list[dict]: return self._get(f"{self._base}/replication") - def get_topology(self) -> dict: return self._get(f"{self._base}/topology") From 5d8b6a8d7188ff8a61f8bae282aa02195ca4cb91 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 18:54:31 -0400 Subject: [PATCH 7/8] fix(mesh): an empty per-call node raises instead of using the default MeshAPI._headers picked the node with `node or self.node`, so an explicit node="" (an unset config value in a multi-node caller) silently fell back to client.mesh.node and the request, a mutation included, targeted the wrong node. The default now applies only when node is None; an empty or blank name, per-call or default, raises ValueError before anything is sent. Tests: every mesh method with node="" and node=" " raises and sends nothing while a default is set (22 cases, all failing on the old code); node=None still uses the default; a blank default is rejected. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- README.md | 2 ++ tests/test_mesh.py | 27 +++++++++++++++++++++++++++ wave_sdk/mesh.py | 12 ++++++++---- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a055822..d0b8c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,8 @@ each mapped method actually sends. `/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. Mesh ids are encoded as one path segment, and the mutations + `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` diff --git a/README.md b/README.md index 457d79c..e263036 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ peers_b = client.mesh.list_peers(node="studio-b") # any method takes node= for ``` 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. diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 789703b..1b636de 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -96,6 +96,33 @@ def test_mesh_reads_are_still_retried(wave, recorder, monkeypatch): 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"): diff --git a/wave_sdk/mesh.py b/wave_sdk/mesh.py index f54ee87..049fb7a 100644 --- a/wave_sdk/mesh.py +++ b/wave_sdk/mesh.py @@ -5,7 +5,8 @@ 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. +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 @@ -45,10 +46,13 @@ def __init__(self, client: WaveClient, node: str | None = None): self.node = node def _headers(self, node: str | None = None) -> dict[str, str]: - name = node or self.node - if not name: + # 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 an x-wave-node target; " + "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: From c89e09f91723580357504886c7df5b03a99389a2 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Wed, 30 Sep 2026 18:55:10 -0400 Subject: [PATCH 8/8] test(contract): tolerate only a model rejecting the mock body after send The contract harness swallowed ValueError, TypeError, KeyError and AttributeError once a request had gone out, so a real bug in response handling could pass as "sent the right route". Of the 64 mapped operations, the 28 that raise after sending all raise a pydantic ValidationError because the mock answers {}, so that is now the only exception tolerated; anything else propagates and fails the test. Pre-send failures already raised and still do. Three meta-tests pin the harness itself: a method that raises before sending fails, one that sends and then raises AttributeError fails, and the real usage.get() still reports GET /v1/usage. Co-Authored-By: Claude Opus 5.5 --- tests/test_contract_coverage.py | 36 +++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/test_contract_coverage.py b/tests/test_contract_coverage.py index dd24301..46736b8 100644 --- a/tests/test_contract_coverage.py +++ b/tests/test_contract_coverage.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Any +import pydantic import pytest from tests.conftest import Recorder, attach @@ -198,8 +199,11 @@ def _send(monkeypatch: pytest.MonkeyPatch, namespace: str, method: str, args: tu warnings.simplefilter("error", DeprecationWarning) try: call(*args, **kwargs) - except (ValueError, TypeError, KeyError, AttributeError) as exc: - # The mock answers `{}`; a response model may reject it after the request was sent. + 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 @@ -212,6 +216,34 @@ def _send(monkeypatch: pytest.MonkeyPatch, namespace: str, method: str, args: tu 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 or truncated fixture silently passing everything.""" assert SNAPSHOT["total_ops"] == len(OPERATIONS) >= 250