From aff0a0477fb83d8c00cffe51849e97ba30a1d85a Mon Sep 17 00:00:00 2001
From: "onepin-pipeline-bot[bot]"
<290953255+onepin-pipeline-bot[bot]@users.noreply.github.com>
Date: Fri, 16 Oct 2026 05:56:02 +0000
Subject: [PATCH] feat: sync SDK to OnePin API v0.41.155
---
.spec-sha | 2 +-
guards.log | 66 ++++++++++++--
src/onepin/.fern/metadata.json | 2 +-
src/onepin/__init__.py | 8 +-
src/onepin/core/http_client.py | 81 +++++++++++++-----
src/onepin/core/pagination.py | 85 ++++++++++++++++++-
src/onepin/core/pydantic_utilities.py | 64 ++++++++++++--
src/onepin/core/request_options.py | 3 +
src/onepin/reference.md | 64 ++++++++++++--
src/onepin/types/node_type.py | 1 +
src/onepin/types/pause_reason.py | 5 +-
.../types/template_estimate_response.py | 5 ++
src/onepin/types/upload_out.py | 2 +-
src/onepin/types/workflow_list_item.py | 5 ++
src/onepin/types/workflow_list_status.py | 4 +-
src/onepin/types/workflow_run_list_item.py | 4 +
src/onepin/uploads/client.py | 52 ++++++++++--
src/onepin/uploads/raw_client.py | 50 ++++++++++-
.../uploads/types/upload_request_category.py | 2 +-
src/onepin/workflows/__init__.py | 8 +-
src/onepin/workflows/client.py | 79 +++++++++++++----
src/onepin/workflows/raw_client.py | 67 ++++++++++++---
src/onepin/workflows/types/__init__.py | 10 ++-
...orkflow_id_uploads_get_request_category.py | 7 ++
.../types/list_workflows_request_sort_item.py | 4 +-
25 files changed, 584 insertions(+), 96 deletions(-)
create mode 100644 src/onepin/workflows/types/list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category.py
diff --git a/.spec-sha b/.spec-sha
index eb4a8f8..335cef6 100644
--- a/.spec-sha
+++ b/.spec-sha
@@ -1 +1 @@
-8c8c6f7f8bdffec317e65c2abf9e2af93692424f
+da90f167064000a4c5ff595855e1e266dfd43d8f
diff --git a/guards.log b/guards.log
index f9f955c..61f7872 100644
--- a/guards.log
+++ b/guards.log
@@ -1,13 +1,13 @@
........................................................................ [ 17%]
..................................................................s..... [ 34%]
-........................................................................ [ 51%]
-.........F.............................................................. [ 68%]
+...........................................................F....F....... [ 51%]
+................F....................................................... [ 68%]
........................................................................ [ 85%]
............................................................... [100%]
=================================== FAILURES ===================================
-__________________ test_no_unexposed_sdk_params[voices.list] ___________________
+_________________ test_no_unexposed_sdk_params[workflows.list] _________________
-cmd = Cmd(group='voices', name='list', method='voices.list', help='List available voices.', subgroup=None, args=[], options=...ider', 'gender', 'category'], success_msg=None, destructive=False, redact=False, fallback_methods=(), gate='buildable')
+cmd = Cmd(group='workflows', name='list', method='workflows.list', help='List workflows in the workspace.', subgroup=None, a...', 'last_run_status', 'updated_at'], success_msg=None, destructive=False, redact=False, fallback_methods=(), gate=None)
@pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path))
def test_no_unexposed_sdk_params(cmd: Cmd) -> None:
@@ -24,10 +24,60 @@ cmd = Cmd(group='voices', name='list', method='voices.list', help='List availabl
f"for it. Either add an Opt(...) to the row in _spec.py, or record the decision in "
f"_INTENTIONALLY_UNEXPOSED with the reason."
)
-E AssertionError: voices.list: voices.list accepts ['provider_voice_id'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
-E assert not {'provider_voice_id'}
+E AssertionError: workflows.list: workflows.list accepts ['exclude_empty'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
+E assert not {'exclude_empty'}
+
+tests/build/test_sdk_contract.py:233: AssertionError
+_______________ test_no_unexposed_sdk_params[workflows.uploads] ________________
+
+cmd = Cmd(group='workflows', name='uploads', method='workflows.list_workflow_uploads', help="List a workflow's uploads.", su...'category', 'status', 'size_bytes'], success_msg=None, destructive=False, redact=False, fallback_methods=(), gate=None)
+
+ @pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path))
+ def test_no_unexposed_sdk_params(cmd: Cmd) -> None:
+ """Every param the SDK accepts is reachable from the CLI, or allowlisted with a reason.
+
+ This is the drift check the regen PR runs: the generated client gains a param, no
+ hand-written flag gains with it, and this goes red naming the command and the param
+ instead of leaving the gap to be found in use months later.
+ """
+ missing = _unexposed_params(cmd) - set(_INTENTIONALLY_UNEXPOSED.get(cmd.path, {}))
+
+> assert not missing, (
+ f"{'.'.join(cmd.path)}: {cmd.method} accepts {sorted(missing)} but the CLI has no flag "
+ f"for it. Either add an Opt(...) to the row in _spec.py, or record the decision in "
+ f"_INTENTIONALLY_UNEXPOSED with the reason."
+ )
+E AssertionError: workflows.uploads: workflows.list_workflow_uploads accepts ['category'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
+E assert not {'category'}
+
+tests/build/test_sdk_contract.py:233: AssertionError
+_________________ test_no_unexposed_sdk_params[uploads.delete] _________________
+
+cmd = Cmd(group='uploads', name='delete', method='uploads.delete', help='Delete an upload.', subgroup=None, args=[('upload_i... columns=[], success_msg='Deleted upload {upload_id}.', destructive=True, redact=False, fallback_methods=(), gate=None)
+
+ @pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path))
+ def test_no_unexposed_sdk_params(cmd: Cmd) -> None:
+ """Every param the SDK accepts is reachable from the CLI, or allowlisted with a reason.
+
+ This is the drift check the regen PR runs: the generated client gains a param, no
+ hand-written flag gains with it, and this goes red naming the command and the param
+ instead of leaving the gap to be found in use months later.
+ """
+ missing = _unexposed_params(cmd) - set(_INTENTIONALLY_UNEXPOSED.get(cmd.path, {}))
+
+> assert not missing, (
+ f"{'.'.join(cmd.path)}: {cmd.method} accepts {sorted(missing)} but the CLI has no flag "
+ f"for it. Either add an Opt(...) to the row in _spec.py, or record the decision in "
+ f"_INTENTIONALLY_UNEXPOSED with the reason."
+ )
+E AssertionError: uploads.delete: uploads.delete accepts ['if_unreferenced'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
+E assert not {'if_unreferenced'}
tests/build/test_sdk_contract.py:233: AssertionError
=========================== short test summary info ============================
-FAILED tests/build/test_sdk_contract.py::test_no_unexposed_sdk_params[voices.list] - AssertionError: voices.list: voices.list accepts ['provider_voice_id'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
-assert not {'provider_voice_id'}
+FAILED tests/build/test_sdk_contract.py::test_no_unexposed_sdk_params[workflows.list] - AssertionError: workflows.list: workflows.list accepts ['exclude_empty'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
+assert not {'exclude_empty'}
+FAILED tests/build/test_sdk_contract.py::test_no_unexposed_sdk_params[workflows.uploads] - AssertionError: workflows.uploads: workflows.list_workflow_uploads accepts ['category'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
+assert not {'category'}
+FAILED tests/build/test_sdk_contract.py::test_no_unexposed_sdk_params[uploads.delete] - AssertionError: uploads.delete: uploads.delete accepts ['if_unreferenced'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason.
+assert not {'if_unreferenced'}
diff --git a/src/onepin/.fern/metadata.json b/src/onepin/.fern/metadata.json
index 5e7f85f..893414a 100644
--- a/src/onepin/.fern/metadata.json
+++ b/src/onepin/.fern/metadata.json
@@ -30,7 +30,7 @@
}
]
},
- "originGitCommit": "45e96724ab4ffc17fdc44d6f21e1f71206c318cc",
+ "originGitCommit": "5f8c05331687cedffed0c6ac9e97d81b9c338aa9",
"originGitCommitIsDirty": false,
"invokedBy": "ci",
"ciProvider": "github"
diff --git a/src/onepin/__init__.py b/src/onepin/__init__.py
index fd0b641..1b98e79 100644
--- a/src/onepin/__init__.py
+++ b/src/onepin/__init__.py
@@ -301,7 +301,11 @@
ListVoicesRequestSourceItem,
PreviewVoicesRequestLanguage,
)
- from .workflows import ListWorkflowsRequestOrderItem, ListWorkflowsRequestSortItem
+ from .workflows import (
+ ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory,
+ ListWorkflowsRequestOrderItem,
+ ListWorkflowsRequestSortItem,
+ )
_dynamic_imports: typing.Dict[str, str] = {
"ApiCountedListResponseCatalogVoiceOut": ".types",
"ApiCountedListResponseVoiceOut": ".types",
@@ -404,6 +408,7 @@
"ListVoicesRequestOrderItem": ".voices",
"ListVoicesRequestSortItem": ".voices",
"ListVoicesRequestSourceItem": ".voices",
+ "ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory": ".workflows",
"ListWorkflowsRequestOrderItem": ".workflows",
"ListWorkflowsRequestSortItem": ".workflows",
"Meta": ".types",
@@ -726,6 +731,7 @@ def __dir__():
"ListVoicesRequestOrderItem",
"ListVoicesRequestSortItem",
"ListVoicesRequestSourceItem",
+ "ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory",
"ListWorkflowsRequestOrderItem",
"ListWorkflowsRequestSortItem",
"Meta",
diff --git a/src/onepin/core/http_client.py b/src/onepin/core/http_client.py
index 3e5a786..b6f2ef2 100644
--- a/src/onepin/core/http_client.py
+++ b/src/onepin/core/http_client.py
@@ -184,8 +184,33 @@ def _should_retry(response: httpx.Response) -> bool:
)
+_AUTH_HEADERS: typing.FrozenSet[str] = frozenset()
+
+
def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]:
- return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()}
+ return {
+ k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS or k.lower() in _AUTH_HEADERS else v)
+ for k, v in headers.items()
+ }
+
+
+def _merge_headers(*header_dicts: typing.Optional[typing.Dict[str, typing.Any]]) -> typing.Dict[str, typing.Any]:
+ """
+ Merge header dicts left to right. Header names are case-insensitive, so a later
+ `x-api-key` replaces an earlier `X-API-Key` instead of producing two headers.
+ """
+ merged: typing.Dict[str, typing.Any] = {}
+ keys_by_lower: typing.Dict[str, str] = {}
+ for header_dict in header_dicts:
+ if not header_dict:
+ continue
+ for key, value in header_dict.items():
+ existing_key = keys_by_lower.get(key.lower())
+ if existing_key is not None:
+ del merged[existing_key]
+ keys_by_lower[key.lower()] = key
+ merged[key] = value
+ return merged
def _build_url(base_url: str, path: typing.Optional[str]) -> str:
@@ -425,11 +450,11 @@ def request(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
- {
- **self.base_headers(),
- **(headers if headers is not None else {}),
- **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}),
- }
+ _merge_headers(
+ self.base_headers(),
+ headers,
+ request_options.get("additional_headers") if request_options is not None else None,
+ )
)
)
_request_headers = drop_content_type_without_body(
@@ -463,7 +488,12 @@ def request(
files=request_files,
timeout=timeout,
)
- except (httpx.ConnectError, httpx.RemoteProtocolError):
+ except (httpx.ConnectError, httpx.RemoteProtocolError) as error:
+ # A protocol error may occur after the server applied the request, even while reading a 200 body.
+ if isinstance(error, httpx.RemoteProtocolError) and not (
+ request_options is not None and request_options.get("retry_remote_protocol_errors", False)
+ ):
+ raise
if retries < max_retries:
time.sleep(_retry_timeout_from_retries(retries=retries))
return self.request(
@@ -594,11 +624,11 @@ def stream(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
- {
- **self.base_headers(),
- **(headers if headers is not None else {}),
- **(request_options.get("additional_headers", {}) if request_options is not None else {}),
- }
+ _merge_headers(
+ self.base_headers(),
+ headers,
+ request_options.get("additional_headers") if request_options is not None else None,
+ )
)
)
_request_headers = drop_content_type_without_body(
@@ -735,11 +765,11 @@ async def request(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
- {
- **_headers,
- **(headers if headers is not None else {}),
- **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}),
- }
+ _merge_headers(
+ _headers,
+ headers,
+ request_options.get("additional_headers") if request_options is not None else None,
+ )
)
)
_request_headers = drop_content_type_without_body(
@@ -773,7 +803,12 @@ async def request(
files=request_files,
timeout=timeout,
)
- except (httpx.ConnectError, httpx.RemoteProtocolError):
+ except (httpx.ConnectError, httpx.RemoteProtocolError) as error:
+ # A protocol error may occur after the server applied the request, even while reading a 200 body.
+ if isinstance(error, httpx.RemoteProtocolError) and not (
+ request_options is not None and request_options.get("retry_remote_protocol_errors", False)
+ ):
+ raise
if retries < max_retries:
await asyncio.sleep(_retry_timeout_from_retries(retries=retries))
return await self.request(
@@ -907,11 +942,11 @@ async def stream(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
- {
- **_headers,
- **(headers if headers is not None else {}),
- **(request_options.get("additional_headers", {}) if request_options is not None else {}),
- }
+ _merge_headers(
+ _headers,
+ headers,
+ request_options.get("additional_headers") if request_options is not None else None,
+ )
)
)
_request_headers = drop_content_type_without_body(
diff --git a/src/onepin/core/pagination.py b/src/onepin/core/pagination.py
index 760b089..a4bc194 100644
--- a/src/onepin/core/pagination.py
+++ b/src/onepin/core/pagination.py
@@ -3,7 +3,20 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import AsyncIterator, Awaitable, Callable, Generic, Iterator, List, Optional, TypeVar
+from typing import (
+ Any,
+ AsyncIterator,
+ Awaitable,
+ Callable,
+ Generic,
+ Iterator,
+ List,
+ Mapping,
+ Optional,
+ Sequence,
+ TypeVar,
+ get_args,
+)
# Generic to represent the underlying type of the results within a page
T = TypeVar("T")
@@ -80,3 +93,73 @@ async def iter_pages(self) -> AsyncIterator[AsyncPager[T, R]]:
async def next_page(self) -> Optional[AsyncPager[T, R]]:
return await self.get_next() if self.get_next is not None else None
+
+
+def get_nested_page_value(container: Any, path: Sequence[str], default: Any = None) -> Any:
+ """
+ Read a nested paging value (e.g. `options.offset`) from a request parameter that may be
+ a dict, a model, None, or omitted (`...`). Returns `default` when any segment is missing.
+ """
+ value = container
+ for key in path:
+ if value is None or value is ...:
+ return default
+ value = value.get(key) if isinstance(value, Mapping) else getattr(value, key, None)
+ return default if value is None or value is ... else value
+
+
+def with_nested_page_value(container: Any, path: Sequence[str], value: Any, container_type: Any = None) -> Any:
+ """
+ Return a copy of a request parameter with the nested paging value at `path` replaced,
+ creating omitted containers and keeping every other field. When `container_type` (or the
+ annotation of the parent model's field) is a model, created containers are built through that
+ model so field aliases (e.g. `page_offset` -> `pageOffset`) apply on the wire.
+ """
+ if len(path) == 0:
+ return value
+ key, rest = path[0], path[1:]
+ if container is None or container is ...:
+ return _build_container(container_type, {key: with_nested_page_value(None, rest, value)})
+ if isinstance(container, Mapping):
+ return {**container, key: with_nested_page_value(container.get(key), rest, value)}
+ updated = with_nested_page_value(
+ getattr(container, key, None), rest, value, _get_field_annotation(type(container), key)
+ )
+ if hasattr(container, "model_copy"):
+ return container.model_copy(update={key: updated})
+ return container.copy(update={key: updated})
+
+
+def _build_container(container_type: Any, data: Mapping[str, Any]) -> Any:
+ model = _find_model_class(container_type)
+ if model is None:
+ return data
+ try:
+ if hasattr(model, "model_validate"):
+ return model.model_validate(data)
+ return model.parse_obj(data)
+ except (TypeError, ValueError):
+ return data
+
+
+def _find_model_class(annotation: Any) -> Any:
+ models = []
+ pending = [annotation]
+ while pending:
+ candidate = pending.pop()
+ if isinstance(candidate, type):
+ if hasattr(candidate, "model_validate") or hasattr(candidate, "parse_obj"):
+ models.append(candidate)
+ else:
+ pending.extend(get_args(candidate))
+ return models[0] if len(models) == 1 else None
+
+
+def _get_field_annotation(model: Any, key: str) -> Any:
+ fields = getattr(model, "model_fields", None)
+ if isinstance(fields, Mapping):
+ return fields[key].annotation if key in fields else None
+ legacy_fields = getattr(model, "__fields__", None)
+ if isinstance(legacy_fields, Mapping) and key in legacy_fields:
+ return legacy_fields[key].outer_type_
+ return None
diff --git a/src/onepin/core/pydantic_utilities.py b/src/onepin/core/pydantic_utilities.py
index 70816b9..4e63e0f 100644
--- a/src/onepin/core/pydantic_utilities.py
+++ b/src/onepin/core/pydantic_utilities.py
@@ -14,6 +14,7 @@
Callable,
ClassVar,
Dict,
+ FrozenSet,
List,
Mapping,
Optional,
@@ -37,8 +38,8 @@
IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.")
if IS_PYDANTIC_V2:
- _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined]
- _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined]
+ _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined,var-annotated]
+ _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined,var-annotated]
def parse_datetime(value: Any) -> dt.datetime: # type: ignore[misc]
if isinstance(value, dt.datetime):
@@ -129,7 +130,7 @@ def _decimal_encoder(dec_value: Any) -> Any:
from pydantic.typing import is_union as is_union # type: ignore[no-redef]
from .datetime_utils import serialize_datetime
-from .serialization import convert_and_respect_annotation_metadata
+from .serialization import convert_and_respect_annotation_metadata, get_field_to_alias_mapping
from typing_extensions import TypeAlias
T = TypeVar("T")
@@ -237,6 +238,59 @@ def _coerce_keys_to_aliases(model: type, data: Any) -> Any:
return rewritten
+_declared_keys_cache: "weakref.WeakKeyDictionary[type, Tuple[FrozenSet[str], FrozenSet[str]]]" = (
+ weakref.WeakKeyDictionary()
+)
+
+
+def _get_declared_keys(model: type) -> Tuple[FrozenSet[str], FrozenSet[str]]:
+ """
+ Keys that refer to a declared field (field names, Pydantic aliases and FieldMetadata aliases), together with
+ their casefolded forms. Computed once per model class.
+ """
+ cached = _declared_keys_cache.get(model)
+ if cached is None:
+ fields: Mapping[str, Any] = (
+ getattr(model, "model_fields", {}) if IS_PYDANTIC_V2 else getattr(model, "__fields__", {})
+ )
+ keys: Set[str] = set(fields)
+ for field in fields.values():
+ alias = getattr(field, "alias", None)
+ if isinstance(alias, str):
+ keys.add(alias)
+ keys.update(get_field_to_alias_mapping(model).values())
+ cached = (frozenset(keys), frozenset(key.casefold() for key in keys))
+ _declared_keys_cache[model] = cached
+ return cached
+
+
+def _allows_extra_fields(model: type) -> bool:
+ if IS_PYDANTIC_V2:
+ return getattr(model, "model_config", {}).get("extra") == "allow"
+ extra = getattr(getattr(model, "__config__", None), "extra", None)
+ return getattr(extra, "value", extra) == "allow"
+
+
+def _drop_extras_colliding_with_fields(model: type, data: Any) -> Any:
+ """
+ Drop unknown keys that match a declared field case-insensitively (e.g. `VALUE` next to `value`). Kept as
+ extras, they would be sent again after the declared field, and a case-insensitive decoder such as Go's
+ encoding/json would read them instead of it.
+ """
+ if not isinstance(data, Mapping) or not _allows_extra_fields(model):
+ return data
+
+ declared_keys, declared_casefolded = _get_declared_keys(model)
+ colliding = {
+ key
+ for key in data
+ if isinstance(key, str) and key not in declared_keys and key.casefold() in declared_casefolded
+ }
+ if not colliding:
+ return data
+ return {key: value for key, value in data.items() if key not in colliding}
+
+
def parse_obj_as(type_: Type[T], object_: Any) -> T:
# convert_and_respect_annotation_metadata is required for TypedDict aliasing.
#
@@ -279,7 +333,7 @@ class UniversalBaseModel(pydantic.BaseModel):
@pydantic.model_validator(mode="before") # type: ignore[attr-defined]
@classmethod
def _coerce_field_names_to_aliases(cls, data: Any) -> Any:
- return _coerce_keys_to_aliases(cls, data)
+ return _coerce_keys_to_aliases(cls, _drop_extras_colliding_with_fields(cls, data))
@pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined]
def serialize_model(self) -> Any: # type: ignore[name-defined]
@@ -295,7 +349,7 @@ class Config:
@pydantic.root_validator(pre=True)
def _coerce_field_names_to_aliases(cls, values: Any) -> Any:
- return _coerce_keys_to_aliases(cls, values) # type: ignore[arg-type]
+ return _coerce_keys_to_aliases(cls, _drop_extras_colliding_with_fields(cls, values)) # type: ignore[arg-type]
@classmethod
def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model":
diff --git a/src/onepin/core/request_options.py b/src/onepin/core/request_options.py
index caa6f66..bd6bc01 100644
--- a/src/onepin/core/request_options.py
+++ b/src/onepin/core/request_options.py
@@ -20,6 +20,8 @@ class RequestOptions(typing.TypedDict, total=False):
- max_retries: int. The max number of retries to attempt if the API call fails.
+ - retry_remote_protocol_errors: bool. Retry server disconnects and incomplete responses. Defaults to False, since the server may already have applied the request. Enable only when replaying the request is safe; bounded by max_retries.
+
- additional_headers: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's header dict
- additional_query_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's query parameters dict
@@ -32,6 +34,7 @@ class RequestOptions(typing.TypedDict, total=False):
timeout: NotRequired[float]
timeout_in_seconds: NotRequired[int]
max_retries: NotRequired[int]
+ retry_remote_protocol_errors: NotRequired[bool]
additional_headers: NotRequired[typing.Dict[str, typing.Any]]
additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]]
additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]]
diff --git a/src/onepin/reference.md b/src/onepin/reference.md
index 48e5e42..4ebaf38 100644
--- a/src/onepin/reference.md
+++ b/src/onepin/reference.md
@@ -5468,7 +5468,8 @@ The two-step upload flow:
`category` controls which file formats are accepted:
- `script` — text-based formats (txt, pdf)
-- `dictionary` — audio formats (mp3, wav, m4a, ogg, webm)
+- `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm)
+- `audio` — bring-your-own-audio source clips (wav, mp3)
The presigned URL expires within a short window (see `upload_url` TTL in the
response). If the URL expires before the PUT completes, discard this upload
@@ -5527,7 +5528,7 @@ client.uploads.create(
-
-**category:** `UploadRequestCategory` — File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm).
+**category:** `UploadRequestCategory` — File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3).
@@ -5798,6 +5799,19 @@ Delete is owner-scoped: the lookup is limited to the caller's own uploads, so no
workspace-role gate applies — a caller can only remove their own upload, never another
member's workspace content.
+Pass `if_unreferenced=true` to make the delete reference-aware: if any live
+(non-deleted) workflow in the upload's workspace, or any in-flight (pending, running
+or paused) run's frozen definition, still names the upload, it is retained and the
+response is `{"deleted": false, "retained": "referenced"}` with no storage change;
+otherwise it is deleted as above and the response is `{"deleted": true}`. Without
+the flag the behavior and the empty `{}` response are unchanged.
+
+Unlike the plain delete, the flagged path additionally requires the `editor` role in
+the upload's workspace (non-members get 404, checked first; members below editor get
+403), since it reads the workspace's workflow definitions and runs. API-key callers
+are workspace-scoped already and bypass the role check. An upload not bound to any
+workspace has no role to check and is deleted as before.
+
Dual-auth: Bearer JWT or API key (scope `uploads:write`).
@@ -5847,6 +5861,14 @@ client.uploads.delete(
-
+**if_unreferenced:** `typing.Optional[bool]` — When true, delete only if nothing in the upload's workspace still uses it: no live workflow and no in-flight (pending, running or paused) run references it (a `source_audio` line's `audio_upload_id` or a `source_script` node's `upload_ids`). A referenced upload is kept: nothing is deleted and no storage is released. Default false deletes unconditionally.
+
+
+
+
+
+-
+
**workspace_id:** `typing.Optional[str]`
@@ -6668,8 +6690,9 @@ List workflows in the current workspace.
Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id`
header. Each item includes aggregate stats (`runs_count`, `last_run_at`,
-`last_run_status`, `run_status_counts`) computed over all runs for that
-workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
+`last_run_status`, `run_status_counts`, `last_activity_at`) computed over all
+runs (and, for `last_activity_at`, the chat) of that workflow.
+`run_status_counts` is a per-raw-`RunStatus` map whose values sum to
`runs_count` and are NOT affected by the `status` filter below, so a collapsed
row can render an accurate per-tab total without a separate runs query.
@@ -6677,15 +6700,15 @@ row can render an accurate per-tab total without a separate runs query.
most recent run. `completed` matches only workflows whose latest run succeeded
(completed-only), and `failed` matches only workflows whose latest run failed —
the two buckets are disjoint. A workflow whose latest run was `cancelled` matches
-neither bucket and surfaces only in the unfiltered list. `running` matches active
-(running or paused) workflows. `draft` matches workflows with no runs yet.
-`paused` is accepted but currently returns no results.
+neither bucket and surfaces only in the unfiltered list. `running` matches a latest
+run that is pending, running or paused; `active` matches pending or running only,
+and `paused` matches paused only. `draft` matches workflows with no runs yet.
**Multi-sort:** `sort` and `order` are parallel query lists.
`?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by
`runs_count DESC`, then by `name ASC`. When `order` has fewer entries than
`sort`, missing positions use per-field defaults (`name=asc`,
-`updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to
+`updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to
`updated_at DESC`. A stable `id ASC` tiebreaker is always appended so
offset/limit pagination is consistent when sort keys tie.
@@ -6699,6 +6722,9 @@ keys off the latest run only; `has_failed_run=true` matches workflows with a
completed still matches if an earlier run failed. It composes with the other
filters (AND). `cancelled` runs do not count as failures.
+**Empty workflows:** `exclude_empty=true` hides workflows that are still
+placeholder-named with no nodes, no user chat message and no runs.
+
`pagination.total` reflects the filtered count for the current query.
@@ -6738,7 +6764,7 @@ client.workflows.list()
-
-**status:** `typing.Optional[WorkflowListStatus]` — UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows.
+**status:** `typing.Optional[WorkflowListStatus]` — UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only.
@@ -6802,6 +6828,14 @@ client.workflows.list()
-
+**exclude_empty:** `typing.Optional[bool]` — Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`.
+
+
+
+
+
+-
+
**offset:** `typing.Optional[int]` — Zero-based pagination offset.
@@ -7561,6 +7595,10 @@ Script Input nodes reference by id (e.g. a file attached in the assistant
chat, which stays bound to its assistant session). In-progress or abandoned
uploads are excluded. Each item includes a short-lived download URL for the
uploaded file.
+
+Pass `category` to list just one kind of upload. The filter is applied in
+the query before pagination, so a single audio clip is still returned even
+when it sits behind more than `limit` script or dictionary uploads.
@@ -7625,6 +7663,14 @@ client.workflows.list_workflow_uploads(
-
+**category:** `typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory]` — Restrict to one upload category (`script`, `dictionary`, or `audio`). Applied before pagination, so `limit` bounds the chosen category rather than the mixed total. Omit for all.
+
+
+
+
+
+-
+
**workspace_id:** `typing.Optional[str]`
diff --git a/src/onepin/types/node_type.py b/src/onepin/types/node_type.py
index b3d8f98..791449a 100644
--- a/src/onepin/types/node_type.py
+++ b/src/onepin/types/node_type.py
@@ -5,6 +5,7 @@
NodeType = typing.Union[
typing.Literal[
"source_script",
+ "source_audio",
"operator_translator",
"operator_normalizer",
"operator_generator",
diff --git a/src/onepin/types/pause_reason.py b/src/onepin/types/pause_reason.py
index 1c897ee..33aa4f4 100644
--- a/src/onepin/types/pause_reason.py
+++ b/src/onepin/types/pause_reason.py
@@ -3,5 +3,8 @@
import typing
PauseReason = typing.Union[
- typing.Literal["user", "provider_outage", "provider_rate_limited", "provider_billing", "node_timeout"], typing.Any
+ typing.Literal[
+ "user", "provider_outage", "provider_rate_limited", "provider_billing", "node_timeout", "insufficient_credits"
+ ],
+ typing.Any,
]
diff --git a/src/onepin/types/template_estimate_response.py b/src/onepin/types/template_estimate_response.py
index 91893a3..a5eabca 100644
--- a/src/onepin/types/template_estimate_response.py
+++ b/src/onepin/types/template_estimate_response.py
@@ -24,6 +24,11 @@ class TemplateEstimateResponse(UniversalBaseModel):
Character count of the synthetic input used to compute the per-unit rates. Divide your expected script length by this value and multiply by the per-unit credit fields to project total cost.
"""
+ variable_cost_is_character_complete: typing.Optional[bool] = pydantic.Field(default=None)
+ """
+ True when the per-`unit_chars` variable credits are a complete script-length framing. False when at least one variable node bills on audio duration (seconds), so its cost does not scale with script characters and a 'per N characters' line would mislead.
+ """
+
variable_min_credits_per_unit: int = pydantic.Field()
"""
Minimum variable credits per `unit_chars` characters (best-case pricing, scales with script length).
diff --git a/src/onepin/types/upload_out.py b/src/onepin/types/upload_out.py
index ee14da9..2ad76dd 100644
--- a/src/onepin/types/upload_out.py
+++ b/src/onepin/types/upload_out.py
@@ -30,7 +30,7 @@ class UploadOut(UniversalBaseModel):
category: str = pydantic.Field()
"""
- Upload category: `script` or `dictionary`.
+ Upload category: `script`, `dictionary`, or `audio`.
"""
content_type: str = pydantic.Field()
diff --git a/src/onepin/types/workflow_list_item.py b/src/onepin/types/workflow_list_item.py
index 45a83d7..239fd42 100644
--- a/src/onepin/types/workflow_list_item.py
+++ b/src/onepin/types/workflow_list_item.py
@@ -64,6 +64,11 @@ class WorkflowListItem(UniversalBaseModel):
Raw RunStatus of the most recent run. One of: `pending`, `running`, `completed`, `failed`, `cancelled`, `paused`. Null if never run.
"""
+ last_activity_at: dt.datetime = pydantic.Field()
+ """
+ Latest of the last chat message (any role, the opener greeting excluded) and the last run; `created_at` when neither exists. What `sort=last_activity_at` orders by.
+ """
+
run_status_counts: typing.Optional[RunStatusCounts] = pydantic.Field(default=None)
"""
Number of runs per raw RunStatus, computed over ALL runs of this workflow (same population as `runs_count`; NOT affected by the list-level `status` filter). The sum of all values equals `runs_count`; statuses with no runs are 0.
diff --git a/src/onepin/types/workflow_list_status.py b/src/onepin/types/workflow_list_status.py
index 8f86b37..1dd7589 100644
--- a/src/onepin/types/workflow_list_status.py
+++ b/src/onepin/types/workflow_list_status.py
@@ -2,4 +2,6 @@
import typing
-WorkflowListStatus = typing.Union[typing.Literal["draft", "running", "completed", "failed", "paused"], typing.Any]
+WorkflowListStatus = typing.Union[
+ typing.Literal["draft", "running", "active", "completed", "failed", "paused"], typing.Any
+]
diff --git a/src/onepin/types/workflow_run_list_item.py b/src/onepin/types/workflow_run_list_item.py
index ff886f6..8f98844 100644
--- a/src/onepin/types/workflow_run_list_item.py
+++ b/src/onepin/types/workflow_run_list_item.py
@@ -42,6 +42,10 @@ class WorkflowRunListItem(UniversalBaseModel):
triggered_by: typing.Optional[TriggeredByOut] = None
output_line_count: typing.Optional[int] = None
delivered_audio_ms: typing.Optional[int] = None
+ script_first_line: typing.Optional[str] = pydantic.Field(default=None)
+ """
+ The run's first script line, cut at 500 characters. Null until the run's source step has parsed the script.
+ """
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/uploads/client.py b/src/onepin/uploads/client.py
index 568f29a..d328da6 100644
--- a/src/onepin/uploads/client.py
+++ b/src/onepin/uploads/client.py
@@ -50,7 +50,8 @@ def create(
`category` controls which file formats are accepted:
- `script` — text-based formats (txt, pdf)
- - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm)
+ - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm)
+ - `audio` — bring-your-own-audio source clips (wav, mp3)
The presigned URL expires within a short window (see `upload_url` TTL in the
response). If the URL expires before the PUT completes, discard this upload
@@ -67,7 +68,7 @@ def create(
Original filename including extension (e.g. `script.txt`). Must include a file extension.
category : UploadRequestCategory
- File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm).
+ File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3).
workspace_id : typing.Optional[str]
@@ -227,6 +228,7 @@ def delete(
self,
upload_id: str,
*,
+ if_unreferenced: typing.Optional[bool] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> ApiResponseDict:
@@ -249,12 +251,28 @@ def delete(
workspace-role gate applies — a caller can only remove their own upload, never another
member's workspace content.
+ Pass `if_unreferenced=true` to make the delete reference-aware: if any live
+ (non-deleted) workflow in the upload's workspace, or any in-flight (pending, running
+ or paused) run's frozen definition, still names the upload, it is retained and the
+ response is `{"deleted": false, "retained": "referenced"}` with no storage change;
+ otherwise it is deleted as above and the response is `{"deleted": true}`. Without
+ the flag the behavior and the empty `{}` response are unchanged.
+
+ Unlike the plain delete, the flagged path additionally requires the `editor` role in
+ the upload's workspace (non-members get 404, checked first; members below editor get
+ 403), since it reads the workspace's workflow definitions and runs. API-key callers
+ are workspace-scoped already and bypass the role check. An upload not bound to any
+ workspace has no role to check and is deleted as before.
+
Dual-auth: Bearer JWT or API key (scope `uploads:write`).
Parameters
----------
upload_id : str
+ if_unreferenced : typing.Optional[bool]
+ When true, delete only if nothing in the upload's workspace still uses it: no live workflow and no in-flight (pending, running or paused) run references it (a `source_audio` line's `audio_upload_id` or a `source_script` node's `upload_ids`). A referenced upload is kept: nothing is deleted and no storage is released. Default false deletes unconditionally.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -276,7 +294,9 @@ def delete(
upload_id="upload_id",
)
"""
- _response = self._raw_client.delete(upload_id, workspace_id=workspace_id, request_options=request_options)
+ _response = self._raw_client.delete(
+ upload_id, if_unreferenced=if_unreferenced, workspace_id=workspace_id, request_options=request_options
+ )
return _response.data
@@ -315,7 +335,8 @@ async def create(
`category` controls which file formats are accepted:
- `script` — text-based formats (txt, pdf)
- - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm)
+ - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm)
+ - `audio` — bring-your-own-audio source clips (wav, mp3)
The presigned URL expires within a short window (see `upload_url` TTL in the
response). If the URL expires before the PUT completes, discard this upload
@@ -332,7 +353,7 @@ async def create(
Original filename including extension (e.g. `script.txt`). Must include a file extension.
category : UploadRequestCategory
- File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm).
+ File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3).
workspace_id : typing.Optional[str]
@@ -516,6 +537,7 @@ async def delete(
self,
upload_id: str,
*,
+ if_unreferenced: typing.Optional[bool] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> ApiResponseDict:
@@ -538,12 +560,28 @@ async def delete(
workspace-role gate applies — a caller can only remove their own upload, never another
member's workspace content.
+ Pass `if_unreferenced=true` to make the delete reference-aware: if any live
+ (non-deleted) workflow in the upload's workspace, or any in-flight (pending, running
+ or paused) run's frozen definition, still names the upload, it is retained and the
+ response is `{"deleted": false, "retained": "referenced"}` with no storage change;
+ otherwise it is deleted as above and the response is `{"deleted": true}`. Without
+ the flag the behavior and the empty `{}` response are unchanged.
+
+ Unlike the plain delete, the flagged path additionally requires the `editor` role in
+ the upload's workspace (non-members get 404, checked first; members below editor get
+ 403), since it reads the workspace's workflow definitions and runs. API-key callers
+ are workspace-scoped already and bypass the role check. An upload not bound to any
+ workspace has no role to check and is deleted as before.
+
Dual-auth: Bearer JWT or API key (scope `uploads:write`).
Parameters
----------
upload_id : str
+ if_unreferenced : typing.Optional[bool]
+ When true, delete only if nothing in the upload's workspace still uses it: no live workflow and no in-flight (pending, running or paused) run references it (a `source_audio` line's `audio_upload_id` or a `source_script` node's `upload_ids`). A referenced upload is kept: nothing is deleted and no storage is released. Default false deletes unconditionally.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -573,5 +611,7 @@ async def main() -> None:
asyncio.run(main())
"""
- _response = await self._raw_client.delete(upload_id, workspace_id=workspace_id, request_options=request_options)
+ _response = await self._raw_client.delete(
+ upload_id, if_unreferenced=if_unreferenced, workspace_id=workspace_id, request_options=request_options
+ )
return _response.data
diff --git a/src/onepin/uploads/raw_client.py b/src/onepin/uploads/raw_client.py
index c49a39e..7bfb30d 100644
--- a/src/onepin/uploads/raw_client.py
+++ b/src/onepin/uploads/raw_client.py
@@ -46,7 +46,8 @@ def create(
`category` controls which file formats are accepted:
- `script` — text-based formats (txt, pdf)
- - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm)
+ - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm)
+ - `audio` — bring-your-own-audio source clips (wav, mp3)
The presigned URL expires within a short window (see `upload_url` TTL in the
response). If the URL expires before the PUT completes, discard this upload
@@ -63,7 +64,7 @@ def create(
Original filename including extension (e.g. `script.txt`). Must include a file extension.
category : UploadRequestCategory
- File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm).
+ File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3).
workspace_id : typing.Optional[str]
@@ -293,6 +294,7 @@ def delete(
self,
upload_id: str,
*,
+ if_unreferenced: typing.Optional[bool] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> HttpResponse[ApiResponseDict]:
@@ -315,12 +317,28 @@ def delete(
workspace-role gate applies — a caller can only remove their own upload, never another
member's workspace content.
+ Pass `if_unreferenced=true` to make the delete reference-aware: if any live
+ (non-deleted) workflow in the upload's workspace, or any in-flight (pending, running
+ or paused) run's frozen definition, still names the upload, it is retained and the
+ response is `{"deleted": false, "retained": "referenced"}` with no storage change;
+ otherwise it is deleted as above and the response is `{"deleted": true}`. Without
+ the flag the behavior and the empty `{}` response are unchanged.
+
+ Unlike the plain delete, the flagged path additionally requires the `editor` role in
+ the upload's workspace (non-members get 404, checked first; members below editor get
+ 403), since it reads the workspace's workflow definitions and runs. API-key callers
+ are workspace-scoped already and bypass the role check. An upload not bound to any
+ workspace has no role to check and is deleted as before.
+
Dual-auth: Bearer JWT or API key (scope `uploads:write`).
Parameters
----------
upload_id : str
+ if_unreferenced : typing.Optional[bool]
+ When true, delete only if nothing in the upload's workspace still uses it: no live workflow and no in-flight (pending, running or paused) run references it (a `source_audio` line's `audio_upload_id` or a `source_script` node's `upload_ids`). A referenced upload is kept: nothing is deleted and no storage is released. Default false deletes unconditionally.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -334,6 +352,9 @@ def delete(
_response = self._client_wrapper.httpx_client.request(
f"api/v1/uploads/{encode_path_param(upload_id)}",
method="DELETE",
+ params={
+ "if_unreferenced": if_unreferenced,
+ },
headers={
"X-Workspace-Id": str(workspace_id) if workspace_id is not None else None,
},
@@ -394,7 +415,8 @@ async def create(
`category` controls which file formats are accepted:
- `script` — text-based formats (txt, pdf)
- - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm)
+ - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm)
+ - `audio` — bring-your-own-audio source clips (wav, mp3)
The presigned URL expires within a short window (see `upload_url` TTL in the
response). If the URL expires before the PUT completes, discard this upload
@@ -411,7 +433,7 @@ async def create(
Original filename including extension (e.g. `script.txt`). Must include a file extension.
category : UploadRequestCategory
- File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm).
+ File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3).
workspace_id : typing.Optional[str]
@@ -641,6 +663,7 @@ async def delete(
self,
upload_id: str,
*,
+ if_unreferenced: typing.Optional[bool] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> AsyncHttpResponse[ApiResponseDict]:
@@ -663,12 +686,28 @@ async def delete(
workspace-role gate applies — a caller can only remove their own upload, never another
member's workspace content.
+ Pass `if_unreferenced=true` to make the delete reference-aware: if any live
+ (non-deleted) workflow in the upload's workspace, or any in-flight (pending, running
+ or paused) run's frozen definition, still names the upload, it is retained and the
+ response is `{"deleted": false, "retained": "referenced"}` with no storage change;
+ otherwise it is deleted as above and the response is `{"deleted": true}`. Without
+ the flag the behavior and the empty `{}` response are unchanged.
+
+ Unlike the plain delete, the flagged path additionally requires the `editor` role in
+ the upload's workspace (non-members get 404, checked first; members below editor get
+ 403), since it reads the workspace's workflow definitions and runs. API-key callers
+ are workspace-scoped already and bypass the role check. An upload not bound to any
+ workspace has no role to check and is deleted as before.
+
Dual-auth: Bearer JWT or API key (scope `uploads:write`).
Parameters
----------
upload_id : str
+ if_unreferenced : typing.Optional[bool]
+ When true, delete only if nothing in the upload's workspace still uses it: no live workflow and no in-flight (pending, running or paused) run references it (a `source_audio` line's `audio_upload_id` or a `source_script` node's `upload_ids`). A referenced upload is kept: nothing is deleted and no storage is released. Default false deletes unconditionally.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -682,6 +721,9 @@ async def delete(
_response = await self._client_wrapper.httpx_client.request(
f"api/v1/uploads/{encode_path_param(upload_id)}",
method="DELETE",
+ params={
+ "if_unreferenced": if_unreferenced,
+ },
headers={
"X-Workspace-Id": str(workspace_id) if workspace_id is not None else None,
},
diff --git a/src/onepin/uploads/types/upload_request_category.py b/src/onepin/uploads/types/upload_request_category.py
index a02008b..8c6015b 100644
--- a/src/onepin/uploads/types/upload_request_category.py
+++ b/src/onepin/uploads/types/upload_request_category.py
@@ -2,4 +2,4 @@
import typing
-UploadRequestCategory = typing.Union[typing.Literal["script", "dictionary"], typing.Any]
+UploadRequestCategory = typing.Union[typing.Literal["script", "dictionary", "audio"], typing.Any]
diff --git a/src/onepin/workflows/__init__.py b/src/onepin/workflows/__init__.py
index 44fce9b..1b3fa25 100644
--- a/src/onepin/workflows/__init__.py
+++ b/src/onepin/workflows/__init__.py
@@ -6,12 +6,17 @@
from importlib import import_module
if typing.TYPE_CHECKING:
- from .types import ListWorkflowsRequestOrderItem, ListWorkflowsRequestSortItem
+ from .types import (
+ ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory,
+ ListWorkflowsRequestOrderItem,
+ ListWorkflowsRequestSortItem,
+ )
from . import runs
from .runs import ListRunsRequestOrder, ListRunsRequestSort
_dynamic_imports: typing.Dict[str, str] = {
"ListRunsRequestOrder": ".runs",
"ListRunsRequestSort": ".runs",
+ "ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory": ".types",
"ListWorkflowsRequestOrderItem": ".types",
"ListWorkflowsRequestSortItem": ".types",
"runs": ".runs",
@@ -42,6 +47,7 @@ def __dir__():
__all__ = [
"ListRunsRequestOrder",
"ListRunsRequestSort",
+ "ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory",
"ListWorkflowsRequestOrderItem",
"ListWorkflowsRequestSortItem",
"runs",
diff --git a/src/onepin/workflows/client.py b/src/onepin/workflows/client.py
index 78da03d..e603781 100644
--- a/src/onepin/workflows/client.py
+++ b/src/onepin/workflows/client.py
@@ -25,6 +25,9 @@
from ..types.workflow_list_status import WorkflowListStatus
from ..types.workflow_run_data_response import WorkflowRunDataResponse
from .raw_client import AsyncRawWorkflowsClient, RawWorkflowsClient
+from .types.list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category import (
+ ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory,
+)
from .types.list_workflows_request_order_item import ListWorkflowsRequestOrderItem
from .types.list_workflows_request_sort_item import ListWorkflowsRequestSortItem
@@ -62,6 +65,7 @@ def list(
last_run_before: typing.Optional[dt.datetime] = None,
has_failed_run: typing.Optional[bool] = None,
include_definition: typing.Optional[bool] = None,
+ exclude_empty: typing.Optional[bool] = None,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
workspace_id: typing.Optional[str] = None,
@@ -72,8 +76,9 @@ def list(
Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id`
header. Each item includes aggregate stats (`runs_count`, `last_run_at`,
- `last_run_status`, `run_status_counts`) computed over all runs for that
- workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
+ `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all
+ runs (and, for `last_activity_at`, the chat) of that workflow.
+ `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
`runs_count` and are NOT affected by the `status` filter below, so a collapsed
row can render an accurate per-tab total without a separate runs query.
@@ -81,15 +86,15 @@ def list(
most recent run. `completed` matches only workflows whose latest run succeeded
(completed-only), and `failed` matches only workflows whose latest run failed —
the two buckets are disjoint. A workflow whose latest run was `cancelled` matches
- neither bucket and surfaces only in the unfiltered list. `running` matches active
- (running or paused) workflows. `draft` matches workflows with no runs yet.
- `paused` is accepted but currently returns no results.
+ neither bucket and surfaces only in the unfiltered list. `running` matches a latest
+ run that is pending, running or paused; `active` matches pending or running only,
+ and `paused` matches paused only. `draft` matches workflows with no runs yet.
**Multi-sort:** `sort` and `order` are parallel query lists.
`?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by
`runs_count DESC`, then by `name ASC`. When `order` has fewer entries than
`sort`, missing positions use per-field defaults (`name=asc`,
- `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to
+ `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to
`updated_at DESC`. A stable `id ASC` tiebreaker is always appended so
offset/limit pagination is consistent when sort keys tie.
@@ -103,12 +108,15 @@ def list(
completed still matches if an earlier run failed. It composes with the other
filters (AND). `cancelled` runs do not count as failures.
+ **Empty workflows:** `exclude_empty=true` hides workflows that are still
+ placeholder-named with no nodes, no user chat message and no runs.
+
`pagination.total` reflects the filtered count for the current query.
Parameters
----------
status : typing.Optional[WorkflowListStatus]
- UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows.
+ UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only.
search : typing.Optional[str]
Case-insensitive search over name and description.
@@ -131,6 +139,9 @@ def list(
include_definition : typing.Optional[bool]
Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET.
+ exclude_empty : typing.Optional[bool]
+ Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`.
+
offset : typing.Optional[int]
Zero-based pagination offset.
@@ -165,6 +176,7 @@ def list(
last_run_before=last_run_before,
has_failed_run=has_failed_run,
include_definition=include_definition,
+ exclude_empty=exclude_empty,
offset=offset,
limit=limit,
workspace_id=workspace_id,
@@ -572,6 +584,7 @@ def list_workflow_uploads(
*,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
+ category: typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> ApiListResponseUploadOut:
@@ -585,6 +598,10 @@ def list_workflow_uploads(
uploads are excluded. Each item includes a short-lived download URL for the
uploaded file.
+ Pass `category` to list just one kind of upload. The filter is applied in
+ the query before pagination, so a single audio clip is still returned even
+ when it sits behind more than `limit` script or dictionary uploads.
+
Parameters
----------
workflow_id : str
@@ -595,6 +612,9 @@ def list_workflow_uploads(
limit : typing.Optional[int]
Maximum items to return (1–100).
+ category : typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory]
+ Restrict to one upload category (`script`, `dictionary`, or `audio`). Applied before pagination, so `limit` bounds the chosen category rather than the mixed total. Omit for all.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -617,7 +637,12 @@ def list_workflow_uploads(
)
"""
_response = self._raw_client.list_workflow_uploads(
- workflow_id, offset=offset, limit=limit, workspace_id=workspace_id, request_options=request_options
+ workflow_id,
+ offset=offset,
+ limit=limit,
+ category=category,
+ workspace_id=workspace_id,
+ request_options=request_options,
)
return _response.data
@@ -1522,6 +1547,7 @@ async def list(
last_run_before: typing.Optional[dt.datetime] = None,
has_failed_run: typing.Optional[bool] = None,
include_definition: typing.Optional[bool] = None,
+ exclude_empty: typing.Optional[bool] = None,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
workspace_id: typing.Optional[str] = None,
@@ -1532,8 +1558,9 @@ async def list(
Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id`
header. Each item includes aggregate stats (`runs_count`, `last_run_at`,
- `last_run_status`, `run_status_counts`) computed over all runs for that
- workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
+ `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all
+ runs (and, for `last_activity_at`, the chat) of that workflow.
+ `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
`runs_count` and are NOT affected by the `status` filter below, so a collapsed
row can render an accurate per-tab total without a separate runs query.
@@ -1541,15 +1568,15 @@ async def list(
most recent run. `completed` matches only workflows whose latest run succeeded
(completed-only), and `failed` matches only workflows whose latest run failed —
the two buckets are disjoint. A workflow whose latest run was `cancelled` matches
- neither bucket and surfaces only in the unfiltered list. `running` matches active
- (running or paused) workflows. `draft` matches workflows with no runs yet.
- `paused` is accepted but currently returns no results.
+ neither bucket and surfaces only in the unfiltered list. `running` matches a latest
+ run that is pending, running or paused; `active` matches pending or running only,
+ and `paused` matches paused only. `draft` matches workflows with no runs yet.
**Multi-sort:** `sort` and `order` are parallel query lists.
`?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by
`runs_count DESC`, then by `name ASC`. When `order` has fewer entries than
`sort`, missing positions use per-field defaults (`name=asc`,
- `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to
+ `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to
`updated_at DESC`. A stable `id ASC` tiebreaker is always appended so
offset/limit pagination is consistent when sort keys tie.
@@ -1563,12 +1590,15 @@ async def list(
completed still matches if an earlier run failed. It composes with the other
filters (AND). `cancelled` runs do not count as failures.
+ **Empty workflows:** `exclude_empty=true` hides workflows that are still
+ placeholder-named with no nodes, no user chat message and no runs.
+
`pagination.total` reflects the filtered count for the current query.
Parameters
----------
status : typing.Optional[WorkflowListStatus]
- UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows.
+ UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only.
search : typing.Optional[str]
Case-insensitive search over name and description.
@@ -1591,6 +1621,9 @@ async def list(
include_definition : typing.Optional[bool]
Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET.
+ exclude_empty : typing.Optional[bool]
+ Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`.
+
offset : typing.Optional[int]
Zero-based pagination offset.
@@ -1633,6 +1666,7 @@ async def main() -> None:
last_run_before=last_run_before,
has_failed_run=has_failed_run,
include_definition=include_definition,
+ exclude_empty=exclude_empty,
offset=offset,
limit=limit,
workspace_id=workspace_id,
@@ -2096,6 +2130,7 @@ async def list_workflow_uploads(
*,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
+ category: typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> ApiListResponseUploadOut:
@@ -2109,6 +2144,10 @@ async def list_workflow_uploads(
uploads are excluded. Each item includes a short-lived download URL for the
uploaded file.
+ Pass `category` to list just one kind of upload. The filter is applied in
+ the query before pagination, so a single audio clip is still returned even
+ when it sits behind more than `limit` script or dictionary uploads.
+
Parameters
----------
workflow_id : str
@@ -2119,6 +2158,9 @@ async def list_workflow_uploads(
limit : typing.Optional[int]
Maximum items to return (1–100).
+ category : typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory]
+ Restrict to one upload category (`script`, `dictionary`, or `audio`). Applied before pagination, so `limit` bounds the chosen category rather than the mixed total. Omit for all.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -2149,7 +2191,12 @@ async def main() -> None:
asyncio.run(main())
"""
_response = await self._raw_client.list_workflow_uploads(
- workflow_id, offset=offset, limit=limit, workspace_id=workspace_id, request_options=request_options
+ workflow_id,
+ offset=offset,
+ limit=limit,
+ category=category,
+ workspace_id=workspace_id,
+ request_options=request_options,
)
return _response.data
diff --git a/src/onepin/workflows/raw_client.py b/src/onepin/workflows/raw_client.py
index e7bd5b6..ec32f0f 100644
--- a/src/onepin/workflows/raw_client.py
+++ b/src/onepin/workflows/raw_client.py
@@ -33,6 +33,9 @@
from ..types.workflow_definition_input import WorkflowDefinitionInput
from ..types.workflow_list_status import WorkflowListStatus
from ..types.workflow_run_data_response import WorkflowRunDataResponse
+from .types.list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category import (
+ ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory,
+)
from .types.list_workflows_request_order_item import ListWorkflowsRequestOrderItem
from .types.list_workflows_request_sort_item import ListWorkflowsRequestSortItem
from pydantic import ValidationError
@@ -56,6 +59,7 @@ def list(
last_run_before: typing.Optional[dt.datetime] = None,
has_failed_run: typing.Optional[bool] = None,
include_definition: typing.Optional[bool] = None,
+ exclude_empty: typing.Optional[bool] = None,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
workspace_id: typing.Optional[str] = None,
@@ -66,8 +70,9 @@ def list(
Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id`
header. Each item includes aggregate stats (`runs_count`, `last_run_at`,
- `last_run_status`, `run_status_counts`) computed over all runs for that
- workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
+ `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all
+ runs (and, for `last_activity_at`, the chat) of that workflow.
+ `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
`runs_count` and are NOT affected by the `status` filter below, so a collapsed
row can render an accurate per-tab total without a separate runs query.
@@ -75,15 +80,15 @@ def list(
most recent run. `completed` matches only workflows whose latest run succeeded
(completed-only), and `failed` matches only workflows whose latest run failed —
the two buckets are disjoint. A workflow whose latest run was `cancelled` matches
- neither bucket and surfaces only in the unfiltered list. `running` matches active
- (running or paused) workflows. `draft` matches workflows with no runs yet.
- `paused` is accepted but currently returns no results.
+ neither bucket and surfaces only in the unfiltered list. `running` matches a latest
+ run that is pending, running or paused; `active` matches pending or running only,
+ and `paused` matches paused only. `draft` matches workflows with no runs yet.
**Multi-sort:** `sort` and `order` are parallel query lists.
`?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by
`runs_count DESC`, then by `name ASC`. When `order` has fewer entries than
`sort`, missing positions use per-field defaults (`name=asc`,
- `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to
+ `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to
`updated_at DESC`. A stable `id ASC` tiebreaker is always appended so
offset/limit pagination is consistent when sort keys tie.
@@ -97,12 +102,15 @@ def list(
completed still matches if an earlier run failed. It composes with the other
filters (AND). `cancelled` runs do not count as failures.
+ **Empty workflows:** `exclude_empty=true` hides workflows that are still
+ placeholder-named with no nodes, no user chat message and no runs.
+
`pagination.total` reflects the filtered count for the current query.
Parameters
----------
status : typing.Optional[WorkflowListStatus]
- UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows.
+ UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only.
search : typing.Optional[str]
Case-insensitive search over name and description.
@@ -125,6 +133,9 @@ def list(
include_definition : typing.Optional[bool]
Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET.
+ exclude_empty : typing.Optional[bool]
+ Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`.
+
offset : typing.Optional[int]
Zero-based pagination offset.
@@ -153,6 +164,7 @@ def list(
"last_run_before": serialize_datetime(last_run_before) if last_run_before is not None else None,
"has_failed_run": has_failed_run,
"include_definition": include_definition,
+ "exclude_empty": exclude_empty,
"offset": offset,
"limit": limit,
},
@@ -764,6 +776,7 @@ def list_workflow_uploads(
*,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
+ category: typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> HttpResponse[ApiListResponseUploadOut]:
@@ -777,6 +790,10 @@ def list_workflow_uploads(
uploads are excluded. Each item includes a short-lived download URL for the
uploaded file.
+ Pass `category` to list just one kind of upload. The filter is applied in
+ the query before pagination, so a single audio clip is still returned even
+ when it sits behind more than `limit` script or dictionary uploads.
+
Parameters
----------
workflow_id : str
@@ -787,6 +804,9 @@ def list_workflow_uploads(
limit : typing.Optional[int]
Maximum items to return (1–100).
+ category : typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory]
+ Restrict to one upload category (`script`, `dictionary`, or `audio`). Applied before pagination, so `limit` bounds the chosen category rather than the mixed total. Omit for all.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -803,6 +823,7 @@ def list_workflow_uploads(
params={
"offset": offset,
"limit": limit,
+ "category": category,
},
headers={
"X-Workspace-Id": str(workspace_id) if workspace_id is not None else None,
@@ -2085,6 +2106,7 @@ async def list(
last_run_before: typing.Optional[dt.datetime] = None,
has_failed_run: typing.Optional[bool] = None,
include_definition: typing.Optional[bool] = None,
+ exclude_empty: typing.Optional[bool] = None,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
workspace_id: typing.Optional[str] = None,
@@ -2095,8 +2117,9 @@ async def list(
Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id`
header. Each item includes aggregate stats (`runs_count`, `last_run_at`,
- `last_run_status`, `run_status_counts`) computed over all runs for that
- workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
+ `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all
+ runs (and, for `last_activity_at`, the chat) of that workflow.
+ `run_status_counts` is a per-raw-`RunStatus` map whose values sum to
`runs_count` and are NOT affected by the `status` filter below, so a collapsed
row can render an accurate per-tab total without a separate runs query.
@@ -2104,15 +2127,15 @@ async def list(
most recent run. `completed` matches only workflows whose latest run succeeded
(completed-only), and `failed` matches only workflows whose latest run failed —
the two buckets are disjoint. A workflow whose latest run was `cancelled` matches
- neither bucket and surfaces only in the unfiltered list. `running` matches active
- (running or paused) workflows. `draft` matches workflows with no runs yet.
- `paused` is accepted but currently returns no results.
+ neither bucket and surfaces only in the unfiltered list. `running` matches a latest
+ run that is pending, running or paused; `active` matches pending or running only,
+ and `paused` matches paused only. `draft` matches workflows with no runs yet.
**Multi-sort:** `sort` and `order` are parallel query lists.
`?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by
`runs_count DESC`, then by `name ASC`. When `order` has fewer entries than
`sort`, missing positions use per-field defaults (`name=asc`,
- `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to
+ `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to
`updated_at DESC`. A stable `id ASC` tiebreaker is always appended so
offset/limit pagination is consistent when sort keys tie.
@@ -2126,12 +2149,15 @@ async def list(
completed still matches if an earlier run failed. It composes with the other
filters (AND). `cancelled` runs do not count as failures.
+ **Empty workflows:** `exclude_empty=true` hides workflows that are still
+ placeholder-named with no nodes, no user chat message and no runs.
+
`pagination.total` reflects the filtered count for the current query.
Parameters
----------
status : typing.Optional[WorkflowListStatus]
- UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows.
+ UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only.
search : typing.Optional[str]
Case-insensitive search over name and description.
@@ -2154,6 +2180,9 @@ async def list(
include_definition : typing.Optional[bool]
Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET.
+ exclude_empty : typing.Optional[bool]
+ Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`.
+
offset : typing.Optional[int]
Zero-based pagination offset.
@@ -2182,6 +2211,7 @@ async def list(
"last_run_before": serialize_datetime(last_run_before) if last_run_before is not None else None,
"has_failed_run": has_failed_run,
"include_definition": include_definition,
+ "exclude_empty": exclude_empty,
"offset": offset,
"limit": limit,
},
@@ -2793,6 +2823,7 @@ async def list_workflow_uploads(
*,
offset: typing.Optional[int] = None,
limit: typing.Optional[int] = None,
+ category: typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory] = None,
workspace_id: typing.Optional[str] = None,
request_options: typing.Optional[RequestOptions] = None,
) -> AsyncHttpResponse[ApiListResponseUploadOut]:
@@ -2806,6 +2837,10 @@ async def list_workflow_uploads(
uploads are excluded. Each item includes a short-lived download URL for the
uploaded file.
+ Pass `category` to list just one kind of upload. The filter is applied in
+ the query before pagination, so a single audio clip is still returned even
+ when it sits behind more than `limit` script or dictionary uploads.
+
Parameters
----------
workflow_id : str
@@ -2816,6 +2851,9 @@ async def list_workflow_uploads(
limit : typing.Optional[int]
Maximum items to return (1–100).
+ category : typing.Optional[ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory]
+ Restrict to one upload category (`script`, `dictionary`, or `audio`). Applied before pagination, so `limit` bounds the chosen category rather than the mixed total. Omit for all.
+
workspace_id : typing.Optional[str]
request_options : typing.Optional[RequestOptions]
@@ -2832,6 +2870,7 @@ async def list_workflow_uploads(
params={
"offset": offset,
"limit": limit,
+ "category": category,
},
headers={
"X-Workspace-Id": str(workspace_id) if workspace_id is not None else None,
diff --git a/src/onepin/workflows/types/__init__.py b/src/onepin/workflows/types/__init__.py
index 6e08df6..c8ffaad 100644
--- a/src/onepin/workflows/types/__init__.py
+++ b/src/onepin/workflows/types/__init__.py
@@ -6,9 +6,13 @@
from importlib import import_module
if typing.TYPE_CHECKING:
+ from .list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category import (
+ ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory,
+ )
from .list_workflows_request_order_item import ListWorkflowsRequestOrderItem
from .list_workflows_request_sort_item import ListWorkflowsRequestSortItem
_dynamic_imports: typing.Dict[str, str] = {
+ "ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory": ".list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category",
"ListWorkflowsRequestOrderItem": ".list_workflows_request_order_item",
"ListWorkflowsRequestSortItem": ".list_workflows_request_sort_item",
}
@@ -35,4 +39,8 @@ def __dir__():
return sorted(lazy_attrs)
-__all__ = ["ListWorkflowsRequestOrderItem", "ListWorkflowsRequestSortItem"]
+__all__ = [
+ "ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory",
+ "ListWorkflowsRequestOrderItem",
+ "ListWorkflowsRequestSortItem",
+]
diff --git a/src/onepin/workflows/types/list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category.py b/src/onepin/workflows/types/list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category.py
new file mode 100644
index 0000000..aa79d6f
--- /dev/null
+++ b/src/onepin/workflows/types/list_workflow_uploads_api_v1workflows_workflow_id_uploads_get_request_category.py
@@ -0,0 +1,7 @@
+# This file was auto-generated by Fern from our API Definition.
+
+import typing
+
+ListWorkflowUploadsApiV1WorkflowsWorkflowIdUploadsGetRequestCategory = typing.Union[
+ typing.Literal["script", "dictionary", "audio"], typing.Any
+]
diff --git a/src/onepin/workflows/types/list_workflows_request_sort_item.py b/src/onepin/workflows/types/list_workflows_request_sort_item.py
index ef228b5..c3f41ea 100644
--- a/src/onepin/workflows/types/list_workflows_request_sort_item.py
+++ b/src/onepin/workflows/types/list_workflows_request_sort_item.py
@@ -2,4 +2,6 @@
import typing
-ListWorkflowsRequestSortItem = typing.Union[typing.Literal["name", "updated_at", "runs_count"], typing.Any]
+ListWorkflowsRequestSortItem = typing.Union[
+ typing.Literal["name", "updated_at", "runs_count", "last_activity_at"], typing.Any
+]