From a6da529f43d6ff58cbccd2720170934205bcf953 Mon Sep 17 00:00:00 2001 From: Kim Burgaard Date: Mon, 5 Oct 2026 00:36:36 -0700 Subject: [PATCH] Fix list shapes, paginate termination and the attrs dependency --- CHANGELOG.md | 28 + README.md | 89 +-- poetry.lock | 4 +- pyproject.toml | 4 + seclai/seclai.py | 969 ++++++++++++++++++----------- tests/test_gated_lists.py | 827 ++++++++++++++++++++++++ tests/test_new_methods.py | 30 +- tests/test_paginate.py | 536 ++++++++++++++++ tests/test_runtime_dependencies.py | 64 ++ 9 files changed, 2146 insertions(+), 405 deletions(-) create mode 100644 tests/test_gated_lists.py create mode 100644 tests/test_paginate.py create mode 100644 tests/test_runtime_dependencies.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e170d31..5625a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.7.1] - 2026-10-05 + +### Changed + +- Raise `SeclaiError` from every version-gated list method when a 200 response is not a list: an error-shaped object, text, `null` or an empty body. Most of these methods returned such a body unchanged, and `list_evaluation_criteria()` and `list_run_evaluation_results()` returned `[]` for an empty one +- Read `{"data": null}` as an empty list in every version-gated list method: a list method returns `[]` and a dict method holds `[]` under its documented key. Most of them returned `{"data": None}` unchanged +- Return `{key: [...]}`, under the method's documented key, from a dict-returning version-gated list method that is answered with a bare array. It returned the array, except from `list_evaluation_criteria_page()` and `list_run_evaluation_results_page()`, which already wrapped it +- Change when `paginate()` stops. It now also stops after a page that reports `pagination.has_next` as false or that reaches the `total` the body reports, so a walk whose last page is full can make one request fewer, and after a page holding more than `limit` items unless the body says more exist. A page identical to the one before it is not yielded: the walk raises `SeclaiError` if that page reports more items, and ends if it reports no paging information. Two consecutive pages that are legitimately identical are treated the same way +- Raise `ValueError` from `paginate()` when `limit` is not a positive integer, before any request + +### Fixed + +- Return the list from `get_agent_callers()`, `list_inbound_email_rejections()`, `list_solution_conversations()`, `list_governance_ai_conversations()`, `list_models()`, `list_memory_bank_templates()` and `get_agents_using_memory_bank()` when `api_version` is `2026-07-27` or later. They returned the `{data, pagination}` object, four of them from a method annotated `list` +- Keep the items under the documented key when `api_version` is `2026-07-27` or later, in `list_knowledge_bases()`, `list_memory_banks()`, `list_agent_email_optouts()`, `list_blocked_email_senders()`, `set_auto_block_mode()`, `list_organization_alert_preferences()`, `list_email_domains()`, `list_alert_configs()`, `list_model_alerts()`, `list_experiments()`, `get_generation_tiers()`, `list_embedding_models()` and `list_reranker_models()`. The items were only under `data`, so `result["knowledge_bases"]` raised `KeyError`. `data` and `pagination` are still present +- Fill the flat `total`, `page` and `limit` a method documents from `pagination` when `api_version` is `2026-07-27` or later. They were absent from the four evaluation listings, the knowledge-base and memory-bank listings and every listing with a `total` +- Declare `attrs` as a runtime dependency. The generated client imports it, so `import seclai` failed with `ModuleNotFoundError` unless another installed package happened to provide `attrs` +- End `paginate()` on an endpoint that ignores `page` and `limit`. `client.paginate("GET", "/alerts/configs", items_key="configs")` never ended on the default API version once an account had 50 alert configs +- Raise `SeclaiError` from `paginate()` when an endpoint that pages by `offset` is walked with the default `param_style="page"` on the default API version. Every request returned the first page, so the walk never ended +- Send one `authorization` and one `x-account-id` on the first typed-method call, such as `list_sources()` or `run_agent()`, when the client uses a bearer-token provider or an SSO profile and `default_headers` spells either header in another case. Both values were sent on that call; later calls sent only the resolved credential, which is now the one sent every time +- Apply the unknown-version guard to a `Seclai-Version` in the default headers of a supplied `http_client`, at construction and on each request. This is a new rejection: a value this release was not built against was sent unchecked and now raises `SeclaiConfigurationError`, as the same value in `default_headers` does. `allow_unknown_api_version=True` permits any value +- Correct the documentation of `list_alert_configs()`: on the default API version it ignores `page` and `limit` and returns every configuration. The README said it paged + +## [1.7.0] - 2026-10-05 + +_Documentation-only release: the `content_version_ids` guidance of `list_source_contents()` now says to keep a request to about 100 ids, since they travel in the query string._ + ## [1.6.0] - 2026-10-04 ### Changed @@ -183,6 +209,8 @@ _Stable release. Packaging, CI, and documentation deployment only; no API change _Initial release._ +[1.7.1]: https://github.com/seclai/seclai-python/releases/tag/1.7.1 +[1.7.0]: https://github.com/seclai/seclai-python/releases/tag/1.7.0 [1.6.0]: https://github.com/seclai/seclai-python/releases/tag/1.6.0 [1.5.0]: https://github.com/seclai/seclai-python/releases/tag/1.5.0 [1.4.0]: https://github.com/seclai/seclai-python/releases/tag/1.4.0 diff --git a/README.md b/README.md index e6a2859..1f2243e 100644 --- a/README.md +++ b/README.md @@ -174,45 +174,57 @@ reshape responses, and this client would decode them incorrectly rather than reject them. Upgrade the package to adopt a new version, or pass `allow_unknown_api_version=True` if you have to move first and accept that risk. -The guard covers the header however it is supplied — `api_version`, -`default_headers`, or a per-request `headers` argument — and nothing else. An -account pinned server-side can still be +The guard covers the header however it reaches the wire: `api_version`, +`default_headers`, a per-request `headers` argument, or the default headers of +an `http_client` you supply, which are checked at construction and again on +each request, exactly as a value in `default_headers` is. It covers nothing +else. The typed methods that go through the generated client — `run_agent()`, +`list_agent_runs()`, `get_agent_run()`, `delete_agent_run()`, `list_sources()`, +`get_content_detail()`, `delete_content()`, `list_content_embeddings()`, +`upload_file_to_source()` and `upload_file_to_content()` — do not use a supplied +`http_client` at all, so nothing it carries reaches them. An account pinned +server-side can still be newer than this release — `get_api_version()` reports the `effective_version` the request resolved to, and comparing it against `LATEST_API_VERSION` is how you detect the gap. **What `2026-07-27` changes.** Undeclared query parameters become a 422 instead -of being ignored, and list endpoints move to the canonical -`{"data": [...], "pagination": {...}}` envelope. The affected methods read both -shapes, so they keep working either way — but the metadata moves: +of being ignored, and every list endpoint that answered with a bare array or +under a per-resource key moves to the canonical +`{"data": [...], "pagination": {...}}` envelope. The methods for those endpoints +return what they document on either shape, so code written against the default +still reads the result after you opt in: -| Method | Before | From 2026-07-27 | Legacy paging | -| --- | --- | --- | --- | -| `list_evaluation_criteria_page()` | bare list | `data` + `pagination` | none — returns everything | -| `list_run_evaluation_results_page()` | bare list | `data` + `pagination` | none — returns everything | -| `list_alert_configs()` | `configs` + `total` | `data` + `pagination` | `page` / `limit` | -| `list_model_alerts()` | `alerts` + `total` | `data` + `pagination` | `page` (sent as `offset`) / `limit` | -| `list_experiments()` | `experiments` + `total` | `data` + `pagination` | `limit` / `offset` | -| `get_generation_tiers()` | `tiers` | `data` + `pagination` | none | - -`unwrap_items()` reads either shape, so a call site does not have to branch on -the version: - -```python -from seclai import unwrap_items - -items = unwrap_items(client.list_alert_configs(), "configs") -items = unwrap_items(client.list_model_alerts(), "alerts") -``` - -Prefer `pagination` over the flat keys. The legacy keys will be deprecated and -then removed once the canonical envelope is the default. - -The two evaluation endpoints are **unpaginated** on the legacy shape — they -ignore `page`/`limit` and return everything — so a paginate-until-empty loop over -them only terminates once you have opted in. `get_generation_tiers()` takes no -paging arguments at all and always returns the full set. The remaining three -paginate on either shape. +| Declared return | Methods | From 2026-07-27 | +| --- | --- | --- | +| A list | `list_evaluation_criteria()`, `list_run_evaluation_results()`, `get_agent_callers()`, `list_inbound_email_rejections()`, `list_governance_ai_conversations()`, `list_solution_conversations()`, `list_models()`, `list_memory_bank_templates()`, `get_agents_using_memory_bank()`, `list_cloud_drive_providers()`, `list_cloud_drives()`, `get_agents_using_cloud_drive()`, `list_cloud_drive_rejections()` | Unchanged | +| `data`, from a bare array by default | `list_evaluation_criteria_page()`, `list_run_evaluation_results_page()` | `data`, plus `pagination` | +| `data` with flat `total`/`page`/`limit` | `list_evaluation_results()`, `list_agent_evaluation_results()`, `list_evaluation_runs()`, `list_compatible_runs()` | Unchanged, plus `pagination` | +| A per-resource key | `list_agent_email_optouts()` and `list_blocked_email_senders()` / `set_auto_block_mode()` (`items`), `list_alert_configs()` (`configs`), `list_organization_alert_preferences()` (`preferences`), `list_email_domains()` (`domains`), `list_knowledge_bases()` (`knowledge_bases`), `list_memory_banks()` (`memory_banks`), `list_model_alerts()` (`alerts`), `list_experiments()` (`experiments`), `get_generation_tiers()` (`tiers`), `list_embedding_models()` and `list_reranker_models()` (`models`) | The same key, plus `data` and `pagination` | + +Where a method documents flat `total`, `page` or `limit`, the client fills them +from `pagination` after you opt in. Fields that sit beside a list, such as +`auto_block_mode`, the embedding defaults or the email-domain plan capabilities, +are present on both shapes. `pagination` is present only once you opt in, so +read it with `.get("pagination")`. + +A 200 response that is not a list at all — an error-shaped object, text, or an +empty body — raises `SeclaiError` from every one of these methods. +`unwrap_items()` still reads either shape of any of these results. + +Opting in also turns paging on for endpoints that returned everything by +default, so the same call can return fewer rows: + +- `list_evaluation_criteria()` and `list_run_evaluation_results()` return every + item by default and ignore `page`/`limit`. After you opt in they return one + page: 50 items unless you pass `limit`, since this client sends `limit=50`. + The list carries no sign of that; use `list_evaluation_criteria_page()` or + `list_run_evaluation_results_page()` to see `pagination`. +- `list_alert_configs()` ignores `page` and `limit` by default and returns every + config; after you opt in it returns one page of 50. +- `set_auto_block_mode()` returns the first 50 blocked senders on either shape. + Its `total` is the account's full count by default, and the number of rows it + returned after you opt in. **Later versions.** Each is cumulative, and none changes a response shape this client decodes: @@ -225,11 +237,6 @@ client decodes: | `2026-09-30` | A run's and a step's `output`, and a step's `input`, are the text rather than a JSON manifest; files are in `attachments` on every version | | `2026-10-03` | A new LLM step written without `attachments` takes its parent's files, and a new retrieval step's matched media are its files | -The cloud-drive listings and `list_embedding_models()` / `list_reranker_models()` -follow the `2026-07-27` envelope rule as well. The cloud-drive methods return -the items on either shape; read the two model listings with -`unwrap_items(result, "models")`. - ## Resources ### Identity @@ -702,8 +709,8 @@ print(removed.get("cleanup_note")) # set when the domain was Seclai-managed tiers = client.get_generation_tiers() # Embedding and reranker models, with their pricing -embedders = unwrap_items(client.list_embedding_models(), "models") -rerankers = unwrap_items(client.list_reranker_models(), "models") +embedders = client.list_embedding_models()["models"] +rerankers = client.list_reranker_models()["models"] alerts = client.list_model_alerts() client.mark_model_alert_read("alert_id") @@ -760,7 +767,7 @@ client.submit_ai_feedback({"rating": 5, "comment": "Helpful!"}) ### Pagination -All list methods accept `page` and `limit` parameters. For auto-pagination across all pages, use the `paginate` helper: +List methods take the paging arguments their endpoint declares: most take `page` and `limit`, some take `limit` and `offset`, some take `limit` alone, and listings that are always returned whole take none. Each method's signature says which. For auto-pagination across all pages, use the `paginate` helper. It stops after a page that is short or empty, and when the response says there is no next page or its `total` has been reached. A page longer than `limit` also ends it, unless the response says more exist. A page identical to the one before it is not yielded: `paginate` raises `SeclaiError` if that page reports more items — usually the endpoint pages by `offset`, so pass `param_style="offset"` — and otherwise stops: ```python # Sync — yields items one by one (generator) diff --git a/poetry.lock b/poetry.lock index c98596a..be679ca 100644 --- a/poetry.lock +++ b/poetry.lock @@ -37,7 +37,7 @@ version = "25.4.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, @@ -1032,4 +1032,4 @@ typing-extensions = ">=4.12.0" [metadata] lock-version = "2.1" python-versions = ">=3.11,<4.0" -content-hash = "5e71739f6027288347b06b1c86843785e221a3290b9ba4dfa29c7efbb5ebab31" +content-hash = "8c595ce3f7a5d6e06e7cc62a6d8705e5fa3307d89f2b77911d16fee44d4ae920" diff --git a/pyproject.toml b/pyproject.toml index 74719eb..866f6b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,10 @@ httpx = "^0.28.1" # `dateutil.parser.isoparse` for datetime fields, so this must be a runtime # dependency (types-python-dateutil below only covers typing stubs). python-dateutil = "^2.8.1" +# Required at runtime by the generated client and models, which are attrs +# classes. The floor is the one openapi-python-client declares; attrs is +# calendar-versioned, so a caret would cap it at the 22.x releases. +attrs = ">=22.2.0" [tool.poetry.group.dev.dependencies] pytest = ">=8,<9" diff --git a/seclai/seclai.py b/seclai/seclai.py index 8a1ab8e..d32a751 100644 --- a/seclai/seclai.py +++ b/seclai/seclai.py @@ -37,6 +37,7 @@ run = await client.run_agent("agent_123", body=...) """ +import json import logging import mimetypes import os @@ -140,16 +141,119 @@ def unwrap_items(payload: Any, *legacy_keys: str) -> list[dict[str, Any]]: ) -def _as_page(result: Any) -> dict[str, Any]: - """Normalise a version-gated list response to the ``{data, ...}`` envelope. +#: The flat counters of a list that documents ``total``, ``page`` and ``limit``. +_PAGE_COUNTERS = ("total", "page", "limit") - The canonical envelope is passed through untouched, including its - ``pagination`` key; the legacy bare array is wrapped so callers can read - ``["data"]`` either way and use ``.get("pagination")`` to tell them apart. + +def _keyed_list(payload: Any, key: str, flat: tuple[str, ...] = ()) -> dict[str, Any]: + """Return a version-gated list as the object its method documents. + + The items sit under ``key`` on either shape and a ``flat`` counter the body + lacks is filled from ``pagination``; ``data`` and ``pagination`` stay when + the API sent them. Raises `SeclaiError` for a body that is not a list. """ - if isinstance(result, dict): - return cast(dict[str, Any], result) - return {"data": result} + if isinstance(payload, list): + return {key: payload} + if not isinstance(payload, dict): + raise SeclaiError( + f"Unrecognised list response: expected a list or object, " + f"got {type(payload).__name__}." + ) + if isinstance(payload.get("data"), list): + items = payload["data"] + elif isinstance(payload.get(key), list): + items = payload[key] + elif "data" in payload and payload["data"] is None: + items = [] + else: + expected = ", ".join(repr(k) for k in dict.fromkeys(("data", key))) + raise SeclaiError( + f"Unrecognised list response: expected a bare array or an object " + f"with a list under one of {expected}, got keys {sorted(payload)}." + ) + body: dict[str, Any] = {**payload, key: items} + pagination = body.get("pagination") + if isinstance(pagination, dict): + for counter in flat: + if counter not in body and counter in pagination: + body[counter] = pagination[counter] + return body + + +class _PageWalk: + """The stopping rule of ``paginate()``, fed one response at a time.""" + + def __init__(self, *, limit: int, param_style: str) -> None: + if param_style not in ("page", "offset"): + raise ValueError( + f"param_style must be 'page' or 'offset', not {param_style!r}" + ) + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + raise ValueError(f"limit must be a positive integer, not {limit!r}") + self.finished = False + self._limit = limit + self._param_style = param_style + self._page = 1 + self._yielded = 0 + self._previous: str | None = None + + def _next_params(self) -> dict[str, int]: + """The query parameters that ask for the next page.""" + if self._param_style == "offset": + # Items consumed, not pages × limit: they differ when the endpoint + # returns more than `limit` per page. + return {"offset": self._yielded, "limit": self._limit} + return {"page": self._page, "limit": self._limit} + + def _accept(self, result: Any, items_key: str) -> list[dict[str, Any]] | None: + """Return the page's items to yield, or ``None`` for a repeated page. + + Raises `SeclaiError` when a repeated page says more items exist. + """ + items = unwrap_items(result, items_key) + # Serialised before the caller sees the items, so mutating them later + # cannot change what the next page is compared with. + snapshot = json.dumps(items, sort_keys=True, default=repr) + pagination = result.get("pagination") if isinstance(result, dict) else None + if not isinstance(pagination, dict): + pagination = {} + has_next = pagination.get("has_next") + flat_total = result.get("total") if isinstance(result, dict) else None + total = next( + ( + value + for value in (pagination.get("total"), flat_total) + # `bool` is an `int`; a `True` total would read as 1. + if isinstance(value, int) and not isinstance(value, bool) + ), + None, + ) + if snapshot == self._previous: + if has_next is True or (total is not None and total > self._yielded): + other = "offset" if self._param_style == "page" else "page" + raise SeclaiError( + f"The endpoint returned the same page for a different " + f"{self._param_style!r} value while reporting more items. It " + f"probably pages by {other!r}: pass param_style={other!r}." + ) + return None + end = (self._page - 1) * self._limit + len(items) + self._yielded += len(items) + more_reported = has_next is True or ( + total is not None and total > self._yielded + ) + self.finished = ( + isinstance(result, list) + or len(items) < self._limit + # Longer than asked: the endpoint ignored `limit`. That is the whole + # collection unless the body itself says more exist. + or (len(items) > self._limit and not more_reported) + or has_next is False + or (total is not None and end >= total) + ) + self._previous = snapshot + self._page += 1 + return items def _empty_page(*, page: int, limit: int) -> dict[str, Any]: @@ -383,6 +487,7 @@ def _merge_request_headers( *, options: ClientOptions, request_headers: Mapping[str, str] | None, + client_headers: httpx.Headers | None = None, ) -> dict[str, str]: """Merge client default headers with per-request overrides including dynamic auth.""" merged = _build_default_headers( @@ -401,6 +506,7 @@ def _merge_request_headers( _merge_headers(merged, auth_headers) _validate_request_version(options, request_headers) _merge_headers(merged, request_headers) + _validate_client_version(options, merged, client_headers) return merged @@ -408,6 +514,7 @@ async def _merge_request_headers_async( *, options: ClientOptions, request_headers: Mapping[str, str] | None, + client_headers: httpx.Headers | None = None, ) -> dict[str, str]: """Merge client default headers with per-request overrides including dynamic auth (async).""" merged = _build_default_headers( @@ -426,6 +533,7 @@ async def _merge_request_headers_async( _merge_headers(merged, auth_headers) _validate_request_version(options, request_headers) _merge_headers(merged, request_headers) + _validate_client_version(options, merged, client_headers) return merged @@ -506,6 +614,42 @@ def _validate_request_version( raise SeclaiConfigurationError(f"{e} (via headers['Seclai-Version'])") from e +def _validate_client_version( + options: ClientOptions, + headers: Mapping[str, str], + client_headers: httpx.Headers | None, +) -> None: + """Apply the unknown-version guard to a ``Seclai-Version`` the httpx client carries. + + httpx sends its client's default only when ``headers`` does not set one. + """ + if client_headers is None or "seclai-version" not in client_headers: + return + if any(key.lower() == "seclai-version" for key in headers): + return + try: + validate_api_version( + client_headers["seclai-version"], + allow_unknown=options.allow_unknown_api_version, + ) + except ValueError as e: + raise SeclaiConfigurationError( + f"{e} (via http_client.headers['Seclai-Version'])" + ) from e + + +def _with_auth_headers( + client: GeneratedClient, auth_headers: Mapping[str, str] +) -> GeneratedClient: + """Add headers as ``with_headers`` does, replacing one whatever its spelling.""" + evolved = client.with_headers(dict(auth_headers)) + replaced = {name.lower() for name in auth_headers} + for name in [k for k in evolved._headers if k not in auth_headers]: + if name.lower() in replaced: + del evolved._headers[name] + return evolved + + class _SeclaiBase: """Shared implementation for Seclai sync/async clients. @@ -675,7 +819,7 @@ def _sync_generated_client(self) -> GeneratedClient: # original instance (preserving any user-set httpx client) but # store the evolved copy so get_httpx_client() will use the # refreshed headers when it lazily creates an httpx.Client. - evolved = gc.with_headers(auth_headers) + evolved = _with_auth_headers(gc, auth_headers) self._generated_client_instance = evolved # Re-attach the existing httpx client to the evolved instance # so pre-configured transports (e.g. in tests) aren't lost. @@ -705,7 +849,7 @@ async def _async_generated_client(self) -> GeneratedClient: raise SeclaiConfigurationError( f"Auth resolution failed: {exc}" ) from exc - evolved = gc.with_headers(auth_headers) + evolved = _with_auth_headers(gc, auth_headers) self._generated_client_instance = evolved if gc._async_client is not None: evolved.set_async_httpx_client(gc._async_client) @@ -820,7 +964,11 @@ def __init__( timeout: Request timeout (seconds). api_key_header: Header name to use for the API key. default_headers: Extra headers to include on every request. - http_client: Optional pre-configured ``httpx.Client`` to use. + http_client: Optional pre-configured ``httpx.Client`` to use. A + ``Seclai-Version`` among its default headers is checked as one + in ``default_headers`` is, at construction and on each request. + The typed methods that go through the generated client do not + use this client. profile: SSO profile name from ``~/.seclai/config``. config_dir: Override the config directory path. auto_refresh: Auto-refresh expired SSO tokens. Defaults to ``True``. @@ -852,6 +1000,10 @@ def __init__( headers=self._default_headers(), ) self._owns_client = http_client is None + if http_client is not None: + _validate_client_version( + self._options, self._default_headers(), http_client.headers + ) def close(self) -> None: """Close underlying HTTP resources owned by this client. @@ -911,7 +1063,9 @@ def request( params=params, json=json, headers=_merge_request_headers( - options=self._options, request_headers=headers + options=self._options, + client_headers=self._client.headers, + request_headers=headers, ), ) _raise_for_status(response) @@ -1009,7 +1163,9 @@ def run_streaming_agent_and_wait( path = f"/agents/{agent_id}/runs/stream" merged_headers = _merge_request_headers( - options=self._options, request_headers=headers + options=self._options, + client_headers=self._client.headers, + request_headers=headers, ) _setdefault_header(merged_headers, "accept", "text/event-stream") @@ -1944,13 +2100,10 @@ def get_agent_callers(self, agent_id: str) -> list[dict[str, Any]]: agent_id: Agent identifier. Returns: - The calling agents; each must be disabled before this agent can be paused. - + The calling agents, as a list on every API version; each must be + disabled before this agent can be paused. """ - return cast( - list[dict[str, Any]], - self.request("GET", f"/agents/{agent_id}/callers"), - ) + return unwrap_items(self.request("GET", f"/agents/{agent_id}/callers")) # ── Agent Export ──────────────────────────────────────────────────────────── @@ -2103,7 +2256,9 @@ def upload_agent_input( f"/agents/{agent_id}/upload-input", files={"file": payload}, headers=_merge_request_headers( - options=self._options, request_headers=None + options=self._options, + client_headers=self._client.headers, + request_headers=None, ), ) _raise_for_status(response) @@ -2186,7 +2341,11 @@ def download_agent_run_attachment( "GET", f"/v2/agent-runs/{run_id}/attachments/{attachment_id}", params=_strip_none({"download_name": download_name}), - headers=_merge_request_headers(options=self._options, request_headers=None), + headers=_merge_request_headers( + options=self._options, + client_headers=self._client.headers, + request_headers=None, + ), ) response = self._client.send(request, stream=True) if response.is_error: @@ -2357,7 +2516,7 @@ def list_evaluation_criteria_page( f"/agents/{agent_id}/evaluation-criteria", params=_strip_none({"page": page, "limit": limit}), ) - return _as_page(result) + return _keyed_list(result, "data") def create_evaluation_criteria( self, agent_id: str, body: dict[str, Any] @@ -2454,15 +2613,19 @@ def list_evaluation_results( limit: Items per page. Returns: - Paginated evaluation results. + The page of results under ``data`` with ``total``, ``page`` and + ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", f"/agents/evaluation-criteria/{criteria_id}/results", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) def create_evaluation_result( @@ -2497,15 +2660,19 @@ def list_compatible_runs( limit: Items per page. Returns: - Paginated list of compatible runs. + The page of compatible runs under ``data`` with ``total``, ``page`` and + ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", f"/agents/evaluation-criteria/{criteria_id}/compatible-runs", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) def test_draft_evaluation( @@ -2540,15 +2707,19 @@ def list_agent_evaluation_results( limit: Items per page. Returns: - Paginated evaluation results with criteria. + The page of results with their criteria under ``data`` with ``total``, + ``page`` and ``limit``, on every API version. Once the client opts in + with ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", f"/agents/{agent_id}/evaluation-results", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) def list_run_evaluation_results( @@ -2595,7 +2766,7 @@ def list_run_evaluation_results_page( f"/agents/{agent_id}/runs/{run_id}/evaluation-results", params=_strip_none({"page": page, "limit": limit}), ) - return _as_page(result) + return _keyed_list(result, "data") def list_evaluation_runs( self, agent_id: str, *, page: int = 1, limit: int = 50 @@ -2608,15 +2779,19 @@ def list_evaluation_runs( limit: Items per page. Returns: - Paginated evaluation run summaries. + The page of run summaries under ``data`` with ``total``, ``page`` and + ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", f"/agents/{agent_id}/evaluation-runs", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) def get_non_manual_evaluation_summary(self, agent_id: str) -> dict[str, Any]: @@ -2661,11 +2836,11 @@ def list_agent_email_optouts( offset: Rows to skip. Returns: - The page of opt-outs plus the total count. - + The page of opt-outs under ``items`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", "/agents/agent-email-optouts", @@ -2673,6 +2848,8 @@ def list_agent_email_optouts( {"agent_id": agent_id, "limit": limit, "offset": offset} ), ), + "items", + ("total",), ) def remove_agent_email_optout(self, optout_id: str) -> None: @@ -2697,16 +2874,19 @@ def list_blocked_email_senders( offset: Rows to skip. Returns: - The page of blocked senders plus ``auto_block_mode``. - + The page of blocked senders under ``items`` with ``total`` and + ``auto_block_mode``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", "/agents/blocked-email-senders", params=_strip_none({"limit": limit, "offset": offset}), ), + "items", + ("total",), ) def block_email_sender(self, body: dict[str, Any]) -> dict[str, Any]: @@ -2741,12 +2921,16 @@ def set_auto_block_mode(self, body: dict[str, Any]) -> dict[str, Any]: body: ``mode``: ``disabled``, ``input``, or ``input_and_output``. Returns: - The updated blocked-sender list. - + The first 50 blocked senders under ``items`` with ``auto_block_mode``, + on every API version. ``total`` is the account's count by default, and + the number of rows returned once the client opts in with + ``api_version="2026-07-27"`` or later, where the response also carries + ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request("PUT", "/agents/blocked-email-senders/mode", json=body), + "items", + ("total",), ) def list_inbound_email_rejections( @@ -2762,16 +2946,14 @@ def list_inbound_email_rejections( limit: Maximum results (1-200, default 50). Returns: - The discarded inbound emails. - + The discarded inbound emails, as a list on every API version. """ - return cast( - list[dict[str, Any]], + return unwrap_items( self.request( "GET", "/agents/inbound-email-rejections", params=_strip_none({"agent_id": agent_id, "limit": limit}), - ), + ) ) def get_inbound_email_status(self) -> dict[str, Any]: @@ -2855,10 +3037,12 @@ def list_knowledge_bases( order: Sort order (``"asc"`` or ``"desc"``). Returns: - Paginated list of knowledge bases. + The page of knowledge bases under ``knowledge_bases`` with ``total``, + ``page`` and ``limit``, on every API version. Once the client opts in + with ``api_version="2026-07-27"`` or later the response also carries + ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", "/knowledge_bases", @@ -2866,6 +3050,8 @@ def list_knowledge_bases( {"page": page, "limit": limit, "sort": sort, "order": order} ), ), + "knowledge_bases", + _PAGE_COUNTERS, ) def create_knowledge_base(self, body: dict[str, Any]) -> dict[str, Any]: @@ -2944,10 +3130,12 @@ def list_memory_banks( order: Sort order (``"asc"`` or ``"desc"``). Returns: - Paginated list of memory banks. + The page of memory banks under ``memory_banks`` with ``total``, ``page`` + and ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", "/memory_banks", @@ -2955,6 +3143,8 @@ def list_memory_banks( {"page": page, "limit": limit, "sort": sort, "order": order} ), ), + "memory_banks", + _PAGE_COUNTERS, ) def create_memory_bank(self, body: dict[str, Any]) -> dict[str, Any]: @@ -3021,9 +3211,12 @@ def get_agents_using_memory_bank(self, memory_bank_id: str) -> JSONValue: memory_bank_id: Memory bank identifier. Returns: - List of agents using this memory bank. + The agents using this memory bank, as a list on every API version. """ - return self.request("GET", f"/memory_banks/{memory_bank_id}/agents") + return cast( + JSONValue, + unwrap_items(self.request("GET", f"/memory_banks/{memory_bank_id}/agents")), + ) def get_memory_bank_stats(self, memory_bank_id: str) -> JSONValue: """Get statistics for a memory bank. @@ -3095,9 +3288,11 @@ def list_memory_bank_templates(self) -> JSONValue: """List available memory bank templates. Returns: - Available templates. + The available templates, as a list on every API version. """ - return self.request("GET", "/memory_banks/templates") + return cast( + JSONValue, unwrap_items(self.request("GET", "/memory_banks/templates")) + ) def generate_memory_bank_config(self, body: dict[str, Any]) -> dict[str, Any]: """Use the AI assistant to generate memory bank configuration. @@ -3402,7 +3597,11 @@ def download_source_export(self, source_id: str, export_id: str) -> httpx.Respon request = self._client.build_request( "GET", f"/sources/{source_id}/exports/{export_id}/download", - headers=_merge_request_headers(options=self._options, request_headers=None), + headers=_merge_request_headers( + options=self._options, + client_headers=self._client.headers, + request_headers=None, + ), ) response = self._client.send(request, stream=True) if response.is_error: @@ -3729,14 +3928,13 @@ def list_solution_conversations(self, solution_id: str) -> list[dict[str, Any]]: solution_id: Solution identifier. Returns: - List of conversations. + The conversation turns, as a list on every API version. """ - return cast( - list[dict[str, Any]], + return unwrap_items( self.request( "GET", f"/solutions/{solution_id}/conversations", - ), + ) ) def add_solution_conversation_turn( @@ -3897,14 +4095,13 @@ def list_governance_ai_conversations(self) -> list[dict[str, Any]]: """List governance AI assistant conversations. Returns: - List of governance conversations. + The governance conversations, as a list on every API version. """ - return cast( - list[dict[str, Any]], + return unwrap_items( self.request( "GET", "/governance/ai-assistant/conversations", - ), + ) ) def accept_governance_ai_plan(self, conversation_id: str) -> dict[str, Any]: @@ -4028,21 +4225,25 @@ def list_alert_configs(self, *, page: int = 1, limit: int = 50) -> JSONValue: """List alert configurations. Args: - page: Page number (1-indexed). - limit: Items per page. + page: Page number (1-indexed). **Ignored** unless the client opts in + with ``api_version="2026-07-27"`` or later — by default the + response is unpaginated and always contains every configuration. + limit: Items per page. Ignored by default, as above. Returns: - The configurations, under ``configs`` alongside ``total`` by default, - and under ``data`` with ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(client.list_alert_configs(), "configs") + The configurations under ``configs`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. It is then + one page rather than every configuration. """ - return self.request( - "GET", - "/alerts/configs", - params=_strip_none({"page": page, "limit": limit}), + return _keyed_list( + self.request( + "GET", + "/alerts/configs", + params=_strip_none({"page": page, "limit": limit}), + ), + "configs", + ("total",), ) def create_alert_config(self, body: dict[str, Any]) -> JSONValue: @@ -4093,14 +4294,17 @@ def list_organization_alert_preferences(self) -> dict[str, Any]: """List organization alert preferences. Returns: - Alert preferences for the organization. + The preferences under ``preferences`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", "/alerts/organization-preferences/list", ), + "preferences", + ("total",), ) def update_organization_alert_preference( @@ -4134,20 +4338,21 @@ def list_model_alerts(self, *, page: int = 1, limit: int = 50) -> JSONValue: limit: Items per page. Returns: - The alerts, under ``alerts`` alongside ``total`` by default, and under - ``data`` with ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(client.list_model_alerts(), "alerts") + The alerts under ``alerts`` with ``total``, on every API version. Once + the client opts in with ``api_version="2026-07-27"`` or later the + response also carries ``data`` and ``pagination``. """ # `offset` is declared `minimum: 0`, so a defensive caller passing page=0 # would turn a previously-ignored parameter into a hard 422. offset = max(page - 1, 0) * limit - return self.request( - "GET", - "/models/alerts", - params=_strip_none({"offset": offset, "limit": limit}), + return _keyed_list( + self.request( + "GET", + "/models/alerts", + params=_strip_none({"offset": offset, "limit": limit}), + ), + "alerts", + ("total",), ) def mark_all_model_alerts_read(self) -> None: @@ -4198,17 +4403,22 @@ def list_models( supports_thinking: Filter to models that support extended thinking. Returns: - List of provider groups with their models. + The provider groups with their models, as a list on every API version. """ - return self.request( - "GET", - "/models", - params=_strip_none( - { - "provider": provider, - "supports_tool_use": supports_tool_use, - "supports_thinking": supports_thinking, - } + return cast( + JSONValue, + unwrap_items( + self.request( + "GET", + "/models", + params=_strip_none( + { + "provider": provider, + "supports_tool_use": supports_tool_use, + "supports_thinking": supports_thinking, + } + ), + ) ), ) @@ -4228,14 +4438,11 @@ def get_generation_tiers(self) -> dict[str, Any]: Returns: Each ``(modality, tier)`` mapped to its generator, credits, and price - label — under ``tiers`` by default, and under ``data`` with - ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(client.get_generation_tiers(), "tiers") + label, under ``tiers`` on every API version. Once the client opts in + with ``api_version="2026-07-27"`` or later the response also carries + ``data`` and ``pagination``. """ - return cast(dict[str, Any], self.request("GET", "/models/generation-tiers")) + return _keyed_list(self.request("GET", "/models/generation-tiers"), "tiers") def list_embedding_models( self, *, supports_input_media: str | None = None @@ -4247,36 +4454,30 @@ def list_embedding_models( modality. Returns: - The embedders under ``models`` by default, and under ``data`` with - ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later; the defaults and pricing sit - beside them on either shape. Read the list with - :func:`unwrap_items`:: - - items = unwrap_items(client.list_embedding_models(), "models") + The embedders under ``models`` on every API version, with the defaults + and pricing beside them. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( self.request( "GET", "/models/embedders", params=_strip_none({"supports_input_media": supports_input_media}), ), + "models", ) def list_reranker_models(self) -> dict[str, Any]: """List the reranker models a knowledge base can use, and their pricing. Returns: - The rerankers under ``models`` by default, and under ``data`` with - ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later; the default and pricing sit - beside them on either shape. Read the list with - :func:`unwrap_items`:: - - items = unwrap_items(client.list_reranker_models(), "models") + The rerankers under ``models`` on every API version, with the default + and pricing beside them. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast(dict[str, Any], self.request("GET", "/models/rerankers")) + return _keyed_list(self.request("GET", "/models/rerankers"), "models") # ── Model Playground Experiments ────────────────────────────────────────── @@ -4299,25 +4500,26 @@ def list_experiments( offset: Pagination offset. Returns: - The experiments, under ``experiments`` alongside ``total`` by default, - and under ``data`` with ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(client.list_experiments(), "experiments") + The experiments under ``experiments`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. """ - return self.request( - "GET", - "/models/playground/experiments", - params=_strip_none( - { - "days": days, - "start_date": start_date, - "end_date": end_date, - "limit": limit, - "offset": offset, - } + return _keyed_list( + self.request( + "GET", + "/models/playground/experiments", + params=_strip_none( + { + "days": days, + "start_date": start_date, + "end_date": end_date, + "limit": limit, + "offset": offset, + } + ), ), + "experiments", + ("total",), ) def create_experiment(self, body: dict[str, Any]) -> JSONValue: @@ -4505,10 +4707,12 @@ def list_email_domains(self) -> dict[str, Any]: """List the account's agent-email domains and plan capabilities. Returns: - Domains with verification status and required DNS records. - + The domains under ``domains`` with verification status and required DNS + records, and the plan capabilities beside them, on every API version. + Once the client opts in with ``api_version="2026-07-27"`` or later the + response also carries ``data`` and ``pagination``. """ - return cast(dict[str, Any], self.request("GET", "/email-domains")) + return _keyed_list(self.request("GET", "/email-domains"), "domains") def add_email_domain(self, body: dict[str, Any]) -> dict[str, Any]: """Add and provision a vanity or custom agent-email domain. @@ -4804,55 +5008,55 @@ def paginate( """Auto-paginate a list endpoint. Yields individual items from each page. - Stops when a page returns fewer items than ``limit`` or the items list is empty. + + The walk ends after a page that is a bare array, that is short or + empty, that reports ``pagination.has_next`` false, or that reaches the + ``total`` the body reports. A page holding more than ``limit`` items + also ends it — the endpoint ignored ``limit`` and returned everything — + unless the body itself says more exist. + + A page identical to the one before it is never yielded: the endpoint + did not advance for the cursor it was sent. If that page reports more + items (``has_next`` true, or a ``total`` above the number yielded) the + walk raises `SeclaiError` rather than return a short result; if it + reports no paging information the walk ends quietly. Two consecutive + pages that are legitimately identical cannot be told apart from that, + and are treated the same way. Args: method: HTTP method (typically ``"GET"``). path: API path. params: Extra query parameters (the cursor and ``limit`` are managed automatically). - limit: Items per page. + limit: Items per page, a positive integer. items_key: Per-resource key the items sit under when the response is not the ``data`` envelope, which is always read first. param_style: ``"page"`` (default) sends a 1-indexed ``page``; ``"offset"`` sends a 0-based ``offset``. A few endpoints — - ``/models/alerts`` among them — declare only ``offset`` and - reject or ignore ``page``, which makes every request after the - first return page 1. + ``/models/alerts`` among them — declare only ``offset``. They + ignore ``page`` by default, which the walk reports as an error + on the second page, and reject it with a 422 once the client + opts in with ``api_version="2026-07-27"`` or later. Yields: Individual item dicts from each page. Raises: - ValueError: If ``param_style`` is neither ``"page"`` nor ``"offset"``. + ValueError: If ``param_style`` is neither ``"page"`` nor ``"offset"``, + or ``limit`` is not a positive integer. SeclaiError: If a page is neither a list nor an object carrying - ``data`` or ``items_key``. + ``data`` or ``items_key``, or if the endpoint returns the same + page twice while reporting more items. """ - if param_style not in ("page", "offset"): - raise ValueError( - f"param_style must be 'page' or 'offset', not {param_style!r}" - ) - page = 1 + walk = _PageWalk(limit=limit, param_style=param_style) base_params = dict(params) if params else {} - while True: - if param_style == "offset": - base_params["offset"] = (page - 1) * limit - else: - base_params["page"] = page - base_params["limit"] = limit + while not walk.finished: + base_params.update(walk._next_params()) result = self.request(method, path, params=base_params) - # A bare array is an unpaginated response: it is everything, so - # asking for a second page would return the same items again. - if isinstance(result, list): - for item in result: - yield cast(dict[str, Any], item) + items = walk._accept(result, items_key) + if items is None: return - items = unwrap_items(result, items_key) - for item in items: - yield item - if len(items) < limit: - return - page += 1 + yield from items # ── High-level Abstractions ─────────────────────────────────────────────── @@ -4921,7 +5125,9 @@ def run_streaming_agent( path = f"/agents/{agent_id}/runs/stream" merged_headers = _merge_request_headers( - options=self._options, request_headers=headers + options=self._options, + client_headers=self._client.headers, + request_headers=headers, ) _setdefault_header(merged_headers, "accept", "text/event-stream") timeout_seconds = self._options.timeout if timeout is None else timeout @@ -5064,7 +5270,11 @@ def __init__( timeout: Request timeout (seconds). api_key_header: Header name to use for the API key. default_headers: Extra headers to include on every request. - http_client: Optional pre-configured ``httpx.AsyncClient`` to use. + http_client: Optional pre-configured ``httpx.AsyncClient`` to use. A + ``Seclai-Version`` among its default headers is checked as one + in ``default_headers`` is, at construction and on each request. + The typed methods that go through the generated client do not + use this client. profile: SSO profile name from ``~/.seclai/config``. config_dir: Override the config directory path. auto_refresh: Auto-refresh expired SSO tokens. Defaults to ``True``. @@ -5096,6 +5306,10 @@ def __init__( headers=self._default_headers(), ) self._owns_client = http_client is None + if http_client is not None: + _validate_client_version( + self._options, self._default_headers(), http_client.headers + ) async def aclose(self) -> None: """Close underlying HTTP resources owned by this client. @@ -5155,7 +5369,9 @@ async def request( params=params, json=json, headers=await _merge_request_headers_async( - options=self._options, request_headers=headers + options=self._options, + client_headers=self._client.headers, + request_headers=headers, ), ) _raise_for_status(response) @@ -5238,7 +5454,9 @@ async def run_streaming_agent_and_wait( path = f"/agents/{agent_id}/runs/stream" merged_headers = await _merge_request_headers_async( - options=self._options, request_headers=headers + options=self._options, + client_headers=self._client.headers, + request_headers=headers, ) _setdefault_header(merged_headers, "accept", "text/event-stream") @@ -6181,13 +6399,10 @@ async def get_agent_callers(self, agent_id: str) -> list[dict[str, Any]]: agent_id: Agent identifier. Returns: - The calling agents; each must be disabled before this agent can be paused. - + The calling agents, as a list on every API version; each must be + disabled before this agent can be paused. """ - return cast( - list[dict[str, Any]], - await self.request("GET", f"/agents/{agent_id}/callers"), - ) + return unwrap_items(await self.request("GET", f"/agents/{agent_id}/callers")) # ── Agent Export ────────────────────────────────────────────────────────── @@ -6349,7 +6564,9 @@ async def upload_agent_input( f"/agents/{agent_id}/upload-input", files={"file": payload}, headers=await _merge_request_headers_async( - options=self._options, request_headers=None + options=self._options, + client_headers=self._client.headers, + request_headers=None, ), ) _raise_for_status(response) @@ -6429,7 +6646,9 @@ async def download_agent_run_attachment( A streaming ``httpx.Response``. Must be closed by the caller. """ headers = await _merge_request_headers_async( - options=self._options, request_headers=None + options=self._options, + client_headers=self._client.headers, + request_headers=None, ) request = self._client.build_request( "GET", @@ -6606,7 +6825,7 @@ async def list_evaluation_criteria_page( f"/agents/{agent_id}/evaluation-criteria", params=_strip_none({"page": page, "limit": limit}), ) - return _as_page(result) + return _keyed_list(result, "data") async def create_evaluation_criteria( self, agent_id: str, body: dict[str, Any] @@ -6703,15 +6922,19 @@ async def list_evaluation_results( limit: Items per page. Returns: - Paginated evaluation results. + The page of results under ``data`` with ``total``, ``page`` and + ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", f"/agents/evaluation-criteria/{criteria_id}/results", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) async def create_evaluation_result( @@ -6746,15 +6969,19 @@ async def list_compatible_runs( limit: Items per page. Returns: - Paginated list of compatible runs. + The page of compatible runs under ``data`` with ``total``, ``page`` and + ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", f"/agents/evaluation-criteria/{criteria_id}/compatible-runs", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) async def test_draft_evaluation( @@ -6789,15 +7016,19 @@ async def list_agent_evaluation_results( limit: Items per page. Returns: - Paginated evaluation results with criteria. + The page of results with their criteria under ``data`` with ``total``, + ``page`` and ``limit``, on every API version. Once the client opts in + with ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", f"/agents/{agent_id}/evaluation-results", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) async def list_run_evaluation_results( @@ -6844,7 +7075,7 @@ async def list_run_evaluation_results_page( f"/agents/{agent_id}/runs/{run_id}/evaluation-results", params=_strip_none({"page": page, "limit": limit}), ) - return _as_page(result) + return _keyed_list(result, "data") async def list_evaluation_runs( self, agent_id: str, *, page: int = 1, limit: int = 50 @@ -6857,15 +7088,19 @@ async def list_evaluation_runs( limit: Items per page. Returns: - Paginated evaluation run summaries. + The page of run summaries under ``data`` with ``total``, ``page`` and + ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries + ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", f"/agents/{agent_id}/evaluation-runs", params=_strip_none({"page": page, "limit": limit}), ), + "data", + _PAGE_COUNTERS, ) async def get_non_manual_evaluation_summary(self, agent_id: str) -> dict[str, Any]: @@ -6910,11 +7145,11 @@ async def list_agent_email_optouts( offset: Rows to skip. Returns: - The page of opt-outs plus the total count. - + The page of opt-outs under ``items`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", "/agents/agent-email-optouts", @@ -6922,6 +7157,8 @@ async def list_agent_email_optouts( {"agent_id": agent_id, "limit": limit, "offset": offset} ), ), + "items", + ("total",), ) async def remove_agent_email_optout(self, optout_id: str) -> None: @@ -6946,16 +7183,19 @@ async def list_blocked_email_senders( offset: Rows to skip. Returns: - The page of blocked senders plus ``auto_block_mode``. - + The page of blocked senders under ``items`` with ``total`` and + ``auto_block_mode``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", "/agents/blocked-email-senders", params=_strip_none({"limit": limit, "offset": offset}), ), + "items", + ("total",), ) async def block_email_sender(self, body: dict[str, Any]) -> dict[str, Any]: @@ -6990,12 +7230,16 @@ async def set_auto_block_mode(self, body: dict[str, Any]) -> dict[str, Any]: body: ``mode``: ``disabled``, ``input``, or ``input_and_output``. Returns: - The updated blocked-sender list. - + The first 50 blocked senders under ``items`` with ``auto_block_mode``, + on every API version. ``total`` is the account's count by default, and + the number of rows returned once the client opts in with + ``api_version="2026-07-27"`` or later, where the response also carries + ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request("PUT", "/agents/blocked-email-senders/mode", json=body), + "items", + ("total",), ) async def list_inbound_email_rejections( @@ -7011,16 +7255,14 @@ async def list_inbound_email_rejections( limit: Maximum results (1-200, default 50). Returns: - The discarded inbound emails. - + The discarded inbound emails, as a list on every API version. """ - return cast( - list[dict[str, Any]], + return unwrap_items( await self.request( "GET", "/agents/inbound-email-rejections", params=_strip_none({"agent_id": agent_id, "limit": limit}), - ), + ) ) async def get_inbound_email_status(self) -> dict[str, Any]: @@ -7107,10 +7349,12 @@ async def list_knowledge_bases( order: Sort order (``"asc"`` or ``"desc"``). Returns: - Paginated list of knowledge bases. + The page of knowledge bases under ``knowledge_bases`` with ``total``, + ``page`` and ``limit``, on every API version. Once the client opts in + with ``api_version="2026-07-27"`` or later the response also carries + ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", "/knowledge_bases", @@ -7118,6 +7362,8 @@ async def list_knowledge_bases( {"page": page, "limit": limit, "sort": sort, "order": order} ), ), + "knowledge_bases", + _PAGE_COUNTERS, ) async def create_knowledge_base(self, body: dict[str, Any]) -> dict[str, Any]: @@ -7198,10 +7444,12 @@ async def list_memory_banks( order: Sort order (``"asc"`` or ``"desc"``). Returns: - Paginated list of memory banks. + The page of memory banks under ``memory_banks`` with ``total``, ``page`` + and ``limit``, on every API version. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", "/memory_banks", @@ -7209,6 +7457,8 @@ async def list_memory_banks( {"page": page, "limit": limit, "sort": sort, "order": order} ), ), + "memory_banks", + _PAGE_COUNTERS, ) async def create_memory_bank(self, body: dict[str, Any]) -> dict[str, Any]: @@ -7277,9 +7527,14 @@ async def get_agents_using_memory_bank(self, memory_bank_id: str) -> JSONValue: memory_bank_id: Memory bank identifier. Returns: - List of agents using this memory bank. + The agents using this memory bank, as a list on every API version. """ - return await self.request("GET", f"/memory_banks/{memory_bank_id}/agents") + return cast( + JSONValue, + unwrap_items( + await self.request("GET", f"/memory_banks/{memory_bank_id}/agents") + ), + ) async def get_memory_bank_stats(self, memory_bank_id: str) -> JSONValue: """Get statistics for a memory bank. @@ -7353,9 +7608,12 @@ async def list_memory_bank_templates(self) -> JSONValue: """List available memory bank templates. Returns: - Available templates. + The available templates, as a list on every API version. """ - return await self.request("GET", "/memory_banks/templates") + return cast( + JSONValue, + unwrap_items(await self.request("GET", "/memory_banks/templates")), + ) async def generate_memory_bank_config(self, body: dict[str, Any]) -> dict[str, Any]: """Use the AI assistant to generate memory bank configuration. @@ -7666,7 +7924,9 @@ async def download_source_export( A streaming ``httpx.Response``. Must be closed by the caller. """ headers = await _merge_request_headers_async( - options=self._options, request_headers=None + options=self._options, + client_headers=self._client.headers, + request_headers=None, ) request = self._client.build_request( "GET", @@ -8004,14 +8264,13 @@ async def list_solution_conversations( solution_id: Solution identifier. Returns: - List of conversations. + The conversation turns, as a list on every API version. """ - return cast( - list[dict[str, Any]], + return unwrap_items( await self.request( "GET", f"/solutions/{solution_id}/conversations", - ), + ) ) async def add_solution_conversation_turn( @@ -8174,14 +8433,13 @@ async def list_governance_ai_conversations(self) -> list[dict[str, Any]]: """List governance AI assistant conversations. Returns: - List of governance conversations. + The governance conversations, as a list on every API version. """ - return cast( - list[dict[str, Any]], + return unwrap_items( await self.request( "GET", "/governance/ai-assistant/conversations", - ), + ) ) async def accept_governance_ai_plan(self, conversation_id: str) -> dict[str, Any]: @@ -8307,21 +8565,25 @@ async def list_alert_configs(self, *, page: int = 1, limit: int = 50) -> JSONVal """List alert configurations. Args: - page: Page number (1-indexed). - limit: Items per page. + page: Page number (1-indexed). **Ignored** unless the client opts in + with ``api_version="2026-07-27"`` or later — by default the + response is unpaginated and always contains every configuration. + limit: Items per page. Ignored by default, as above. Returns: - The configurations, under ``configs`` alongside ``total`` by default, - and under ``data`` with ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(await client.list_alert_configs(), "configs") + The configurations under ``configs`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. It is then + one page rather than every configuration. """ - return await self.request( - "GET", - "/alerts/configs", - params=_strip_none({"page": page, "limit": limit}), + return _keyed_list( + await self.request( + "GET", + "/alerts/configs", + params=_strip_none({"page": page, "limit": limit}), + ), + "configs", + ("total",), ) async def create_alert_config(self, body: dict[str, Any]) -> JSONValue: @@ -8374,14 +8636,17 @@ async def list_organization_alert_preferences(self) -> dict[str, Any]: """List organization alert preferences. Returns: - Alert preferences for the organization. + The preferences under ``preferences`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", "/alerts/organization-preferences/list", ), + "preferences", + ("total",), ) async def update_organization_alert_preference( @@ -8415,20 +8680,21 @@ async def list_model_alerts(self, *, page: int = 1, limit: int = 50) -> JSONValu limit: Items per page. Returns: - The alerts, under ``alerts`` alongside ``total`` by default, and under - ``data`` with ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(await client.list_model_alerts(), "alerts") + The alerts under ``alerts`` with ``total``, on every API version. Once + the client opts in with ``api_version="2026-07-27"`` or later the + response also carries ``data`` and ``pagination``. """ # `offset` is declared `minimum: 0`, so a defensive caller passing page=0 # would turn a previously-ignored parameter into a hard 422. offset = max(page - 1, 0) * limit - return await self.request( - "GET", - "/models/alerts", - params=_strip_none({"offset": offset, "limit": limit}), + return _keyed_list( + await self.request( + "GET", + "/models/alerts", + params=_strip_none({"offset": offset, "limit": limit}), + ), + "alerts", + ("total",), ) async def mark_all_model_alerts_read(self) -> None: @@ -8479,17 +8745,22 @@ async def list_models( supports_thinking: Filter to models that support extended thinking. Returns: - List of provider groups with their models. + The provider groups with their models, as a list on every API version. """ - return await self.request( - "GET", - "/models", - params=_strip_none( - { - "provider": provider, - "supports_tool_use": supports_tool_use, - "supports_thinking": supports_thinking, - } + return cast( + JSONValue, + unwrap_items( + await self.request( + "GET", + "/models", + params=_strip_none( + { + "provider": provider, + "supports_tool_use": supports_tool_use, + "supports_thinking": supports_thinking, + } + ), + ) ), ) @@ -8509,16 +8780,12 @@ async def get_generation_tiers(self) -> dict[str, Any]: Returns: Each ``(modality, tier)`` mapped to its generator, credits, and price - label — under ``tiers`` by default, and under ``data`` with - ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(await client.get_generation_tiers(), "tiers") + label, under ``tiers`` on every API version. Once the client opts in + with ``api_version="2026-07-27"`` or later the response also carries + ``data`` and ``pagination``. """ - return cast( - dict[str, Any], - await self.request("GET", "/models/generation-tiers"), + return _keyed_list( + await self.request("GET", "/models/generation-tiers"), "tiers" ) async def list_embedding_models( @@ -8531,36 +8798,30 @@ async def list_embedding_models( modality. Returns: - The embedders under ``models`` by default, and under ``data`` with - ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later; the defaults and pricing sit - beside them on either shape. Read the list with - :func:`unwrap_items`:: - - items = unwrap_items(await client.list_embedding_models(), "models") + The embedders under ``models`` on every API version, with the defaults + and pricing beside them. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast( - dict[str, Any], + return _keyed_list( await self.request( "GET", "/models/embedders", params=_strip_none({"supports_input_media": supports_input_media}), ), + "models", ) async def list_reranker_models(self) -> dict[str, Any]: """List the reranker models a knowledge base can use, and their pricing. Returns: - The rerankers under ``models`` by default, and under ``data`` with - ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later; the default and pricing sit - beside them on either shape. Read the list with - :func:`unwrap_items`:: - - items = unwrap_items(await client.list_reranker_models(), "models") + The rerankers under ``models`` on every API version, with the default + and pricing beside them. Once the client opts in with + ``api_version="2026-07-27"`` or later the response also carries ``data`` + and ``pagination``. """ - return cast(dict[str, Any], await self.request("GET", "/models/rerankers")) + return _keyed_list(await self.request("GET", "/models/rerankers"), "models") # ── Model Playground Experiments ────────────────────────────────────────── @@ -8583,25 +8844,26 @@ async def list_experiments( offset: Pagination offset. Returns: - The experiments, under ``experiments`` alongside ``total`` by default, - and under ``data`` with ``pagination`` once the client opts in with - ``api_version="2026-07-27"`` or later. Read either with - :func:`unwrap_items`:: - - items = unwrap_items(await client.list_experiments(), "experiments") + The experiments under ``experiments`` with ``total``, on every API + version. Once the client opts in with ``api_version="2026-07-27"`` or + later the response also carries ``data`` and ``pagination``. """ - return await self.request( - "GET", - "/models/playground/experiments", - params=_strip_none( - { - "days": days, - "start_date": start_date, - "end_date": end_date, - "limit": limit, - "offset": offset, - } + return _keyed_list( + await self.request( + "GET", + "/models/playground/experiments", + params=_strip_none( + { + "days": days, + "start_date": start_date, + "end_date": end_date, + "limit": limit, + "offset": offset, + } + ), ), + "experiments", + ("total",), ) async def create_experiment(self, body: dict[str, Any]) -> JSONValue: @@ -8794,10 +9056,12 @@ async def list_email_domains(self) -> dict[str, Any]: """List the account's agent-email domains and plan capabilities. Returns: - Domains with verification status and required DNS records. - + The domains under ``domains`` with verification status and required DNS + records, and the plan capabilities beside them, on every API version. + Once the client opts in with ``api_version="2026-07-27"`` or later the + response also carries ``data`` and ``pagination``. """ - return cast(dict[str, Any], await self.request("GET", "/email-domains")) + return _keyed_list(await self.request("GET", "/email-domains"), "domains") async def add_email_domain(self, body: dict[str, Any]) -> dict[str, Any]: """Add and provision a vanity or custom agent-email domain. @@ -9096,57 +9360,58 @@ async def paginate( ) -> AsyncGenerator[dict[str, Any]]: """Auto-paginate a list endpoint, yielding items lazily. - Fetches pages sequentially until a page returns fewer items than ``limit`` - or the items list is empty. Use ``async for item in client.paginate(...):`` + Fetches pages sequentially; use ``async for item in client.paginate(...):`` to iterate. + The walk ends after a page that is a bare array, that is short or + empty, that reports ``pagination.has_next`` false, or that reaches the + ``total`` the body reports. A page holding more than ``limit`` items + also ends it — the endpoint ignored ``limit`` and returned everything — + unless the body itself says more exist. + + A page identical to the one before it is never yielded: the endpoint + did not advance for the cursor it was sent. If that page reports more + items (``has_next`` true, or a ``total`` above the number yielded) the + walk raises `SeclaiError` rather than return a short result; if it + reports no paging information the walk ends quietly. Two consecutive + pages that are legitimately identical cannot be told apart from that, + and are treated the same way. + Args: method: HTTP method (typically ``"GET"``). path: API path. params: Extra query parameters (the cursor and ``limit`` are managed automatically). - limit: Items per page. + limit: Items per page, a positive integer. items_key: Per-resource key the items sit under when the response is not the ``data`` envelope, which is always read first. param_style: ``"page"`` (default) sends a 1-indexed ``page``; ``"offset"`` sends a 0-based ``offset``. A few endpoints — - ``/models/alerts`` among them — declare only ``offset`` and - reject or ignore ``page``, which makes every request after the - first return page 1. + ``/models/alerts`` among them — declare only ``offset``. They + ignore ``page`` by default, which the walk reports as an error + on the second page, and reject it with a 422 once the client + opts in with ``api_version="2026-07-27"`` or later. Yields: Individual items from each page. Raises: - ValueError: If ``param_style`` is neither ``"page"`` nor ``"offset"``. + ValueError: If ``param_style`` is neither ``"page"`` nor ``"offset"``, + or ``limit`` is not a positive integer. SeclaiError: If a page is neither a list nor an object carrying - ``data`` or ``items_key``. + ``data`` or ``items_key``, or if the endpoint returns the same + page twice while reporting more items. """ - if param_style not in ("page", "offset"): - raise ValueError( - f"param_style must be 'page' or 'offset', not {param_style!r}" - ) - page = 1 + walk = _PageWalk(limit=limit, param_style=param_style) base_params = dict(params) if params else {} - while True: - if param_style == "offset": - base_params["offset"] = (page - 1) * limit - else: - base_params["page"] = page - base_params["limit"] = limit + while not walk.finished: + base_params.update(walk._next_params()) result = await self.request(method, path, params=base_params) - # A bare array is an unpaginated response: it is everything, so - # asking for a second page would return the same items again. - if isinstance(result, list): - for item in result: - yield cast(dict[str, Any], item) + items = walk._accept(result, items_key) + if items is None: break - items = unwrap_items(result, items_key) for item in items: yield item - if len(items) < limit: - break - page += 1 # ── High-level Abstractions ─────────────────────────────────────────────── @@ -9217,7 +9482,9 @@ async def run_streaming_agent( path = f"/agents/{agent_id}/runs/stream" merged_headers = await _merge_request_headers_async( - options=self._options, request_headers=headers + options=self._options, + client_headers=self._client.headers, + request_headers=headers, ) _setdefault_header(merged_headers, "accept", "text/event-stream") timeout_seconds = self._options.timeout if timeout is None else timeout diff --git a/tests/test_gated_lists.py b/tests/test_gated_lists.py new file mode 100644 index 0000000..1f951bf --- /dev/null +++ b/tests/test_gated_lists.py @@ -0,0 +1,827 @@ +"""Version-gated list endpoints: both wire shapes, through every method. + +The API answers each of these endpoints in its default shape (a bare array, or +an object with a per-resource key) and, once the request resolves to +``Seclai-Version`` 2026-07-27 or later, as ``{data, pagination, ...extras}``. +""" + +from __future__ import annotations + +import pathlib +import re +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +import seclai.auth as auth_mod +from seclai import ( + AsyncSeclai, + Seclai, + SeclaiConfigurationError, + SeclaiError, + unwrap_items, +) + +# Every `METHOD path` the API serves through `versioned_list_response`, +# `versioned_offset_list_response` or `versioned_complete_list_response`. +# Regenerate from the API repository when one is added: +# grep -rn "versioned_.*list_response(" backend/api/src/api/routers/api/ +# and read each call site's route decorator for the method and path. +GATED = [ + "GET /alerts/configs", + "GET /alerts/organization-preferences/list", + "GET /models/generation-tiers", + "GET /models/alerts", + "GET /models/playground/experiments", + "GET /models", + "GET /models/embedders", + "GET /models/rerankers", + "GET /agents/inbound-email-rejections", + "GET /agents/agent-email-optouts", + "GET /agents/blocked-email-senders", + "PUT /agents/blocked-email-senders/mode", + "GET /agents/{agent_id}/callers", + "GET /cloud-drives/providers", + "GET /cloud-drives", + "GET /cloud-drives/{connection_id}/agents", + "GET /cloud-drives/{connection_id}/rejections", + "GET /agents/{agent_id}/evaluation-criteria", + "GET /agents/evaluation-criteria/{criteria_id}/results", + "GET /agents/{agent_id}/runs/{run_id}/evaluation-results", + "GET /agents/{agent_id}/evaluation-runs", + "GET /agents/{agent_id}/evaluation-results", + "GET /agents/evaluation-criteria/{criteria_id}/compatible-runs", + "GET /solutions/{solution_id}/conversations", + "GET /email-domains", + "GET /knowledge_bases", + "GET /governance/ai-assistant/conversations", + "GET /memory_banks/templates", + "GET /memory_banks", + "GET /memory_banks/{memory_bank_id}/agents", +] + +ITEMS = [{"id": "a"}, {"id": "b"}] +# The single page a naturally bounded list reports once opted in. +COMPLETE = { + "page": 1, + "limit": 2, + "total": 2, + "pages": 1, + "has_next": False, + "has_prev": False, +} +# A real page: the SDK's default `limit=50`. +PAGED = {**COMPLETE, "limit": 50} +EMBEDDER_EXTRAS = { + "storage_credits": [{"dimensions": 1024, "credits": 1}], + "file_processing_credits_per_mb": 2, + "default_model_type": "m", + "default_dimension": 1024, +} +RERANKER_EXTRAS = {"default_model_type": "r", "search_processing_credits": 3} +DOMAIN_EXTRAS = { + "can_add_vanity": True, + "can_add_custom": False, + "has_vanity": False, + "has_custom": True, + "vanity_plan_names": ["pro"], + "custom_plan_names": [], +} + +Call = Callable[[Any], Any] + +# (method, gated pair, call, default body, opted-in body, +# return on the default body — what 1.7.0 returned, return when opted in) +ROWS: list[tuple[str, str, Call, Any, Any, Any, Any]] = [ + ( + "list_alert_configs", + "GET /alerts/configs", + lambda c: c.list_alert_configs(), + {"configs": ITEMS, "total": 2}, + {"data": ITEMS, "pagination": PAGED}, + {"configs": [{"id": "a"}, {"id": "b"}], "total": 2}, + {"data": ITEMS, "pagination": PAGED, "configs": ITEMS, "total": 2}, + ), + ( + "list_organization_alert_preferences", + "GET /alerts/organization-preferences/list", + lambda c: c.list_organization_alert_preferences(), + {"preferences": ITEMS, "total": 2}, + {"data": ITEMS, "pagination": COMPLETE}, + {"preferences": [{"id": "a"}, {"id": "b"}], "total": 2}, + {"data": ITEMS, "pagination": COMPLETE, "preferences": ITEMS, "total": 2}, + ), + ( + "get_generation_tiers", + "GET /models/generation-tiers", + lambda c: c.get_generation_tiers(), + {"tiers": ITEMS}, + {"data": ITEMS, "pagination": COMPLETE}, + {"tiers": [{"id": "a"}, {"id": "b"}]}, + {"data": ITEMS, "pagination": COMPLETE, "tiers": ITEMS}, + ), + ( + "list_model_alerts", + "GET /models/alerts", + lambda c: c.list_model_alerts(), + {"alerts": ITEMS, "total": 2}, + {"data": ITEMS, "pagination": PAGED}, + {"alerts": [{"id": "a"}, {"id": "b"}], "total": 2}, + {"data": ITEMS, "pagination": PAGED, "alerts": ITEMS, "total": 2}, + ), + ( + "list_experiments", + "GET /models/playground/experiments", + lambda c: c.list_experiments(), + {"experiments": ITEMS, "total": 2}, + {"data": ITEMS, "pagination": PAGED}, + {"experiments": [{"id": "a"}, {"id": "b"}], "total": 2}, + {"data": ITEMS, "pagination": PAGED, "experiments": ITEMS, "total": 2}, + ), + ( + "list_models", + "GET /models", + lambda c: c.list_models(), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_embedding_models", + "GET /models/embedders", + lambda c: c.list_embedding_models(), + {"models": ITEMS, **EMBEDDER_EXTRAS}, + {"data": ITEMS, "pagination": COMPLETE, **EMBEDDER_EXTRAS}, + { + "models": [{"id": "a"}, {"id": "b"}], + "storage_credits": [{"dimensions": 1024, "credits": 1}], + "file_processing_credits_per_mb": 2, + "default_model_type": "m", + "default_dimension": 1024, + }, + {"data": ITEMS, "pagination": COMPLETE, "models": ITEMS, **EMBEDDER_EXTRAS}, + ), + ( + "list_reranker_models", + "GET /models/rerankers", + lambda c: c.list_reranker_models(), + {"models": ITEMS, **RERANKER_EXTRAS}, + {"data": ITEMS, "pagination": COMPLETE, **RERANKER_EXTRAS}, + { + "models": [{"id": "a"}, {"id": "b"}], + "default_model_type": "r", + "search_processing_credits": 3, + }, + {"data": ITEMS, "pagination": COMPLETE, "models": ITEMS, **RERANKER_EXTRAS}, + ), + ( + "list_inbound_email_rejections", + "GET /agents/inbound-email-rejections", + lambda c: c.list_inbound_email_rejections(), + ITEMS, + {"data": ITEMS, "pagination": PAGED}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_agent_email_optouts", + "GET /agents/agent-email-optouts", + lambda c: c.list_agent_email_optouts(), + {"items": ITEMS, "total": 2}, + {"data": ITEMS, "pagination": PAGED}, + {"items": [{"id": "a"}, {"id": "b"}], "total": 2}, + {"data": ITEMS, "pagination": PAGED, "items": ITEMS, "total": 2}, + ), + ( + "list_blocked_email_senders", + "GET /agents/blocked-email-senders", + lambda c: c.list_blocked_email_senders(), + {"items": ITEMS, "total": 2, "auto_block_mode": "input"}, + {"data": ITEMS, "pagination": PAGED, "auto_block_mode": "input"}, + {"items": [{"id": "a"}, {"id": "b"}], "total": 2, "auto_block_mode": "input"}, + { + "data": ITEMS, + "pagination": PAGED, + "auto_block_mode": "input", + "items": ITEMS, + "total": 2, + }, + ), + ( + "set_auto_block_mode", + "PUT /agents/blocked-email-senders/mode", + lambda c: c.set_auto_block_mode({"mode": "input"}), + # By default `total` is the account's count, here larger than the rows. + {"items": ITEMS, "total": 70, "auto_block_mode": "input"}, + {"data": ITEMS, "pagination": COMPLETE, "auto_block_mode": "input"}, + {"items": [{"id": "a"}, {"id": "b"}], "total": 70, "auto_block_mode": "input"}, + { + "data": ITEMS, + "pagination": COMPLETE, + "auto_block_mode": "input", + "items": ITEMS, + "total": 2, + }, + ), + ( + "get_agent_callers", + "GET /agents/{agent_id}/callers", + lambda c: c.get_agent_callers("a1"), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_cloud_drive_providers", + "GET /cloud-drives/providers", + lambda c: c.list_cloud_drive_providers(), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_cloud_drives", + "GET /cloud-drives", + lambda c: c.list_cloud_drives(), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "get_agents_using_cloud_drive", + "GET /cloud-drives/{connection_id}/agents", + lambda c: c.get_agents_using_cloud_drive("c1"), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_cloud_drive_rejections", + "GET /cloud-drives/{connection_id}/rejections", + lambda c: c.list_cloud_drive_rejections("c1"), + ITEMS, + {"data": ITEMS, "pagination": PAGED}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_evaluation_criteria", + "GET /agents/{agent_id}/evaluation-criteria", + lambda c: c.list_evaluation_criteria("a1"), + ITEMS, + {"data": ITEMS, "pagination": PAGED}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_evaluation_criteria_page", + "GET /agents/{agent_id}/evaluation-criteria", + lambda c: c.list_evaluation_criteria_page("a1"), + ITEMS, + {"data": ITEMS, "pagination": PAGED}, + {"data": [{"id": "a"}, {"id": "b"}]}, + {"data": ITEMS, "pagination": PAGED}, + ), + ( + "list_evaluation_results", + "GET /agents/evaluation-criteria/{criteria_id}/results", + lambda c: c.list_evaluation_results("k1"), + {"data": ITEMS, "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED}, + {"data": [{"id": "a"}, {"id": "b"}], "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED, "total": 2, "page": 1, "limit": 50}, + ), + ( + "list_run_evaluation_results", + "GET /agents/{agent_id}/runs/{run_id}/evaluation-results", + lambda c: c.list_run_evaluation_results("a1", "r1"), + ITEMS, + {"data": ITEMS, "pagination": PAGED}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_run_evaluation_results_page", + "GET /agents/{agent_id}/runs/{run_id}/evaluation-results", + lambda c: c.list_run_evaluation_results_page("a1", "r1"), + ITEMS, + {"data": ITEMS, "pagination": PAGED}, + {"data": [{"id": "a"}, {"id": "b"}]}, + {"data": ITEMS, "pagination": PAGED}, + ), + ( + "list_evaluation_runs", + "GET /agents/{agent_id}/evaluation-runs", + lambda c: c.list_evaluation_runs("a1"), + {"data": ITEMS, "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED}, + {"data": [{"id": "a"}, {"id": "b"}], "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED, "total": 2, "page": 1, "limit": 50}, + ), + ( + "list_agent_evaluation_results", + "GET /agents/{agent_id}/evaluation-results", + lambda c: c.list_agent_evaluation_results("a1"), + {"data": ITEMS, "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED}, + {"data": [{"id": "a"}, {"id": "b"}], "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED, "total": 2, "page": 1, "limit": 50}, + ), + ( + "list_compatible_runs", + "GET /agents/evaluation-criteria/{criteria_id}/compatible-runs", + lambda c: c.list_compatible_runs("k1"), + {"data": ITEMS, "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED}, + {"data": [{"id": "a"}, {"id": "b"}], "total": 2, "page": 1, "limit": 50}, + {"data": ITEMS, "pagination": PAGED, "total": 2, "page": 1, "limit": 50}, + ), + ( + "list_solution_conversations", + "GET /solutions/{solution_id}/conversations", + lambda c: c.list_solution_conversations("s1"), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_email_domains", + "GET /email-domains", + lambda c: c.list_email_domains(), + {"domains": ITEMS, **DOMAIN_EXTRAS}, + {"data": ITEMS, "pagination": COMPLETE, **DOMAIN_EXTRAS}, + { + "domains": [{"id": "a"}, {"id": "b"}], + "can_add_vanity": True, + "can_add_custom": False, + "has_vanity": False, + "has_custom": True, + "vanity_plan_names": ["pro"], + "custom_plan_names": [], + }, + {"data": ITEMS, "pagination": COMPLETE, "domains": ITEMS, **DOMAIN_EXTRAS}, + ), + ( + "list_knowledge_bases", + "GET /knowledge_bases", + lambda c: c.list_knowledge_bases(), + {"knowledge_bases": ITEMS, "page": 1, "limit": 50, "total": 2}, + {"data": ITEMS, "pagination": PAGED}, + { + "knowledge_bases": [{"id": "a"}, {"id": "b"}], + "page": 1, + "limit": 50, + "total": 2, + }, + { + "data": ITEMS, + "pagination": PAGED, + "knowledge_bases": ITEMS, + "total": 2, + "page": 1, + "limit": 50, + }, + ), + ( + "list_governance_ai_conversations", + "GET /governance/ai-assistant/conversations", + lambda c: c.list_governance_ai_conversations(), + ITEMS, + {"data": ITEMS, "pagination": PAGED}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_memory_bank_templates", + "GET /memory_banks/templates", + lambda c: c.list_memory_bank_templates(), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), + ( + "list_memory_banks", + "GET /memory_banks", + lambda c: c.list_memory_banks(), + {"memory_banks": ITEMS, "page": 1, "limit": 50, "total": 2}, + {"data": ITEMS, "pagination": PAGED}, + { + "memory_banks": [{"id": "a"}, {"id": "b"}], + "page": 1, + "limit": 50, + "total": 2, + }, + { + "data": ITEMS, + "pagination": PAGED, + "memory_banks": ITEMS, + "total": 2, + "page": 1, + "limit": 50, + }, + ), + ( + "get_agents_using_memory_bank", + "GET /memory_banks/{memory_bank_id}/agents", + lambda c: c.get_agents_using_memory_bank("m1"), + ITEMS, + {"data": ITEMS, "pagination": COMPLETE}, + [{"id": "a"}, {"id": "b"}], + ITEMS, + ), +] +ROW_IDS = [row[0] for row in ROWS] + + +def _serving(pair: str, body: Any) -> Callable[[httpx.Request], httpx.Response]: + """A handler that answers ``body``, and fails on any other ``METHOD path``.""" + method, template = pair.split(" ") + pattern = re.compile(re.sub(r"\{[^}]+\}", "[^/]+", template) + r"\Z") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == method, request.method + assert pattern.match(request.url.path), request.url.path + return httpx.Response(200, json=body) + + return handler + + +def _sync(handler: Callable[[httpx.Request], httpx.Response], **options: Any) -> Seclai: + http = httpx.Client( + base_url="https://example.invalid", transport=httpx.MockTransport(handler) + ) + options.setdefault("api_key", "test") + client = Seclai(http_client=http, **options) + client._owns_client = True + return client + + +def _async( + handler: Callable[[httpx.Request], httpx.Response], **options: Any +) -> AsyncSeclai: + http = httpx.AsyncClient( + base_url="https://example.invalid", transport=httpx.MockTransport(handler) + ) + options.setdefault("api_key", "test") + client = AsyncSeclai(http_client=http, **options) + client._owns_client = True + return client + + +def test_every_gated_endpoint_has_a_row() -> None: + assert len(GATED) == len(set(GATED)) == 30 + assert {row[1] for row in ROWS} == set(GATED) + + +def test_every_row_names_a_method_on_both_clients() -> None: + for name in ROW_IDS: + assert callable(getattr(Seclai, name)), name + assert callable(getattr(AsyncSeclai, name)), name + + +@pytest.mark.parametrize("row", ROWS, ids=ROW_IDS) +class TestBothShapes: + def test_default_shape_returns_what_1_7_0_returned(self, row: Any) -> None: + _name, pair, call, default_body, _opted, expected, _ = row + with _sync(_serving(pair, default_body)) as client: + assert call(client) == expected + + def test_opted_in_shape(self, row: Any) -> None: + _name, pair, call, _default, opted_body, _, expected = row + with _sync(_serving(pair, opted_body), api_version="2026-07-27") as client: + assert call(client) == expected + + async def test_async_default_shape_returns_what_1_7_0_returned( + self, row: Any + ) -> None: + _name, pair, call, default_body, _opted, expected, _ = row + async with _async(_serving(pair, default_body)) as client: + assert await call(client) == expected + + async def test_async_opted_in_shape(self, row: Any) -> None: + _name, pair, call, _default, opted_body, _, expected = row + async with _async( + _serving(pair, opted_body), api_version="2026-07-27" + ) as client: + assert await call(client) == expected + + def test_items_are_read_the_same_way_on_both_shapes(self, row: Any) -> None: + _name, pair, call, default_body, opted_body, _, _ = row + results = [] + for body in (default_body, opted_body): + with _sync(_serving(pair, body)) as client: + results.append(call(client)) + default_result, opted_result = results + if isinstance(default_result, list): + assert opted_result == default_result == ITEMS + return + keys = [k for k, v in default_result.items() if v == ITEMS] + assert len(keys) == 1, keys + assert opted_result[keys[0]] == ITEMS + # Whatever the default shape carries, the opted-in result carries too. + assert set(default_result) <= set(opted_result) + + +def _response(make: Callable[[], httpx.Response]) -> Any: + return lambda request: make() + + +NOT_A_LIST: list[tuple[str, Callable[[], httpx.Response]]] = [ + ("error object", lambda: httpx.Response(200, json={"error": {"code": "x"}})), + ("detail object", lambda: httpx.Response(200, json={"detail": "boom"})), + ("empty object", lambda: httpx.Response(200, json={})), + ("text", lambda: httpx.Response(200, text="upstream says hi")), + ("html", lambda: httpx.Response(200, html="gateway")), + ( + "json null", + lambda: httpx.Response( + 200, content=b"null", headers={"content-type": "application/json"} + ), + ), + ("empty body", lambda: httpx.Response(200)), + ("json string", lambda: httpx.Response(200, json="nope")), + ("json number", lambda: httpx.Response(200, json=3)), + ("data is a string", lambda: httpx.Response(200, json={"data": "nope"})), + ("data is an object", lambda: httpx.Response(200, json={"data": {"id": "a"}})), +] + + +@pytest.mark.parametrize("row", ROWS, ids=ROW_IDS) +class TestNotAList: + @pytest.mark.parametrize("make", [m for _, m in NOT_A_LIST], ids=[n for n, _ in NOT_A_LIST]) # fmt: skip + def test_raises(self, row: Any, make: Callable[[], httpx.Response]) -> None: + call = row[2] + with _sync(_response(make)) as client, pytest.raises(SeclaiError): + call(client) + + @pytest.mark.parametrize("make", [m for _, m in NOT_A_LIST], ids=[n for n, _ in NOT_A_LIST]) # fmt: skip + async def test_async_raises( + self, row: Any, make: Callable[[], httpx.Response] + ) -> None: + call = row[2] + async with _async(_response(make)) as client: + with pytest.raises(SeclaiError): + await call(client) + + def test_documented_key_holding_a_non_list_raises(self, row: Any) -> None: + _name, pair, call, default_body, _, _, _ = row + if not isinstance(default_body, dict): + pytest.skip("the default shape is a bare array") + key = next(k for k, v in default_body.items() if v == ITEMS) + body = {**default_body, key: "nope"} + with _sync(_serving(pair, body)) as client, pytest.raises(SeclaiError): + call(client) + + def test_null_data_is_an_empty_list(self, row: Any) -> None: + _name, pair, call, default_body, _, default_result, _ = row + with _sync(_serving(pair, {"data": None})) as client: + result = call(client) + if isinstance(default_result, list): + assert result == [] + else: + key = next(k for k, v in default_result.items() if v == ITEMS) + assert result[key] == [] + + +class TestKeyedPrecedence: + """Which list a dict-returning method reads when a body carries several.""" + + def _optouts(self, body: Any) -> dict[str, Any]: + with _sync(lambda request: httpx.Response(200, json=body)) as client: + return client.list_agent_email_optouts() + + def test_data_wins_over_the_documented_key(self) -> None: + result = self._optouts({"data": [{"id": "d"}], "items": [{"id": "i"}]}) + assert result["items"] == [{"id": "d"}] + + def test_documented_key_is_read_when_data_is_not_a_list(self) -> None: + for data in (None, "nope"): + result = self._optouts({"data": data, "items": [{"id": "i"}]}) + assert result["items"] == [{"id": "i"}] + + def test_a_flat_counter_the_body_carries_is_kept(self) -> None: + result = self._optouts({"data": [], "total": 9, "pagination": {"total": 1}}) + assert result["total"] == 9 + + def test_a_missing_flat_counter_comes_from_pagination(self) -> None: + result = self._optouts({"data": [], "pagination": {"total": 4, "page": 2}}) + assert result["total"] == 4 + # `page` is not a counter this method documents on its default shape. + assert "page" not in result + + def test_unwrap_items_still_reads_every_result(self) -> None: + for _name, pair, call, default_body, opted_body, _, _ in ROWS: + for body in (default_body, opted_body): + with _sync(_serving(pair, body)) as client: + result = call(client) + keys = [] if isinstance(default_body, list) else list(default_body) + assert unwrap_items(result, *keys[:1]) == ITEMS + + +AUTH_NAMES = ("authorization", "x-account-id") +SPELLED = {"Authorization": "Bearer FROM-DEFAULTS", "X-Account-Id": "FROM-DEFAULTS"} + + +def _credentials(request: httpx.Request) -> list[tuple[str, str]]: + return sorted( + (name.lower(), value) + for name, value in request.headers.multi_items() + if name.lower() in AUTH_NAMES + ) + + +@pytest.fixture +def dynamic_modes( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> dict[str, dict[str, Any]]: + monkeypatch.delenv("SECLAI_API_KEY", raising=False) + monkeypatch.setenv("SECLAI_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(auth_mod, "_resolve_sso_token_sync", lambda state: "TOKEN") + + async def resolve(state: Any) -> str: + return "TOKEN" + + monkeypatch.setattr(auth_mod, "_resolve_sso_token_async", resolve) + return { + "bearer_provider": {"access_token": lambda: "TOKEN", "account_id": "ACCT"}, + "sso": {"account_id": "ACCT"}, + } + + +@pytest.mark.parametrize("mode", ["bearer_provider", "sso"]) +class TestGeneratedClientCredentials: + """One credential per name on the typed-method path, from the first call.""" + + EXPECTED = [("authorization", "Bearer TOKEN"), ("x-account-id", "ACCT")] + + def test_sync(self, mode: str, dynamic_modes: dict[str, dict[str, Any]]) -> None: + client = Seclai(default_headers=SPELLED, **dynamic_modes[mode]) + assert client._options.auth_state.mode == mode + for _call in ("first", "second"): + http = client._sync_generated_client().get_httpx_client() + assert _credentials(http.build_request("GET", "/x")) == self.EXPECTED + client.close() + + async def test_async( + self, mode: str, dynamic_modes: dict[str, dict[str, Any]] + ) -> None: + client = AsyncSeclai(default_headers=SPELLED, **dynamic_modes[mode]) + assert client._options.auth_state.mode == mode + for _call in ("first", "second"): + generated = await client._async_generated_client() + http = generated.get_async_httpx_client() + assert _credentials(http.build_request("GET", "/x")) == self.EXPECTED + await client.aclose() + + def test_typed_method_sends_one_of_each( + self, mode: str, dynamic_modes: dict[str, dict[str, Any]] + ) -> None: + seen: list[list[tuple[str, str]]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(_credentials(request)) + return httpx.Response(200, json={"data": [], "pagination": PAGED}) + + client = Seclai(default_headers=SPELLED, **dynamic_modes[mode]) + generated = client._sync_generated_client() + # Swap only the transport: the headers are the ones the SDK built. + generated.get_httpx_client()._transport = httpx.MockTransport(handler) + client.list_sources() + client.list_sources() + assert seen == [self.EXPECTED, self.EXPECTED] + client.close() + + +UNKNOWN = "2099-01-01" +STREAM_BODY: Any = {"input": "x", "metadata": {}} + + +def _ok(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ok": True}) + + +class TestSuppliedHttpClientVersionGuard: + def _http(self, **headers: str) -> httpx.Client: + return httpx.Client( + base_url="https://example.invalid", + transport=httpx.MockTransport(_ok), + headers=headers, + ) + + def _async_http(self, **headers: str) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url="https://example.invalid", + transport=httpx.MockTransport(_ok), + headers=headers, + ) + + def test_unknown_version_is_rejected_at_construction(self) -> None: + http = self._http(**{"Seclai-Version": UNKNOWN}) + with pytest.raises(SeclaiConfigurationError, match="http_client"): + Seclai(api_key="k", http_client=http) + with pytest.raises(SeclaiConfigurationError, match="http_client"): + AsyncSeclai( + api_key="k", http_client=self._async_http(**{"Seclai-Version": UNKNOWN}) + ) + assert http.headers["seclai-version"] == UNKNOWN + + @pytest.mark.parametrize("value", ["", " ", UNKNOWN, "2026-07-27"]) + @pytest.mark.parametrize("allow", [False, True]) + def test_a_value_is_treated_as_default_headers_treats_it( + self, value: str, allow: bool + ) -> None: + def outcome(**options: Any) -> str: + try: + Seclai(api_key="k", allow_unknown_api_version=allow, **options) + except SeclaiConfigurationError: + return "rejected" + return "accepted" + + via_default_headers = outcome(default_headers={"Seclai-Version": value}) + via_http_client = outcome(http_client=self._http(**{"Seclai-Version": value})) + assert via_http_client == via_default_headers + known_or_allowed = allow or value == "2026-07-27" + assert via_http_client == ("accepted" if known_or_allowed else "rejected") + + def test_known_version_is_accepted_and_sent(self) -> None: + seen: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("seclai-version")) + return httpx.Response(200, json={"ok": True}) + + http = httpx.Client( + base_url="https://example.invalid", + transport=httpx.MockTransport(handler), + headers={"Seclai-Version": "2026-07-27"}, + ) + Seclai(api_key="k", http_client=http).request("GET", "/x") + assert seen == ["2026-07-27"] + + def test_allow_unknown_permits_it(self) -> None: + http = self._http(**{"Seclai-Version": UNKNOWN}) + client = Seclai(api_key="k", http_client=http, allow_unknown_api_version=True) + assert client.request("GET", "/x") == {"ok": True} + + def test_a_version_the_sdk_sends_instead_is_the_one_checked(self) -> None: + seen: list[list[str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get_list("seclai-version")) + return httpx.Response(200, json={"ok": True}) + + http = httpx.Client( + base_url="https://example.invalid", + transport=httpx.MockTransport(handler), + headers={"Seclai-Version": UNKNOWN}, + ) + client = Seclai(api_key="k", http_client=http, api_version="2026-07-27") + client.request("GET", "/x") + client.request("GET", "/x", headers={"seclai-version": "2026-08-03"}) + assert seen == [["2026-07-27"], ["2026-08-03"]] + assert http.headers["seclai-version"] == UNKNOWN + + def test_header_added_after_construction_is_rejected_per_request(self) -> None: + http = self._http() + client = Seclai(api_key="k", http_client=http) + assert client.request("GET", "/x") == {"ok": True} + http.headers["Seclai-Version"] = UNKNOWN + with pytest.raises(SeclaiConfigurationError, match="http_client"): + client.request("GET", "/x") + with pytest.raises(SeclaiConfigurationError, match="http_client"): + client.list_alert_configs() + with pytest.raises(SeclaiConfigurationError, match="http_client"): + client.run_streaming_agent_and_wait("a", STREAM_BODY) + with pytest.raises(SeclaiConfigurationError, match="http_client"): + list(client.run_streaming_agent("a", STREAM_BODY)) + assert http.headers["seclai-version"] == UNKNOWN + + async def test_async_header_added_after_construction_is_rejected(self) -> None: + http = self._async_http() + client = AsyncSeclai(api_key="k", http_client=http) + assert await client.request("GET", "/x") == {"ok": True} + http.headers["Seclai-Version"] = UNKNOWN + with pytest.raises(SeclaiConfigurationError, match="http_client"): + await client.request("GET", "/x") + with pytest.raises(SeclaiConfigurationError, match="http_client"): + await client.run_streaming_agent_and_wait("a", STREAM_BODY) + with pytest.raises(SeclaiConfigurationError, match="http_client"): + async for _event in client.run_streaming_agent("a", STREAM_BODY): + pass + await http.aclose() + + def test_a_client_without_the_header_is_untouched(self) -> None: + http = self._http(**{"X-Other": "1"}) + client = Seclai(api_key="k", http_client=http) + assert client.request("GET", "/x") == {"ok": True} + assert dict(http.headers)["x-other"] == "1" + assert "seclai-version" not in http.headers diff --git a/tests/test_new_methods.py b/tests/test_new_methods.py index 53e0461..da39c5a 100644 --- a/tests/test_new_methods.py +++ b/tests/test_new_methods.py @@ -518,7 +518,7 @@ def test_list_evaluation_results(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response({"data": [], "total": 0, "page": 1, "limit": 50}) client = _sync_client(handler) client.list_evaluation_results("ec1") @@ -544,7 +544,7 @@ def test_list_compatible_runs(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response({"data": [], "total": 0, "page": 1, "limit": 50}) client = _sync_client(handler) client.list_compatible_runs("ec1") @@ -570,7 +570,7 @@ def test_list_agent_evaluation_results(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response({"data": [], "total": 0, "page": 1, "limit": 50}) client = _sync_client(handler) client.list_agent_evaluation_results("a1") @@ -596,7 +596,7 @@ def test_list_evaluation_runs(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response({"data": [], "total": 0, "page": 1, "limit": 50}) client = _sync_client(handler) client.list_evaluation_runs("a1") @@ -628,7 +628,9 @@ def test_list_knowledge_bases(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["method"] = req.method seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response( + {"knowledge_bases": [], "total": 0, "page": 1, "limit": 50} + ) client = _sync_client(handler) client.list_knowledge_bases() @@ -693,7 +695,9 @@ def test_list_memory_banks(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response( + {"memory_banks": [], "total": 0, "page": 1, "limit": 50} + ) client = _sync_client(handler) client.list_memory_banks() @@ -1374,7 +1378,7 @@ def test_list_alert_configs(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response({"configs": [], "total": 0}) client = _sync_client(handler) client.list_alert_configs() @@ -1474,7 +1478,7 @@ def test_list_model_alerts(self) -> None: def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response({"alerts": [], "total": 0}) client = _sync_client(handler) client.list_model_alerts() @@ -1681,7 +1685,9 @@ async def test_async_list_knowledge_bases(self) -> None: async def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return _json_response({"items": []}) + return _json_response( + {"knowledge_bases": [], "total": 0, "page": 1, "limit": 50} + ) client = _async_client(handler) await client.list_knowledge_bases() @@ -3159,13 +3165,15 @@ async def test_async_get_generation_tiers(self) -> None: async def handler(req: httpx.Request) -> httpx.Response: seen["method"] = req.method seen["path"] = req.url.path - return _json_response({"image": {"fast": {"model": "m1"}}}, status=200) + return _json_response( + {"tiers": [{"modality": "image", "tier": "fast"}]}, status=200 + ) client = _async_client(handler) result = await client.get_generation_tiers() assert seen["method"] == "GET" assert seen["path"] == "/models/generation-tiers" - assert result == {"image": {"fast": {"model": "m1"}}} + assert result == {"tiers": [{"modality": "image", "tier": "fast"}]} @pytest.mark.asyncio async def test_async_search_docs(self) -> None: diff --git a/tests/test_paginate.py b/tests/test_paginate.py new file mode 100644 index 0000000..69ca35c --- /dev/null +++ b/tests/test_paginate.py @@ -0,0 +1,536 @@ +"""`paginate()` stopping rule: what is yielded, how many requests, what raises.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from seclai import AsyncSeclai, Seclai, SeclaiAPIStatusError, SeclaiError + +Query = dict[str, str] +Answer = Callable[[Query], Any] + + +def _rows(n: int) -> list[dict[str, Any]]: + return [{"id": f"x{i}"} for i in range(n)] + + +class _Server: + """A handler recording how many requests a walk made.""" + + def __init__(self, answer: Answer) -> None: + self.requests = 0 + self._answer = answer + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests += 1 + body = self._answer(dict(request.url.params)) + if isinstance(body, httpx.Response): + return body + return httpx.Response(200, json=body) + + +def _sync(server: _Server) -> Seclai: + http = httpx.Client( + base_url="https://example.invalid", transport=httpx.MockTransport(server) + ) + client = Seclai(api_key="test", http_client=http) + client._owns_client = True + return client + + +def _async(server: _Server) -> AsyncSeclai: + http = httpx.AsyncClient( + base_url="https://example.invalid", transport=httpx.MockTransport(server) + ) + client = AsyncSeclai(api_key="test", http_client=http) + client._owns_client = True + return client + + +def _everything(body: Any) -> Answer: + """An endpoint that ignores the cursor and `limit`.""" + return lambda query: body + + +def _first_page_only(rows: list[dict[str, Any]], key: str, **extra: Any) -> Answer: + """An endpoint that honours `limit` but not the cursor it was sent.""" + return lambda query: {key: rows[: int(query["limit"])], **extra} + + +def _by_page( + rows: list[dict[str, Any]], key: str, *, total: bool, has_next: bool = False +) -> Answer: + def answer(query: Query) -> Any: + limit = int(query["limit"]) + start = (int(query["page"]) - 1) * limit + body: dict[str, Any] = {key: rows[start : start + limit]} + if total: + body["total"] = len(rows) + if has_next: + body["pagination"] = {"has_next": start + limit < len(rows)} + return body + + return answer + + +def _by_offset(rows: list[dict[str, Any]], key: str) -> Answer: + def answer(query: Query) -> Any: + start, limit = int(query.get("offset", 0)), int(query["limit"]) + return {key: rows[start : start + limit], "total": len(rows)} + + return answer + + +def _envelope(rows: list[dict[str, Any]]) -> Answer: + def answer(query: Query) -> Any: + limit, page = int(query["limit"]), int(query["page"]) + start = (page - 1) * limit + return { + "data": rows[start : start + limit], + "pagination": { + "page": page, + "limit": limit, + "total": len(rows), + "pages": -(-len(rows) // limit), + "has_next": start + limit < len(rows), + "has_prev": page > 1, + }, + } + + return answer + + +def _fails_on_page_two(query: Query) -> Any: + if query["page"] != "1": + return httpx.Response(500, json={"detail": "boom"}) + return {"data": _rows(50), "total": 120} + + +def _own_page_size( + rows: list[dict[str, Any]], size: int, *, signal: str | None +) -> Answer: + """An endpoint that pages honestly but by its own page size, ignoring `limit`.""" + + def answer(query: Query) -> Any: + if "offset" in query: + start = int(query["offset"]) + else: + start = (int(query["page"]) - 1) * size + body: dict[str, Any] = {"data": rows[start : start + size]} + if signal == "total": + body["total"] = len(rows) + if signal == "has_next": + body["pagination"] = {"has_next": start + size < len(rows)} + return body + + return answer + + +SAME = [{"kind": "x"}] * 150 +CONFIGS = {"items_key": "configs"} +ALERTS = {"items_key": "alerts"} + +# (id, path, paginate kwargs, answer, items yielded, requests, exception, mutate) +Case = tuple[str, str, dict[str, Any], Answer, list[Any], int, Any, bool] +CASES: list[Case] = [ + # The README example on the default API version, where the endpoint ignores + # `page`/`limit` and returns every config with `total`. + *[ + ( + f"alert configs, default version, {n} configs", + "/alerts/configs", + CONFIGS, + _everything({"configs": _rows(n), "total": n}), + _rows(n), + 1, + None, + False, + ) + for n in (49, 50, 51, 120) + ], + # A page longer than `limit` ends the walk only when the body does not say + # that more exist: an endpoint with its own page size is walked to the end. + *[ + ( + f"own page size 20, limit 10, 65 rows, {signal} reported", + "/things", + {"limit": 10}, + _own_page_size(_rows(65), 20, signal=signal), + _rows(65), + 4, + None, + False, + ) + for signal in ("total", "has_next") + ], + ( + "own page size 20 by offset, limit 10, 65 rows, total reported", + "/things", + {"limit": 10, "param_style": "offset"}, + _own_page_size(_rows(65), 20, signal="total"), + _rows(65), + 4, + None, + False, + ), + ( + "longer than limit with no paging information is the whole collection", + "/things", + {"limit": 10}, + _own_page_size(_rows(65), 20, signal=None), + _rows(20), + 1, + None, + False, + ), + # Endpoints that return everything on the default version (pattern D). + ( + "organization preferences, exactly a page, with total", + "/alerts/organization-preferences/list", + {"items_key": "preferences"}, + _everything({"preferences": _rows(50), "total": 50}), + _rows(50), + 1, + None, + False, + ), + *[ + ( + f"generation tiers, {n} rows, no counters", + "/models/generation-tiers", + {"items_key": "tiers"}, + _everything({"tiers": _rows(n)}), + _rows(n), + requests, + None, + False, + ) + # Exactly `limit` rows is the one count that needs a second request. + for n, requests in ((49, 1), (50, 2), (60, 1)) + ], + ( + "embedders, exactly a page, no counters", + "/models/embedders", + {"items_key": "models"}, + _everything({"models": _rows(50), "default_model_type": "m"}), + _rows(50), + 2, + None, + False, + ), + ( + "rerankers, exactly a page, no counters", + "/models/rerankers", + {"items_key": "models", "limit": 5}, + _everything({"models": _rows(5), "default_model_type": "r"}), + _rows(5), + 2, + None, + False, + ), + ( + "email domains, exactly a page, no counters", + "/email-domains", + {"items_key": "domains", "limit": 2}, + _everything({"domains": _rows(2), "can_add_vanity": True}), + _rows(2), + 2, + None, + False, + ), + ( + "email domains, more than a page, no counters", + "/email-domains", + {"items_key": "domains", "limit": 1}, + _everything({"domains": _rows(2), "can_add_vanity": True}), + _rows(2), + 1, + None, + False, + ), + # An offset-only endpoint walked with `page`: the body says 120 exist. + ( + "offset-only endpoint walked by page raises", + "/models/alerts", + ALERTS, + _first_page_only(_rows(120), "alerts", total=120), + _rows(50), + 2, + SeclaiError, + False, + ), + ( + "offset-only endpoint walked by offset", + "/models/alerts", + {**ALERTS, "param_style": "offset"}, + _by_offset(_rows(120), "alerts"), + _rows(120), + 3, + None, + False, + ), + ( + "offset walk, total reached on a full page", + "/models/alerts", + {**ALERTS, "param_style": "offset"}, + _by_offset(_rows(100), "alerts"), + _rows(100), + 2, + None, + False, + ), + # A caller that mutates what it is handed must not change the outcome. + ( + "mutating caller, repeated page with total raises", + "/models/alerts", + ALERTS, + _first_page_only(_rows(120), "alerts", total=120), + _rows(50), + 2, + SeclaiError, + True, + ), + ( + "mutating caller, repeated page without total ends", + "/models/alerts", + ALERTS, + _first_page_only(_rows(120), "alerts"), + _rows(50), + 2, + None, + True, + ), + ( + "mutating caller, honest paging", + "/knowledge_bases", + {"items_key": "knowledge_bases"}, + _by_page(_rows(120), "knowledge_bases", total=True), + _rows(120), + 3, + None, + True, + ), + # Honest paging whose last page is full. + ( + "full last page, no counters, ends on the empty page", + "/knowledge_bases", + {"items_key": "knowledge_bases"}, + _by_page(_rows(100), "knowledge_bases", total=False), + _rows(100), + 3, + None, + False, + ), + ( + "full last page, flat total", + "/knowledge_bases", + {"items_key": "knowledge_bases"}, + _by_page(_rows(100), "knowledge_bases", total=True), + _rows(100), + 2, + None, + False, + ), + ( + "full last page, has_next only", + "/knowledge_bases", + {"items_key": "knowledge_bases"}, + _by_page(_rows(100), "knowledge_bases", total=False, has_next=True), + _rows(100), + 2, + None, + False, + ), + ( + "full last page, envelope", + "/alerts/configs", + CONFIGS, + _envelope(_rows(100)), + _rows(100), + 2, + None, + False, + ), + ( + "short last page, envelope", + "/alerts/configs", + CONFIGS, + _envelope(_rows(120)), + _rows(120), + 3, + None, + False, + ), + # 150 rows with no distinguishing field, honestly paged: page 2 equals page + # 1, which is indistinguishable from an endpoint ignoring the cursor. + ( + "identical rows, flat total: raises instead of truncating", + "/knowledge_bases", + {"items_key": "knowledge_bases"}, + _by_page(SAME, "knowledge_bases", total=True), + SAME[:50], + 2, + SeclaiError, + False, + ), + ( + "identical rows, envelope: raises instead of truncating", + "/alerts/configs", + CONFIGS, + _envelope(SAME), + SAME[:50], + 2, + SeclaiError, + False, + ), + ( + "identical rows, no paging information: ends after one page", + "/knowledge_bases", + {"items_key": "knowledge_bases"}, + _by_page(SAME, "knowledge_bases", total=False), + SAME[:50], + 2, + None, + False, + ), + ( + "second page fails", + "/alerts/configs", + {}, + _fails_on_page_two, + _rows(50), + 2, + SeclaiAPIStatusError, + False, + ), + ( + "bare array", + "/cloud-drives", + {}, + _everything(_rows(120)), + _rows(120), + 1, + None, + False, + ), # fmt: skip + ("empty", "/alerts/configs", {}, _everything({"data": []}), [], 1, None, False), + ( + "not a list", + "/alerts/configs", + CONFIGS, + _everything({"error": {"code": "x"}}), + [], + 1, + SeclaiError, + False, + ), + *[ + ( + f"limit={limit!r} is rejected before any request", + "/alerts/configs", + {**CONFIGS, "limit": limit}, + _everything({"configs": [], "total": 0}), + [], + 0, + ValueError, + False, + ) + for limit in (0, -1, True, 2.5, "50") + ], + ( + "param_style is rejected before any request", + "/alerts/configs", + {"param_style": "cursor"}, + _everything({"data": []}), + [], + 0, + ValueError, + False, + ), +] +# A walk that yields this many more items than expected is not terminating. +SLACK = 5 + + +@pytest.mark.parametrize("case", CASES, ids=[c[0] for c in CASES]) +class TestPaginate: + def test_sync(self, case: Case) -> None: + _id, path, kwargs, answer, expected, requests, exception, mutate = case + server = _Server(answer) + seen: list[Any] = [] + raised: BaseException | None = None + with _sync(server) as client: + try: + for item in client.paginate("GET", path, **kwargs): + seen.append(dict(item)) + if mutate: + item["seen"] = True + assert len(seen) <= len(expected) + SLACK, "did not terminate" + except (SeclaiError, ValueError) as exc: + raised = exc + assert seen == expected + assert server.requests == requests + assert type(raised) is (exception or type(None)) + + async def test_async(self, case: Case) -> None: + _id, path, kwargs, answer, expected, requests, exception, mutate = case + server = _Server(answer) + seen: list[Any] = [] + raised: BaseException | None = None + async with _async(server) as client: + try: + async for item in client.paginate("GET", path, **kwargs): + seen.append(dict(item)) + if mutate: + item["seen"] = True + assert len(seen) <= len(expected) + SLACK, "did not terminate" + except (SeclaiError, ValueError) as exc: + raised = exc + assert seen == expected + assert server.requests == requests + assert type(raised) is (exception or type(None)) + + +def test_repeated_page_error_names_param_style() -> None: + server = _Server(_first_page_only(_rows(120), "alerts", total=120)) + with _sync(server) as client, pytest.raises(SeclaiError, match="param_style='offset'"): # fmt: skip + list(client.paginate("GET", "/models/alerts", items_key="alerts")) + + +def test_closing_early_makes_no_further_request() -> None: + server = _Server(_envelope(_rows(120))) + with _sync(server) as client: + walk = client.paginate("GET", "/alerts/configs") + assert next(walk) == {"id": "x0"} + walk.close() + assert list(walk) == [] + assert server.requests == 1 + + +async def test_async_closing_early_makes_no_further_request() -> None: + server = _Server(_envelope(_rows(120))) + async with _async(server) as client: + walk = client.paginate("GET", "/alerts/configs") + assert await anext(walk) == {"id": "x0"} + await walk.aclose() + assert [item async for item in walk] == [] + assert server.requests == 1 + + +def test_caller_params_are_sent_on_every_page_and_not_mutated() -> None: + queries: list[Query] = [] + + def answer(query: Query) -> Any: + queries.append(query) + return _envelope(_rows(120))(query) + + params = {"status": "open"} + with _sync(_Server(answer)) as client: + assert len(list(client.paginate("GET", "/alerts", params=params))) == 120 + assert params == {"status": "open"} + assert [q["status"] for q in queries] == ["open"] * 3 + assert [q["page"] for q in queries] == ["1", "2", "3"] diff --git a/tests/test_runtime_dependencies.py b/tests/test_runtime_dependencies.py new file mode 100644 index 0000000..3e0cd32 --- /dev/null +++ b/tests/test_runtime_dependencies.py @@ -0,0 +1,64 @@ +"""Every third-party module the package imports is a declared runtime dependency. + +The dev group installs more than the published wheel requires, so an import +that only a dev tool provides passes every other test and fails for users. +""" + +from __future__ import annotations + +import ast +import pathlib +import sys +import tomllib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +PACKAGE = ROOT / "seclai" + +# Import name -> distribution name, where they differ. +DISTRIBUTIONS = {"dateutil": "python-dateutil"} + + +def _normalise(name: str) -> str: + return name.lower().replace("_", "-") + + +def _imported_modules() -> dict[str, pathlib.Path]: + """Top-level module names imported under ``seclai/``, with one file each.""" + found: dict[str, pathlib.Path] = {} + for path in sorted(PACKAGE.rglob("*.py")): + for node in ast.walk(ast.parse(path.read_text(), filename=str(path))): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names = [node.module] + else: + continue + for name in names: + found.setdefault(name.split(".")[0], path) + return found + + +def _declared() -> set[str]: + with (ROOT / "pyproject.toml").open("rb") as handle: + dependencies = tomllib.load(handle)["tool"]["poetry"]["dependencies"] + return {_normalise(name) for name in dependencies if name != "python"} + + +def test_package_is_walked() -> None: + imported = _imported_modules() + assert "httpx" in imported + assert any("_generated" in path.parts for path in PACKAGE.rglob("*.py")) + + +def test_every_third_party_import_is_a_runtime_dependency() -> None: + declared = _declared() + undeclared = { + module: str(path.relative_to(ROOT)) + for module, path in _imported_modules().items() + if module not in sys.stdlib_module_names + and module != PACKAGE.name + and _normalise(DISTRIBUTIONS.get(module, module)) not in declared + } + assert ( + undeclared == {} + ), f"imported but not in [tool.poetry.dependencies]: {undeclared}"