Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ dynamic = ["version"]

[tool.poetry]
name = "smallestai"
version = "5.4.2"
version = "5.5.0"
description = ""
readme = "README.md"
authors = []
Expand Down
19 changes: 18 additions & 1 deletion src/smallestai/waves/helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
140 changes: 140 additions & 0 deletions src/smallestai/waves/helpers/tts.py
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions tests/custom/test_tts_helper.py
Original file line number Diff line number Diff line change
@@ -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()
Loading