From 2daa3f83fd8ad4745e04f1740a1028512979d55c Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Wed, 10 Jun 2026 15:58:07 +0000 Subject: [PATCH] fix(llm): serialize is_subscription so subscription mode survives remote transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_subscription` was a plain property backed by the `_is_subscription` PrivateAttr, so it was silently dropped whenever an LLM was serialized — in particular when a RemoteConversation ships the agent's LLM to an agent-server. The server rebuilt the LLM with is_subscription == False, and every subscription-specific behavior stopped applying server-side: - the streaming on_token exemption in responses() (ValueError: Streaming requires an on_token callback), - the Codex system-prompt transform (transform_for_subscription), - reasoning-item stripping for store=false (follow-up requests reference unresolvable item IDs -> 404 from the Codex endpoint). Net effect: LLM.subscription_login() worked locally but silently broke with remote workspaces. Promote the property to a `@computed_field` (so it is included in model_dump) and add a wrap model_validator that restores the backing `_is_subscription` PrivateAttr on validation. The public read API (`llm.is_subscription`) and the existing `_is_subscription` write path are both unchanged, so this is additive and not an API break. Co-Authored-By: Claude Fable 5 --- openhands-sdk/openhands/sdk/llm/llm.py | 27 +++++++++++++++++++++++++ tests/sdk/llm/test_subscription_mode.py | 19 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index ecb5649e7d..c4560e8930 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -17,6 +17,7 @@ Field, PrivateAttr, SecretStr, + computed_field, field_serializer, field_validator, model_validator, @@ -692,6 +693,14 @@ def telemetry(self) -> Telemetry: ) return self._telemetry + @computed_field( + return_type=bool, + description=( + "Whether this LLM uses subscription-based authentication. " + "Serialized so that subscription-specific request handling " + "survives transport to a remote agent-server." + ), + ) @property def is_subscription(self) -> bool: """Check if this LLM uses subscription-based authentication. @@ -705,6 +714,24 @@ def is_subscription(self) -> bool: """ return self._is_subscription + @model_validator(mode="wrap") + @classmethod + def _restore_is_subscription(cls, data, handler): + """Restore the subscription flag when validating serialized data. + + ``is_subscription`` is a computed field backed by the private + ``_is_subscription`` attribute, so plain validation would drop it. + Without this, an LLM created via ``LLM.subscription_login()`` loses + its subscription-specific request handling (streaming exemption, + Codex system prompt transform, reasoning-item stripping) after a + dump/validate round trip - e.g. when shipped to a remote + agent-server. + """ + llm = handler(data) + if isinstance(data, dict) and data.get("is_subscription"): + llm._is_subscription = True + return llm + def restore_metrics(self, metrics: Metrics) -> None: # Only used by ConversationStats to seed metrics self._metrics = metrics diff --git a/tests/sdk/llm/test_subscription_mode.py b/tests/sdk/llm/test_subscription_mode.py index 2b40419e82..42e3172769 100644 --- a/tests/sdk/llm/test_subscription_mode.py +++ b/tests/sdk/llm/test_subscription_mode.py @@ -338,3 +338,22 @@ def test_format_messages_reasoning_item_handling( serialized = json.dumps(input_items, default=str) assert ("rs_should_be_stripped" in serialized) == reasoning_id_present + + +def test_is_subscription_survives_serialization_round_trip(): + """is_subscription must survive model_dump -> model_validate. + + A RemoteConversation serializes the LLM and the agent-server rebuilds it; + if the flag is lost, all subscription-specific request handling (streaming + exemption, system prompt transform, reasoning item stripping) silently + stops applying on the server side. + """ + llm = _make_subscription_llm() + restored = LLM.model_validate(llm.model_dump(context={"expose_secrets": True})) + assert restored.is_subscription is True + + plain = LLM(model="gpt-4o") + restored_plain = LLM.model_validate( + plain.model_dump(context={"expose_secrets": True}) + ) + assert restored_plain.is_subscription is False