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.
- openhands/sdk/agent/agent.py +90 -16
- openhands/sdk/agent/base.py +33 -46
- openhands/sdk/context/condenser/base.py +36 -3
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
- openhands/sdk/context/skills/skill.py +2 -25
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +18 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +211 -36
- openhands/sdk/conversation/impl/remote_conversation.py +151 -12
- openhands/sdk/conversation/stuck_detector.py +18 -9
- openhands/sdk/critic/impl/api/critic.py +10 -7
- openhands/sdk/event/condenser.py +52 -2
- openhands/sdk/git/cached_repo.py +19 -0
- openhands/sdk/hooks/__init__.py +2 -0
- openhands/sdk/hooks/config.py +44 -4
- openhands/sdk/hooks/executor.py +2 -1
- openhands/sdk/llm/__init__.py +16 -0
- openhands/sdk/llm/auth/__init__.py +28 -0
- openhands/sdk/llm/auth/credentials.py +157 -0
- openhands/sdk/llm/auth/openai.py +762 -0
- openhands/sdk/llm/llm.py +222 -33
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- openhands/sdk/llm/options/responses_options.py +8 -7
- openhands/sdk/llm/utils/model_features.py +2 -0
- openhands/sdk/mcp/client.py +53 -6
- openhands/sdk/mcp/tool.py +24 -21
- openhands/sdk/mcp/utils.py +31 -23
- openhands/sdk/plugin/__init__.py +12 -1
- openhands/sdk/plugin/fetch.py +118 -14
- openhands/sdk/plugin/loader.py +111 -0
- openhands/sdk/plugin/plugin.py +155 -13
- openhands/sdk/plugin/types.py +163 -1
- openhands/sdk/secret/secrets.py +13 -1
- openhands/sdk/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- openhands/sdk/workspace/remote/base.py +8 -3
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
- {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.
|
|
53
|
-
from litellm.types.
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
671
|
-
if
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
+
)
|
openhands/sdk/llm/message.py
CHANGED
|
@@ -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(
|
|
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
|
|
275
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
openhands/sdk/mcp/client.py
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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]
|