Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .spec-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
8c8c6f7f8bdffec317e65c2abf9e2af93692424f
a13dbaa5b545aaedb3547d956a25f2d62da5c5a9
16 changes: 8 additions & 8 deletions guards.log
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
........................................................................ [ 17%]
..................................................................s..... [ 34%]
........................................................................ [ 51%]
.........F.............................................................. [ 68%]
...........................................................F............ [ 51%]
........................................................................ [ 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:
Expand All @@ -24,10 +24,10 @@ 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
=========================== 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'}
2 changes: 1 addition & 1 deletion src/onepin/.fern/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
}
]
},
"originGitCommit": "45e96724ab4ffc17fdc44d6f21e1f71206c318cc",
"originGitCommit": "5f8c05331687cedffed0c6ac9e97d81b9c338aa9",
"originGitCommitIsDirty": false,
"invokedBy": "ci",
"ciProvider": "github"
Expand Down
67 changes: 46 additions & 21 deletions src/onepin/core/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -594,11 +619,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(
Expand Down Expand Up @@ -735,11 +760,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(
Expand Down Expand Up @@ -907,11 +932,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(
Expand Down
85 changes: 84 additions & 1 deletion src/onepin/core/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
26 changes: 19 additions & 7 deletions src/onepin/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -6668,24 +6668,25 @@ 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.

**Status filter:** `status` narrows by the UI-derived state of the workflow's
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.

Expand All @@ -6699,6 +6700,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.
</dd>
</dl>
Expand Down Expand Up @@ -6738,7 +6742,7 @@ client.workflows.list()
<dl>
<dd>

**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.

</dd>
</dl>
Expand Down Expand Up @@ -6802,6 +6806,14 @@ client.workflows.list()
<dl>
<dd>

**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`.

</dd>
</dl>

<dl>
<dd>

**offset:** `typing.Optional[int]` — Zero-based pagination offset.

</dd>
Expand Down
5 changes: 5 additions & 0 deletions src/onepin/types/workflow_list_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/onepin/types/workflow_list_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
4 changes: 4 additions & 0 deletions src/onepin/types/workflow_run_list_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading