diff --git a/.spec-sha b/.spec-sha index d984310..bfd1c72 100644 --- a/.spec-sha +++ b/.spec-sha @@ -1 +1 @@ -6e8f07d1484ca4c4b976609f61c1d88b8288b3a3 +f5661addc343a57c82b33f8b9f2c3f481b3a0b51 diff --git a/src/onepin/.fern/metadata.json b/src/onepin/.fern/metadata.json index 0dbb2dc..1151648 100644 --- a/src/onepin/.fern/metadata.json +++ b/src/onepin/.fern/metadata.json @@ -30,7 +30,8 @@ } ] }, - "originGitCommit": "3d712ffd87d0eab81a87aa8c0e603081a11ee5f1", - "originGitCommitIsDirty": true, - "invokedBy": "manual" + "originGitCommit": "e2af8ad29c5c49b8dd9a9cae0421143507777bc3", + "originGitCommitIsDirty": false, + "invokedBy": "ci", + "ciProvider": "github" } \ No newline at end of file diff --git a/src/onepin/README.md b/src/onepin/README.md index 040f91a..6b076f2 100644 --- a/src/onepin/README.md +++ b/src/onepin/README.md @@ -13,7 +13,6 @@ The Onepin Python library provides convenient access to the Onepin APIs from Pyt - [Environments](#environments) - [Async Client](#async-client) - [Exception Handling](#exception-handling) -- [Pagination](#pagination) - [Advanced](#advanced) - [Access Raw Response Data](#access-raw-response-data) - [Retries](#retries) @@ -102,29 +101,6 @@ except ApiError as e: print(e.body) ``` -## Pagination - -Paginated requests will return a `SyncPager` or `AsyncPager`, which can be used as generators for the underlying object. - -```python -from onepin import OnePinClient - -client = OnePinClient( - token="", -) - -client.templates.list() -``` - -```python -# You can also iterate through pages and access the typed response per page -pager = client.templates.list(...) -for page in pager.iter_pages(): - print(page.response) # access the typed response for each page - for item in page: - print(item) -``` - ## Advanced ### Access Raw Response Data diff --git a/src/onepin/__init__.py b/src/onepin/__init__.py index 2426c1b..fd0b641 100644 --- a/src/onepin/__init__.py +++ b/src/onepin/__init__.py @@ -22,6 +22,7 @@ ApiListResponseTemplateOut, ApiListResponseUploadOut, ApiListResponseUsageActivityOut, + ApiListResponseVoiceRecommendedOut, ApiListResponseVoiceSimilarOut, ApiListResponseWorkspaceMemberOut, ApiListResponseWorkspaceOut, @@ -169,6 +170,7 @@ VoiceModelCapabilityOut, VoiceOut, VoicePreviewOut, + VoiceRecommendedOut, VoiceSimilarOut, VoiceSource, WorkflowDefinitionInput, @@ -316,6 +318,7 @@ "ApiListResponseTemplateOut": ".types", "ApiListResponseUploadOut": ".types", "ApiListResponseUsageActivityOut": ".types", + "ApiListResponseVoiceRecommendedOut": ".types", "ApiListResponseVoiceSimilarOut": ".types", "ApiListResponseWorkspaceMemberOut": ".types", "ApiListResponseWorkspaceOut": ".types", @@ -500,6 +503,7 @@ "VoiceModelCapabilityOut": ".types", "VoiceOut": ".types", "VoicePreviewOut": ".types", + "VoiceRecommendedOut": ".types", "VoiceSimilarOut": ".types", "VoiceSource": ".types", "WorkflowDefinitionInput": ".types", @@ -636,6 +640,7 @@ def __dir__(): "ApiListResponseTemplateOut", "ApiListResponseUploadOut", "ApiListResponseUsageActivityOut", + "ApiListResponseVoiceRecommendedOut", "ApiListResponseVoiceSimilarOut", "ApiListResponseWorkspaceMemberOut", "ApiListResponseWorkspaceOut", @@ -820,6 +825,7 @@ def __dir__(): "VoiceModelCapabilityOut", "VoiceOut", "VoicePreviewOut", + "VoiceRecommendedOut", "VoiceSimilarOut", "VoiceSource", "WorkflowDefinitionInput", diff --git a/src/onepin/core/client_wrapper.py b/src/onepin/core/client_wrapper.py index 3e5cde8..7ab817a 100644 --- a/src/onepin/core/client_wrapper.py +++ b/src/onepin/core/client_wrapper.py @@ -29,7 +29,7 @@ def __init__( self._max_stream_reconnection_attempts = max_stream_reconnection_attempts self._logging = logging - def get_headers(self) -> typing.Dict[str, str]: + def get_headers(self, *, include_token: bool = True) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { @@ -39,9 +39,10 @@ def get_headers(self) -> typing.Dict[str, str]: "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", **(self.get_custom_headers() or {}), } - token = self._get_token() - if token is not None: - headers["Authorization"] = f"Bearer {token}" + if include_token: + token = self._get_token() + if token is not None: + headers["Authorization"] = f"Bearer {token}" return headers def _get_token(self) -> typing.Optional[str]: @@ -140,7 +141,7 @@ def __init__( ) async def async_get_headers(self) -> typing.Dict[str, str]: - headers = self.get_headers() + headers = self.get_headers(include_token=self._async_token is None) if self._async_token is not None: token = await self._async_token() headers["Authorization"] = f"Bearer {token}" diff --git a/src/onepin/reference.md b/src/onepin/reference.md index e766f4b..c603402 100644 --- a/src/onepin/reference.md +++ b/src/onepin/reference.md @@ -2611,6 +2611,14 @@ unavailable, or the embed call fails, `search` transparently falls back to the lexical name/tag/descriptor match described below — the response shape (`ApiCountedListResponse[VoiceOut]`) is identical either way. +`provider_voice_id` is the exact vendor wire id and is a FILTER, not a search: it is +never matched by `search` (see the repository predicate for why an ILIKE arm on that +column would rewrite the ranking of every name query), and sending it takes the lexical +path — there is nothing for a semantic ranking to order. It ANDs with every other +filter, including `search`, and the same wire id may be held by more than one visible +row (your own imported voice and the platform catalog row are both admitted, by +design), so it can return several voices; add `provider` to narrow to one vendor. + `language` matches a voice when any of its declared locales matches any requested value. A voice with no declared locales matches NO `language` filter — it must positively declare a locale to surface under it. This holds @@ -2778,6 +2786,14 @@ client.voices.list()
+**provider_voice_id:** `typing.Optional[str]` — Exact vendor wire id (the value submitted to the provider for synthesis). Case- and whitespace-sensitive apart from surrounding blanks; not a substring match and not part of `search`. Pair with `provider` to disambiguate: a wire id may be held by more than one row (your own imported voice and the platform catalog row), so this filter can legitimately return several voices. Ranking is skipped — an exact id has nothing to rank — so `sort`/`order` apply as on a plain browse. + +
+
+ +
+
+ **sort:** `typing.Optional[typing.List[ListVoicesRequestSortItem]]` — Repeat for multi-sort. Pairs with `order` index-wise.
@@ -3056,6 +3072,200 @@ client.voices.get_voice_facets()
+ + + + +
client.voices.recommend_voices(...) -> ApiListResponseVoiceRecommendedOut +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Recommend voices for a language — the ranked answer, not a catalogue page. + +`GET /voices` is a browse: it answers "what exists" and orders by whatever `sort` +says, defaulting to newest-first. That is the right shape for a person scrolling a +catalogue and the wrong one for a caller that will put three voices in front of +someone — there, WHICH three is the entire recommendation, and "most recently added" +is not an opinion about quality. + +This endpoint is the selection half of automatic voice choice, the same one the +in-product assistant's cards are built from, and it is deliberately a separate route +rather than a mode of the list: browse and recommend disagree about vendors on +purpose. A company may be excluded from what we OFFER automatically while staying +fully browsable and fully usable when a customer asks for it by name, so the policy +belongs to the surface that offers rather than the one that lists. + +What it applies, and the list does not: + +* **Measured quality floors, as a GATE.** Every voice offered clears the naturalness + and noise floors. Tier-lexicographic ranking then decides, per voice, which of its + models it is offered under — quality tier first, price only within a tier, which + axis leads following the workspace's Auto-route setting. +* **Spread across companies — and the returned ORDER is that spread, not a quality + ranking.** Companies are taken in turn in a seed-derived order, and within a + company the voices are seed-shuffled too, so one large catalogue cannot sweep the + slate. Do not present the first row as the best one: three voices that all clear + the floors sit inside one tier width, which is below what the measurement can + resolve, so ordering them by score would claim a precision that is not there. The + order is stable for the same request, which is what makes `exclude`/`offer_round` + the way to get different ones rather than re-asking and hoping. +* **Build re-resolution.** Every voice is re-resolved through the same gate a run + uses, and `recommended_model` is that gate's answer — so a recommendation cannot + name a pairing synthesis would then refuse. + +`exclude` plus `offer_round` is how "show me different ones" works: pass the ids +already shown and raise the round. The slate is deterministic in its inputs, so the +same request returns the same voices — paging is the caller's to drive, not a +hidden cursor's. + +`provider`, `model`, `gender` and `age` narrow the eligible pool without turning this +into a browse. + +`style` is how the customer said it should SOUND, in their words. It ranks by meaning +inside the same eligibility, and it is not the same request as `GET /voices?search=`, +which fuses a NAME-matching arm into the ranking and applies neither the vendor +steer-away nor the hard gender filter a stated style implies. Ask for a name there and +for a sound here. A future `similar_to` will add the third route — voices near one the +customer already chose — and is intentionally not part of this cut. + +An empty `data` means nothing is buildable for this language under these constraints, +which is a real answer and not an error. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from onepin import OnePinClient +from onepin.environment import OnePinClientEnvironment + +client = OnePinClient( + token="", + environment=OnePinClientEnvironment.PROD, +) + +client.voices.recommend_voices( + language="language", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**language:** `str` — Locale the voices must speak, e.g. `ko-kr`. A bare family widens to the product default. + +
+
+ +
+
+ +**limit:** `typing.Optional[int]` — How many to recommend (1–6). + +
+
+ +
+
+ +**exclude:** `typing.Optional[typing.List[str]]` — Voice ids already offered. Repeat for each; newest kept when over the cap. + +
+
+ +
+
+ +**offer_round:** `typing.Optional[int]` — Increment to draw a different slate of equally-ranked voices. + +
+
+ +
+
+ +**gender:** `typing.Optional[typing.List[VoiceGender]]` — Repeat for OR + +
+
+ +
+
+ +**age:** `typing.Optional[typing.List[VoiceAge]]` — Repeat for OR + +
+
+ +
+
+ +**style:** `typing.Optional[str]` — How it should SOUND, in the customer's words — 'a calm professional woman'. Not a name. + +
+
+ +
+
+ +**provider:** `typing.Optional[typing.List[str]]` — Repeat for OR, e.g. ?provider=elevenlabs&provider=rime + +
+
+ +
+
+ +**model:** `typing.Optional[typing.List[str]]` — Repeat for OR. Filters platform voices by TTS model, e.g. ?model=arcana&model=sonic-2 + +
+
+ +
+
+ +**workspace_id:** `typing.Optional[str]` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ +
@@ -4651,8 +4861,8 @@ client.workspaces.get_workspace(
-**workspace_id:** `str` - +**workspace_id:** `str` +
@@ -4660,7 +4870,7 @@ client.workspaces.get_workspace(
**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. - +
@@ -4976,7 +5186,9 @@ workspace → the **organization's** subscription (or ``null`` on free tier), re member or an org admin. Use this instead of ``/users/me/subscription`` when rendering a workspace's plan, so an org workspace shows the ORGANIZATION's plan. Membership is existence-hidden (404); the org-admin fallback applies (an org admin with no materialized -member row can still read). +member row can still read). Before returning a locally active subscription, the endpoint +verifies it with Stripe; a Stripe-confirmed terminal subscription is persisted and returns +``null``, while a verification failure returns 502 ``BILLING_ERROR``. @@ -5099,8 +5311,8 @@ client.workspaces.get_workspace_credits(
-**workspace_id:** `str` - +**workspace_id:** `str` +
@@ -5108,7 +5320,7 @@ client.workspaces.get_workspace_credits(
**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. - +
@@ -5332,6 +5544,97 @@ client.uploads.create( + + + + +
client.uploads.put_upload_content(...) -> ApiResponseUploadOut +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Send the file's bytes to this API instead of to the presigned URL (alternative step 1). + +A substitute for the PUT to `upload_url`, not a new step: it writes the same staging +object that URL would have written, so `POST /uploads/{id}` confirms either one without +knowing which door the bytes came through. Use exactly one of the two. + +Prefer the presigned URL. It uploads straight to object storage and costs this API +nothing, and it is what every client that can reach object storage should use. This +route exists for callers that **cannot** — a sandboxed agent whose network policy +permits this API's host and not the storage host. There the presigned PUT fails before +it gets any HTTP status at all, and no retry against the same URL can succeed. + +Send the raw bytes as the request body with `Content-Type` set to the `content_type` +from `POST /uploads`, exactly as the presigned PUT requires — this route enforces the +same match, so a file is never stored under a format the confirm step will reject. +Not base64, not multipart. Unlike the presigned URL, this one does not expire: it +authenticates per request, so a slow upload cannot outlive its credential. + +Dual-auth: Bearer JWT or API key (scope `uploads:write`). +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from onepin import OnePinClient +from onepin.environment import OnePinClientEnvironment + +client = OnePinClient( + token="", + environment=OnePinClientEnvironment.PROD, +) + +client.uploads.put_upload_content( + upload_id="upload_id", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**upload_id:** `str` + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ +
@@ -5422,7 +5725,7 @@ client.uploads.confirm(
-**context_type:** `UploadConfirmRequestContextType` — Type of resource this upload is being attached to: `workflow`, `playground`, or `assistant_session`. +**context_type:** `UploadConfirmRequestContextType` — Type of resource this upload is being attached to: `workflow`, `playground`, `assistant_session`, or `mcp` (the MCP surface's workspace-scoped scratch space).
@@ -7405,7 +7708,7 @@ client.workflows.estimate_workflow(
-**request:** `WorkflowRunStartIn` +**request:** `WorkflowRunStartIn`
@@ -7413,7 +7716,7 @@ client.workflows.estimate_workflow(
-**workspace_id:** `typing.Optional[str]` +**workspace_id:** `typing.Optional[str]`
@@ -7503,7 +7806,7 @@ client.workflows.preview_run(
-**request:** `WorkflowRunStartIn` +**request:** `WorkflowRunStartIn`
@@ -7511,7 +7814,7 @@ client.workflows.preview_run(
-**workspace_id:** `typing.Optional[str]` +**workspace_id:** `typing.Optional[str]`
@@ -9065,7 +9368,7 @@ client.workflows.runs.start(
-**request:** `WorkflowRunStartIn` +**request:** `WorkflowRunStartIn`
@@ -9073,7 +9376,7 @@ client.workflows.runs.start(
-**workspace_id:** `typing.Optional[str]` +**workspace_id:** `typing.Optional[str]`
@@ -9535,3 +9838,4 @@ client.workflows.runs.cancel( + diff --git a/src/onepin/templates/client.py b/src/onepin/templates/client.py index 1e25d50..877e5e1 100644 --- a/src/onepin/templates/client.py +++ b/src/onepin/templates/client.py @@ -3,7 +3,6 @@ import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.pagination import AsyncPager, SyncPager from ..core.request_options import RequestOptions from ..types.api_list_response_template_out import ApiListResponseTemplateOut from ..types.api_response_dict import ApiResponseDict @@ -11,7 +10,6 @@ from ..types.api_response_template_out import ApiResponseTemplateOut from ..types.api_response_workflow_out import ApiResponseWorkflowOut from ..types.template_category import TemplateCategory -from ..types.template_out import TemplateOut from ..types.workflow_definition_input import WorkflowDefinitionInput from .raw_client import AsyncRawTemplatesClient, RawTemplatesClient from .types.list_templates_request_sort import ListTemplatesRequestSort @@ -45,7 +43,7 @@ def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[TemplateOut, ApiListResponseTemplateOut]: + ) -> ApiListResponseTemplateOut: """ Browse the public template gallery across all workspaces. @@ -84,7 +82,7 @@ def list( Returns ------- - SyncPager[TemplateOut, ApiListResponseTemplateOut] + ApiListResponseTemplateOut Successful Response Examples @@ -94,14 +92,9 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - response = client.templates.list() - for item in response: - yield item - # alternatively, you can paginate page-by-page - for page in response.iter_pages(): - yield page + client.templates.list() """ - return self._raw_client.list( + _response = self._raw_client.list( category=category, search=search, sort=sort, @@ -110,6 +103,7 @@ def list( favorites_only=favorites_only, request_options=request_options, ) + return _response.data def create_template( self, @@ -586,7 +580,7 @@ async def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[TemplateOut, ApiListResponseTemplateOut]: + ) -> ApiListResponseTemplateOut: """ Browse the public template gallery across all workspaces. @@ -625,7 +619,7 @@ async def list( Returns ------- - AsyncPager[TemplateOut, ApiListResponseTemplateOut] + ApiListResponseTemplateOut Successful Response Examples @@ -640,18 +634,12 @@ async def list( async def main() -> None: - response = await client.templates.list() - async for item in response: - yield item - - # alternatively, you can paginate page-by-page - async for page in response.iter_pages(): - yield page + await client.templates.list() asyncio.run(main()) """ - return await self._raw_client.list( + _response = await self._raw_client.list( category=category, search=search, sort=sort, @@ -660,6 +648,7 @@ async def main() -> None: favorites_only=favorites_only, request_options=request_options, ) + return _response.data async def create_template( self, diff --git a/src/onepin/templates/raw_client.py b/src/onepin/templates/raw_client.py index 44f3b74..c417687 100644 --- a/src/onepin/templates/raw_client.py +++ b/src/onepin/templates/raw_client.py @@ -7,7 +7,6 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import encode_path_param -from ..core.pagination import AsyncPager, SyncPager from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -19,7 +18,6 @@ from ..types.api_response_template_out import ApiResponseTemplateOut from ..types.api_response_workflow_out import ApiResponseWorkflowOut from ..types.template_category import TemplateCategory -from ..types.template_out import TemplateOut from ..types.workflow_definition_input import WorkflowDefinitionInput from .types.list_templates_request_sort import ListTemplatesRequestSort from pydantic import ValidationError @@ -42,7 +40,7 @@ def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[TemplateOut, ApiListResponseTemplateOut]: + ) -> HttpResponse[ApiListResponseTemplateOut]: """ Browse the public template gallery across all workspaces. @@ -81,11 +79,9 @@ def list( Returns ------- - SyncPager[TemplateOut, ApiListResponseTemplateOut] + HttpResponse[ApiListResponseTemplateOut] Successful Response """ - offset = offset if offset is not None else 0 - _response = self._client_wrapper.httpx_client.request( "api/v1/templates", method="GET", @@ -101,25 +97,14 @@ def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiListResponseTemplateOut, parse_obj_as( type_=ApiListResponseTemplateOut, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - _get_next = lambda: self.list( - category=category, - search=search, - sort=sort, - offset=offset + len(_items or []), - limit=limit, - favorites_only=favorites_only, - request_options=request_options, - ) - return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -794,7 +779,7 @@ async def list( limit: typing.Optional[int] = None, favorites_only: typing.Optional[bool] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[TemplateOut, ApiListResponseTemplateOut]: + ) -> AsyncHttpResponse[ApiListResponseTemplateOut]: """ Browse the public template gallery across all workspaces. @@ -833,11 +818,9 @@ async def list( Returns ------- - AsyncPager[TemplateOut, ApiListResponseTemplateOut] + AsyncHttpResponse[ApiListResponseTemplateOut] Successful Response """ - offset = offset if offset is not None else 0 - _response = await self._client_wrapper.httpx_client.request( "api/v1/templates", method="GET", @@ -853,28 +836,14 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiListResponseTemplateOut, parse_obj_as( type_=ApiListResponseTemplateOut, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - - async def _get_next(): - return await self.list( - category=category, - search=search, - sort=sort, - offset=offset + len(_items or []), - limit=limit, - favorites_only=favorites_only, - request_options=request_options, - ) - - return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), diff --git a/src/onepin/types/__init__.py b/src/onepin/types/__init__.py index 8f6e263..a7b48e5 100644 --- a/src/onepin/types/__init__.py +++ b/src/onepin/types/__init__.py @@ -21,6 +21,7 @@ from .api_list_response_template_out import ApiListResponseTemplateOut from .api_list_response_upload_out import ApiListResponseUploadOut from .api_list_response_usage_activity_out import ApiListResponseUsageActivityOut + from .api_list_response_voice_recommended_out import ApiListResponseVoiceRecommendedOut from .api_list_response_voice_similar_out import ApiListResponseVoiceSimilarOut from .api_list_response_workspace_member_out import ApiListResponseWorkspaceMemberOut from .api_list_response_workspace_out import ApiListResponseWorkspaceOut @@ -172,6 +173,7 @@ from .voice_model_capability_out import VoiceModelCapabilityOut from .voice_out import VoiceOut from .voice_preview_out import VoicePreviewOut + from .voice_recommended_out import VoiceRecommendedOut from .voice_similar_out import VoiceSimilarOut from .voice_source import VoiceSource from .workflow_definition_input import WorkflowDefinitionInput @@ -269,6 +271,7 @@ "ApiListResponseTemplateOut": ".api_list_response_template_out", "ApiListResponseUploadOut": ".api_list_response_upload_out", "ApiListResponseUsageActivityOut": ".api_list_response_usage_activity_out", + "ApiListResponseVoiceRecommendedOut": ".api_list_response_voice_recommended_out", "ApiListResponseVoiceSimilarOut": ".api_list_response_voice_similar_out", "ApiListResponseWorkspaceMemberOut": ".api_list_response_workspace_member_out", "ApiListResponseWorkspaceOut": ".api_list_response_workspace_out", @@ -416,6 +419,7 @@ "VoiceModelCapabilityOut": ".voice_model_capability_out", "VoiceOut": ".voice_out", "VoicePreviewOut": ".voice_preview_out", + "VoiceRecommendedOut": ".voice_recommended_out", "VoiceSimilarOut": ".voice_similar_out", "VoiceSource": ".voice_source", "WorkflowDefinitionInput": ".workflow_definition_input", @@ -537,6 +541,7 @@ def __dir__(): "ApiListResponseTemplateOut", "ApiListResponseUploadOut", "ApiListResponseUsageActivityOut", + "ApiListResponseVoiceRecommendedOut", "ApiListResponseVoiceSimilarOut", "ApiListResponseWorkspaceMemberOut", "ApiListResponseWorkspaceOut", @@ -684,6 +689,7 @@ def __dir__(): "VoiceModelCapabilityOut", "VoiceOut", "VoicePreviewOut", + "VoiceRecommendedOut", "VoiceSimilarOut", "VoiceSource", "WorkflowDefinitionInput", diff --git a/src/onepin/types/api_list_response_voice_recommended_out.py b/src/onepin/types/api_list_response_voice_recommended_out.py new file mode 100644 index 0000000..a8b9728 --- /dev/null +++ b/src/onepin/types/api_list_response_voice_recommended_out.py @@ -0,0 +1,24 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .meta import Meta +from .pagination_meta import PaginationMeta +from .voice_recommended_out import VoiceRecommendedOut + + +class ApiListResponseVoiceRecommendedOut(UniversalBaseModel): + data: typing.List[VoiceRecommendedOut] + meta: Meta + pagination: PaginationMeta + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/onepin/types/node_detail_out.py b/src/onepin/types/node_detail_out.py index 6f205fb..c319b8a 100644 --- a/src/onepin/types/node_detail_out.py +++ b/src/onepin/types/node_detail_out.py @@ -28,6 +28,11 @@ class NodeDetailOut(UniversalBaseModel): outputs: typing.List[PortOut] input_schema: typing.Optional[typing.Dict[str, typing.Any]] = None config_schema: typing.Optional[typing.Dict[str, typing.Any]] = None + usage: typing.Optional[str] = pydantic.Field(default=None) + """ + Model-facing guidance for placing and wiring this node (and, for a scored node, what its score means). Read from the live catalog per request; may be null. The static examples in this spec do not carry it. + """ + options: typing.Optional[typing.Dict[str, NodeDetailOutOptionsValue]] = None if IS_PYDANTIC_V2: diff --git a/src/onepin/types/node_ports_out.py b/src/onepin/types/node_ports_out.py index d9b4043..5ae0cb2 100644 --- a/src/onepin/types/node_ports_out.py +++ b/src/onepin/types/node_ports_out.py @@ -21,6 +21,10 @@ class NodePortsOut(UniversalBaseModel): outputs: typing.List[PortOut] input_schema: typing.Optional[typing.Dict[str, typing.Any]] = None config_schema: typing.Optional[typing.Dict[str, typing.Any]] = None + usage: typing.Optional[str] = pydantic.Field(default=None) + """ + Model-facing guidance for placing and wiring this node (and, for a scored node, what its score means). Read from the live catalog per request; may be null. The static examples in this spec do not carry it. + """ if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/onepin/types/voice_recommended_out.py b/src/onepin/types/voice_recommended_out.py new file mode 100644 index 0000000..a144c92 --- /dev/null +++ b/src/onepin/types/voice_recommended_out.py @@ -0,0 +1,171 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .voice_accent import VoiceAccent +from .voice_age import VoiceAge +from .voice_availability import VoiceAvailability +from .voice_category import VoiceCategory +from .voice_gender import VoiceGender +from .voice_import_state import VoiceImportState +from .voice_model_capability_out import VoiceModelCapabilityOut +from .voice_source import VoiceSource + + +class VoiceRecommendedOut(UniversalBaseModel): + id: str = pydantic.Field() + """ + Unique voice identifier. + """ + + name: str = pydantic.Field() + """ + Display name of the voice. + """ + + provider: str = pydantic.Field() + """ + Speech synthesis provider code for this voice. + """ + + provider_voice_id: str = pydantic.Field() + """ + Provider-assigned voice identifier used when submitting synthesis requests. + """ + + description: typing.Optional[str] = pydantic.Field(default=None) + """ + Human-readable description of the voice's character and style. + """ + + is_active: bool = pydantic.Field() + """ + Whether the voice is available for use. Inactive voices are not returned by list or synthesis endpoints. + """ + + gender: typing.Optional[VoiceGender] = pydantic.Field(default=None) + """ + Perceived gender presentation of the voice. + """ + + accent: typing.Optional[VoiceAccent] = pydantic.Field(default=None) + """ + Accent of the voice, if classified. + """ + + age: typing.Optional[VoiceAge] = pydantic.Field(default=None) + """ + Perceived age range of the voice, if classified. + """ + + category: typing.Optional[VoiceCategory] = pydantic.Field(default=None) + """ + Intended use-case category (e.g. narration, conversational). + """ + + color: typing.Optional[str] = pydantic.Field(default=None) + """ + Brand color associated with the voice in the UI, as a hex string. + """ + + tags: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Freeform keyword tags for filtering and search. + """ + + uses_count: typing.Optional[int] = pydantic.Field(default=None) + """ + Number of times this voice has been used in workflow runs across the platform. + """ + + user_id: typing.Optional[str] = pydantic.Field(default=None) + """ + Owner user ID for workspace-cloned or user-created voices. Null for platform voices. + """ + + source: typing.Optional[VoiceSource] = pydantic.Field(default=None) + """ + Origin of the voice: `platform` for system-provided voices, `recorded`/`uploaded` for voices added or cloned by the workspace, `provider_imported` for voices imported from the workspace's own provider account. + """ + + availability: typing.Optional[VoiceAvailability] = pydantic.Field(default=None) + """ + Whether an imported voice can currently be selected. `unavailable` rows stay listed and readable but are rejected by workflow save, run, and synthesis. Null for platform and legacy workspace voices. + """ + + unavailable_reason: typing.Optional[VoiceImportState] = pydantic.Field(default=None) + """ + Why an imported voice is unavailable. Null when `availability` is `available` or absent. + """ + + duration_seconds: typing.Optional[float] = pydantic.Field(default=None) + """ + Duration of the audio sample in seconds, if available. + """ + + sample_url: typing.Optional[str] = pydantic.Field(default=None) + """ + Time-limited presigned URL for the audio preview sample. For a voice that declares no English locale this is one of its `preview_locales` clips when any exists, so the sample is never in a language the voice cannot speak; otherwise it is the voice's default clip, whose spoken language is not guaranteed. It does not vary with the `language` filter — read `language_sample_url` (same response, no extra request) or GET /voices/{voice_id}/preview for that. Valid for 1 hour; regenerate by fetching the voice again. + """ + + language_sample_url: typing.Optional[str] = pydantic.Field(default=None) + """ + Time-limited presigned URL for the preview clip in the requested `language`, and the one field on this model that DOES follow that filter. Populated only when the request carried `language=`; null without it, and null when the voice has no preview clip in a locale that filter expands to. It is the same row GET /voices/{voice_id}/preview?language= would serve, resolved through the same repository method, so a caller that plays this hears exactly what that endpoint would return — without a request per voice. Read `language_sample_locale` for the region actually served. Valid for 1 hour; regenerate by fetching the list again. + """ + + language_sample_locale: typing.Optional[str] = pydantic.Field(default=None) + """ + The actual regioned locale `language_sample_url` was served from, never a reflection of the request. A bare-family filter like `?language=en` expands to both `en-us` and `en-gb` and which one wins is deterministic but arbitrary, so label the clip from this and not from what you asked for. Null exactly when `language_sample_url` is. + """ + + supported_languages: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + BCP-47 language codes this voice supports, restricted to the officially supported locales. Null means the voice declares no locales at all; it is not matched by any `language` filter — a voice must positively declare a locale to surface under that filter. An empty array is a different state: the voice declared locales, but none of them is officially supported. Platform voices in that state are absent from the list endpoints entirely, so an empty array is only seen on a single-voice read. Voices your workspace owns are exempt from the restriction and report every locale they declare. + """ + + preview_locales: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Locales this voice has ready-to-play preview audio for. Fetch it with GET /voices/{voice_id}/preview?language=. This is what the voice can be HEARD in, not what it can SPEAK — see supported_languages for that. Empty means no locale preview exists yet. + """ + + supported_models: typing.Optional[typing.List[str]] = pydantic.Field(default=None) + """ + Deprecated compatibility union of model identifiers this voice is compatible with. Null means compatible with all available models for the provider. + """ + + model_capabilities: typing.Optional[typing.List[VoiceModelCapabilityOut]] = pydantic.Field(default=None) + """ + Authoritative model-to-language observations after a successful full provider sync. Empty means no authoritative pair observation is available yet. + """ + + is_favorite: typing.Optional[bool] = pydantic.Field(default=None) + """ + Whether this voice is in the current workspace's favorites list. + """ + + created_at: dt.datetime = pydantic.Field() + """ + When the voice was added to the platform or workspace. + """ + + updated_at: dt.datetime = pydantic.Field() + """ + When the voice record was last updated. + """ + + recommended_model: str = pydantic.Field() + """ + The TTS model this recommendation was ranked and verified under for the requested language. Use it verbatim when pinning the voice: a voice can support several models that differ in quality and price, so the catalog alone cannot say which one was recommended, and picking a different one discards the ranking that chose it. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/onepin/uploads/client.py b/src/onepin/uploads/client.py index 6f4dd08..568f29a 100644 --- a/src/onepin/uploads/client.py +++ b/src/onepin/uploads/client.py @@ -96,6 +96,56 @@ def create( ) return _response.data + def put_upload_content( + self, upload_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> ApiResponseUploadOut: + """ + Send the file's bytes to this API instead of to the presigned URL (alternative step 1). + + A substitute for the PUT to `upload_url`, not a new step: it writes the same staging + object that URL would have written, so `POST /uploads/{id}` confirms either one without + knowing which door the bytes came through. Use exactly one of the two. + + Prefer the presigned URL. It uploads straight to object storage and costs this API + nothing, and it is what every client that can reach object storage should use. This + route exists for callers that **cannot** — a sandboxed agent whose network policy + permits this API's host and not the storage host. There the presigned PUT fails before + it gets any HTTP status at all, and no retry against the same URL can succeed. + + Send the raw bytes as the request body with `Content-Type` set to the `content_type` + from `POST /uploads`, exactly as the presigned PUT requires — this route enforces the + same match, so a file is never stored under a format the confirm step will reject. + Not base64, not multipart. Unlike the presigned URL, this one does not expire: it + authenticates per request, so a slow upload cannot outlive its credential. + + Dual-auth: Bearer JWT or API key (scope `uploads:write`). + + Parameters + ---------- + upload_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ApiResponseUploadOut + Successful Response + + Examples + -------- + from onepin import OnePinClient + + client = OnePinClient( + token="YOUR_TOKEN", + ) + client.uploads.put_upload_content( + upload_id="upload_id", + ) + """ + _response = self._raw_client.put_upload_content(upload_id, request_options=request_options) + return _response.data + def confirm( self, upload_id: str, @@ -136,7 +186,7 @@ def confirm( upload_id : str context_type : UploadConfirmRequestContextType - Type of resource this upload is being attached to: `workflow`, `playground`, or `assistant_session`. + Type of resource this upload is being attached to: `workflow`, `playground`, `assistant_session`, or `mcp` (the MCP surface's workspace-scoped scratch space). context_id : str ID of the resource to attach this upload to. Must be an existing resource of the given `context_type` that the caller has access to. @@ -319,6 +369,64 @@ async def main() -> None: ) return _response.data + async def put_upload_content( + self, upload_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> ApiResponseUploadOut: + """ + Send the file's bytes to this API instead of to the presigned URL (alternative step 1). + + A substitute for the PUT to `upload_url`, not a new step: it writes the same staging + object that URL would have written, so `POST /uploads/{id}` confirms either one without + knowing which door the bytes came through. Use exactly one of the two. + + Prefer the presigned URL. It uploads straight to object storage and costs this API + nothing, and it is what every client that can reach object storage should use. This + route exists for callers that **cannot** — a sandboxed agent whose network policy + permits this API's host and not the storage host. There the presigned PUT fails before + it gets any HTTP status at all, and no retry against the same URL can succeed. + + Send the raw bytes as the request body with `Content-Type` set to the `content_type` + from `POST /uploads`, exactly as the presigned PUT requires — this route enforces the + same match, so a file is never stored under a format the confirm step will reject. + Not base64, not multipart. Unlike the presigned URL, this one does not expire: it + authenticates per request, so a slow upload cannot outlive its credential. + + Dual-auth: Bearer JWT or API key (scope `uploads:write`). + + Parameters + ---------- + upload_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ApiResponseUploadOut + Successful Response + + Examples + -------- + import asyncio + + from onepin import AsyncOnePinClient + + client = AsyncOnePinClient( + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.uploads.put_upload_content( + upload_id="upload_id", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.put_upload_content(upload_id, request_options=request_options) + return _response.data + async def confirm( self, upload_id: str, @@ -359,7 +467,7 @@ async def confirm( upload_id : str context_type : UploadConfirmRequestContextType - Type of resource this upload is being attached to: `workflow`, `playground`, or `assistant_session`. + Type of resource this upload is being attached to: `workflow`, `playground`, `assistant_session`, or `mcp` (the MCP surface's workspace-scoped scratch space). context_id : str ID of the resource to attach this upload to. Must be an existing resource of the given `context_type` that the caller has access to. diff --git a/src/onepin/uploads/raw_client.py b/src/onepin/uploads/raw_client.py index 5d627ad..c49a39e 100644 --- a/src/onepin/uploads/raw_client.py +++ b/src/onepin/uploads/raw_client.py @@ -119,6 +119,77 @@ def create( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def put_upload_content( + self, upload_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> HttpResponse[ApiResponseUploadOut]: + """ + Send the file's bytes to this API instead of to the presigned URL (alternative step 1). + + A substitute for the PUT to `upload_url`, not a new step: it writes the same staging + object that URL would have written, so `POST /uploads/{id}` confirms either one without + knowing which door the bytes came through. Use exactly one of the two. + + Prefer the presigned URL. It uploads straight to object storage and costs this API + nothing, and it is what every client that can reach object storage should use. This + route exists for callers that **cannot** — a sandboxed agent whose network policy + permits this API's host and not the storage host. There the presigned PUT fails before + it gets any HTTP status at all, and no retry against the same URL can succeed. + + Send the raw bytes as the request body with `Content-Type` set to the `content_type` + from `POST /uploads`, exactly as the presigned PUT requires — this route enforces the + same match, so a file is never stored under a format the confirm step will reject. + Not base64, not multipart. Unlike the presigned URL, this one does not expire: it + authenticates per request, so a slow upload cannot outlive its credential. + + Dual-auth: Bearer JWT or API key (scope `uploads:write`). + + Parameters + ---------- + upload_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ApiResponseUploadOut] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + f"api/v1/uploads/{encode_path_param(upload_id)}/content", + method="PUT", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ApiResponseUploadOut, + parse_obj_as( + type_=ApiResponseUploadOut, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def confirm( self, upload_id: str, @@ -159,7 +230,7 @@ def confirm( upload_id : str context_type : UploadConfirmRequestContextType - Type of resource this upload is being attached to: `workflow`, `playground`, or `assistant_session`. + Type of resource this upload is being attached to: `workflow`, `playground`, `assistant_session`, or `mcp` (the MCP surface's workspace-scoped scratch space). context_id : str ID of the resource to attach this upload to. Must be an existing resource of the given `context_type` that the caller has access to. @@ -396,6 +467,77 @@ async def create( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def put_upload_content( + self, upload_id: str, *, request_options: typing.Optional[RequestOptions] = None + ) -> AsyncHttpResponse[ApiResponseUploadOut]: + """ + Send the file's bytes to this API instead of to the presigned URL (alternative step 1). + + A substitute for the PUT to `upload_url`, not a new step: it writes the same staging + object that URL would have written, so `POST /uploads/{id}` confirms either one without + knowing which door the bytes came through. Use exactly one of the two. + + Prefer the presigned URL. It uploads straight to object storage and costs this API + nothing, and it is what every client that can reach object storage should use. This + route exists for callers that **cannot** — a sandboxed agent whose network policy + permits this API's host and not the storage host. There the presigned PUT fails before + it gets any HTTP status at all, and no retry against the same URL can succeed. + + Send the raw bytes as the request body with `Content-Type` set to the `content_type` + from `POST /uploads`, exactly as the presigned PUT requires — this route enforces the + same match, so a file is never stored under a format the confirm step will reject. + Not base64, not multipart. Unlike the presigned URL, this one does not expire: it + authenticates per request, so a slow upload cannot outlive its credential. + + Dual-auth: Bearer JWT or API key (scope `uploads:write`). + + Parameters + ---------- + upload_id : str + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ApiResponseUploadOut] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + f"api/v1/uploads/{encode_path_param(upload_id)}/content", + method="PUT", + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ApiResponseUploadOut, + parse_obj_as( + type_=ApiResponseUploadOut, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def confirm( self, upload_id: str, @@ -436,7 +578,7 @@ async def confirm( upload_id : str context_type : UploadConfirmRequestContextType - Type of resource this upload is being attached to: `workflow`, `playground`, or `assistant_session`. + Type of resource this upload is being attached to: `workflow`, `playground`, `assistant_session`, or `mcp` (the MCP surface's workspace-scoped scratch space). context_id : str ID of the resource to attach this upload to. Must be an existing resource of the given `context_type` that the caller has access to. diff --git a/src/onepin/uploads/types/upload_confirm_request_context_type.py b/src/onepin/uploads/types/upload_confirm_request_context_type.py index c0fb534..8141ef5 100644 --- a/src/onepin/uploads/types/upload_confirm_request_context_type.py +++ b/src/onepin/uploads/types/upload_confirm_request_context_type.py @@ -3,5 +3,5 @@ import typing UploadConfirmRequestContextType = typing.Union[ - typing.Literal["workflow", "playground", "assistant_session"], typing.Any + typing.Literal["workflow", "playground", "assistant_session", "mcp"], typing.Any ] diff --git a/src/onepin/voices/client.py b/src/onepin/voices/client.py index 154da2b..ed2020b 100644 --- a/src/onepin/voices/client.py +++ b/src/onepin/voices/client.py @@ -3,9 +3,9 @@ import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.pagination import AsyncPager, SyncPager from ..core.request_options import RequestOptions from ..types.api_counted_list_response_voice_out import ApiCountedListResponseVoiceOut +from ..types.api_list_response_voice_recommended_out import ApiListResponseVoiceRecommendedOut from ..types.api_list_response_voice_similar_out import ApiListResponseVoiceSimilarOut from ..types.api_response_dict import ApiResponseDict from ..types.api_response_voice_facets_out import ApiResponseVoiceFacetsOut @@ -15,7 +15,6 @@ from ..types.voice_age import VoiceAge from ..types.voice_category import VoiceCategory from ..types.voice_gender import VoiceGender -from ..types.voice_out import VoiceOut from .raw_client import AsyncRawVoicesClient, RawVoicesClient from .types.get_voice_facets_api_v1voices_facets_get_request_source_item import ( GetVoiceFacetsApiV1VoicesFacetsGetRequestSourceItem, @@ -54,6 +53,7 @@ def list( category: typing.Optional[typing.Sequence[VoiceCategory]] = None, accent: typing.Optional[typing.Sequence[VoiceAccent]] = None, search: typing.Optional[str] = None, + provider_voice_id: typing.Optional[str] = None, sort: typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] = None, order: typing.Optional[typing.Sequence[ListVoicesRequestOrderItem]] = None, buildable: typing.Optional[bool] = None, @@ -62,7 +62,7 @@ def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: + ) -> ApiCountedListResponseVoiceOut: """ List TTS voices available to the current workspace. @@ -83,6 +83,14 @@ def list( lexical name/tag/descriptor match described below — the response shape (`ApiCountedListResponse[VoiceOut]`) is identical either way. + `provider_voice_id` is the exact vendor wire id and is a FILTER, not a search: it is + never matched by `search` (see the repository predicate for why an ILIKE arm on that + column would rewrite the ranking of every name query), and sending it takes the lexical + path — there is nothing for a semantic ranking to order. It ANDs with every other + filter, including `search`, and the same wire id may be held by more than one visible + row (your own imported voice and the platform catalog row are both admitted, by + design), so it can return several voices; add `provider` to narrow to one vendor. + `language` matches a voice when any of its declared locales matches any requested value. A voice with no declared locales matches NO `language` filter — it must positively declare a locale to surface under it. This holds @@ -170,6 +178,9 @@ def list( search : typing.Optional[str] Searches name, tags, and the voice's summary-derived descriptor text (closely tracks the served description; summary beyond 200 chars is not searched). + provider_voice_id : typing.Optional[str] + Exact vendor wire id (the value submitted to the provider for synthesis). Case- and whitespace-sensitive apart from surrounding blanks; not a substring match and not part of `search`. Pair with `provider` to disambiguate: a wire id may be held by more than one row (your own imported voice and the platform catalog row), so this filter can legitimately return several voices. Ranking is skipped — an exact id has nothing to rank — so `sort`/`order` apply as on a plain browse. + sort : typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] Repeat for multi-sort. Pairs with `order` index-wise. @@ -195,7 +206,7 @@ def list( Returns ------- - SyncPager[VoiceOut, ApiCountedListResponseVoiceOut] + ApiCountedListResponseVoiceOut Successful Response Examples @@ -205,14 +216,9 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - response = client.voices.list() - for item in response: - yield item - # alternatively, you can paginate page-by-page - for page in response.iter_pages(): - yield page + client.voices.list() """ - return self._raw_client.list( + _response = self._raw_client.list( offset=offset, limit=limit, favorites_only=favorites_only, @@ -222,6 +228,7 @@ def list( category=category, accent=accent, search=search, + provider_voice_id=provider_voice_id, sort=sort, order=order, buildable=buildable, @@ -231,6 +238,7 @@ def list( workspace_id=workspace_id, request_options=request_options, ) + return _response.data def get_voice_facets( self, @@ -380,6 +388,138 @@ def get_voice_facets( ) return _response.data + def recommend_voices( + self, + *, + language: str, + limit: typing.Optional[int] = None, + exclude: typing.Optional[typing.Sequence[str]] = None, + offer_round: typing.Optional[int] = None, + gender: typing.Optional[typing.Sequence[VoiceGender]] = None, + age: typing.Optional[typing.Sequence[VoiceAge]] = None, + style: typing.Optional[str] = None, + provider: typing.Optional[typing.Sequence[str]] = None, + model: typing.Optional[typing.Sequence[str]] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ApiListResponseVoiceRecommendedOut: + """ + Recommend voices for a language — the ranked answer, not a catalogue page. + + `GET /voices` is a browse: it answers "what exists" and orders by whatever `sort` + says, defaulting to newest-first. That is the right shape for a person scrolling a + catalogue and the wrong one for a caller that will put three voices in front of + someone — there, WHICH three is the entire recommendation, and "most recently added" + is not an opinion about quality. + + This endpoint is the selection half of automatic voice choice, the same one the + in-product assistant's cards are built from, and it is deliberately a separate route + rather than a mode of the list: browse and recommend disagree about vendors on + purpose. A company may be excluded from what we OFFER automatically while staying + fully browsable and fully usable when a customer asks for it by name, so the policy + belongs to the surface that offers rather than the one that lists. + + What it applies, and the list does not: + + * **Measured quality floors, as a GATE.** Every voice offered clears the naturalness + and noise floors. Tier-lexicographic ranking then decides, per voice, which of its + models it is offered under — quality tier first, price only within a tier, which + axis leads following the workspace's Auto-route setting. + * **Spread across companies — and the returned ORDER is that spread, not a quality + ranking.** Companies are taken in turn in a seed-derived order, and within a + company the voices are seed-shuffled too, so one large catalogue cannot sweep the + slate. Do not present the first row as the best one: three voices that all clear + the floors sit inside one tier width, which is below what the measurement can + resolve, so ordering them by score would claim a precision that is not there. The + order is stable for the same request, which is what makes `exclude`/`offer_round` + the way to get different ones rather than re-asking and hoping. + * **Build re-resolution.** Every voice is re-resolved through the same gate a run + uses, and `recommended_model` is that gate's answer — so a recommendation cannot + name a pairing synthesis would then refuse. + + `exclude` plus `offer_round` is how "show me different ones" works: pass the ids + already shown and raise the round. The slate is deterministic in its inputs, so the + same request returns the same voices — paging is the caller's to drive, not a + hidden cursor's. + + `provider`, `model`, `gender` and `age` narrow the eligible pool without turning this + into a browse. + + `style` is how the customer said it should SOUND, in their words. It ranks by meaning + inside the same eligibility, and it is not the same request as `GET /voices?search=`, + which fuses a NAME-matching arm into the ranking and applies neither the vendor + steer-away nor the hard gender filter a stated style implies. Ask for a name there and + for a sound here. A future `similar_to` will add the third route — voices near one the + customer already chose — and is intentionally not part of this cut. + + An empty `data` means nothing is buildable for this language under these constraints, + which is a real answer and not an error. + + Parameters + ---------- + language : str + Locale the voices must speak, e.g. `ko-kr`. A bare family widens to the product default. + + limit : typing.Optional[int] + How many to recommend (1–6). + + exclude : typing.Optional[typing.Sequence[str]] + Voice ids already offered. Repeat for each; newest kept when over the cap. + + offer_round : typing.Optional[int] + Increment to draw a different slate of equally-ranked voices. + + gender : typing.Optional[typing.Sequence[VoiceGender]] + Repeat for OR + + age : typing.Optional[typing.Sequence[VoiceAge]] + Repeat for OR + + style : typing.Optional[str] + How it should SOUND, in the customer's words — 'a calm professional woman'. Not a name. + + provider : typing.Optional[typing.Sequence[str]] + Repeat for OR, e.g. ?provider=elevenlabs&provider=rime + + model : typing.Optional[typing.Sequence[str]] + Repeat for OR. Filters platform voices by TTS model, e.g. ?model=arcana&model=sonic-2 + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ApiListResponseVoiceRecommendedOut + Successful Response + + Examples + -------- + from onepin import OnePinClient + + client = OnePinClient( + token="YOUR_TOKEN", + ) + client.voices.recommend_voices( + language="language", + ) + """ + _response = self._raw_client.recommend_voices( + language=language, + limit=limit, + exclude=exclude, + offer_round=offer_round, + gender=gender, + age=age, + style=style, + provider=provider, + model=model, + workspace_id=workspace_id, + request_options=request_options, + ) + return _response.data + def get( self, voice_id: str, @@ -687,6 +827,7 @@ async def list( category: typing.Optional[typing.Sequence[VoiceCategory]] = None, accent: typing.Optional[typing.Sequence[VoiceAccent]] = None, search: typing.Optional[str] = None, + provider_voice_id: typing.Optional[str] = None, sort: typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] = None, order: typing.Optional[typing.Sequence[ListVoicesRequestOrderItem]] = None, buildable: typing.Optional[bool] = None, @@ -695,7 +836,7 @@ async def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: + ) -> ApiCountedListResponseVoiceOut: """ List TTS voices available to the current workspace. @@ -716,6 +857,14 @@ async def list( lexical name/tag/descriptor match described below — the response shape (`ApiCountedListResponse[VoiceOut]`) is identical either way. + `provider_voice_id` is the exact vendor wire id and is a FILTER, not a search: it is + never matched by `search` (see the repository predicate for why an ILIKE arm on that + column would rewrite the ranking of every name query), and sending it takes the lexical + path — there is nothing for a semantic ranking to order. It ANDs with every other + filter, including `search`, and the same wire id may be held by more than one visible + row (your own imported voice and the platform catalog row are both admitted, by + design), so it can return several voices; add `provider` to narrow to one vendor. + `language` matches a voice when any of its declared locales matches any requested value. A voice with no declared locales matches NO `language` filter — it must positively declare a locale to surface under it. This holds @@ -803,6 +952,9 @@ async def list( search : typing.Optional[str] Searches name, tags, and the voice's summary-derived descriptor text (closely tracks the served description; summary beyond 200 chars is not searched). + provider_voice_id : typing.Optional[str] + Exact vendor wire id (the value submitted to the provider for synthesis). Case- and whitespace-sensitive apart from surrounding blanks; not a substring match and not part of `search`. Pair with `provider` to disambiguate: a wire id may be held by more than one row (your own imported voice and the platform catalog row), so this filter can legitimately return several voices. Ranking is skipped — an exact id has nothing to rank — so `sort`/`order` apply as on a plain browse. + sort : typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] Repeat for multi-sort. Pairs with `order` index-wise. @@ -828,7 +980,7 @@ async def list( Returns ------- - AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut] + ApiCountedListResponseVoiceOut Successful Response Examples @@ -843,18 +995,12 @@ async def list( async def main() -> None: - response = await client.voices.list() - async for item in response: - yield item - - # alternatively, you can paginate page-by-page - async for page in response.iter_pages(): - yield page + await client.voices.list() asyncio.run(main()) """ - return await self._raw_client.list( + _response = await self._raw_client.list( offset=offset, limit=limit, favorites_only=favorites_only, @@ -864,6 +1010,7 @@ async def main() -> None: category=category, accent=accent, search=search, + provider_voice_id=provider_voice_id, sort=sort, order=order, buildable=buildable, @@ -873,6 +1020,7 @@ async def main() -> None: workspace_id=workspace_id, request_options=request_options, ) + return _response.data async def get_voice_facets( self, @@ -1030,6 +1178,146 @@ async def main() -> None: ) return _response.data + async def recommend_voices( + self, + *, + language: str, + limit: typing.Optional[int] = None, + exclude: typing.Optional[typing.Sequence[str]] = None, + offer_round: typing.Optional[int] = None, + gender: typing.Optional[typing.Sequence[VoiceGender]] = None, + age: typing.Optional[typing.Sequence[VoiceAge]] = None, + style: typing.Optional[str] = None, + provider: typing.Optional[typing.Sequence[str]] = None, + model: typing.Optional[typing.Sequence[str]] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> ApiListResponseVoiceRecommendedOut: + """ + Recommend voices for a language — the ranked answer, not a catalogue page. + + `GET /voices` is a browse: it answers "what exists" and orders by whatever `sort` + says, defaulting to newest-first. That is the right shape for a person scrolling a + catalogue and the wrong one for a caller that will put three voices in front of + someone — there, WHICH three is the entire recommendation, and "most recently added" + is not an opinion about quality. + + This endpoint is the selection half of automatic voice choice, the same one the + in-product assistant's cards are built from, and it is deliberately a separate route + rather than a mode of the list: browse and recommend disagree about vendors on + purpose. A company may be excluded from what we OFFER automatically while staying + fully browsable and fully usable when a customer asks for it by name, so the policy + belongs to the surface that offers rather than the one that lists. + + What it applies, and the list does not: + + * **Measured quality floors, as a GATE.** Every voice offered clears the naturalness + and noise floors. Tier-lexicographic ranking then decides, per voice, which of its + models it is offered under — quality tier first, price only within a tier, which + axis leads following the workspace's Auto-route setting. + * **Spread across companies — and the returned ORDER is that spread, not a quality + ranking.** Companies are taken in turn in a seed-derived order, and within a + company the voices are seed-shuffled too, so one large catalogue cannot sweep the + slate. Do not present the first row as the best one: three voices that all clear + the floors sit inside one tier width, which is below what the measurement can + resolve, so ordering them by score would claim a precision that is not there. The + order is stable for the same request, which is what makes `exclude`/`offer_round` + the way to get different ones rather than re-asking and hoping. + * **Build re-resolution.** Every voice is re-resolved through the same gate a run + uses, and `recommended_model` is that gate's answer — so a recommendation cannot + name a pairing synthesis would then refuse. + + `exclude` plus `offer_round` is how "show me different ones" works: pass the ids + already shown and raise the round. The slate is deterministic in its inputs, so the + same request returns the same voices — paging is the caller's to drive, not a + hidden cursor's. + + `provider`, `model`, `gender` and `age` narrow the eligible pool without turning this + into a browse. + + `style` is how the customer said it should SOUND, in their words. It ranks by meaning + inside the same eligibility, and it is not the same request as `GET /voices?search=`, + which fuses a NAME-matching arm into the ranking and applies neither the vendor + steer-away nor the hard gender filter a stated style implies. Ask for a name there and + for a sound here. A future `similar_to` will add the third route — voices near one the + customer already chose — and is intentionally not part of this cut. + + An empty `data` means nothing is buildable for this language under these constraints, + which is a real answer and not an error. + + Parameters + ---------- + language : str + Locale the voices must speak, e.g. `ko-kr`. A bare family widens to the product default. + + limit : typing.Optional[int] + How many to recommend (1–6). + + exclude : typing.Optional[typing.Sequence[str]] + Voice ids already offered. Repeat for each; newest kept when over the cap. + + offer_round : typing.Optional[int] + Increment to draw a different slate of equally-ranked voices. + + gender : typing.Optional[typing.Sequence[VoiceGender]] + Repeat for OR + + age : typing.Optional[typing.Sequence[VoiceAge]] + Repeat for OR + + style : typing.Optional[str] + How it should SOUND, in the customer's words — 'a calm professional woman'. Not a name. + + provider : typing.Optional[typing.Sequence[str]] + Repeat for OR, e.g. ?provider=elevenlabs&provider=rime + + model : typing.Optional[typing.Sequence[str]] + Repeat for OR. Filters platform voices by TTS model, e.g. ?model=arcana&model=sonic-2 + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + ApiListResponseVoiceRecommendedOut + Successful Response + + Examples + -------- + import asyncio + + from onepin import AsyncOnePinClient + + client = AsyncOnePinClient( + token="YOUR_TOKEN", + ) + + + async def main() -> None: + await client.voices.recommend_voices( + language="language", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.recommend_voices( + language=language, + limit=limit, + exclude=exclude, + offer_round=offer_round, + gender=gender, + age=age, + style=style, + provider=provider, + model=model, + workspace_id=workspace_id, + request_options=request_options, + ) + return _response.data + async def get( self, voice_id: str, diff --git a/src/onepin/voices/raw_client.py b/src/onepin/voices/raw_client.py index 8e34fc2..a92657f 100644 --- a/src/onepin/voices/raw_client.py +++ b/src/onepin/voices/raw_client.py @@ -7,12 +7,12 @@ from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import encode_path_param -from ..core.pagination import AsyncPager, SyncPager from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions from ..errors.unprocessable_entity_error import UnprocessableEntityError from ..types.api_counted_list_response_voice_out import ApiCountedListResponseVoiceOut +from ..types.api_list_response_voice_recommended_out import ApiListResponseVoiceRecommendedOut from ..types.api_list_response_voice_similar_out import ApiListResponseVoiceSimilarOut from ..types.api_response_dict import ApiResponseDict from ..types.api_response_voice_facets_out import ApiResponseVoiceFacetsOut @@ -22,7 +22,6 @@ from ..types.voice_age import VoiceAge from ..types.voice_category import VoiceCategory from ..types.voice_gender import VoiceGender -from ..types.voice_out import VoiceOut from .types.get_voice_facets_api_v1voices_facets_get_request_source_item import ( GetVoiceFacetsApiV1VoicesFacetsGetRequestSourceItem, ) @@ -50,6 +49,7 @@ def list( category: typing.Optional[typing.Sequence[VoiceCategory]] = None, accent: typing.Optional[typing.Sequence[VoiceAccent]] = None, search: typing.Optional[str] = None, + provider_voice_id: typing.Optional[str] = None, sort: typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] = None, order: typing.Optional[typing.Sequence[ListVoicesRequestOrderItem]] = None, buildable: typing.Optional[bool] = None, @@ -58,7 +58,7 @@ def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: + ) -> HttpResponse[ApiCountedListResponseVoiceOut]: """ List TTS voices available to the current workspace. @@ -79,6 +79,14 @@ def list( lexical name/tag/descriptor match described below — the response shape (`ApiCountedListResponse[VoiceOut]`) is identical either way. + `provider_voice_id` is the exact vendor wire id and is a FILTER, not a search: it is + never matched by `search` (see the repository predicate for why an ILIKE arm on that + column would rewrite the ranking of every name query), and sending it takes the lexical + path — there is nothing for a semantic ranking to order. It ANDs with every other + filter, including `search`, and the same wire id may be held by more than one visible + row (your own imported voice and the platform catalog row are both admitted, by + design), so it can return several voices; add `provider` to narrow to one vendor. + `language` matches a voice when any of its declared locales matches any requested value. A voice with no declared locales matches NO `language` filter — it must positively declare a locale to surface under it. This holds @@ -166,6 +174,9 @@ def list( search : typing.Optional[str] Searches name, tags, and the voice's summary-derived descriptor text (closely tracks the served description; summary beyond 200 chars is not searched). + provider_voice_id : typing.Optional[str] + Exact vendor wire id (the value submitted to the provider for synthesis). Case- and whitespace-sensitive apart from surrounding blanks; not a substring match and not part of `search`. Pair with `provider` to disambiguate: a wire id may be held by more than one row (your own imported voice and the platform catalog row), so this filter can legitimately return several voices. Ranking is skipped — an exact id has nothing to rank — so `sort`/`order` apply as on a plain browse. + sort : typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] Repeat for multi-sort. Pairs with `order` index-wise. @@ -191,11 +202,9 @@ def list( Returns ------- - SyncPager[VoiceOut, ApiCountedListResponseVoiceOut] + HttpResponse[ApiCountedListResponseVoiceOut] Successful Response """ - offset = offset if offset is not None else 0 - _response = self._client_wrapper.httpx_client.request( "api/v1/voices", method="GET", @@ -209,6 +218,7 @@ def list( "category": category, "accent": accent, "search": search, + "provider_voice_id": provider_voice_id, "sort": sort, "order": order, "buildable": buildable, @@ -223,35 +233,14 @@ def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiCountedListResponseVoiceOut, parse_obj_as( type_=ApiCountedListResponseVoiceOut, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - _get_next = lambda: self.list( - offset=offset + len(_items or []), - limit=limit, - favorites_only=favorites_only, - source=source, - gender=gender, - age=age, - category=category, - accent=accent, - search=search, - sort=sort, - order=order, - buildable=buildable, - provider=provider, - model=model, - language=language, - workspace_id=workspace_id, - request_options=request_options, - ) - return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -445,6 +434,161 @@ def get_voice_facets( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def recommend_voices( + self, + *, + language: str, + limit: typing.Optional[int] = None, + exclude: typing.Optional[typing.Sequence[str]] = None, + offer_round: typing.Optional[int] = None, + gender: typing.Optional[typing.Sequence[VoiceGender]] = None, + age: typing.Optional[typing.Sequence[VoiceAge]] = None, + style: typing.Optional[str] = None, + provider: typing.Optional[typing.Sequence[str]] = None, + model: typing.Optional[typing.Sequence[str]] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[ApiListResponseVoiceRecommendedOut]: + """ + Recommend voices for a language — the ranked answer, not a catalogue page. + + `GET /voices` is a browse: it answers "what exists" and orders by whatever `sort` + says, defaulting to newest-first. That is the right shape for a person scrolling a + catalogue and the wrong one for a caller that will put three voices in front of + someone — there, WHICH three is the entire recommendation, and "most recently added" + is not an opinion about quality. + + This endpoint is the selection half of automatic voice choice, the same one the + in-product assistant's cards are built from, and it is deliberately a separate route + rather than a mode of the list: browse and recommend disagree about vendors on + purpose. A company may be excluded from what we OFFER automatically while staying + fully browsable and fully usable when a customer asks for it by name, so the policy + belongs to the surface that offers rather than the one that lists. + + What it applies, and the list does not: + + * **Measured quality floors, as a GATE.** Every voice offered clears the naturalness + and noise floors. Tier-lexicographic ranking then decides, per voice, which of its + models it is offered under — quality tier first, price only within a tier, which + axis leads following the workspace's Auto-route setting. + * **Spread across companies — and the returned ORDER is that spread, not a quality + ranking.** Companies are taken in turn in a seed-derived order, and within a + company the voices are seed-shuffled too, so one large catalogue cannot sweep the + slate. Do not present the first row as the best one: three voices that all clear + the floors sit inside one tier width, which is below what the measurement can + resolve, so ordering them by score would claim a precision that is not there. The + order is stable for the same request, which is what makes `exclude`/`offer_round` + the way to get different ones rather than re-asking and hoping. + * **Build re-resolution.** Every voice is re-resolved through the same gate a run + uses, and `recommended_model` is that gate's answer — so a recommendation cannot + name a pairing synthesis would then refuse. + + `exclude` plus `offer_round` is how "show me different ones" works: pass the ids + already shown and raise the round. The slate is deterministic in its inputs, so the + same request returns the same voices — paging is the caller's to drive, not a + hidden cursor's. + + `provider`, `model`, `gender` and `age` narrow the eligible pool without turning this + into a browse. + + `style` is how the customer said it should SOUND, in their words. It ranks by meaning + inside the same eligibility, and it is not the same request as `GET /voices?search=`, + which fuses a NAME-matching arm into the ranking and applies neither the vendor + steer-away nor the hard gender filter a stated style implies. Ask for a name there and + for a sound here. A future `similar_to` will add the third route — voices near one the + customer already chose — and is intentionally not part of this cut. + + An empty `data` means nothing is buildable for this language under these constraints, + which is a real answer and not an error. + + Parameters + ---------- + language : str + Locale the voices must speak, e.g. `ko-kr`. A bare family widens to the product default. + + limit : typing.Optional[int] + How many to recommend (1–6). + + exclude : typing.Optional[typing.Sequence[str]] + Voice ids already offered. Repeat for each; newest kept when over the cap. + + offer_round : typing.Optional[int] + Increment to draw a different slate of equally-ranked voices. + + gender : typing.Optional[typing.Sequence[VoiceGender]] + Repeat for OR + + age : typing.Optional[typing.Sequence[VoiceAge]] + Repeat for OR + + style : typing.Optional[str] + How it should SOUND, in the customer's words — 'a calm professional woman'. Not a name. + + provider : typing.Optional[typing.Sequence[str]] + Repeat for OR, e.g. ?provider=elevenlabs&provider=rime + + model : typing.Optional[typing.Sequence[str]] + Repeat for OR. Filters platform voices by TTS model, e.g. ?model=arcana&model=sonic-2 + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[ApiListResponseVoiceRecommendedOut] + Successful Response + """ + _response = self._client_wrapper.httpx_client.request( + "api/v1/voices/recommendations", + method="GET", + params={ + "language": language, + "limit": limit, + "exclude": exclude, + "offer_round": offer_round, + "gender": gender, + "age": age, + "style": style, + "provider": provider, + "model": model, + }, + headers={ + "X-Workspace-Id": str(workspace_id) if workspace_id is not None else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ApiListResponseVoiceRecommendedOut, + parse_obj_as( + type_=ApiListResponseVoiceRecommendedOut, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def get( self, voice_id: str, @@ -860,6 +1004,7 @@ async def list( category: typing.Optional[typing.Sequence[VoiceCategory]] = None, accent: typing.Optional[typing.Sequence[VoiceAccent]] = None, search: typing.Optional[str] = None, + provider_voice_id: typing.Optional[str] = None, sort: typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] = None, order: typing.Optional[typing.Sequence[ListVoicesRequestOrderItem]] = None, buildable: typing.Optional[bool] = None, @@ -868,7 +1013,7 @@ async def list( language: typing.Optional[typing.Sequence[ListVoicesRequestLanguageItem]] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut]: + ) -> AsyncHttpResponse[ApiCountedListResponseVoiceOut]: """ List TTS voices available to the current workspace. @@ -889,6 +1034,14 @@ async def list( lexical name/tag/descriptor match described below — the response shape (`ApiCountedListResponse[VoiceOut]`) is identical either way. + `provider_voice_id` is the exact vendor wire id and is a FILTER, not a search: it is + never matched by `search` (see the repository predicate for why an ILIKE arm on that + column would rewrite the ranking of every name query), and sending it takes the lexical + path — there is nothing for a semantic ranking to order. It ANDs with every other + filter, including `search`, and the same wire id may be held by more than one visible + row (your own imported voice and the platform catalog row are both admitted, by + design), so it can return several voices; add `provider` to narrow to one vendor. + `language` matches a voice when any of its declared locales matches any requested value. A voice with no declared locales matches NO `language` filter — it must positively declare a locale to surface under it. This holds @@ -976,6 +1129,9 @@ async def list( search : typing.Optional[str] Searches name, tags, and the voice's summary-derived descriptor text (closely tracks the served description; summary beyond 200 chars is not searched). + provider_voice_id : typing.Optional[str] + Exact vendor wire id (the value submitted to the provider for synthesis). Case- and whitespace-sensitive apart from surrounding blanks; not a substring match and not part of `search`. Pair with `provider` to disambiguate: a wire id may be held by more than one row (your own imported voice and the platform catalog row), so this filter can legitimately return several voices. Ranking is skipped — an exact id has nothing to rank — so `sort`/`order` apply as on a plain browse. + sort : typing.Optional[typing.Sequence[ListVoicesRequestSortItem]] Repeat for multi-sort. Pairs with `order` index-wise. @@ -1001,11 +1157,9 @@ async def list( Returns ------- - AsyncPager[VoiceOut, ApiCountedListResponseVoiceOut] + AsyncHttpResponse[ApiCountedListResponseVoiceOut] Successful Response """ - offset = offset if offset is not None else 0 - _response = await self._client_wrapper.httpx_client.request( "api/v1/voices", method="GET", @@ -1019,6 +1173,7 @@ async def list( "category": category, "accent": accent, "search": search, + "provider_voice_id": provider_voice_id, "sort": sort, "order": order, "buildable": buildable, @@ -1033,38 +1188,14 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiCountedListResponseVoiceOut, parse_obj_as( type_=ApiCountedListResponseVoiceOut, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - - async def _get_next(): - return await self.list( - offset=offset + len(_items or []), - limit=limit, - favorites_only=favorites_only, - source=source, - gender=gender, - age=age, - category=category, - accent=accent, - search=search, - sort=sort, - order=order, - buildable=buildable, - provider=provider, - model=model, - language=language, - workspace_id=workspace_id, - request_options=request_options, - ) - - return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -1258,6 +1389,161 @@ async def get_voice_facets( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def recommend_voices( + self, + *, + language: str, + limit: typing.Optional[int] = None, + exclude: typing.Optional[typing.Sequence[str]] = None, + offer_round: typing.Optional[int] = None, + gender: typing.Optional[typing.Sequence[VoiceGender]] = None, + age: typing.Optional[typing.Sequence[VoiceAge]] = None, + style: typing.Optional[str] = None, + provider: typing.Optional[typing.Sequence[str]] = None, + model: typing.Optional[typing.Sequence[str]] = None, + workspace_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[ApiListResponseVoiceRecommendedOut]: + """ + Recommend voices for a language — the ranked answer, not a catalogue page. + + `GET /voices` is a browse: it answers "what exists" and orders by whatever `sort` + says, defaulting to newest-first. That is the right shape for a person scrolling a + catalogue and the wrong one for a caller that will put three voices in front of + someone — there, WHICH three is the entire recommendation, and "most recently added" + is not an opinion about quality. + + This endpoint is the selection half of automatic voice choice, the same one the + in-product assistant's cards are built from, and it is deliberately a separate route + rather than a mode of the list: browse and recommend disagree about vendors on + purpose. A company may be excluded from what we OFFER automatically while staying + fully browsable and fully usable when a customer asks for it by name, so the policy + belongs to the surface that offers rather than the one that lists. + + What it applies, and the list does not: + + * **Measured quality floors, as a GATE.** Every voice offered clears the naturalness + and noise floors. Tier-lexicographic ranking then decides, per voice, which of its + models it is offered under — quality tier first, price only within a tier, which + axis leads following the workspace's Auto-route setting. + * **Spread across companies — and the returned ORDER is that spread, not a quality + ranking.** Companies are taken in turn in a seed-derived order, and within a + company the voices are seed-shuffled too, so one large catalogue cannot sweep the + slate. Do not present the first row as the best one: three voices that all clear + the floors sit inside one tier width, which is below what the measurement can + resolve, so ordering them by score would claim a precision that is not there. The + order is stable for the same request, which is what makes `exclude`/`offer_round` + the way to get different ones rather than re-asking and hoping. + * **Build re-resolution.** Every voice is re-resolved through the same gate a run + uses, and `recommended_model` is that gate's answer — so a recommendation cannot + name a pairing synthesis would then refuse. + + `exclude` plus `offer_round` is how "show me different ones" works: pass the ids + already shown and raise the round. The slate is deterministic in its inputs, so the + same request returns the same voices — paging is the caller's to drive, not a + hidden cursor's. + + `provider`, `model`, `gender` and `age` narrow the eligible pool without turning this + into a browse. + + `style` is how the customer said it should SOUND, in their words. It ranks by meaning + inside the same eligibility, and it is not the same request as `GET /voices?search=`, + which fuses a NAME-matching arm into the ranking and applies neither the vendor + steer-away nor the hard gender filter a stated style implies. Ask for a name there and + for a sound here. A future `similar_to` will add the third route — voices near one the + customer already chose — and is intentionally not part of this cut. + + An empty `data` means nothing is buildable for this language under these constraints, + which is a real answer and not an error. + + Parameters + ---------- + language : str + Locale the voices must speak, e.g. `ko-kr`. A bare family widens to the product default. + + limit : typing.Optional[int] + How many to recommend (1–6). + + exclude : typing.Optional[typing.Sequence[str]] + Voice ids already offered. Repeat for each; newest kept when over the cap. + + offer_round : typing.Optional[int] + Increment to draw a different slate of equally-ranked voices. + + gender : typing.Optional[typing.Sequence[VoiceGender]] + Repeat for OR + + age : typing.Optional[typing.Sequence[VoiceAge]] + Repeat for OR + + style : typing.Optional[str] + How it should SOUND, in the customer's words — 'a calm professional woman'. Not a name. + + provider : typing.Optional[typing.Sequence[str]] + Repeat for OR, e.g. ?provider=elevenlabs&provider=rime + + model : typing.Optional[typing.Sequence[str]] + Repeat for OR. Filters platform voices by TTS model, e.g. ?model=arcana&model=sonic-2 + + workspace_id : typing.Optional[str] + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[ApiListResponseVoiceRecommendedOut] + Successful Response + """ + _response = await self._client_wrapper.httpx_client.request( + "api/v1/voices/recommendations", + method="GET", + params={ + "language": language, + "limit": limit, + "exclude": exclude, + "offer_round": offer_round, + "gender": gender, + "age": age, + "style": style, + "provider": provider, + "model": model, + }, + headers={ + "X-Workspace-Id": str(workspace_id) if workspace_id is not None else None, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + ApiListResponseVoiceRecommendedOut, + parse_obj_as( + type_=ApiListResponseVoiceRecommendedOut, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def get( self, voice_id: str, diff --git a/src/onepin/workflows/client.py b/src/onepin/workflows/client.py index db6cb44..78da03d 100644 --- a/src/onepin/workflows/client.py +++ b/src/onepin/workflows/client.py @@ -6,7 +6,6 @@ import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.pagination import AsyncPager, SyncPager from ..core.request_options import RequestOptions from ..types.api_counted_list_response_workflow_list_item import ApiCountedListResponseWorkflowListItem from ..types.api_list_response_upload_out import ApiListResponseUploadOut @@ -23,7 +22,6 @@ from ..types.api_response_workflow_run_overview_out import ApiResponseWorkflowRunOverviewOut from ..types.api_response_workflow_validate_out import ApiResponseWorkflowValidateOut from ..types.workflow_definition_input import WorkflowDefinitionInput -from ..types.workflow_list_item import WorkflowListItem from ..types.workflow_list_status import WorkflowListStatus from ..types.workflow_run_data_response import WorkflowRunDataResponse from .raw_client import AsyncRawWorkflowsClient, RawWorkflowsClient @@ -68,7 +66,7 @@ def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: + ) -> ApiCountedListResponseWorkflowListItem: """ List workflows in the current workspace. @@ -146,7 +144,7 @@ def list( Returns ------- - SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] + ApiCountedListResponseWorkflowListItem Successful Response Examples @@ -156,14 +154,9 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - response = client.workflows.list() - for item in response: - yield item - # alternatively, you can paginate page-by-page - for page in response.iter_pages(): - yield page + client.workflows.list() """ - return self._raw_client.list( + _response = self._raw_client.list( status=status, search=search, sort=sort, @@ -177,6 +170,7 @@ def list( workspace_id=workspace_id, request_options=request_options, ) + return _response.data def create_workflow( self, @@ -1532,7 +1526,7 @@ async def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: + ) -> ApiCountedListResponseWorkflowListItem: """ List workflows in the current workspace. @@ -1610,7 +1604,7 @@ async def list( Returns ------- - AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] + ApiCountedListResponseWorkflowListItem Successful Response Examples @@ -1625,18 +1619,12 @@ async def list( async def main() -> None: - response = await client.workflows.list() - async for item in response: - yield item - - # alternatively, you can paginate page-by-page - async for page in response.iter_pages(): - yield page + await client.workflows.list() asyncio.run(main()) """ - return await self._raw_client.list( + _response = await self._raw_client.list( status=status, search=search, sort=sort, @@ -1650,6 +1638,7 @@ async def main() -> None: workspace_id=workspace_id, request_options=request_options, ) + return _response.data async def create_workflow( self, diff --git a/src/onepin/workflows/raw_client.py b/src/onepin/workflows/raw_client.py index 11a9bcc..e7bd5b6 100644 --- a/src/onepin/workflows/raw_client.py +++ b/src/onepin/workflows/raw_client.py @@ -9,7 +9,6 @@ from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.jsonable_encoder import encode_path_param -from ..core.pagination import AsyncPager, SyncPager from ..core.parse_error import ParsingError from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -32,7 +31,6 @@ from ..types.api_response_workflow_run_overview_out import ApiResponseWorkflowRunOverviewOut from ..types.api_response_workflow_validate_out import ApiResponseWorkflowValidateOut from ..types.workflow_definition_input import WorkflowDefinitionInput -from ..types.workflow_list_item import WorkflowListItem from ..types.workflow_list_status import WorkflowListStatus from ..types.workflow_run_data_response import WorkflowRunDataResponse from .types.list_workflows_request_order_item import ListWorkflowsRequestOrderItem @@ -62,7 +60,7 @@ def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: + ) -> HttpResponse[ApiCountedListResponseWorkflowListItem]: """ List workflows in the current workspace. @@ -140,11 +138,9 @@ def list( Returns ------- - SyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] + HttpResponse[ApiCountedListResponseWorkflowListItem] Successful Response """ - offset = offset if offset is not None else 0 - _response = self._client_wrapper.httpx_client.request( "api/v1/workflows", method="GET", @@ -167,30 +163,14 @@ def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiCountedListResponseWorkflowListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowListItem, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - _get_next = lambda: self.list( - status=status, - search=search, - sort=sort, - order=order, - last_run_after=last_run_after, - last_run_before=last_run_before, - has_failed_run=has_failed_run, - include_definition=include_definition, - offset=offset + len(_items or []), - limit=limit, - workspace_id=workspace_id, - request_options=request_options, - ) - return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -2109,7 +2089,7 @@ async def list( limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem]: + ) -> AsyncHttpResponse[ApiCountedListResponseWorkflowListItem]: """ List workflows in the current workspace. @@ -2187,11 +2167,9 @@ async def list( Returns ------- - AsyncPager[WorkflowListItem, ApiCountedListResponseWorkflowListItem] + AsyncHttpResponse[ApiCountedListResponseWorkflowListItem] Successful Response """ - offset = offset if offset is not None else 0 - _response = await self._client_wrapper.httpx_client.request( "api/v1/workflows", method="GET", @@ -2214,33 +2192,14 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiCountedListResponseWorkflowListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowListItem, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - - async def _get_next(): - return await self.list( - status=status, - search=search, - sort=sort, - order=order, - last_run_after=last_run_after, - last_run_before=last_run_before, - has_failed_run=has_failed_run, - include_definition=include_definition, - offset=offset + len(_items or []), - limit=limit, - workspace_id=workspace_id, - request_options=request_options, - ) - - return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), diff --git a/src/onepin/workflows/runs/client.py b/src/onepin/workflows/runs/client.py index d0ccc51..8f6a24b 100644 --- a/src/onepin/workflows/runs/client.py +++ b/src/onepin/workflows/runs/client.py @@ -3,7 +3,6 @@ import typing from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ...core.pagination import AsyncPager, SyncPager from ...core.request_options import RequestOptions from ...types.api_counted_list_response_workflow_run_list_item import ApiCountedListResponseWorkflowRunListItem from ...types.api_response_list_workflow_run_step_out import ApiResponseListWorkflowRunStepOut @@ -11,7 +10,6 @@ from ...types.api_response_workflow_run_out import ApiResponseWorkflowRunOut from ...types.api_response_workflow_run_status_out import ApiResponseWorkflowRunStatusOut from ...types.node_type import NodeType -from ...types.workflow_run_list_item import WorkflowRunListItem from .raw_client import AsyncRawRunsClient, RawRunsClient from .types.list_runs_request_order import ListRunsRequestOrder from .types.list_runs_request_sort import ListRunsRequestSort @@ -47,7 +45,7 @@ def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: + ) -> ApiCountedListResponseWorkflowRunListItem: """ List runs for a workflow, newest first by default. @@ -94,7 +92,7 @@ def list( Returns ------- - SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] + ApiCountedListResponseWorkflowRunListItem Successful Response Examples @@ -104,16 +102,11 @@ def list( client = OnePinClient( token="YOUR_TOKEN", ) - response = client.workflows.runs.list( + client.workflows.runs.list( workflow_id="workflow_id", ) - for item in response: - yield item - # alternatively, you can paginate page-by-page - for page in response.iter_pages(): - yield page """ - return self._raw_client.list( + _response = self._raw_client.list( workflow_id, offset=offset, limit=limit, @@ -124,6 +117,7 @@ def list( workspace_id=workspace_id, request_options=request_options, ) + return _response.data def start( self, @@ -479,7 +473,7 @@ async def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: + ) -> ApiCountedListResponseWorkflowRunListItem: """ List runs for a workflow, newest first by default. @@ -526,7 +520,7 @@ async def list( Returns ------- - AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] + ApiCountedListResponseWorkflowRunListItem Successful Response Examples @@ -541,20 +535,14 @@ async def list( async def main() -> None: - response = await client.workflows.runs.list( + await client.workflows.runs.list( workflow_id="workflow_id", ) - async for item in response: - yield item - - # alternatively, you can paginate page-by-page - async for page in response.iter_pages(): - yield page asyncio.run(main()) """ - return await self._raw_client.list( + _response = await self._raw_client.list( workflow_id, offset=offset, limit=limit, @@ -565,6 +553,7 @@ async def main() -> None: workspace_id=workspace_id, request_options=request_options, ) + return _response.data async def start( self, diff --git a/src/onepin/workflows/runs/raw_client.py b/src/onepin/workflows/runs/raw_client.py index b77f883..0599dae 100644 --- a/src/onepin/workflows/runs/raw_client.py +++ b/src/onepin/workflows/runs/raw_client.py @@ -7,7 +7,6 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...core.http_response import AsyncHttpResponse, HttpResponse from ...core.jsonable_encoder import encode_path_param -from ...core.pagination import AsyncPager, SyncPager from ...core.parse_error import ParsingError from ...core.pydantic_utilities import parse_obj_as from ...core.request_options import RequestOptions @@ -18,7 +17,6 @@ from ...types.api_response_workflow_run_out import ApiResponseWorkflowRunOut from ...types.api_response_workflow_run_status_out import ApiResponseWorkflowRunStatusOut from ...types.node_type import NodeType -from ...types.workflow_run_list_item import WorkflowRunListItem from .types.list_runs_request_order import ListRunsRequestOrder from .types.list_runs_request_sort import ListRunsRequestSort from pydantic import ValidationError @@ -43,7 +41,7 @@ def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: + ) -> HttpResponse[ApiCountedListResponseWorkflowRunListItem]: """ List runs for a workflow, newest first by default. @@ -90,11 +88,9 @@ def list( Returns ------- - SyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] + HttpResponse[ApiCountedListResponseWorkflowRunListItem] Successful Response """ - offset = offset if offset is not None else 0 - _response = self._client_wrapper.httpx_client.request( f"api/v1/workflows/{encode_path_param(workflow_id)}/runs", method="GET", @@ -113,27 +109,14 @@ def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiCountedListResponseWorkflowRunListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowRunListItem, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - _get_next = lambda: self.list( - workflow_id, - offset=offset + len(_items or []), - limit=limit, - status=status, - search=search, - sort=sort, - order=order, - workspace_id=workspace_id, - request_options=request_options, - ) - return SyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return HttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), @@ -604,7 +587,7 @@ async def list( order: typing.Optional[ListRunsRequestOrder] = None, workspace_id: typing.Optional[str] = None, request_options: typing.Optional[RequestOptions] = None, - ) -> AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem]: + ) -> AsyncHttpResponse[ApiCountedListResponseWorkflowRunListItem]: """ List runs for a workflow, newest first by default. @@ -651,11 +634,9 @@ async def list( Returns ------- - AsyncPager[WorkflowRunListItem, ApiCountedListResponseWorkflowRunListItem] + AsyncHttpResponse[ApiCountedListResponseWorkflowRunListItem] Successful Response """ - offset = offset if offset is not None else 0 - _response = await self._client_wrapper.httpx_client.request( f"api/v1/workflows/{encode_path_param(workflow_id)}/runs", method="GET", @@ -674,30 +655,14 @@ async def list( ) try: if 200 <= _response.status_code < 300: - _parsed_response = typing.cast( + _data = typing.cast( ApiCountedListResponseWorkflowRunListItem, parse_obj_as( type_=ApiCountedListResponseWorkflowRunListItem, # type: ignore object_=_response.json(), ), ) - _items = _parsed_response.data - _has_next = len(_items or []) > 0 - - async def _get_next(): - return await self.list( - workflow_id, - offset=offset + len(_items or []), - limit=limit, - status=status, - search=search, - sort=sort, - order=order, - workspace_id=workspace_id, - request_options=request_options, - ) - - return AsyncPager(has_next=_has_next, items=_items, get_next=_get_next, response=_parsed_response) + return AsyncHttpResponse(response=_response, data=_data) if _response.status_code == 422: raise UnprocessableEntityError( headers=dict(_response.headers), diff --git a/src/onepin/workspaces/client.py b/src/onepin/workspaces/client.py index 367f245..a11294b 100644 --- a/src/onepin/workspaces/client.py +++ b/src/onepin/workspaces/client.py @@ -395,7 +395,9 @@ def get_workspace_subscription( member or an org admin. Use this instead of ``/users/me/subscription`` when rendering a workspace's plan, so an org workspace shows the ORGANIZATION's plan. Membership is existence-hidden (404); the org-admin fallback applies (an org admin with no materialized - member row can still read). + member row can still read). Before returning a locally active subscription, the endpoint + verifies it with Stripe; a Stripe-confirmed terminal subscription is persisted and returns + ``null``, while a verification failure returns 502 ``BILLING_ERROR``. Parameters ---------- @@ -955,7 +957,9 @@ async def get_workspace_subscription( member or an org admin. Use this instead of ``/users/me/subscription`` when rendering a workspace's plan, so an org workspace shows the ORGANIZATION's plan. Membership is existence-hidden (404); the org-admin fallback applies (an org admin with no materialized - member row can still read). + member row can still read). Before returning a locally active subscription, the endpoint + verifies it with Stripe; a Stripe-confirmed terminal subscription is persisted and returns + ``null``, while a verification failure returns 502 ``BILLING_ERROR``. Parameters ---------- diff --git a/src/onepin/workspaces/raw_client.py b/src/onepin/workspaces/raw_client.py index 5855970..447ad45 100644 --- a/src/onepin/workspaces/raw_client.py +++ b/src/onepin/workspaces/raw_client.py @@ -618,7 +618,9 @@ def get_workspace_subscription( member or an org admin. Use this instead of ``/users/me/subscription`` when rendering a workspace's plan, so an org workspace shows the ORGANIZATION's plan. Membership is existence-hidden (404); the org-admin fallback applies (an org admin with no materialized - member row can still read). + member row can still read). Before returning a locally active subscription, the endpoint + verifies it with Stripe; a Stripe-confirmed terminal subscription is persisted and returns + ``null``, while a verification failure returns 502 ``BILLING_ERROR``. Parameters ---------- @@ -1452,7 +1454,9 @@ async def get_workspace_subscription( member or an org admin. Use this instead of ``/users/me/subscription`` when rendering a workspace's plan, so an org workspace shows the ORGANIZATION's plan. Membership is existence-hidden (404); the org-admin fallback applies (an org admin with no materialized - member row can still read). + member row can still read). Before returning a locally active subscription, the endpoint + verifies it with Stripe; a Stripe-confirmed terminal subscription is persisted and returns + ``null``, while a verification failure returns 502 ``BILLING_ERROR``. Parameters ----------