From bbf072c8bf592d86a9de43912236220e691ee792 Mon Sep 17 00:00:00 2001 From: Mathieu Diaz Date: Fri, 31 Jul 2026 10:27:00 +0200 Subject: [PATCH] fix(environments): restore flat kwargs on create/update_environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the `desktop` kind turned the `POST /environments` and `PUT /environments/{id}` bodies into unions discriminated on `kind`. Fern cannot inline a union into keyword arguments, so the generated clients collapsed to a single `request=` parameter — a breaking change that shipped in 1.0.6 with no changelog entry: TypeError: EnvironmentsClient.create_environment() got an unexpected keyword argument 'id' That broke the documented call style and every caller written against <=1.0.5. Add a hand-written `FlatEnvironmentsClient` (and its async twin) that collects flat fields into the union member for the requested `kind` (`web` when omitted), and wire it in through `Client.environments`. `request=` keeps working untouched, so this is additive. Flat fields that do not belong to the selected kind raise `TypeError` here rather than riding along as pydantic extras — the union members are `extra="allow"`, so a typo would otherwise reach the server as an opaque 422. `update_environment` resolves its path segment from the positional argument, the flat `id`, or `request.id`, so all three historical call shapes work. Tests assert the serialized request body rather than the model, and pin that both call styles put the same bytes on the wire. Co-Authored-By: Claude Opus 5 (1M context) --- src/hai_agents/client.py | 17 +- src/hai_agents/environments_flat.py | 352 ++++++++++++++++++++++++++++ tests/test_environments_flat.py | 218 +++++++++++++++++ 3 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 src/hai_agents/environments_flat.py create mode 100644 tests/test_environments_flat.py diff --git a/src/hai_agents/client.py b/src/hai_agents/client.py index 82e5354..e03ca85 100644 --- a/src/hai_agents/client.py +++ b/src/hai_agents/client.py @@ -2,7 +2,9 @@ Fern emits the API surface as ``BaseClient``/``AsyncBaseClient``; these thin subclasses add the object-oriented sugar (``run_session``, ``start_session``, -``session``) that delegates to the hand-written polling helpers. +``session``) that delegates to the hand-written polling helpers, and swap in the +resource clients that accept flat keyword arguments for the union-bodied +environment endpoints. """ from __future__ import annotations @@ -12,6 +14,7 @@ import typing_extensions from .base_client import AsyncBaseClient, BaseClient +from .environments_flat import AsyncFlatEnvironmentsClient, FlatEnvironmentsClient from .polling import ( AnswerT, AsyncSessionHandle, @@ -84,6 +87,12 @@ def sessions(self) -> SessionsClient: self._sessions = LocalSessionsClient(client_wrapper=self._client_wrapper) return self._sessions + @property + def environments(self) -> FlatEnvironmentsClient: + if self._environments is None: + self._environments = FlatEnvironmentsClient(client_wrapper=self._client_wrapper) + return typing.cast(FlatEnvironmentsClient, self._environments) + class AsyncClient(AsyncBaseClient): async def run_session( @@ -140,3 +149,9 @@ def sessions(self) -> AsyncSessionsClient: self._sessions = LocalAsyncSessionsClient(client_wrapper=self._client_wrapper) return self._sessions + + @property + def environments(self) -> AsyncFlatEnvironmentsClient: + if self._environments is None: + self._environments = AsyncFlatEnvironmentsClient(client_wrapper=self._client_wrapper) + return typing.cast(AsyncFlatEnvironmentsClient, self._environments) diff --git a/src/hai_agents/environments_flat.py b/src/hai_agents/environments_flat.py new file mode 100644 index 0000000..22d6539 --- /dev/null +++ b/src/hai_agents/environments_flat.py @@ -0,0 +1,352 @@ +"""Environments clients that accept flat keyword arguments as well as ``request=``. + +Fern renders the ``POST /environments`` and ``PUT /environments/{id}`` bodies as unions +discriminated on ``kind`` (``web`` | ``desktop``), and it cannot inline a union into keyword +arguments — so the generated clients expose a single ``request=`` parameter. Adding the +``desktop`` kind therefore regressed the call style the docs and every caller written against +``<=1.0.5`` use:: + + client.environments.create_environment(id="wide-browser", start_url="https://x.test") + +These subclasses restore it. Flat fields are collected into the union member for the requested +``kind`` (``web`` when omitted), and an explicit ``request=`` model is forwarded untouched, so +both styles work. Unknown or kind-mismatched field names raise ``TypeError`` here instead of +riding along as pydantic extras — the union members are declared ``extra="allow"``, so a typo +would otherwise reach the server and come back as an opaque 422. +""" + +from __future__ import annotations + +import typing + +import typing_extensions + +from .core.request_options import RequestOptions +from .environments.client import AsyncEnvironmentsClient, EnvironmentsClient +from .environments.types.create_environment_request import ( + CreateEnvironmentRequest, + CreateEnvironmentRequest_Desktop, + CreateEnvironmentRequest_Web, +) +from .environments.types.update_environment_request_body import ( + UpdateEnvironmentRequestBody, + UpdateEnvironmentRequestBody_Desktop, + UpdateEnvironmentRequestBody_Web, +) +from .types.browser_host import BrowserHost +from .types.browser_mode import BrowserMode +from .types.browser_network import BrowserNetwork +from .types.desktop_host import DesktopHost +from .types.environment import Environment + +__all__ = [ + "AsyncFlatEnvironmentsClient", + "EnvironmentSpecParams", + "FlatEnvironmentsClient", +] + + +class EnvironmentSpecParams(typing_extensions.TypedDict, total=False): + """Flat environment-spec kwargs; mirror new union member fields here to keep autocomplete. + + A superset of the ``web`` and ``desktop`` members — which fields are actually legal depends + on ``kind``, and is enforced at call time against the selected member's model fields. + """ + + id: typing_extensions.Required[str] + kind: typing.Literal["web", "desktop"] + host: typing.Union[BrowserHost, DesktopHost] + start_url: typing.Optional[str] + headless: typing.Optional[bool] + session_id: typing.Optional[str] + mode: typing.Optional[BrowserMode] + vault_id: typing.Optional[str] + browser_profile_id: typing.Optional[str] + use_default_browser_profile: typing.Optional[bool] + persist_browser_profile: typing.Optional[bool] + network: typing.Optional[BrowserNetwork] + + +_CREATE_MEMBERS: typing.Mapping[str, typing.Any] = { + "web": CreateEnvironmentRequest_Web, + "desktop": CreateEnvironmentRequest_Desktop, +} +_UPDATE_MEMBERS: typing.Mapping[str, typing.Any] = { + "web": UpdateEnvironmentRequestBody_Web, + "desktop": UpdateEnvironmentRequestBody_Desktop, +} + + +def _build_spec( + members: typing.Mapping[str, typing.Any], + fields: typing.Dict[str, typing.Any], + *, + method: str, + default_id: typing.Optional[str] = None, +) -> typing.Any: + """Collect flat ``fields`` into the union member matching their ``kind``.""" + fields = dict(fields) + if default_id is not None: + fields.setdefault("id", default_id) + kind = fields.pop("kind", None) or "web" + if kind not in members: + raise TypeError( + f"{method}() cannot build a request body for kind={kind!r}; known kinds are " + f"{', '.join(sorted(members))}. Pass request= to send a kind this SDK " + "version does not model yet." + ) + model_cls = members[kind] + allowed = set(model_cls.model_fields) - {"kind"} + unexpected = sorted(set(fields) - allowed) + if unexpected: + raise TypeError( + f"{method}() got unexpected keyword argument(s) {', '.join(unexpected)} for a " + f"{kind!r} environment; valid fields are {', '.join(sorted(allowed))}." + ) + if "id" not in fields: + raise TypeError(f"{method}() missing required keyword argument: 'id'") + return model_cls(**fields) + + +def _resolve_request( + request: typing.Optional[typing.Any], + fields: typing.Dict[str, typing.Any], + members: typing.Mapping[str, typing.Any], + *, + method: str, + default_id: typing.Optional[str] = None, +) -> typing.Any: + if request is None: + return _build_spec(members, fields, method=method, default_id=default_id) + if fields: + raise TypeError( + f"{method}() takes either request= or flat environment fields, not both; got " + f"request= together with {', '.join(sorted(fields))}." + ) + return request + + +def _path_id( + positional: typing.Optional[str], + request: typing.Optional[typing.Any], + fields: typing.Mapping[str, typing.Any], + *, + method: str, +) -> str: + """Resolve the ``{id}`` path segment from the positional arg, the flat fields, or ``request``.""" + for candidate in (positional, fields.get("id"), getattr(request, "id", None)): + if candidate is not None: + return candidate + raise TypeError(f"{method}() missing the environment id; pass it positionally or as id=.") + + +class FlatEnvironmentsClient(EnvironmentsClient): + def create_environment( + self, + *, + request: typing.Optional[CreateEnvironmentRequest] = None, + request_options: typing.Optional[RequestOptions] = None, + **fields: typing_extensions.Unpack[EnvironmentSpecParams], + ) -> Environment: + """Create an environment from flat fields, or from a prebuilt ``request`` model. + + Parameters + ---------- + request : typing.Optional[CreateEnvironmentRequest] + A prebuilt union member. Mutually exclusive with the flat fields below. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + **fields : typing_extensions.Unpack[EnvironmentSpecParams] + Environment spec fields, collected into the member for ``kind`` (``web`` by default). + + Returns + ------- + Environment + Successful Response + + Examples + -------- + from hai_agents import Client + + client = Client( + api_key="YOUR_API_KEY", + ) + client.environments.create_environment( + id="wide-browser", + mode={"type": "visual", "width": 1920, "height": 1080}, + ) + """ + return super().create_environment( + request=_resolve_request(request, fields, _CREATE_MEMBERS, method="create_environment"), + request_options=request_options, + ) + + def update_environment( + self, + id: typing.Optional[str] = None, + /, + *, + request: typing.Optional[UpdateEnvironmentRequestBody] = None, + request_options: typing.Optional[RequestOptions] = None, + **fields: typing_extensions.Unpack[EnvironmentSpecParams], + ) -> Environment: + """Replace an environment spec from flat fields, or from a prebuilt ``request`` model. + + Parameters + ---------- + id : typing.Optional[str] + Environment to replace. Defaults to the ``id`` field of the new spec, so it only + needs passing when the two differ. + + request : typing.Optional[UpdateEnvironmentRequestBody] + A prebuilt union member. Mutually exclusive with the flat fields below. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + **fields : typing_extensions.Unpack[EnvironmentSpecParams] + Environment spec fields, collected into the member for ``kind`` (``web`` by default). + + Returns + ------- + Environment + Successful Response + + Examples + -------- + from hai_agents import Client + + client = Client( + api_key="YOUR_API_KEY", + ) + client.environments.update_environment( + id="wide-browser", + start_url="https://example.com", + ) + """ + environment_id = _path_id(id, request, fields, method="update_environment") + if request is not None: + fields.pop("id", None) # it only carried the path segment + return super().update_environment( + environment_id, + request=_resolve_request( + request, fields, _UPDATE_MEMBERS, method="update_environment", default_id=environment_id + ), + request_options=request_options, + ) + + +class AsyncFlatEnvironmentsClient(AsyncEnvironmentsClient): + async def create_environment( + self, + *, + request: typing.Optional[CreateEnvironmentRequest] = None, + request_options: typing.Optional[RequestOptions] = None, + **fields: typing_extensions.Unpack[EnvironmentSpecParams], + ) -> Environment: + """Create an environment from flat fields, or from a prebuilt ``request`` model. + + Parameters + ---------- + request : typing.Optional[CreateEnvironmentRequest] + A prebuilt union member. Mutually exclusive with the flat fields below. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + **fields : typing_extensions.Unpack[EnvironmentSpecParams] + Environment spec fields, collected into the member for ``kind`` (``web`` by default). + + Returns + ------- + Environment + Successful Response + + Examples + -------- + import asyncio + + from hai_agents import AsyncClient + + client = AsyncClient( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.environments.create_environment( + id="wide-browser", + mode={"type": "visual", "width": 1920, "height": 1080}, + ) + + + asyncio.run(main()) + """ + return await super().create_environment( + request=_resolve_request(request, fields, _CREATE_MEMBERS, method="create_environment"), + request_options=request_options, + ) + + async def update_environment( + self, + id: typing.Optional[str] = None, + /, + *, + request: typing.Optional[UpdateEnvironmentRequestBody] = None, + request_options: typing.Optional[RequestOptions] = None, + **fields: typing_extensions.Unpack[EnvironmentSpecParams], + ) -> Environment: + """Replace an environment spec from flat fields, or from a prebuilt ``request`` model. + + Parameters + ---------- + id : typing.Optional[str] + Environment to replace. Defaults to the ``id`` field of the new spec, so it only + needs passing when the two differ. + + request : typing.Optional[UpdateEnvironmentRequestBody] + A prebuilt union member. Mutually exclusive with the flat fields below. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + **fields : typing_extensions.Unpack[EnvironmentSpecParams] + Environment spec fields, collected into the member for ``kind`` (``web`` by default). + + Returns + ------- + Environment + Successful Response + + Examples + -------- + import asyncio + + from hai_agents import AsyncClient + + client = AsyncClient( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.environments.update_environment( + id="wide-browser", + start_url="https://example.com", + ) + + + asyncio.run(main()) + """ + environment_id = _path_id(id, request, fields, method="update_environment") + if request is not None: + fields.pop("id", None) # it only carried the path segment + return await super().update_environment( + environment_id, + request=_resolve_request( + request, fields, _UPDATE_MEMBERS, method="update_environment", default_id=environment_id + ), + request_options=request_options, + ) diff --git a/tests/test_environments_flat.py b/tests/test_environments_flat.py new file mode 100644 index 0000000..34b9486 --- /dev/null +++ b/tests/test_environments_flat.py @@ -0,0 +1,218 @@ +"""Flat keyword arguments on the union-bodied environment endpoints still reach the wire. + +``create_environment``/``update_environment`` take a ``kind``-discriminated union body, which +Fern can only express as a single ``request=`` model. These tests pin the hand-written flat-kwargs +layer that keeps the documented call style working, and assert against the serialized request body +rather than the model — an ergonomic wrapper that drops or mangles a field is no fix. +""" + +from __future__ import annotations + +import json +import typing + +import httpx +import pytest + +from hai_agents import AsyncClient, Client +from hai_agents.environments import ( + CreateEnvironmentRequest_Desktop, + CreateEnvironmentRequest_Web, + UpdateEnvironmentRequestBody_Web, +) + +ENVIRONMENT_RESPONSE = {"id": "wide-browser", "kind": "web"} + + +class _Recorder: + """Captures the single request a call makes, and replays a canned environment.""" + + def __init__(self) -> None: + self.request: typing.Optional[httpx.Request] = None + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.request = request + return httpx.Response(200, json=ENVIRONMENT_RESPONSE) + + @property + def body(self) -> typing.Dict[str, typing.Any]: + assert self.request is not None, "no request was sent" + return json.loads(self.request.content) + + @property + def path(self) -> str: + assert self.request is not None, "no request was sent" + return self.request.url.path + + +@pytest.fixture +def recorder() -> _Recorder: + return _Recorder() + + +@pytest.fixture +def client(recorder: _Recorder) -> Client: + return Client( + api_key="hk-test", + base_url="http://api.test", + httpx_client=httpx.Client(transport=httpx.MockTransport(recorder)), + ) + + +@pytest.fixture +def async_client(recorder: _Recorder) -> AsyncClient: + return AsyncClient( + api_key="hk-test", + base_url="http://api.test", + httpx_client=httpx.AsyncClient(transport=httpx.MockTransport(recorder)), + ) + + +def test_create_accepts_flat_fields(client: Client, recorder: _Recorder) -> None: + """The call style the docs use, and everything written against <=1.0.5.""" + client.environments.create_environment( + id="wide-browser", + start_url="https://www.google.com", + mode={"type": "visual", "width": 1920, "height": 1080}, + ) + assert recorder.body == { + "kind": "web", + "id": "wide-browser", + "start_url": "https://www.google.com", + "mode": {"type": "visual", "width": 1920, "height": 1080}, + } + + +def test_create_defaults_to_web_but_accepts_an_explicit_kind(client: Client, recorder: _Recorder) -> None: + client.environments.create_environment(id="wide-browser", kind="web", vault_id="vault_1") + assert recorder.body["kind"] == "web" + + +def test_create_routes_desktop_fields_to_the_desktop_member(client: Client, recorder: _Recorder) -> None: + client.environments.create_environment(id="my-box", kind="desktop", host="user_device") + assert recorder.body == {"kind": "desktop", "id": "my-box", "host": "user_device"} + + +def test_create_still_accepts_a_prebuilt_request(client: Client, recorder: _Recorder) -> None: + """The generated 1.0.6+ style must keep working — this is additive, not a swap.""" + client.environments.create_environment( + request=CreateEnvironmentRequest_Web(id="wide-browser", vault_id="vault_1"), + ) + assert recorder.body == {"kind": "web", "id": "wide-browser", "vault_id": "vault_1"} + + +def test_create_accepts_a_prebuilt_desktop_request(client: Client, recorder: _Recorder) -> None: + client.environments.create_environment( + request=CreateEnvironmentRequest_Desktop(id="my-box", host="user_device"), + ) + assert recorder.body["kind"] == "desktop" + + +@pytest.mark.parametrize( + "spec", + [ + {"id": "wide-browser", "start_url": "https://x.test", "headless": True}, + {"id": "wide-browser", "mode": {"type": "text", "markdown": True}}, + {"id": "wide-browser", "network": {"proxy_url": "http://user:pass@proxy.test:8080"}}, + {"id": "my-box", "kind": "desktop", "host": "user_device"}, + ], +) +def test_flat_fields_and_prebuilt_request_send_the_same_body(spec: typing.Dict[str, typing.Any]) -> None: + """This layer is ergonomics only: neither style may produce a body the other cannot.""" + bodies = [] + for call_style in ("flat", "request"): + recorder = _Recorder() + client = Client( + api_key="hk-test", + base_url="http://api.test", + httpx_client=httpx.Client(transport=httpx.MockTransport(recorder)), + ) + if call_style == "flat": + client.environments.create_environment(**spec) + else: + member = CreateEnvironmentRequest_Desktop if spec.get("kind") == "desktop" else CreateEnvironmentRequest_Web + client.environments.create_environment(request=member(**spec)) + bodies.append(recorder.body) + assert bodies[0] == bodies[1] + + +def test_update_derives_the_path_from_the_spec_id(client: Client, recorder: _Recorder) -> None: + client.environments.update_environment(id="wide-browser", start_url="https://example.com") + assert recorder.path == "/api/v2/environments/wide-browser" + assert recorder.body == { + "kind": "web", + "id": "wide-browser", + "start_url": "https://example.com", + } + + +def test_update_accepts_the_path_positionally_alongside_the_spec_id(client: Client, recorder: _Recorder) -> None: + """The <=1.0.5 signature was ``update_environment(id_, *, id, ...)``; both ids still land.""" + client.environments.update_environment("wide-browser", id="wide-browser", headless=True) + assert recorder.path == "/api/v2/environments/wide-browser" + assert recorder.body["id"] == "wide-browser" + assert recorder.body["headless"] is True + + +def test_update_path_and_spec_id_may_differ(client: Client, recorder: _Recorder) -> None: + client.environments.update_environment("old-id", id="new-id") + assert recorder.path == "/api/v2/environments/old-id" + assert recorder.body["id"] == "new-id" + + +def test_update_still_accepts_a_prebuilt_request_keyed_by_id(client: Client, recorder: _Recorder) -> None: + """``id=`` here is the path segment, as in the generated client — not a stray flat field.""" + client.environments.update_environment( + id="wide-browser", + request=UpdateEnvironmentRequestBody_Web(id="wide-browser", start_url="https://example.com"), + ) + assert recorder.path == "/api/v2/environments/wide-browser" + assert recorder.body["start_url"] == "https://example.com" + + +def test_unknown_field_fails_fast_instead_of_riding_along_as_an_extra(client: Client) -> None: + """Union members are ``extra="allow"``, so without this guard a typo becomes a server 422.""" + with pytest.raises(TypeError, match="start_ur1"): + client.environments.create_environment(id="wide-browser", start_ur1="https://x.test") + + +def test_browser_field_on_a_desktop_environment_is_rejected(client: Client) -> None: + with pytest.raises(TypeError, match="start_url"): + client.environments.create_environment(id="my-box", kind="desktop", host="user_device", start_url="https://x") + + +def test_mixing_request_and_flat_fields_is_rejected(client: Client) -> None: + with pytest.raises(TypeError, match="not both"): + client.environments.create_environment( + request=CreateEnvironmentRequest_Web(id="wide-browser"), + start_url="https://x.test", + ) + + +def test_missing_id_is_reported_as_a_missing_argument(client: Client) -> None: + with pytest.raises(TypeError, match="'id'"): + client.environments.create_environment(start_url="https://x.test") + + +def test_unmodelled_kind_points_at_the_request_escape_hatch(client: Client) -> None: + with pytest.raises(TypeError, match="request="): + client.environments.create_environment(id="x", kind="quantum") + + +async def test_async_create_accepts_flat_fields(async_client: AsyncClient, recorder: _Recorder) -> None: + await async_client.environments.create_environment(id="wide-browser", vault_id="vault_1") + assert recorder.body == {"kind": "web", "id": "wide-browser", "vault_id": "vault_1"} + + +async def test_async_update_derives_the_path_from_the_spec_id(async_client: AsyncClient, recorder: _Recorder) -> None: + await async_client.environments.update_environment(id="wide-browser", start_url="https://example.com") + assert recorder.path == "/api/v2/environments/wide-browser" + assert recorder.body["start_url"] == "https://example.com" + + +async def test_async_mixing_request_and_flat_fields_is_rejected(async_client: AsyncClient) -> None: + with pytest.raises(TypeError, match="not both"): + await async_client.environments.create_environment( + request=CreateEnvironmentRequest_Web(id="wide-browser"), + start_url="https://x.test", + )