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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/)
Expand All @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions fern/generators.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/onepin/.fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
# (src/onepin/) and never overwrites the paths listed here.
_cli/
_version_gate.py
_client.py
_auth_resolve.py
py.typed
8 changes: 8 additions & 0 deletions src/onepin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -349,6 +351,9 @@
"OnePinClient": ".client",
"OnePinClientEnvironment": ".environment",
"OnePinUpgradeRequiredError": "._version_gate",
"OnepinAuthError": "._auth_resolve",
"OnepinClient": "._client",
"AsyncOnepinClient": "._client",
"PaginationMeta": ".types",
"PlanLimits": ".types",
"PlanTier": ".types",
Expand Down Expand Up @@ -606,6 +611,9 @@ def __dir__():
"OnePinClient",
"OnePinClientEnvironment",
"OnePinUpgradeRequiredError",
"OnepinAuthError",
"OnepinClient",
"AsyncOnepinClient",
"PaginationMeta",
"PlanLimits",
"PlanTier",
Expand Down
51 changes: 51 additions & 0 deletions src/onepin/_auth_resolve.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/onepin/_cli/_skill/onepin/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | — |
Expand Down
99 changes: 99 additions & 0 deletions src/onepin/_client.py
Original file line number Diff line number Diff line change
@@ -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"]
77 changes: 77 additions & 0 deletions tests/unit/test_auth_resolve.py
Original file line number Diff line number Diff line change
@@ -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")
Loading