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 +]