Skip to content

fix(environments): restore flat kwargs on create/update_environment - #201

Closed
mathieu-hai wants to merge 1 commit into
mainfrom
fix/environments-flat-kwargs
Closed

fix(environments): restore flat kwargs on create/update_environment#201
mathieu-hai wants to merge 1 commit into
mainfrom
fix/environments-flat-kwargs

Conversation

@mathieu-hai

@mathieu-hai mathieu-hai commented Jul 31, 2026

Copy link
Copy Markdown

Le problème

Repéré par @h-julie-dujardin en testant l'intégration 1Password, et signalé dans H-Tech-Hub-docs-new#200 :

TypeError: EnvironmentsClient.create_environment() got an unexpected keyword argument 'id'

Cause racine : #161 (« Local control for user_device environments ») a ajouté le kind desktop, ce qui a transformé les bodies de POST /environments et PUT /environments/{id} en unions discriminées sur kind. Fern ne sait pas inliner une union en arguments nommés, donc les clients générés se sont réduits à un seul paramètre request= :

def create_environment(self, *, request: CreateEnvironmentRequest, request_options=None) -> Environment

C'est un breaking change livré dans un bump patch (1.0.5 → 1.0.6), sans entrée de changelog. Il casse le style d'appel documenté et tout code écrit contre <=1.0.5. Le format wire n'a pas changé — seule la signature Python a régressé.

⚠️ À lire avant de merger : la source de vérité est dans agent_platform

Les fichiers écrits à la main de ce repo (client.py, polling.py, tools.py, webhook_verification.py) ne survivent pas par convention de nommage — ils sont recopiés depuis sdk-codegen/python/*.py.static par generate.sh, qui fait rm -rf src/hai_agents avant de régénérer. Une correction posée uniquement ici serait donc écrasée au prochain sync.

Le correctif de fond est donc hcompai/agent_platform#1550, à merger en premier. Cette PR-ci porte les mêmes fichiers côté mirror pour livrer sans attendre une régénération : le prochain sync réécrira un contenu identique. Merci @mathieudiaz pour le pointeur.

Le correctif

Un FlatEnvironmentsClient (+ son jumeau async) qui rassemble les champs à plat dans le membre d'union correspondant au kind demandé (web par défaut), branché via Client.environments.

# remarche
client.environments.create_environment(id="wide-browser", start_url="https://www.google.com")

# continue de marcher, inchangé
client.environments.create_environment(request=CreateEnvironmentRequest_Web(id="wide-browser"))

Purement additif : aucun appel existant ne change de comportement.

Pourquoi pas un fix dans le spec ou la config Fern

Aplatir le oneOf en un objet unique donnerait des kwargs à plat natifs, sans overlay. Mais kind deviendrait un champ optionnel ordinaire, que Fern omet quand il n'est pas fourni — et le serveur exige le tag (422 Unable to extract tag using discriminator 'kind', la discrimination tourne avant les defaults par variante). On échangerait une TypeError franche contre un 422 silencieux. Construire la classe membre est ce qui garantit que le tag part sur le wire ; c'est exactement ce que test_create_accepts_flat_fields vérifie.

Détails qui méritent un œil en review

  • Les champs inconnus lèvent TypeError. Les membres d'union sont extra="allow", donc sans ce garde-fou une faute de frappe (start_ur1=) partirait au serveur et reviendrait en 422 opaque. On restaure l'erreur de la 1.0.5 : immédiate et lisible. Idem pour un champ browser passé à un environnement desktop.
  • update_environment et la collision sur id. Le segment de path et le body portent tous les deux un id (d'où le id_ de la 1.0.5). Le path est résolu depuis l'argument positionnel, puis le id à plat, puis request.id — donc les trois formes historiques passent, y compris update_environment("x", id="x", ...).
  • Kind non modélisé. kind="…" inconnu lève une TypeError qui pointe vers request= comme échappatoire, plutôt que de tomber silencieusement sur web.
  • with_raw_response n'est pas couvert — il garde la signature générée request=. Volontaire : c'est la surface bas niveau. À dire si vous voulez qu'elle suive.

Tests

tests/test_environments_flat.py — 21 tests via httpx.MockTransport, qui assertent le body sérialisé et pas le modèle : un wrapper ergonomique qui perd ou mange un champ ne serait pas un correctif. Un test paramétré vérifie que les deux styles d'appel envoient exactement les mêmes octets.

184 passed, 10 skipped en local avec --all-extras.

tests/ n'est pas dans l'arbre régénéré (generate.sh ne wipe que src/hai_agents), donc ces tests survivent aux syncs et gardent le mirror.

Suites à donner

  • Une release est nécessaire pour que le correctif serve à quelque chose — je n'ai pas touché à version dans pyproject.toml, les bumps sont portés par les commits de sync.
  • Une fois publié, H-Tech-Hub-docs-new#200 peut revenir au style à plat. En attendant elle devrait être mergée telle quelle : la doc actuelle fait planter les gens sur la version installable.
  • Le SDK TS n'a pas été vérifié pour la même régression sur ces endpoints.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant