diff --git a/changelog.md b/changelog.md index 1f66a57b..0feb8ae3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,20 @@ +## 5.5.0 - 2026-08-05 + +DevX pass (backward-compatible). + +* **errors**: a plan/entitlement-gated request (HTTP 400, "…please upgrade to a + higher plan…") is now raised as `PlanNotEntitledError` — a subclass of + `BadRequestError`, so `except BadRequestError` still catches it. Import it from + `smallestai` or the new `smallestai.errors` module. +* **errors**: error messages now carry an actionable hint — 401 points to + `SMALLEST_API_KEY`, a plan-gated 400 points to upgrading, an org-gated 403 + points to your account team. +* **waves**: new TTS convenience helpers — `synthesize_to_file`, + `synthesize_bytes` (and `synthesize_with_expiry`) over `client.waves`. +* **waves**: Enterprise content-expiry opt-in — pass `expire_content=True` to the + TTS helpers to send `x-expire-content`, or use `synthesize_with_expiry` to also + read the `x-content-expiry` outcome. + ## 5.4.2 - 2026-08-05 * **observability**: every request now sends `X-Source: smallest-python-sdk` so diff --git a/pyproject.toml b/pyproject.toml index b73d083e..ae784bdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ dynamic = ["version"] [tool.poetry] name = "smallestai" -version = "5.4.2" +version = "5.5.0" description = "" readme = "README.md" authors = [] diff --git a/src/smallestai/waves/helpers/__init__.py b/src/smallestai/waves/helpers/__init__.py index 70e87f70..670d7e76 100644 --- a/src/smallestai/waves/helpers/__init__.py +++ b/src/smallestai/waves/helpers/__init__.py @@ -1,3 +1,20 @@ from .streaming_stt import build_stt_stream_query, stream_speech_to_text +from .tts import ( + CONTENT_EXPIRY_HEADER, + DEFAULT_TTS_MODEL, + EXPIRE_CONTENT_HEADER, + synthesize_bytes, + synthesize_to_file, + synthesize_with_expiry, +) -__all__ = ["stream_speech_to_text", "build_stt_stream_query"] +__all__ = [ + "stream_speech_to_text", + "build_stt_stream_query", + "synthesize_bytes", + "synthesize_to_file", + "synthesize_with_expiry", + "DEFAULT_TTS_MODEL", + "EXPIRE_CONTENT_HEADER", + "CONTENT_EXPIRY_HEADER", +] diff --git a/src/smallestai/waves/helpers/tts.py b/src/smallestai/waves/helpers/tts.py new file mode 100644 index 00000000..787b983e --- /dev/null +++ b/src/smallestai/waves/helpers/tts.py @@ -0,0 +1,140 @@ +"""Convenience wrappers over the waves text-to-speech endpoints. + +`client.waves` exposes several `synthesize_*` methods (per model / transport). +For the common case — "turn this text into an audio file" — start here: + + from smallestai import SmallestAI + from smallestai.waves.helpers import synthesize_to_file, synthesize_bytes + + client = SmallestAI(api_key="...") + synthesize_to_file(client, "Hello from Smallest.", voice_id="kanik", path="hello.wav") + audio = synthesize_bytes(client, "Hello again.", voice_id="kanik") + +Both wrap `client.waves.synthesize_tts` (which streams audio chunks) and accept +any of its keyword options via `**kwargs` (e.g. `sample_rate`, `speed`, +`language`). Sync client only. + +Content expiry (Enterprise): pass `expire_content=True` to opt a request's +free-text content into deletion after 7 days (billing/usage unaffected). The +opt-in is silently ignored on non-Enterprise plans, so to confirm it took effect +use `synthesize_with_expiry`, which also returns the `x-content-expiry` outcome. +""" + +import typing + +# The current default TTS model. NB: the tts endpoint uses the underscore form +# (`lightning_v3.1`); the get-voices endpoint uses the hyphen form. See waves CLI. +DEFAULT_TTS_MODEL = "lightning_v3.1" + +# Enterprise content-expiry opt-in (request) and its outcome (response). +EXPIRE_CONTENT_HEADER = "x-expire-content" +CONTENT_EXPIRY_HEADER = "x-content-expiry" + + +def _kwargs_with_expiry(kwargs: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Return a copy of kwargs with the x-expire-content header set on + request_options (merging any headers the caller already passed).""" + kwargs = dict(kwargs) + request_options = dict(kwargs.get("request_options") or {}) + headers = dict(request_options.get("additional_headers") or {}) + headers[EXPIRE_CONTENT_HEADER] = "true" + request_options["additional_headers"] = headers + kwargs["request_options"] = request_options + return kwargs + + +def _synthesize_stream( + client: typing.Any, + text: str, + *, + voice_id: str, + model: str, + output_format: str, + expire_content: bool, + kwargs: typing.Dict[str, typing.Any], +) -> typing.Iterator[bytes]: + if expire_content: + kwargs = _kwargs_with_expiry(kwargs) + return client.waves.synthesize_tts( + text=text, voice_id=voice_id, model=model, output_format=output_format, **kwargs + ) + + +def synthesize_bytes( + client: typing.Any, + text: str, + *, + voice_id: str, + model: str = DEFAULT_TTS_MODEL, + output_format: str = "wav", + expire_content: bool = False, + **kwargs: typing.Any, +) -> bytes: + """Synthesize `text` and return the full audio as bytes (buffers the stream).""" + return b"".join( + _synthesize_stream( + client, + text, + voice_id=voice_id, + model=model, + output_format=output_format, + expire_content=expire_content, + kwargs=kwargs, + ) + ) + + +def synthesize_to_file( + client: typing.Any, + text: str, + *, + voice_id: str, + path: str, + model: str = DEFAULT_TTS_MODEL, + output_format: str = "wav", + expire_content: bool = False, + **kwargs: typing.Any, +) -> int: + """Synthesize `text` and stream it to `path`. Returns the number of bytes written.""" + written = 0 + with open(path, "wb") as fh: + for chunk in _synthesize_stream( + client, + text, + voice_id=voice_id, + model=model, + output_format=output_format, + expire_content=expire_content, + kwargs=kwargs, + ): + fh.write(chunk) + written += len(chunk) + return written + + +def synthesize_with_expiry( + client: typing.Any, + text: str, + *, + voice_id: str, + model: str = DEFAULT_TTS_MODEL, + output_format: str = "wav", + **kwargs: typing.Any, +) -> typing.Tuple[bytes, typing.Optional[str]]: + """Synthesize with the Enterprise content-expiry opt-in and report the outcome. + + Returns ``(audio_bytes, outcome)`` where ``outcome`` is the value of the + ``x-content-expiry`` response header: ``"not-entitled"`` means the plan does + not include content expiry (content is retained), a truthy value means it was + applied, and ``None`` means the header was absent. Use this when you need to + confirm the opt-in took effect (a non-Enterprise request still succeeds — the + header is simply ignored).""" + kwargs = _kwargs_with_expiry(kwargs) + # Streaming raw responses are exposed as a context manager yielding an + # object with `.data` (the byte stream) and `.headers`. + with client.waves.with_raw_response.synthesize_tts( + text=text, voice_id=voice_id, model=model, output_format=output_format, **kwargs + ) as raw: + audio = b"".join(raw.data) + outcome = raw.headers.get(CONTENT_EXPIRY_HEADER) if raw.headers is not None else None + return audio, outcome diff --git a/tests/custom/test_tts_helper.py b/tests/custom/test_tts_helper.py new file mode 100644 index 00000000..ca5a004a --- /dev/null +++ b/tests/custom/test_tts_helper.py @@ -0,0 +1,82 @@ +"""waves TTS convenience helpers collapse the synthesize_* surface to two obvious +entry points. Verified with a mocked client (no network).""" +import os +import tempfile +import unittest +from unittest import mock + +from smallestai.waves.helpers import ( + CONTENT_EXPIRY_HEADER, + DEFAULT_TTS_MODEL, + EXPIRE_CONTENT_HEADER, + synthesize_bytes, + synthesize_to_file, + synthesize_with_expiry, +) + + +def _client(chunks): + c = mock.MagicMock() + c.waves.synthesize_tts.return_value = iter(chunks) + return c + + +class TtsHelperTest(unittest.TestCase): + def test_synthesize_bytes_joins_chunks(self): + c = _client([b"ab", b"cd", b"e"]) + self.assertEqual(synthesize_bytes(c, "hi", voice_id="v"), b"abcde") + + def test_synthesize_to_file_writes_and_counts(self): + c = _client([b"12", b"345"]) + path = os.path.join(tempfile.gettempdir(), "ut_tts_helper.bin") + n = synthesize_to_file(c, "hi", voice_id="v", path=path) + self.assertEqual(n, 5) + with open(path, "rb") as fh: + self.assertEqual(fh.read(), b"12345") + + def test_defaults_and_kwargs_forwarded(self): + c = _client([b"x"]) + synthesize_bytes(c, "hi", voice_id="v", speed=1.2) + _, kw = c.waves.synthesize_tts.call_args + self.assertEqual(kw["voice_id"], "v") + self.assertEqual(kw["model"], DEFAULT_TTS_MODEL) + self.assertEqual(kw["output_format"], "wav") + self.assertEqual(kw["speed"], 1.2) + + def test_expire_content_sets_header_and_preserves_caller_headers(self): + c = _client([b"x"]) + synthesize_bytes( + c, + "hi", + voice_id="v", + expire_content=True, + request_options={"additional_headers": {"x-trace": "1"}}, + ) + _, kw = c.waves.synthesize_tts.call_args + headers = kw["request_options"]["additional_headers"] + self.assertEqual(headers[EXPIRE_CONTENT_HEADER], "true") + self.assertEqual(headers["x-trace"], "1") # caller header preserved + + def test_no_header_when_expire_content_false(self): + c = _client([b"x"]) + synthesize_bytes(c, "hi", voice_id="v") + _, kw = c.waves.synthesize_tts.call_args + self.assertNotIn("request_options", kw) + + def test_synthesize_with_expiry_returns_outcome(self): + c = mock.MagicMock() + raw = mock.MagicMock() + raw.data = iter([b"ab", b"cd"]) + raw.headers = {CONTENT_EXPIRY_HEADER: "not-entitled"} + cm = mock.MagicMock() + cm.__enter__.return_value = raw + c.waves.with_raw_response.synthesize_tts.return_value = cm + audio, outcome = synthesize_with_expiry(c, "hi", voice_id="v") + self.assertEqual(audio, b"abcd") + self.assertEqual(outcome, "not-entitled") + _, kw = c.waves.with_raw_response.synthesize_tts.call_args + self.assertEqual(kw["request_options"]["additional_headers"][EXPIRE_CONTENT_HEADER], "true") + + +if __name__ == "__main__": + unittest.main()