diff --git a/README.md b/README.md index e7c26e7..63cc593 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # onepin -**Onepin is a voice workflow platform that orchestrates, validates, and ships production-ready audio across 100+ TTS models.** This is the official Python SDK + CLI for [Onepin](https://onepin.ai). +**Onepin is a voice workflow platform that orchestrates, validates, and ships production-ready audio across 24 TTS models.** This is the official Python SDK + CLI for [Onepin](https://onepin.ai). [![PyPI version](https://img.shields.io/pypi/v/onepin)](https://pypi.org/project/onepin/) [![Python versions](https://img.shields.io/pypi/pyversions/onepin)](https://pypi.org/project/onepin/) @@ -13,7 +13,7 @@ TTS turns text into speech. Onepin runs the production around it: plan the line, - **Orchestrate** voice workflows as graphs — sources, generators, validators, destinations — from Python or the CLI. - **Validate** every line. Word accuracy and naturalness are scored against your thresholds, with automatic retries on a miss. -- **Reach 100+ TTS models** through one definition: ElevenLabs, Cartesia, Google, Naver, and more, with no per-vendor rewrite. +- **Reach 24 TTS models** through one definition: ElevenLabs, Cartesia, Google, Naver, and more, with no per-vendor rewrite. - **Ship publish-ready voice.** Pull a full-run export or a single node's output straight from a run. - **Drive it from your agent.** The bundled Agent Skill runs Onepin from Claude Code, Cursor, Codex, Gemini, and Copilot. diff --git a/fern/generators.yml b/fern/generators.yml index 90c3d2b..c968fe3 100644 --- a/fern/generators.yml +++ b/fern/generators.yml @@ -40,3 +40,13 @@ groups: - make_client - make_async_client - OnePinUpgradeRequiredError + # Hand-rolled credential-resolving client (src/onepin/_client.py, preserved by + # .fernignore). Gives programmatic users `from onepin import OnepinClient`, which + # reads the same key `onepin login` stores. See also _auth_resolve.py. + - from: _client + imports: + - OnepinClient + - AsyncOnepinClient + - from: _auth_resolve + imports: + - OnepinAuthError diff --git a/pyproject.toml b/pyproject.toml index 854b587..2abde2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "onepin" dynamic = ["version"] -description = "Onepin Python SDK + CLI — orchestrate, validate & ship production-ready voice audio across 30 TTS models" +description = "Onepin Python SDK + CLI — orchestrate, validate & ship production-ready voice audio across 24 TTS models" readme = "README.md" keywords = [ "onepin", "tts", "text-to-speech", "speech-synthesis", "voice", diff --git a/src/onepin/.fernignore b/src/onepin/.fernignore index 5b45c2a..9a3327b 100644 --- a/src/onepin/.fernignore +++ b/src/onepin/.fernignore @@ -3,4 +3,6 @@ # (src/onepin/) and never overwrites the paths listed here. _cli/ _version_gate.py +_client.py +_auth_resolve.py py.typed diff --git a/src/onepin/__init__.py b/src/onepin/__init__.py index e36cecb..a6a29e8 100644 --- a/src/onepin/__init__.py +++ b/src/onepin/__init__.py @@ -208,6 +208,8 @@ workspaces, ) from ._default_clients import DefaultAioHttpClient, DefaultAsyncHttpxClient + from ._auth_resolve import OnepinAuthError + from ._client import AsyncOnepinClient, OnepinClient from ._version_gate import OnePinUpgradeRequiredError, make_async_client, make_client from .client import AsyncOnePinClient, OnePinClient from .dictionary import ( @@ -349,6 +351,9 @@ "OnePinClient": ".client", "OnePinClientEnvironment": ".environment", "OnePinUpgradeRequiredError": "._version_gate", + "OnepinAuthError": "._auth_resolve", + "OnepinClient": "._client", + "AsyncOnepinClient": "._client", "PaginationMeta": ".types", "PlanLimits": ".types", "PlanTier": ".types", @@ -606,6 +611,9 @@ def __dir__(): "OnePinClient", "OnePinClientEnvironment", "OnePinUpgradeRequiredError", + "OnepinAuthError", + "OnepinClient", + "AsyncOnepinClient", "PaginationMeta", "PlanLimits", "PlanTier", diff --git a/src/onepin/_auth_resolve.py b/src/onepin/_auth_resolve.py new file mode 100644 index 0000000..d99761d --- /dev/null +++ b/src/onepin/_auth_resolve.py @@ -0,0 +1,51 @@ +"""Credential resolution for the public :class:`~onepin._client.OnepinClient`. + +Hand-written; preserved across ``fern generate`` via ``.fernignore`` and re-exported from the +package ``__init__`` via ``fern/generators.yml`` ``additional_init_exports``. + +Reuses the exact same priority chain the CLI uses (:mod:`onepin._cli.auth.resolver`): + + 1. an explicit ``api_key`` argument + 2. the ``ONEPIN_API_KEY`` environment variable + 3. ``~/.onepin/credentials`` (written by ``onepin login``) + +so ``onepin login`` authenticates the CLI and the SDK in one step. The CLI auth modules depend +only on the standard library, so importing this never pulls ``click``/``typer`` into +``import onepin``. ``base_url`` is resolved independently (explicit arg > ``ONEPIN_BASE_URL`` > +file), matching the CLI. +""" + +from __future__ import annotations + +import typing + +from onepin._cli.auth.resolver import ResolvedCredentials, resolve_credentials + + +class OnepinAuthError(RuntimeError): + """No Onepin API key could be resolved for :class:`~onepin._client.OnepinClient`.""" + + +def resolve_credentials_for_client( + api_key: typing.Optional[str] = None, + base_url: typing.Optional[str] = None, +) -> ResolvedCredentials: + """Resolve an API key (and base URL) for constructing a client. + + Args: + api_key: An explicit key; wins over the environment and the credentials file. + base_url: An explicit base URL; wins over ``ONEPIN_BASE_URL`` and the file. + + Returns: + The resolved credentials, guaranteed to carry a non-empty ``api_key``. + + Raises: + OnepinAuthError: If no key is found in the argument, the environment, or the file. + """ + creds = resolve_credentials(flag_api_key=api_key, flag_base_url=base_url) + if not creds.api_key: + raise OnepinAuthError( + "No Onepin API key found. Pass OnepinClient(api_key=...), set ONEPIN_API_KEY, " + "or run `onepin login`." + ) + return creds diff --git a/src/onepin/_cli/_skill/onepin/reference.md b/src/onepin/_cli/_skill/onepin/reference.md index 7b2f70b..01f301b 100644 --- a/src/onepin/_cli/_skill/onepin/reference.md +++ b/src/onepin/_cli/_skill/onepin/reference.md @@ -64,7 +64,7 @@ of the GA nodes (keyed by display name + category), not a slug reference. |------|----------|---------|------------| | **Single script input** | source | Entry point; emits one script in one locale to its output. | script text / `.txt`/`.csv` | | **Normalizer** | processing | Text normalization before TTS (e.g. `"123 main st."` → `"one two three main street"`); multilingual. | language code | -| **Voice Generator** | processing | TTS for one script in one locale → an audio object. **100+ TTS models** across providers, each with different config support and pricing (the spread from cheapest-but-reasonable to most expensive is ~40×). | **auto-route** picks the best model for the language/locale from Onepin's TTS benchmark, balancing performance against price, or set provider + model manually; speed, emotion, tone, and more (model-dependent) | +| **Voice Generator** | processing | TTS for one script in one locale → an audio object. **24 TTS models** across providers, each with different config support and pricing (the spread from cheapest-but-reasonable to most expensive is ~40×). | **auto-route** picks the best model for the language/locale from Onepin's TTS benchmark, balancing performance against price, or set provider + model manually; speed, emotion, tone, and more (model-dependent) | | **Validator – Word accuracy** | validation | ASR-based word accuracy in [0, 100] (from WER); emits **pass** / **fail** pins. | threshold (default 85, adjustable), max-retry | | **Validator – Naturalness** | validation | Internal-AI naturalness score in [0, 100]; emits **pass** / **fail** pins. | threshold (default 85, adjustable), max-retry | | **Onepin storage** | output (sink) | Aggregates audio + scripts + validation results for download/visualization. | — | diff --git a/src/onepin/_client.py b/src/onepin/_client.py new file mode 100644 index 0000000..cec20bd --- /dev/null +++ b/src/onepin/_client.py @@ -0,0 +1,99 @@ +"""Public ``OnepinClient`` / ``AsyncOnepinClient`` — credential-resolving, version-gated. + +Hand-written; preserved across ``fern generate`` via ``.fernignore`` and re-exported from the +package ``__init__`` via ``fern/generators.yml`` ``additional_init_exports``:: + + from onepin import OnepinClient + + client = OnepinClient() # key from `onepin login`, then ONEPIN_API_KEY + client = OnepinClient(api_key="op_live_...") # explicit + client = OnepinClient(base_url="https://api.onepin.ai", timeout=120.0) + +These subclass the Fern-generated clients, so every resource (``client.workflows``, +``client.runs``, ...) is inherited unchanged. Two things are added on top: + +- **Credential auto-resolution** — the API key comes from the ``api_key`` argument, then + ``ONEPIN_API_KEY``, then ``~/.onepin/credentials`` (see :mod:`onepin._auth_resolve`), and is + passed to the generated client as the bearer ``token``. +- **The version gate** — the same httpx response hook :func:`onepin.make_client` installs, so an + out-of-date SDK raises :class:`~onepin._version_gate.OnePinUpgradeRequiredError`. +""" + +from __future__ import annotations + +import typing + +import httpx + +from onepin._auth_resolve import OnepinAuthError as OnepinAuthError # re-export +from onepin._auth_resolve import resolve_credentials_for_client +from onepin._version_gate import _async_response_hook, _response_hook, _user_agent +from onepin.client import AsyncOnePinClient as _AsyncGeneratedClient +from onepin.client import OnePinClient as _GeneratedClient + +_DEFAULT_TIMEOUT = 60 + + +class OnepinClient(_GeneratedClient): + """The Onepin client. Resolves credentials, then behaves like the generated client.""" + + def __init__( + self, + *, + api_key: typing.Optional[str] = None, + base_url: typing.Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + headers: typing.Optional[typing.Dict[str, str]] = None, + httpx_client: typing.Optional[httpx.Client] = None, + **kwargs: typing.Any, + ) -> None: + creds = resolve_credentials_for_client(api_key=api_key, base_url=base_url) + merged_headers = dict(headers or {}) + merged_headers.setdefault("User-Agent", _user_agent()) + if httpx_client is None: + httpx_client = httpx.Client( + timeout=timeout, + follow_redirects=True, + event_hooks={"response": [_response_hook]}, + ) + super().__init__( + base_url=creds.base_url, + token=creds.api_key, + headers=merged_headers, + httpx_client=httpx_client, + **kwargs, + ) + + +class AsyncOnepinClient(_AsyncGeneratedClient): + """The async Onepin client. Same resolution as :class:`OnepinClient`; ``await`` its methods.""" + + def __init__( + self, + *, + api_key: typing.Optional[str] = None, + base_url: typing.Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + headers: typing.Optional[typing.Dict[str, str]] = None, + httpx_client: typing.Optional[httpx.AsyncClient] = None, + **kwargs: typing.Any, + ) -> None: + creds = resolve_credentials_for_client(api_key=api_key, base_url=base_url) + merged_headers = dict(headers or {}) + merged_headers.setdefault("User-Agent", _user_agent()) + if httpx_client is None: + httpx_client = httpx.AsyncClient( + timeout=timeout, + follow_redirects=True, + event_hooks={"response": [_async_response_hook]}, + ) + super().__init__( + base_url=creds.base_url, + token=creds.api_key, + headers=merged_headers, + httpx_client=httpx_client, + **kwargs, + ) + + +__all__ = ["AsyncOnepinClient", "OnepinAuthError", "OnepinClient"] diff --git a/tests/unit/test_auth_resolve.py b/tests/unit/test_auth_resolve.py new file mode 100644 index 0000000..7eb904a --- /dev/null +++ b/tests/unit/test_auth_resolve.py @@ -0,0 +1,77 @@ +"""Credential resolution + OnepinClient construction (hand-rolled, .fernignore-preserved).""" + +from __future__ import annotations + +import pytest + +from onepin import AsyncOnepinClient, OnepinAuthError, OnepinClient +from onepin._auth_resolve import resolve_credentials_for_client + + +@pytest.fixture(autouse=True) +def _isolate_env(tmp_path, monkeypatch): + """Every test starts with no env keys and an empty HOME (no real ~/.onepin/credentials).""" + monkeypatch.delenv("ONEPIN_API_KEY", raising=False) + monkeypatch.delenv("ONEPIN_BASE_URL", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + +def _write_credentials(tmp_path, *, api_key="op_live_file", base_url="https://file.onepin.ai"): + cfg = tmp_path / ".onepin" + cfg.mkdir(parents=True, exist_ok=True) + (cfg / "credentials").write_text(f'[default]\napi_key = "{api_key}"\nbase_url = "{base_url}"\n') + + +def test_explicit_api_key_wins(): + creds = resolve_credentials_for_client(api_key="op_live_explicit") + assert creds.api_key == "op_live_explicit" + assert creds.source == "flag" + + +def test_env_var_used_when_no_argument(monkeypatch): + monkeypatch.setenv("ONEPIN_API_KEY", "op_live_env") + creds = resolve_credentials_for_client() + assert creds.api_key == "op_live_env" + assert creds.source == "env" + + +def test_explicit_argument_beats_env(monkeypatch): + monkeypatch.setenv("ONEPIN_API_KEY", "op_live_env") + creds = resolve_credentials_for_client(api_key="op_live_explicit") + assert creds.api_key == "op_live_explicit" + + +def test_credentials_file_used_when_no_arg_or_env(tmp_path): + _write_credentials(tmp_path) + creds = resolve_credentials_for_client() + assert creds.api_key == "op_live_file" + assert creds.base_url == "https://file.onepin.ai" + assert creds.source == "file" + + +def test_no_key_anywhere_raises(): + with pytest.raises(OnepinAuthError): + resolve_credentials_for_client() + + +def test_base_url_argument_wins_over_file(tmp_path): + _write_credentials(tmp_path) + creds = resolve_credentials_for_client(base_url="https://override.onepin.ai") + assert creds.base_url == "https://override.onepin.ai" + + +def test_client_constructs_with_explicit_key_offline(): + client = OnepinClient(api_key="op_live_x", base_url="https://api.onepin.ai") + # Resource groups are inherited from the generated client; no network happens here. + assert hasattr(client, "workflows") + assert hasattr(client, "voices") + + +def test_client_without_any_key_raises(): + with pytest.raises(OnepinAuthError): + OnepinClient() + + +def test_async_client_constructs_with_explicit_key_offline(): + client = AsyncOnepinClient(api_key="op_live_x") + assert hasattr(client, "workflows")