From 7cca36d63fe19119159c7f960cf2778d21460b65 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Thu, 23 Jul 2026 15:33:40 -0700 Subject: [PATCH 1/3] feat: let wrappers identify themselves in X-Sonilo-Client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI and video kit both build on the SDK, so every call they made reported as sdk-python — making CLI and kit traffic indistinguishable from direct SDK use in server-side analytics, with no way to recover the split retroactively. Sonilo/AsyncSonilo now take optional client_name/client_version, defaulting to sdk-python and the SDK version. The CLI sends cli-python and the video kit sends kit-python-video, each with its own package version. A caller-supplied client keeps its owner's identity; only the kit's internally constructed default clients are tagged. Released as a patch (0.5.1) rather than a minor: the change is purely additive, and a 0.6.0 would have forced pin bumps and re-releases across sonilo-cli and sonilo-video-kit, which pin <0.6. --- pyproject.toml | 2 +- sonilo-cli/pyproject.toml | 2 +- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- sonilo-cli/src/sonilo_cli/__main__.py | 4 +- sonilo-cli/tests/test_cli.py | 13 +++ sonilo-video-kit/pyproject.toml | 2 +- .../src/sonilo_video_kit/_version.py | 1 + sonilo-video-kit/src/sonilo_video_kit/duck.py | 6 +- .../src/sonilo_video_kit/generate.py | 6 +- sonilo-video-kit/tests/test_smoke.py | 14 ++++ src/sonilo/_async_client.py | 8 +- src/sonilo/_client.py | 23 +++++- src/sonilo/_version.py | 2 +- tests/test_client_identity.py | 82 +++++++++++++++++++ 14 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 sonilo-video-kit/src/sonilo_video_kit/_version.py create mode 100644 tests/test_client_identity.py diff --git a/pyproject.toml b/pyproject.toml index 91e01c5..fd4e058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.5.0" +version = "0.5.1" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 2be4520..f20eb40 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo-cli" -version = "0.1.0" +version = "0.1.1" description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index de48b44..0dc5d6a 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 29fce0e..2eb8b4a 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -43,7 +43,9 @@ def build_client(api_key: Optional[str]) -> Sonilo: "no API key — pass --api-key or set the " "SONILO_API_KEY environment variable" ) - return Sonilo(api_key=key) + # Identify as the CLI rather than inheriting the SDK's own name, so CLI + # traffic stays separable from direct SDK use in server-side analytics. + return Sonilo(api_key=key, client_name="cli-python", client_version=__version__) def cmd_account(client: Sonilo, args: argparse.Namespace) -> None: diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 9d7d5ff..f8e959c 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -369,3 +369,16 @@ def test_video_to_video_sound_requires_a_video_source(): with pytest.raises(SystemExit) as exc: run(["video-to-video-sound"]) assert exc.value.code == 1 + + +def test_cli_identifies_itself_not_the_sdk(): + """CLI traffic must be separable from direct SDK use in analytics.""" + import sonilo_cli + from sonilo_cli.__main__ import build_client + + client = build_client("sk-test") + try: + assert client._http.headers["x-sonilo-client"] == "cli-python" + assert client._http.headers["x-sonilo-client-version"] == sonilo_cli.__version__ + finally: + client.close() diff --git a/sonilo-video-kit/pyproject.toml b/sonilo-video-kit/pyproject.toml index d0dd737..2aaefaf 100644 --- a/sonilo-video-kit/pyproject.toml +++ b/sonilo-video-kit/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo-video-kit" -version = "0.1.1" +version = "0.1.2" description = "Video helpers for the Sonilo API: generate a soundtrack and mix it into your video with ffmpeg" readme = "README.md" license = "MIT" diff --git a/sonilo-video-kit/src/sonilo_video_kit/_version.py b/sonilo-video-kit/src/sonilo_video_kit/_version.py new file mode 100644 index 0000000..b3f4756 --- /dev/null +++ b/sonilo-video-kit/src/sonilo_video_kit/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.2" diff --git a/sonilo-video-kit/src/sonilo_video_kit/duck.py b/sonilo-video-kit/src/sonilo_video_kit/duck.py index 32fea36..abd5e4f 100644 --- a/sonilo-video-kit/src/sonilo_video_kit/duck.py +++ b/sonilo-video-kit/src/sonilo_video_kit/duck.py @@ -72,7 +72,11 @@ def effective_download_cap(output_bytes: Optional[int]) -> int: def _default_client() -> DuckingClient: from sonilo import Sonilo - return Sonilo() + from sonilo_video_kit._version import __version__ + + # Only the kit's own default client is tagged; a caller-supplied client + # keeps whatever identity its owner gave it. + return Sonilo(client_name="kit-python-video", client_version=__version__) def _paid_note(task_id: str) -> str: diff --git a/sonilo-video-kit/src/sonilo_video_kit/generate.py b/sonilo-video-kit/src/sonilo_video_kit/generate.py index f3eff7e..ce88a22 100644 --- a/sonilo-video-kit/src/sonilo_video_kit/generate.py +++ b/sonilo-video-kit/src/sonilo_video_kit/generate.py @@ -23,5 +23,9 @@ def generate_music_for_video( ) -> Any: if client is None: from sonilo import Sonilo - client = Sonilo() + + from sonilo_video_kit._version import __version__ + # Only the kit's own default client is tagged; a caller-supplied client + # keeps whatever identity its owner gave it. + client = Sonilo(client_name="kit-python-video", client_version=__version__) return client.video_to_music.generate(video=video, prompt=prompt, segments=segments) diff --git a/sonilo-video-kit/tests/test_smoke.py b/sonilo-video-kit/tests/test_smoke.py index 44e058f..c786c95 100644 --- a/sonilo-video-kit/tests/test_smoke.py +++ b/sonilo-video-kit/tests/test_smoke.py @@ -3,3 +3,17 @@ def test_import(): assert hasattr(sonilo_video_kit, "__all__") + + +def test_default_clients_identify_as_the_video_kit(): + """Only the kit's own default client is tagged; a caller-supplied client + keeps its owner's identity.""" + from sonilo_video_kit._version import __version__ + from sonilo_video_kit.duck import _default_client + + client = _default_client() + try: + assert client._http.headers["x-sonilo-client"] == "kit-python-video" + assert client._http.headers["x-sonilo-client-version"] == __version__ + finally: + client.close() diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 40a84d4..3dfdc58 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -28,11 +28,17 @@ def __init__( api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT, + client_name: Optional[str] = None, + client_version: Optional[str] = None, ) -> None: + """`client_name`/`client_version` identify a wrapper built on this SDK + (the CLI, the video kit) in the `X-Sonilo-Client` headers. Leave both + unset for direct SDK use. + """ key = _resolve_api_key(api_key) self._http = httpx.AsyncClient( base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"), - headers=_default_headers(key, "sdk-python"), + headers=_default_headers(key, client_name, client_version), timeout=timeout, ) self.text_to_music = AsyncTextToMusic(self) diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index 200dd4e..540ac7f 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -23,6 +23,11 @@ DEFAULT_BASE_URL = "https://api.sonilo.com" DEFAULT_TIMEOUT = 600.0 +#: Reported in `X-Sonilo-Client` unless a wrapper overrides it. First-party +#: wrappers (the CLI, the video kit) pass their own name so their traffic stays +#: distinguishable from direct SDK use in server-side analytics. +DEFAULT_CLIENT_NAME = "sdk-python" + def _resolve_api_key(api_key: Optional[str]) -> str: key = api_key or os.environ.get("SONILO_API_KEY") @@ -33,11 +38,15 @@ def _resolve_api_key(api_key: Optional[str]) -> str: return key -def _default_headers(api_key: str, client_name: str) -> Dict[str, str]: +def _default_headers( + api_key: str, + client_name: Optional[str] = None, + client_version: Optional[str] = None, +) -> Dict[str, str]: return { "Authorization": f"Bearer {api_key}", - "X-Sonilo-Client": client_name, - "X-Sonilo-Client-Version": __version__, + "X-Sonilo-Client": client_name or DEFAULT_CLIENT_NAME, + "X-Sonilo-Client-Version": client_version or __version__, } @@ -49,11 +58,17 @@ def __init__( api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT, + client_name: Optional[str] = None, + client_version: Optional[str] = None, ) -> None: + """`client_name`/`client_version` identify a wrapper built on this SDK + (the CLI, the video kit) in the `X-Sonilo-Client` headers. Leave both + unset for direct SDK use. + """ key = _resolve_api_key(api_key) self._http = httpx.Client( base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"), - headers=_default_headers(key, "sdk-python"), + headers=_default_headers(key, client_name, client_version), timeout=timeout, ) self.text_to_music = TextToMusic(self) diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 3d18726..dd9b22c 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/tests/test_client_identity.py b/tests/test_client_identity.py new file mode 100644 index 0000000..b0ee776 --- /dev/null +++ b/tests/test_client_identity.py @@ -0,0 +1,82 @@ +"""Client-identity headers. + +First-party wrappers (the CLI, the video kit) sit on top of this SDK, so +without an override every one of their calls reports as `sdk-python` and +becomes indistinguishable from direct SDK use in server-side analytics. +""" + +import httpx +import pytest +import respx + +from sonilo import AsyncSonilo, Sonilo +from sonilo._client import DEFAULT_CLIENT_NAME +from sonilo._version import __version__ + +BASE = "https://api.sonilo.com" +SERVICES = {"available_services": []} + + +def _stub(): + return respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(200, json=SERVICES) + ) + + +@respx.mock +def test_defaults_to_sdk_python(): + route = _stub() + Sonilo(api_key="sk-test").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "sdk-python" + assert headers["x-sonilo-client-version"] == __version__ + + +def test_default_client_name_constant(): + assert DEFAULT_CLIENT_NAME == "sdk-python" + + +@respx.mock +def test_client_name_and_version_are_overridable(): + route = _stub() + Sonilo(api_key="sk-test", client_name="cli-python", client_version="1.2.3").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "cli-python" + assert headers["x-sonilo-client-version"] == "1.2.3" + + +@respx.mock +def test_overrides_are_independent(): + """Naming a wrapper must not force it to also restate a version, and vice versa.""" + route = _stub() + Sonilo(api_key="sk-test", client_name="kit-python-video").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "kit-python-video" + assert headers["x-sonilo-client-version"] == __version__ + + Sonilo(api_key="sk-test", client_version="9.9.9").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "sdk-python" + assert headers["x-sonilo-client-version"] == "9.9.9" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_client_honours_the_same_overrides(): + route = _stub() + await AsyncSonilo( + api_key="sk-test", client_name="cli-python", client_version="1.2.3" + ).account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "cli-python" + assert headers["x-sonilo-client-version"] == "1.2.3" + + +@respx.mock +@pytest.mark.asyncio +async def test_async_defaults_to_sdk_python(): + route = _stub() + await AsyncSonilo(api_key="sk-test").account.services() + headers = route.calls.last.request.headers + assert headers["x-sonilo-client"] == "sdk-python" + assert headers["x-sonilo-client-version"] == __version__ From ea315c7a23d30f2986149ddf371107e46e63422b Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Thu, 23 Jul 2026 15:35:46 -0700 Subject: [PATCH 2/3] test(video-kit): set an API key for the default-client identity test _default_client() builds Sonilo() without an explicit key, so the SDK reads SONILO_API_KEY. The test passed locally only because a key happened to be exported; CI has none and failed on both matrix entries. --- sonilo-video-kit/tests/test_smoke.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sonilo-video-kit/tests/test_smoke.py b/sonilo-video-kit/tests/test_smoke.py index c786c95..8db6871 100644 --- a/sonilo-video-kit/tests/test_smoke.py +++ b/sonilo-video-kit/tests/test_smoke.py @@ -5,12 +5,15 @@ def test_import(): assert hasattr(sonilo_video_kit, "__all__") -def test_default_clients_identify_as_the_video_kit(): +def test_default_clients_identify_as_the_video_kit(monkeypatch): """Only the kit's own default client is tagged; a caller-supplied client keeps its owner's identity.""" from sonilo_video_kit._version import __version__ from sonilo_video_kit.duck import _default_client + # _default_client() builds Sonilo() with no explicit key, so the SDK reads + # the environment — set one, or this fails wherever no key is configured. + monkeypatch.setenv("SONILO_API_KEY", "sk-test") client = _default_client() try: assert client._http.headers["x-sonilo-client"] == "kit-python-video" From e60da8bff5bd0fb81ede62ddf3e4b2f3041e3931 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Thu, 23 Jul 2026 15:44:10 -0700 Subject: [PATCH 3/3] refactor: name the kit identity videokit-python for pattern consistency The dashboard's source taxonomy is - (sdk-js, sdk-python). kit-python-video broke that ordering; videokit-python lines up with cli-python and leaves room for videokit-js. --- sonilo-video-kit/src/sonilo_video_kit/duck.py | 2 +- sonilo-video-kit/src/sonilo_video_kit/generate.py | 2 +- sonilo-video-kit/tests/test_smoke.py | 2 +- tests/test_client_identity.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sonilo-video-kit/src/sonilo_video_kit/duck.py b/sonilo-video-kit/src/sonilo_video_kit/duck.py index abd5e4f..c56f07b 100644 --- a/sonilo-video-kit/src/sonilo_video_kit/duck.py +++ b/sonilo-video-kit/src/sonilo_video_kit/duck.py @@ -76,7 +76,7 @@ def _default_client() -> DuckingClient: # Only the kit's own default client is tagged; a caller-supplied client # keeps whatever identity its owner gave it. - return Sonilo(client_name="kit-python-video", client_version=__version__) + return Sonilo(client_name="videokit-python", client_version=__version__) def _paid_note(task_id: str) -> str: diff --git a/sonilo-video-kit/src/sonilo_video_kit/generate.py b/sonilo-video-kit/src/sonilo_video_kit/generate.py index ce88a22..f5245c7 100644 --- a/sonilo-video-kit/src/sonilo_video_kit/generate.py +++ b/sonilo-video-kit/src/sonilo_video_kit/generate.py @@ -27,5 +27,5 @@ def generate_music_for_video( from sonilo_video_kit._version import __version__ # Only the kit's own default client is tagged; a caller-supplied client # keeps whatever identity its owner gave it. - client = Sonilo(client_name="kit-python-video", client_version=__version__) + client = Sonilo(client_name="videokit-python", client_version=__version__) return client.video_to_music.generate(video=video, prompt=prompt, segments=segments) diff --git a/sonilo-video-kit/tests/test_smoke.py b/sonilo-video-kit/tests/test_smoke.py index 8db6871..624d4b5 100644 --- a/sonilo-video-kit/tests/test_smoke.py +++ b/sonilo-video-kit/tests/test_smoke.py @@ -16,7 +16,7 @@ def test_default_clients_identify_as_the_video_kit(monkeypatch): monkeypatch.setenv("SONILO_API_KEY", "sk-test") client = _default_client() try: - assert client._http.headers["x-sonilo-client"] == "kit-python-video" + assert client._http.headers["x-sonilo-client"] == "videokit-python" assert client._http.headers["x-sonilo-client-version"] == __version__ finally: client.close() diff --git a/tests/test_client_identity.py b/tests/test_client_identity.py index b0ee776..5ff7757 100644 --- a/tests/test_client_identity.py +++ b/tests/test_client_identity.py @@ -49,9 +49,9 @@ def test_client_name_and_version_are_overridable(): def test_overrides_are_independent(): """Naming a wrapper must not force it to also restate a version, and vice versa.""" route = _stub() - Sonilo(api_key="sk-test", client_name="kit-python-video").account.services() + Sonilo(api_key="sk-test", client_name="videokit-python").account.services() headers = route.calls.last.request.headers - assert headers["x-sonilo-client"] == "kit-python-video" + assert headers["x-sonilo-client"] == "videokit-python" assert headers["x-sonilo-client-version"] == __version__ Sonilo(api_key="sk-test", client_version="9.9.9").account.services()