openhands-sdk 1.9.1__py3-none-any.whl → 1.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. openhands/sdk/agent/agent.py +90 -16
  2. openhands/sdk/agent/base.py +33 -46
  3. openhands/sdk/context/condenser/base.py +36 -3
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
  5. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
  6. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
  7. openhands/sdk/context/skills/skill.py +2 -25
  8. openhands/sdk/context/view.py +108 -122
  9. openhands/sdk/conversation/__init__.py +2 -0
  10. openhands/sdk/conversation/conversation.py +18 -3
  11. openhands/sdk/conversation/exceptions.py +18 -0
  12. openhands/sdk/conversation/impl/local_conversation.py +211 -36
  13. openhands/sdk/conversation/impl/remote_conversation.py +151 -12
  14. openhands/sdk/conversation/stuck_detector.py +18 -9
  15. openhands/sdk/critic/impl/api/critic.py +10 -7
  16. openhands/sdk/event/condenser.py +52 -2
  17. openhands/sdk/git/cached_repo.py +19 -0
  18. openhands/sdk/hooks/__init__.py +2 -0
  19. openhands/sdk/hooks/config.py +44 -4
  20. openhands/sdk/hooks/executor.py +2 -1
  21. openhands/sdk/llm/__init__.py +16 -0
  22. openhands/sdk/llm/auth/__init__.py +28 -0
  23. openhands/sdk/llm/auth/credentials.py +157 -0
  24. openhands/sdk/llm/auth/openai.py +762 -0
  25. openhands/sdk/llm/llm.py +222 -33
  26. openhands/sdk/llm/message.py +65 -27
  27. openhands/sdk/llm/options/chat_options.py +2 -1
  28. openhands/sdk/llm/options/responses_options.py +8 -7
  29. openhands/sdk/llm/utils/model_features.py +2 -0
  30. openhands/sdk/mcp/client.py +53 -6
  31. openhands/sdk/mcp/tool.py +24 -21
  32. openhands/sdk/mcp/utils.py +31 -23
  33. openhands/sdk/plugin/__init__.py +12 -1
  34. openhands/sdk/plugin/fetch.py +118 -14
  35. openhands/sdk/plugin/loader.py +111 -0
  36. openhands/sdk/plugin/plugin.py +155 -13
  37. openhands/sdk/plugin/types.py +163 -1
  38. openhands/sdk/secret/secrets.py +13 -1
  39. openhands/sdk/utils/__init__.py +2 -0
  40. openhands/sdk/utils/async_utils.py +36 -1
  41. openhands/sdk/utils/command.py +28 -1
  42. openhands/sdk/workspace/remote/base.py +8 -3
  43. openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
  44. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
  45. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
  46. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
  47. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
openhands/sdk/llm/llm.py CHANGED
@@ -22,12 +22,16 @@ from pydantic import (
22
22
  from pydantic.json_schema import SkipJsonSchema
23
23
 
24
24
  from openhands.sdk.llm.utils.model_info import get_litellm_model_info
25
+ from openhands.sdk.utils.deprecation import warn_deprecated
25
26
  from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secret
26
27
 
27
28
 
28
29
  if TYPE_CHECKING: # type hints only, avoid runtime import cycle
30
+ from openhands.sdk.llm.auth import SupportedVendor
29
31
  from openhands.sdk.tool.tool import ToolDefinition
30
32
 
33
+ from openhands.sdk.llm.auth.openai import transform_for_subscription
34
+
31
35
 
32
36
  with warnings.catch_warnings():
33
37
  warnings.simplefilter("ignore")
@@ -49,8 +53,20 @@ from litellm.exceptions import (
49
53
  Timeout as LiteLLMTimeout,
50
54
  )
51
55
  from litellm.responses.main import responses as litellm_responses
52
- from litellm.types.llms.openai import ResponsesAPIResponse
53
- from litellm.types.utils import ModelResponse
56
+ from litellm.responses.streaming_iterator import SyncResponsesAPIStreamingIterator
57
+ from litellm.types.llms.openai import (
58
+ OutputTextDeltaEvent,
59
+ ReasoningSummaryTextDeltaEvent,
60
+ RefusalDeltaEvent,
61
+ ResponseCompletedEvent,
62
+ ResponsesAPIResponse,
63
+ )
64
+ from litellm.types.utils import (
65
+ Delta,
66
+ ModelResponse,
67
+ ModelResponseStream,
68
+ StreamingChoices,
69
+ )
54
70
  from litellm.utils import (
55
71
  create_pretrained_tokenizer,
56
72
  supports_vision,
@@ -283,10 +299,15 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
283
299
  seed: int | None = Field(
284
300
  default=None, description="The seed to use for random number generation."
285
301
  )
302
+ # REMOVE_AT: 1.15.0 - Remove this field and its handling in chat_options.py
286
303
  safety_settings: list[dict[str, str]] | None = Field(
287
304
  default=None,
288
305
  description=(
289
- "Safety settings for models that support them (like Mistral AI and Gemini)"
306
+ "Deprecated: Safety settings for models that support them "
307
+ "(like Mistral AI and Gemini). This field is deprecated in 1.10.0 "
308
+ "and will be removed in 1.15.0. Safety settings are designed for "
309
+ "consumer-facing content moderation, which is not relevant for "
310
+ "coding agents."
290
311
  ),
291
312
  )
292
313
  usage_id: str = Field(
@@ -329,6 +350,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
329
350
  _model_info: Any = PrivateAttr(default=None)
330
351
  _tokenizer: Any = PrivateAttr(default=None)
331
352
  _telemetry: Telemetry | None = PrivateAttr(default=None)
353
+ _is_subscription: bool = PrivateAttr(default=False)
332
354
 
333
355
  model_config: ClassVar[ConfigDict] = ConfigDict(
334
356
  extra="ignore", arbitrary_types_allowed=True
@@ -342,6 +364,26 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
342
364
  def _validate_secrets(cls, v: str | SecretStr | None, info) -> SecretStr | None:
343
365
  return validate_secret(v, info)
344
366
 
367
+ # REMOVE_AT: 1.15.0 - Remove this validator
368
+ @field_validator("safety_settings", mode="before")
369
+ @classmethod
370
+ def _warn_safety_settings_deprecated(
371
+ cls, v: list[dict[str, str]] | None
372
+ ) -> list[dict[str, str]] | None:
373
+ """Emit deprecation warning when safety_settings is explicitly set."""
374
+ if v is not None:
375
+ warn_deprecated(
376
+ "LLM.safety_settings",
377
+ deprecated_in="1.10.0",
378
+ removed_in="1.15.0",
379
+ details=(
380
+ "Safety settings are designed for consumer-facing content "
381
+ "moderation, which is not relevant for coding agents."
382
+ ),
383
+ stacklevel=4,
384
+ )
385
+ return v
386
+
345
387
  @model_validator(mode="before")
346
388
  @classmethod
347
389
  def _coerce_inputs(cls, data):
@@ -473,6 +515,19 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
473
515
  )
474
516
  return self._telemetry
475
517
 
518
+ @property
519
+ def is_subscription(self) -> bool:
520
+ """Check if this LLM uses subscription-based authentication.
521
+
522
+ Returns True when the LLM was created via `LLM.subscription_login()`,
523
+ which uses the ChatGPT subscription Codex backend rather than the
524
+ standard OpenAI API.
525
+
526
+ Returns:
527
+ bool: True if using subscription-based transport, False otherwise.
528
+ """
529
+ return self._is_subscription
530
+
476
531
  def restore_metrics(self, metrics: Metrics) -> None:
477
532
  # Only used by ConversationStats to seed metrics
478
533
  self._metrics = metrics
@@ -636,7 +691,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
636
691
  raise
637
692
 
638
693
  # =========================================================================
639
- # Responses API (non-stream, v1)
694
+ # Responses API (v1)
640
695
  # =========================================================================
641
696
  def responses(
642
697
  self,
@@ -660,16 +715,19 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
660
715
  store: Whether to store the conversation
661
716
  _return_metrics: Whether to return usage metrics
662
717
  add_security_risk_prediction: Add security_risk field to tool schemas
663
- on_token: Optional callback for streaming tokens (not yet supported)
718
+ on_token: Optional callback for streaming deltas
664
719
  **kwargs: Additional arguments passed to the API
665
720
 
666
721
  Note:
667
722
  Summary field is always added to tool schemas for transparency and
668
723
  explainability of agent actions.
669
724
  """
670
- # Streaming not yet supported
671
- if kwargs.get("stream", False) or self.stream or on_token is not None:
672
- raise ValueError("Streaming is not supported for Responses API yet")
725
+ user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream
726
+ if user_enable_streaming:
727
+ if on_token is None and not self.is_subscription:
728
+ # We allow on_token to be None for subscription mode
729
+ raise ValueError("Streaming requires an on_token callback")
730
+ kwargs["stream"] = True
673
731
 
674
732
  # Build instructions + input list using dedicated Responses formatter
675
733
  instructions, input_items = self.format_messages_for_responses(messages)
@@ -745,12 +803,67 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
745
803
  seed=self.seed,
746
804
  **final_kwargs,
747
805
  )
748
- assert isinstance(ret, ResponsesAPIResponse), (
806
+ if isinstance(ret, ResponsesAPIResponse):
807
+ if user_enable_streaming:
808
+ logger.warning(
809
+ "Responses streaming was requested, but the provider "
810
+ "returned a non-streaming response; no on_token deltas "
811
+ "will be emitted."
812
+ )
813
+ self._telemetry.on_response(ret)
814
+ return ret
815
+
816
+ # When stream=True, LiteLLM returns a streaming iterator rather than
817
+ # a single ResponsesAPIResponse. Drain the iterator and use the
818
+ # completed response.
819
+ if final_kwargs.get("stream", False):
820
+ if not isinstance(ret, SyncResponsesAPIStreamingIterator):
821
+ raise AssertionError(
822
+ f"Expected Responses stream iterator, got {type(ret)}"
823
+ )
824
+
825
+ stream_callback = on_token if user_enable_streaming else None
826
+ for event in ret:
827
+ if stream_callback is None:
828
+ continue
829
+ if isinstance(
830
+ event,
831
+ (
832
+ OutputTextDeltaEvent,
833
+ RefusalDeltaEvent,
834
+ ReasoningSummaryTextDeltaEvent,
835
+ ),
836
+ ):
837
+ delta = event.delta
838
+ if delta:
839
+ stream_callback(
840
+ ModelResponseStream(
841
+ choices=[
842
+ StreamingChoices(
843
+ delta=Delta(content=delta)
844
+ )
845
+ ]
846
+ )
847
+ )
848
+
849
+ completed_event = ret.completed_response
850
+ if completed_event is None:
851
+ raise LLMNoResponseError(
852
+ "Responses stream finished without a completed response"
853
+ )
854
+ if not isinstance(completed_event, ResponseCompletedEvent):
855
+ raise LLMNoResponseError(
856
+ f"Unexpected completed event: {type(completed_event)}"
857
+ )
858
+
859
+ completed_resp = completed_event.response
860
+
861
+ self._telemetry.on_response(completed_resp)
862
+ return completed_resp
863
+
864
+ raise AssertionError(
749
865
  f"Expected ResponsesAPIResponse, got {type(ret)}"
750
866
  )
751
- # telemetry (latency, cost). Token usage mapping we handle after.
752
- self._telemetry.on_response(ret)
753
- return ret
754
867
 
755
868
  try:
756
869
  resp: ResponsesAPIResponse = _one_attempt()
@@ -989,19 +1102,27 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
989
1102
  if self.is_caching_prompt_active():
990
1103
  self._apply_prompt_caching(messages)
991
1104
 
992
- for message in messages:
993
- message.cache_enabled = self.is_caching_prompt_active()
994
- message.vision_enabled = self.vision_is_active()
995
- message.function_calling_enabled = self.native_tool_calling
996
- model_features = get_features(self._model_name_for_capabilities())
997
- message.force_string_serializer = (
998
- self.force_string_serializer
999
- if self.force_string_serializer is not None
1000
- else model_features.force_string_serializer
1105
+ model_features = get_features(self._model_name_for_capabilities())
1106
+ cache_enabled = self.is_caching_prompt_active()
1107
+ vision_enabled = self.vision_is_active()
1108
+ function_calling_enabled = self.native_tool_calling
1109
+ force_string_serializer = (
1110
+ self.force_string_serializer
1111
+ if self.force_string_serializer is not None
1112
+ else model_features.force_string_serializer
1113
+ )
1114
+ send_reasoning_content = model_features.send_reasoning_content
1115
+
1116
+ formatted_messages = [
1117
+ message.to_chat_dict(
1118
+ cache_enabled=cache_enabled,
1119
+ vision_enabled=vision_enabled,
1120
+ function_calling_enabled=function_calling_enabled,
1121
+ force_string_serializer=force_string_serializer,
1122
+ send_reasoning_content=send_reasoning_content,
1001
1123
  )
1002
- message.send_reasoning_content = model_features.send_reasoning_content
1003
-
1004
- formatted_messages = [message.to_chat_dict() for message in messages]
1124
+ for message in messages
1125
+ ]
1005
1126
 
1006
1127
  return formatted_messages
1007
1128
 
@@ -1012,8 +1133,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1012
1133
 
1013
1134
  - Skips prompt caching flags and string serializer concerns
1014
1135
  - Uses Message.to_responses_value to get either instructions (system)
1015
- or input items (others)
1136
+ or input items (others)
1016
1137
  - Concatenates system instructions into a single instructions string
1138
+ - For subscription mode, system prompts are prepended to user content
1017
1139
  """
1018
1140
  msgs = copy.deepcopy(messages)
1019
1141
 
@@ -1023,18 +1145,26 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1023
1145
  # Assign system instructions as a string, collect input items
1024
1146
  instructions: str | None = None
1025
1147
  input_items: list[dict[str, Any]] = []
1148
+ system_chunks: list[str] = []
1149
+
1026
1150
  for m in msgs:
1027
1151
  val = m.to_responses_value(vision_enabled=vision_active)
1028
1152
  if isinstance(val, str):
1029
1153
  s = val.strip()
1030
- if not s:
1031
- continue
1032
- instructions = (
1033
- s if instructions is None else f"{instructions}\n\n---\n\n{s}"
1034
- )
1035
- else:
1036
- if val:
1037
- input_items.extend(val)
1154
+ if s:
1155
+ if self.is_subscription:
1156
+ system_chunks.append(s)
1157
+ else:
1158
+ instructions = (
1159
+ s
1160
+ if instructions is None
1161
+ else f"{instructions}\n\n---\n\n{s}"
1162
+ )
1163
+ elif val:
1164
+ input_items.extend(val)
1165
+
1166
+ if self.is_subscription:
1167
+ return transform_for_subscription(system_chunks, input_items)
1038
1168
  return instructions, input_items
1039
1169
 
1040
1170
  def get_token_count(self, messages: list[Message]) -> int:
@@ -1125,3 +1255,62 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1125
1255
  if v is not None:
1126
1256
  data[field_name] = v
1127
1257
  return cls(**data)
1258
+
1259
+ @classmethod
1260
+ def subscription_login(
1261
+ cls,
1262
+ vendor: SupportedVendor,
1263
+ model: str,
1264
+ force_login: bool = False,
1265
+ open_browser: bool = True,
1266
+ **llm_kwargs,
1267
+ ) -> LLM:
1268
+ """Authenticate with a subscription service and return an LLM instance.
1269
+
1270
+ This method provides subscription-based access to LLM models that are
1271
+ available through chat subscriptions (e.g., ChatGPT Plus/Pro) rather
1272
+ than API credits. It handles credential caching, token refresh, and
1273
+ the OAuth login flow.
1274
+
1275
+ Currently supported vendors:
1276
+ - "openai": ChatGPT Plus/Pro subscription for Codex models
1277
+
1278
+ Supported OpenAI models:
1279
+ - gpt-5.1-codex-max
1280
+ - gpt-5.1-codex-mini
1281
+ - gpt-5.2
1282
+ - gpt-5.2-codex
1283
+
1284
+ Args:
1285
+ vendor: The vendor/provider. Currently only "openai" is supported.
1286
+ model: The model to use. Must be supported by the vendor's
1287
+ subscription service.
1288
+ force_login: If True, always perform a fresh login even if valid
1289
+ credentials exist.
1290
+ open_browser: Whether to automatically open the browser for the
1291
+ OAuth login flow.
1292
+ **llm_kwargs: Additional arguments to pass to the LLM constructor.
1293
+
1294
+ Returns:
1295
+ An LLM instance configured for subscription-based access.
1296
+
1297
+ Raises:
1298
+ ValueError: If the vendor or model is not supported.
1299
+ RuntimeError: If authentication fails.
1300
+
1301
+ Example:
1302
+ >>> from openhands.sdk import LLM
1303
+ >>> # First time: opens browser for OAuth login
1304
+ >>> llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex")
1305
+ >>> # Subsequent calls: reuses cached credentials
1306
+ >>> llm = LLM.subscription_login(vendor="openai", model="gpt-5.2-codex")
1307
+ """
1308
+ from openhands.sdk.llm.auth.openai import subscription_login
1309
+
1310
+ return subscription_login(
1311
+ vendor=vendor,
1312
+ model=model,
1313
+ force_login=force_login,
1314
+ open_browser=open_browser,
1315
+ **llm_kwargs,
1316
+ )
@@ -11,10 +11,11 @@ from litellm.types.responses.main import (
11
11
  from litellm.types.utils import Message as LiteLLMMessage
12
12
  from openai.types.responses.response_output_message import ResponseOutputMessage
13
13
  from openai.types.responses.response_reasoning_item import ResponseReasoningItem
14
- from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
15
15
 
16
16
  from openhands.sdk.logger import get_logger
17
17
  from openhands.sdk.utils import DEFAULT_TEXT_CONTENT_LIMIT, maybe_truncate
18
+ from openhands.sdk.utils.deprecation import warn_deprecated
18
19
 
19
20
 
20
21
  logger = get_logger(__name__)
@@ -209,30 +210,11 @@ class Message(BaseModel):
209
210
  # These are the roles in the LLM's APIs
210
211
  role: Literal["user", "system", "assistant", "tool"]
211
212
  content: Sequence[TextContent | ImageContent] = Field(default_factory=list)
212
- cache_enabled: bool = False
213
- vision_enabled: bool = False
214
- # function calling
215
- function_calling_enabled: bool = False
216
213
  # - tool calls (from LLM)
217
214
  tool_calls: list[MessageToolCall] | None = None
218
215
  # - tool execution result (to LLM)
219
216
  tool_call_id: str | None = None
220
217
  name: str | None = None # name of the tool
221
- force_string_serializer: bool = Field(
222
- default=False,
223
- description=(
224
- "Force using string content serializer when sending to LLM API. "
225
- "Useful for providers that do not support list content, "
226
- "like HuggingFace and Groq."
227
- ),
228
- )
229
- send_reasoning_content: bool = Field(
230
- default=False,
231
- description=(
232
- "Whether to include the full reasoning content when sending to the LLM. "
233
- "Useful for models that support extended reasoning, like Kimi-K2-thinking."
234
- ),
235
- )
236
218
  # reasoning content (from reasoning models like o1, Claude thinking, DeepSeek R1)
237
219
  reasoning_content: str | None = Field(
238
220
  default=None,
@@ -249,6 +231,47 @@ class Message(BaseModel):
249
231
  description="OpenAI Responses reasoning item from model output",
250
232
  )
251
233
 
234
+ # Deprecated fields that were moved to to_chat_dict() parameters.
235
+ # These fields are ignored but accepted for backward compatibility.
236
+ # REMOVE_AT: 1.12.0 - Remove this list and the _handle_deprecated_fields validator
237
+ _DEPRECATED_FIELDS: ClassVar[tuple[str, ...]] = (
238
+ "cache_enabled",
239
+ "vision_enabled",
240
+ "function_calling_enabled",
241
+ "force_string_serializer",
242
+ "send_reasoning_content",
243
+ )
244
+
245
+ model_config = ConfigDict(extra="ignore")
246
+
247
+ @model_validator(mode="before")
248
+ @classmethod
249
+ def _handle_deprecated_fields(cls, data: Any) -> Any:
250
+ """Handle deprecated fields by emitting warnings and removing them.
251
+
252
+ REMOVE_AT: 1.12.0 - Remove this validator along with _DEPRECATED_FIELDS
253
+ """
254
+ if not isinstance(data, dict):
255
+ return data
256
+
257
+ deprecated_found = [f for f in cls._DEPRECATED_FIELDS if f in data]
258
+ for field in deprecated_found:
259
+ warn_deprecated(
260
+ f"Message.{field}",
261
+ deprecated_in="1.9.1",
262
+ removed_in="1.12.0",
263
+ details=(
264
+ f"The '{field}' field has been removed from Message. "
265
+ "Pass it as a parameter to to_chat_dict() instead, or use "
266
+ "LLM.format_messages_for_llm() which handles this automatically."
267
+ ),
268
+ stacklevel=4, # Adjust for validator call depth
269
+ )
270
+ # Remove the deprecated field so Pydantic doesn't complain
271
+ del data[field]
272
+
273
+ return data
274
+
252
275
  @property
253
276
  def contains_image(self) -> bool:
254
277
  return any(isinstance(content, ImageContent) for content in self.content)
@@ -264,17 +287,32 @@ class Message(BaseModel):
264
287
  return [TextContent(text=v)]
265
288
  return v
266
289
 
267
- def to_chat_dict(self) -> dict[str, Any]:
290
+ def to_chat_dict(
291
+ self,
292
+ *,
293
+ cache_enabled: bool,
294
+ vision_enabled: bool,
295
+ function_calling_enabled: bool,
296
+ force_string_serializer: bool,
297
+ send_reasoning_content: bool,
298
+ ) -> dict[str, Any]:
268
299
  """Serialize message for OpenAI Chat Completions.
269
300
 
301
+ Args:
302
+ cache_enabled: Whether prompt caching is active.
303
+ vision_enabled: Whether vision/image processing is enabled.
304
+ function_calling_enabled: Whether native function calling is enabled.
305
+ force_string_serializer: Force string serializer instead of list format.
306
+ send_reasoning_content: Whether to include reasoning_content in output.
307
+
270
308
  Chooses the appropriate content serializer and then injects threading keys:
271
309
  - Assistant tool call turn: role == "assistant" and self.tool_calls
272
310
  - Tool result turn: role == "tool" and self.tool_call_id (with name)
273
311
  """
274
- if not self.force_string_serializer and (
275
- self.cache_enabled or self.vision_enabled or self.function_calling_enabled
312
+ if not force_string_serializer and (
313
+ cache_enabled or vision_enabled or function_calling_enabled
276
314
  ):
277
- message_dict = self._list_serializer()
315
+ message_dict = self._list_serializer(vision_enabled=vision_enabled)
278
316
  else:
279
317
  # some providers, like HF and Groq/llama, don't support a list here, but a
280
318
  # single string
@@ -294,7 +332,7 @@ class Message(BaseModel):
294
332
  message_dict["name"] = self.name
295
333
 
296
334
  # Required for model like kimi-k2-thinking
297
- if self.send_reasoning_content and self.reasoning_content:
335
+ if send_reasoning_content and self.reasoning_content:
298
336
  message_dict["reasoning_content"] = self.reasoning_content
299
337
 
300
338
  return message_dict
@@ -309,7 +347,7 @@ class Message(BaseModel):
309
347
  # tool call keys are added in to_chat_dict to centralize behavior
310
348
  return message_dict
311
349
 
312
- def _list_serializer(self) -> dict[str, Any]:
350
+ def _list_serializer(self, *, vision_enabled: bool) -> dict[str, Any]:
313
351
  content: list[dict[str, Any]] = []
314
352
  role_tool_with_prompt_caching = False
315
353
 
@@ -337,7 +375,7 @@ class Message(BaseModel):
337
375
  d.pop("cache_control", None)
338
376
 
339
377
  # Handle vision-enabled filtering for ImageContent
340
- if isinstance(item, ImageContent) and self.vision_enabled:
378
+ if isinstance(item, ImageContent) and vision_enabled:
341
379
  content.extend(item_dicts)
342
380
  elif not isinstance(item, ImageContent):
343
381
  # Add non-image content (TextContent, etc.)
@@ -71,7 +71,8 @@ def select_chat_options(
71
71
  out.pop("temperature", None)
72
72
  out.pop("top_p", None)
73
73
 
74
- # Mistral / Gemini safety
74
+ # REMOVE_AT: 1.15.0 - Remove this block along with LLM.safety_settings field
75
+ # Mistral / Gemini safety (deprecated)
75
76
  if llm.safety_settings:
76
77
  ml = llm.model.lower()
77
78
  if "mistral" in ml or "gemini" in ml:
@@ -15,15 +15,16 @@ def select_responses_options(
15
15
  ) -> dict[str, Any]:
16
16
  """Behavior-preserving extraction of _normalize_responses_kwargs."""
17
17
  # Apply defaults for keys that are not forced by policy
18
- out = apply_defaults_if_absent(
19
- user_kwargs,
20
- {
21
- "max_output_tokens": llm.max_output_tokens,
22
- },
23
- )
18
+ # Note: max_output_tokens is not supported in subscription mode
19
+ defaults = {}
20
+ if not llm.is_subscription:
21
+ defaults["max_output_tokens"] = llm.max_output_tokens
22
+ out = apply_defaults_if_absent(user_kwargs, defaults)
24
23
 
25
24
  # Enforce sampling/tool behavior for Responses path
26
- out["temperature"] = 1.0
25
+ # Note: temperature is not supported in subscription mode
26
+ if not llm.is_subscription:
27
+ out["temperature"] = 1.0
27
28
  out["tool_choice"] = "auto"
28
29
 
29
30
  # If user didn't set extra_headers, propagate from llm config
@@ -155,6 +155,7 @@ FORCE_STRING_SERIALIZER_MODELS: list[str] = [
155
155
  # in the message input
156
156
  SEND_REASONING_CONTENT_MODELS: list[str] = [
157
157
  "kimi-k2-thinking",
158
+ "kimi-k2.5",
158
159
  "openrouter/minimax-m2", # MiniMax-M2 via OpenRouter (interleaved thinking)
159
160
  "deepseek/deepseek-reasoner",
160
161
  ]
@@ -181,6 +182,7 @@ def get_features(model: str) -> ModelFeatures:
181
182
  # Each entry: (pattern, default_temperature)
182
183
  DEFAULT_TEMPERATURE_MODELS: list[tuple[str, float]] = [
183
184
  ("kimi-k2-thinking", 1.0),
185
+ ("kimi-k2.5", 1.0),
184
186
  ]
185
187
 
186
188
 
@@ -2,27 +2,53 @@
2
2
 
3
3
  import asyncio
4
4
  import inspect
5
- from collections.abc import Callable
6
- from typing import Any
5
+ from collections.abc import Callable, Iterator
6
+ from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from fastmcp import Client as AsyncMCPClient
9
9
 
10
10
  from openhands.sdk.utils.async_executor import AsyncExecutor
11
11
 
12
12
 
13
+ if TYPE_CHECKING:
14
+ from openhands.sdk.mcp.tool import MCPToolDefinition
15
+
16
+
13
17
  class MCPClient(AsyncMCPClient):
14
- """
15
- Behaves exactly like fastmcp.Client (same constructor & async API),
16
- but owns a background event loop and offers:
18
+ """MCP client with sync helpers and lifecycle management.
19
+
20
+ Extends fastmcp.Client with:
17
21
  - call_async_from_sync(awaitable_or_fn, *args, timeout=None, **kwargs)
18
22
  - call_sync_from_async(fn, *args, **kwargs) # await this from async code
23
+
24
+ After create_mcp_tools() populates it, use as a sync context manager:
25
+
26
+ with create_mcp_tools(config) as client:
27
+ for tool in client.tools:
28
+ # use tool
29
+ # Connection automatically closed
30
+
31
+ Or manage lifecycle manually by calling sync_close() when done.
19
32
  """
20
33
 
21
34
  _executor: AsyncExecutor
35
+ _closed: bool
36
+ _tools: "list[MCPToolDefinition]"
22
37
 
23
38
  def __init__(self, *args, **kwargs):
24
39
  super().__init__(*args, **kwargs)
25
40
  self._executor = AsyncExecutor()
41
+ self._closed = False
42
+ self._tools = []
43
+
44
+ @property
45
+ def tools(self) -> "list[MCPToolDefinition]":
46
+ """The MCP tools using this client connection (returns a copy)."""
47
+ return list(self._tools)
48
+
49
+ async def connect(self) -> None:
50
+ """Establish connection to the MCP server."""
51
+ await self.__aenter__()
26
52
 
27
53
  def call_async_from_sync(
28
54
  self,
@@ -56,8 +82,11 @@ class MCPClient(AsyncMCPClient):
56
82
  Synchronously close the MCP client and cleanup resources.
57
83
 
58
84
  This will attempt to call the async close() method if available,
59
- then shutdown the background event loop.
85
+ then shutdown the background event loop. Safe to call multiple times.
60
86
  """
87
+ if self._closed:
88
+ return
89
+
61
90
  # Best-effort: try async close if parent provides it
62
91
  if hasattr(self, "close") and inspect.iscoroutinefunction(self.close):
63
92
  try:
@@ -67,6 +96,7 @@ class MCPClient(AsyncMCPClient):
67
96
 
68
97
  # Always cleanup the executor
69
98
  self._executor.close()
99
+ self._closed = True
70
100
 
71
101
  def __del__(self):
72
102
  """Cleanup on deletion."""
@@ -74,3 +104,20 @@ class MCPClient(AsyncMCPClient):
74
104
  self.sync_close()
75
105
  except Exception:
76
106
  pass # Ignore cleanup errors during deletion
107
+
108
+ # Sync context manager support
109
+ def __enter__(self) -> "MCPClient":
110
+ return self
111
+
112
+ def __exit__(self, *args: object) -> None:
113
+ self.sync_close()
114
+
115
+ # Iteration support for tools
116
+ def __iter__(self) -> "Iterator[MCPToolDefinition]":
117
+ return iter(self._tools)
118
+
119
+ def __len__(self) -> int:
120
+ return len(self._tools)
121
+
122
+ def __getitem__(self, index: int) -> "MCPToolDefinition":
123
+ return self._tools[index]