From c99f9ca9aa1debe659e8775379a10ed076ea2f30 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Sun, 6 Sep 2026 15:58:45 -0400 Subject: [PATCH] Add wave.compose: the PR4 Composer rendering behind POST /v1/compose wave.compose.compose(intent, budget_usd=None, flow_id=None, referer=None) proposes a plan across WAVE products for a plain-English intent and returns a typed ComposeProposal matching the wire contract field for field via pydantic aliases (camelCase on the wire, snake_case in Python). A proposal never executes anything: executes is a literal False, never derived. wave.compose.get_proposal(id) re-reads a stored proposal instead of re-composing. wave.compose.save_flow(proposal) builds the console flows-save body with createdBy.kind: "wave-composer" plus the proposal manifestHash/groundedAt. No machine-auth token exists yet for wave-composer callers, so this method makes zero HTTP calls and never invents a credential; it prints and returns the exact curl a signed-in console session can paste. Never a silent no-op. Bumped 2.1.0 -> 2.2.0 (additive). Tests: a fixture round-trip proving model_validate -> model_dump(by_alias=True) reproduces the wire JSON byte-identically, a MagicMock-boundary test and a real httpx.MockTransport test both proving compose() issues exactly one POST /v1/compose and no other request, and save_flow tests proving zero HTTP calls and no bearer token in its own request headers. 71/71 pytest pass; ruff check clean. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 53 +++++++ README.md | 24 ++- pyproject.toml | 4 +- tests/fixtures/compose_proposal.json | 31 ++++ tests/test_compose.py | 203 ++++++++++++++++++++++++++ tests/test_sdk_exports.py | 21 ++- wave_sdk/__init__.py | 7 + wave_sdk/client.py | 2 +- wave_sdk/compose.py | 211 +++++++++++++++++++++++++++ 9 files changed, 545 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/compose_proposal.json create mode 100644 tests/test_compose.py create mode 100644 wave_sdk/compose.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc016a..6289c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,59 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [2.2.0] - 2026-09-06 + +### Added + +PR4-SDK: `wave.compose` (`ComposeAPI`), the Python SDK's rendering of the +WAVE Composer: `POST /v1/compose`, the shared cross-rendering (API, CLI, SDK, +MCP) contract. Types mirror the API's own `ComposeProposal` wire type field +for field via pydantic aliases, so the wire JSON stays camelCase while +Python attributes stay snake_case. + +- `compose(intent, *, budget_usd=None, flow_id=None, referer=None)` - + `POST /v1/compose` (`composer:write`). Returns a typed `ComposeProposal`: + `stages[]`, `product_ids[]`, `tools[]`, `scopes[]`, `price_rows[]`, + `call_shape`, `next_[]`, `executes` (always `False` - a proposal never + executes anything), `grounding`, `grounded_at`, `manifest_hash`, `engine`, + `flow_id`. +- `get_proposal(proposal_id)` - `GET /v1/compose/proposals/:id` + (`composer:read`), re-reading a stored proposal instead of re-composing. +- `save_flow(proposal)` - builds the `POST /api/console/flows` body with + `createdBy.kind: "wave-composer"` and the proposal's `manifest_hash` / + `grounded_at`. There is no machine-auth token for `wave-composer` callers + yet (the console's flow-save route is session-cookie only until a + composer:write console token ships; OWED). This method never calls the + console route and never invents a credential to do so - it prints, and + returns, the exact `curl` a human in a signed-in console session can + paste. Never a silent no-op. +- `wave.compose` never calls a product route: the only network calls it + makes are `POST /v1/compose` and `GET /v1/compose/proposals/:id`. + +### Testing + +- `tests/test_compose.py` - a fixture round-trip test (`model_validate` -> + `model_dump(by_alias=True)` reproduces the fixture byte-identically), + a transport-mock test asserting `compose()` issues exactly one + `POST /v1/compose` and no other request, and a test that `save_flow()` + makes zero HTTP calls and returns a curl string naming + `/api/console/flows` and `createdBy":{"kind":"wave-composer"` with no + bearer token embedded. +- `tests/fixtures/compose_proposal.json` - a hand-built `ComposeProposal` + fixture (a webinar-captions composition, matching the shape and sample + values of the shared cross-rendering conformance scenario). No upstream + engine source was copied into this repo: that reference is TS test-engine + plumbing (a fake model door, a fake quote stub, a live-index builder) with + no literal request/response JSON to copy verbatim, so this fixture is a + hand-built equivalent in the same scenario rather than a byte-copy. +- Updated `tests/test_sdk_exports.py` for the new API count (43 + client) + and version (2.2.0). + +### Changed + +- Bumped to 2.2.0 (additive, semver-minor): no existing method signature + changed. + ## [2.1.0] - 2026-09-01 (not yet published to PyPI) ### Fixed diff --git a/README.md b/README.md index fc519d9..98bb7f7 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,14 @@ 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 42 APIs +## All 43 APIs ### P1 - Core @@ -101,6 +106,23 @@ captions = client.captions.generate(media_id=transcription.id, media_type="video | `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: + +```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 +``` + ## Error handling ```python diff --git a/pyproject.toml b/pyproject.toml index 243a6f7..02efa13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "wave-sdk" -version = "2.1.0" -description = "Official WAVE SDK for Python - 42 API modules for streaming, production, analytics, and more" +version = "2.2.0" +description = "Official WAVE SDK for Python - 43 API modules for streaming, production, analytics, and more" 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" diff --git a/tests/fixtures/compose_proposal.json b/tests/fixtures/compose_proposal.json new file mode 100644 index 0000000..7cb84a4 --- /dev/null +++ b/tests/fixtures/compose_proposal.json @@ -0,0 +1,31 @@ +{ + "id": "prp_webinar_captions_001", + "intent": "live captions for tomorrow's webinar", + "stages": [ + { "product": "realtime", "why": "carries your webinar audio in real time" }, + { "product": "transcribe", "why": "turns speech into timed text as it happens" }, + { "product": "captions", "why": "puts that text on the stream for viewers" } + ], + "productIds": ["realtime", "transcribe", "captions"], + "tools": ["perception_subscribe", "wave_create_transcription", "wave_create_caption_job"], + "scopes": [ + { "scope": "realtime:read", "mintable": true, "source": "open-by-default.ts:101" }, + { "scope": "transcribe:write", "mintable": true, "source": "open-by-default.ts:118" }, + { "scope": "captions:write", "mintable": true, "source": "open-by-default.ts:126" } + ], + "priceRows": [ + { "product": "captions", "meter": "wave_caption_minutes", "usd": 0.025, "unit": "caption minute", "quotedAt": 1757100000, "validForS": 60 }, + { "product": "realtime", "meter": "wave_realtime_video_minutes", "quote": "quote at call time", "reason": "gateway default, no quote_token" } + ], + "callShape": { + "http": "curl -X POST https://gateway.wave.online/v1/captions -H \"Authorization: Bearer $WAVE_API_KEY\" -d '{\"media_id\":\"...\"}'", + "mcp": { "tool": "wave_create_caption_job", "args": { "media_id": "..." } } + }, + "next": ["Add chapters after the webinar ends", "Save the caption style as a preset"], + "executes": false, + "grounding": "live", + "groundedAt": "2026-09-06T04:12:09Z", + "manifestHash": "sha256:59b188cc00000000000000000000000000000000000000000000000000abcd", + "engine": { "route": "dispatch", "promptHash": "sha256:0000000000000000000000000000000000000000000000000000000000ab", "model": null }, + "flowId": null +} diff --git a/tests/test_compose.py b/tests/test_compose.py new file mode 100644 index 0000000..fdd6430 --- /dev/null +++ b/tests/test_compose.py @@ -0,0 +1,203 @@ +"""Unit tests for `wave_sdk.compose` (`ComposeAPI`, `POST /v1/compose`). + +Three things this file proves: + 1. Fixture round-trip: `tests/fixtures/compose_proposal.json` parses into + `ComposeProposal` and `model_dump(by_alias=True)` reproduces it + byte-identically (field for field, camelCase wire names preserved). + 2. Transport mock: `compose()` issues exactly one request - `POST + /v1/compose` - and nothing else, whether asserted at the WaveClient + method boundary (the pattern the rest of this SDK's tests use) or at + the real httpx transport underneath it. + 3. `save_flow()` makes zero HTTP calls (no machine-auth token exists yet) + and returns/prints an exact curl for a signed-in console session, + never a bearer token. +""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import httpx +import pytest + +from wave_sdk.client import WaveClient +from wave_sdk.compose import ( + ComposeAPI, + ComposeProposal, + QuotedPriceRow, + UnquotedPriceRow, +) + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "compose_proposal.json" + + +@pytest.fixture +def fixture_dict() -> dict: + return json.loads(FIXTURE_PATH.read_text()) + + +@pytest.fixture +def mock_client(): + return MagicMock() + + +# --------------------------------------------------------------------------- +# 1. Fixture round-trip +# --------------------------------------------------------------------------- + + +def test_fixture_round_trips_byte_identical(fixture_dict): + proposal = ComposeProposal.model_validate(fixture_dict) + dumped = proposal.model_dump(by_alias=True) + assert dumped == fixture_dict + + +def test_fixture_price_rows_resolve_to_the_right_variant(fixture_dict): + proposal = ComposeProposal.model_validate(fixture_dict) + assert isinstance(proposal.price_rows[0], QuotedPriceRow) + assert proposal.price_rows[0].usd == 0.025 + assert isinstance(proposal.price_rows[1], UnquotedPriceRow) + assert proposal.price_rows[1].quote == "quote at call time" + + +def test_fixture_executes_is_always_false(fixture_dict): + proposal = ComposeProposal.model_validate(fixture_dict) + assert proposal.executes is False + + +def test_fixture_engine_model_is_null(fixture_dict): + proposal = ComposeProposal.model_validate(fixture_dict) + assert proposal.engine.model is None + + +def test_fixture_snake_case_attrs_are_ergonomic(fixture_dict): + """The Python-facing attribute names are snake_case even though the wire + fixture is camelCase - both must resolve to the same data.""" + proposal = ComposeProposal.model_validate(fixture_dict) + assert proposal.product_ids == fixture_dict["productIds"] + assert proposal.call_shape.http == fixture_dict["callShape"]["http"] + assert proposal.next_ == fixture_dict["next"] + assert proposal.grounded_at == fixture_dict["groundedAt"] + assert proposal.manifest_hash == fixture_dict["manifestHash"] + assert proposal.flow_id is None + + +# --------------------------------------------------------------------------- +# 2. Transport: compose() sends exactly one POST /v1/compose, nothing else +# --------------------------------------------------------------------------- + + +def test_compose_posts_exactly_once_to_v1_compose(mock_client, fixture_dict): + mock_client.post.return_value = fixture_dict + api = ComposeAPI(mock_client) + proposal = api.compose("live captions for tomorrow's webinar", budget_usd=5, flow_id="flw_abc123") + + mock_client.post.assert_called_once_with( + "/v1/compose", + json={"intent": "live captions for tomorrow's webinar", "budgetUsd": 5, "flowId": "flw_abc123"}, + ) + mock_client.get.assert_not_called() + mock_client.put.assert_not_called() + mock_client.patch.assert_not_called() + mock_client.delete.assert_not_called() + assert isinstance(proposal, ComposeProposal) + assert proposal.id == fixture_dict["id"] + + +def test_compose_referer_goes_in_context_never_elsewhere(mock_client, fixture_dict): + mock_client.post.return_value = fixture_dict + api = ComposeAPI(mock_client) + api.compose("test intent", referer="captions.wave.online") + mock_client.post.assert_called_once_with( + "/v1/compose", + json={"intent": "test intent", "context": {"referer": "captions.wave.online"}}, + ) + + +def test_get_proposal_reads_by_id(mock_client, fixture_dict): + mock_client.get.return_value = fixture_dict + api = ComposeAPI(mock_client) + proposal = api.get_proposal("prp_webinar_captions_001") + mock_client.get.assert_called_once_with("/v1/compose/proposals/prp_webinar_captions_001") + mock_client.post.assert_not_called() + assert proposal.id == fixture_dict["id"] + + +def test_compose_only_call_at_the_real_transport_layer(fixture_dict): + """End-to-end through a real `WaveClient`: exactly one HTTP request + reaches the transport, and it is `POST /v1/compose` - never any product + route.""" + requests_seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests_seen.append(request) + return httpx.Response(200, json=fixture_dict) + + client = WaveClient(api_key="test-key") + client._client = httpx.Client( + base_url=client.base_url, + headers=client._build_headers(), + transport=httpx.MockTransport(handler), + ) + api = ComposeAPI(client) + proposal = api.compose("live captions for tomorrow's webinar") + + assert len(requests_seen) == 1 + assert requests_seen[0].method == "POST" + assert requests_seen[0].url.path == "/v1/compose" + assert proposal.id == fixture_dict["id"] + + +# --------------------------------------------------------------------------- +# 3. save_flow(): zero HTTP calls, an honest curl, never a silent no-op +# --------------------------------------------------------------------------- + + +def test_save_flow_makes_zero_http_calls(mock_client, fixture_dict, capsys): + proposal = ComposeProposal.model_validate(fixture_dict) + api = ComposeAPI(mock_client) + curl = api.save_flow(proposal) + + mock_client.post.assert_not_called() + mock_client.get.assert_not_called() + mock_client.put.assert_not_called() + mock_client.patch.assert_not_called() + mock_client.delete.assert_not_called() + assert isinstance(curl, str) + + +def test_save_flow_curl_names_the_flows_route_and_created_by(mock_client, fixture_dict): + proposal = ComposeProposal.model_validate(fixture_dict) + api = ComposeAPI(mock_client) + curl = api.save_flow(proposal) + + assert "/api/console/flows" in curl + assert '"createdBy": {"kind": "wave-composer"}' in curl + assert fixture_dict["manifestHash"] in curl + assert fixture_dict["groundedAt"] in curl + + +def test_save_flow_never_adds_a_bearer_token_of_its_own(mock_client, fixture_dict): + """save_flow()'s own console request carries only a Content-Type header + and a placeholder Cookie - never an Authorization header, since there is + no machine-auth token to put in one. (The proposal body it posts may + itself contain an example product-route curl with its own + `Authorization: Bearer $WAVE_API_KEY` placeholder - that is data the + engine generated describing a DIFFERENT call, not a credential this + method adds.)""" + proposal = ComposeProposal.model_validate(fixture_dict) + api = ComposeAPI(mock_client) + curl = api.save_flow(proposal) + + headers_section = curl.split(" -d '", 1)[0] + assert "Authorization" not in headers_section + assert "Cookie: ComposeProposal: + """`POST /v1/compose` (composer:write). `intent` is 1..280 chars + after the API's own sanitizer; `flow_id` re-proposes a saved flow + against today's manifests; `referer` orders retrieval only.""" + body: dict[str, Any] = {"intent": intent} + if budget_usd is not None: + body["budgetUsd"] = budget_usd + if flow_id is not None: + body["flowId"] = flow_id + if referer is not None: + body["context"] = {"referer": referer} + return ComposeProposal(**self._client.post(self._base, json=body)) + + def get_proposal(self, proposal_id: str) -> ComposeProposal: + """`GET /v1/compose/proposals/:id` (composer:read) - re-read a stored + proposal instead of re-composing.""" + return ComposeProposal(**self._client.get(f"{self._base}/proposals/{proposal_id}")) + + def save_flow( + self, + proposal: ComposeProposal, + *, + console_base_url: str = "https://console.wave.online", + ) -> str: + """Save `proposal` as a flow with `createdBy.kind: "wave-composer"`, + carrying the proposal's `manifestHash` and `groundedAt` so the saved + flow records what it was grounded on. + + There is no machine-auth token for `wave-composer` callers today - + the console's flow-save route is session-cookie only until a + composer:write console token ships (OWED, tracked in the console + and gateway repos). This method NEVER calls the console route and + NEVER invents a credential to do so - a silent no-op would be worse + than an honest gap. It prints, and returns, the exact `curl` a human + in a signed-in console session can paste to do the save themselves. + Once the machine-auth token exists, this is the one place that + gains a `token=` parameter and starts posting for real. + """ + import json as _json + + body = { + **proposal.model_dump(by_alias=True, exclude_none=True), + "createdBy": {"kind": "wave-composer"}, + } + curl = ( + f"curl -X POST {console_base_url.rstrip('/')}/api/console/flows \\\n" + ' -H "Content-Type: application/json" \\\n' + ' -H "Cookie: " \\\n' + f" -d '{_json.dumps(body)}'" + ) + print(curl) # noqa: T201 - the exact curl IS the return value; never a silent no-op. + return curl