-
Notifications
You must be signed in to change notification settings - Fork 0
Add wave.compose: the PR4 Composer rendering behind POST /v1/compose #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The changelog marks Prompt for AI agents |
||
|
|
||
| ### 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Flag AI Slop and Fabricated Changes
Prompt for AI agents
Suggested change
|
||||||
| 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 | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| { | ||
| "id": "prp_webinar_captions_001", | ||
| "intent": "live captions for tomorrow's webinar", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The intent contains an apostrophe, but Assessment: 🟠 Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** tests/fixtures/compose_proposal.json
**Line:** 3:3
**Comment:**
*Logic Error: The intent contains an apostrophe, but `save_flow()` wraps JSON in single quotes, so the generated curl breaks when pasted into a shell.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
| "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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: <paste" in headers_section | ||
|
|
||
|
|
||
| def test_save_flow_prints_the_curl_it_returns(mock_client, fixture_dict, capsys): | ||
| proposal = ComposeProposal.model_validate(fixture_dict) | ||
| api = ComposeAPI(mock_client) | ||
| curl = api.save_flow(proposal) | ||
| printed = capsys.readouterr().out | ||
| assert curl.strip() in printed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Move the
2.2.0entries underUnreleased.This commit adds the SDK API. Release publication occurs separately through the tag-triggered workflow after a validated version tag. Keep these entries under
## [Unreleased]until that release step.🤖 Prompt for AI Agents