openhands-sdk 1.7.3__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 (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, TypeGuard
4
+
5
+ from litellm import ChatCompletionToolParam, Message as LiteLLMMessage
6
+ from litellm.types.utils import Choices, ModelResponse, StreamingChoices
7
+
8
+ from openhands.sdk.llm.exceptions import LLMNoResponseError
9
+ from openhands.sdk.llm.mixins.fn_call_converter import (
10
+ STOP_WORDS,
11
+ convert_fncall_messages_to_non_fncall_messages,
12
+ convert_non_fncall_messages_to_fncall_messages,
13
+ )
14
+ from openhands.sdk.llm.utils.model_features import get_features
15
+
16
+
17
+ class _HostSupports(Protocol):
18
+ model: str
19
+ disable_stop_word: bool | None
20
+ native_tool_calling: bool
21
+
22
+
23
+ class NonNativeToolCallingMixin:
24
+ """Mixin providing prompt-mocked tool-calling support when native FC is off.
25
+
26
+ Host requirements:
27
+ - self.model: str
28
+ - self.disable_stop_word: bool | None
29
+ - self.native_tool_calling -> bool
30
+ """
31
+
32
+ def should_mock_tool_calls(
33
+ self: _HostSupports, tools: list[ChatCompletionToolParam] | None
34
+ ) -> bool:
35
+ return bool(tools) and not self.native_tool_calling
36
+
37
+ def pre_request_prompt_mock(
38
+ self: _HostSupports,
39
+ messages: list[dict],
40
+ tools: list[ChatCompletionToolParam],
41
+ kwargs: dict,
42
+ ) -> tuple[list[dict], dict]:
43
+ """Convert to non-fncall prompting when native tool-calling is off."""
44
+ # Skip in-context learning examples for models that understand the format
45
+ # or have limited context windows
46
+ add_iclex = not any(
47
+ s in self.model for s in ("openhands-lm", "devstral", "nemotron")
48
+ )
49
+ messages = convert_fncall_messages_to_non_fncall_messages(
50
+ messages, tools, add_in_context_learning_example=add_iclex
51
+ )
52
+ if get_features(self.model).supports_stop_words and not self.disable_stop_word:
53
+ kwargs = dict(kwargs)
54
+ kwargs["stop"] = STOP_WORDS
55
+
56
+ # Ensure we don't send tool_choice when mocking
57
+ kwargs.pop("tool_choice", None)
58
+ return messages, kwargs
59
+
60
+ def post_response_prompt_mock(
61
+ self: _HostSupports,
62
+ resp: ModelResponse,
63
+ nonfncall_msgs: list[dict],
64
+ tools: list[ChatCompletionToolParam],
65
+ ) -> ModelResponse:
66
+ if len(resp.choices) < 1:
67
+ raise LLMNoResponseError(
68
+ "Response choices is less than 1 (seen in some providers). Resp: "
69
+ + str(resp)
70
+ )
71
+
72
+ def _all_choices(
73
+ items: list[Choices | StreamingChoices],
74
+ ) -> TypeGuard[list[Choices]]:
75
+ return all(isinstance(c, Choices) for c in items)
76
+
77
+ if not _all_choices(resp.choices):
78
+ raise AssertionError(
79
+ "Expected non-streaming Choices when post-processing mocked tools"
80
+ )
81
+
82
+ # Preserve provider-specific reasoning fields before conversion
83
+ orig_msg = resp.choices[0].message
84
+ non_fn_message: dict = orig_msg.model_dump()
85
+ fn_msgs: list[dict] = convert_non_fncall_messages_to_fncall_messages(
86
+ nonfncall_msgs + [non_fn_message], tools
87
+ )
88
+ last: dict = fn_msgs[-1]
89
+
90
+ for name in ("reasoning_content", "provider_specific_fields"):
91
+ val = getattr(orig_msg, name, None)
92
+ if not val:
93
+ continue
94
+ last[name] = val
95
+
96
+ resp.choices[0].message = LiteLLMMessage.model_validate(last)
97
+ return resp
@@ -0,0 +1 @@
1
+ # options package for LLM parameter selection helpers
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from openhands.sdk.llm.options.common import apply_defaults_if_absent
6
+ from openhands.sdk.llm.utils.model_features import get_features
7
+
8
+
9
+ def select_chat_options(
10
+ llm, user_kwargs: dict[str, Any], has_tools: bool
11
+ ) -> dict[str, Any]:
12
+ """Behavior-preserving extraction of _normalize_call_kwargs.
13
+
14
+ This keeps the exact provider-aware mappings and precedence.
15
+ """
16
+ # First pass: apply simple defaults without touching user-supplied values
17
+ defaults: dict[str, Any] = {
18
+ "top_k": llm.top_k,
19
+ "top_p": llm.top_p,
20
+ "temperature": llm.temperature,
21
+ # OpenAI-compatible param is `max_completion_tokens`
22
+ "max_completion_tokens": llm.max_output_tokens,
23
+ }
24
+ out = apply_defaults_if_absent(user_kwargs, defaults)
25
+
26
+ # Azure -> uses max_tokens instead
27
+ if llm.model.startswith("azure"):
28
+ if "max_completion_tokens" in out:
29
+ out["max_tokens"] = out.pop("max_completion_tokens")
30
+
31
+ # If user didn't set extra_headers, propagate from llm config
32
+ if llm.extra_headers is not None and "extra_headers" not in out:
33
+ out["extra_headers"] = dict(llm.extra_headers)
34
+
35
+ # Reasoning-model quirks
36
+ if get_features(llm.model).supports_reasoning_effort:
37
+ # LiteLLM automatically handles reasoning_effort for all models, including
38
+ # Claude Opus 4.5 (maps to output_config and adds beta header automatically)
39
+ if llm.reasoning_effort is not None:
40
+ out["reasoning_effort"] = llm.reasoning_effort
41
+
42
+ # All reasoning models ignore temp/top_p
43
+ out.pop("temperature", None)
44
+ out.pop("top_p", None)
45
+
46
+ # Gemini 2.5-pro default to low if not set
47
+ if "gemini-2.5-pro" in llm.model:
48
+ if llm.reasoning_effort in {None, "none"}:
49
+ out["reasoning_effort"] = "low"
50
+
51
+ # Extended thinking models
52
+ if get_features(llm.model).supports_extended_thinking:
53
+ if llm.extended_thinking_budget:
54
+ out["thinking"] = {
55
+ "type": "enabled",
56
+ "budget_tokens": llm.extended_thinking_budget,
57
+ }
58
+ # Enable interleaved thinking
59
+ # Merge default header with any user-provided headers; user wins on conflict
60
+ existing = out.get("extra_headers") or {}
61
+ out["extra_headers"] = {
62
+ "anthropic-beta": "interleaved-thinking-2025-05-14",
63
+ **existing,
64
+ }
65
+ # Fix litellm behavior
66
+ out["max_tokens"] = llm.max_output_tokens
67
+ # Anthropic models ignore temp/top_p
68
+ out.pop("temperature", None)
69
+ out.pop("top_p", None)
70
+
71
+ # Mistral / Gemini safety
72
+ if llm.safety_settings:
73
+ ml = llm.model.lower()
74
+ if "mistral" in ml or "gemini" in ml:
75
+ out["safety_settings"] = llm.safety_settings
76
+
77
+ # Tools: if not using native, strip tool_choice so we don't confuse providers
78
+ if not has_tools:
79
+ out.pop("tools", None)
80
+ out.pop("tool_choice", None)
81
+
82
+ # Send prompt_cache_retention only if model supports it
83
+ if (
84
+ get_features(llm.model).supports_prompt_cache_retention
85
+ and llm.prompt_cache_retention
86
+ ):
87
+ out["prompt_cache_retention"] = llm.prompt_cache_retention
88
+
89
+ # Pass through user-provided extra_body unchanged
90
+ if llm.litellm_extra_body:
91
+ out["extra_body"] = llm.litellm_extra_body
92
+
93
+ return out
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def apply_defaults_if_absent(
7
+ user_kwargs: dict[str, Any], defaults: dict[str, Any]
8
+ ) -> dict[str, Any]:
9
+ """Return a new dict with defaults applied when keys are absent.
10
+
11
+ - Pure and deterministic; does not mutate inputs
12
+ - Only applies defaults when the key is missing and default is not None
13
+ - Does not alter user-provided values
14
+ """
15
+ out = dict(user_kwargs)
16
+ for key, value in defaults.items():
17
+ if key not in out and value is not None:
18
+ out[key] = value
19
+ return out
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from openhands.sdk.llm.options.common import apply_defaults_if_absent
6
+ from openhands.sdk.llm.utils.model_features import get_features
7
+
8
+
9
+ def select_responses_options(
10
+ llm,
11
+ user_kwargs: dict[str, Any],
12
+ *,
13
+ include: list[str] | None,
14
+ store: bool | None,
15
+ ) -> dict[str, Any]:
16
+ """Behavior-preserving extraction of _normalize_responses_kwargs."""
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
+ )
24
+
25
+ # Enforce sampling/tool behavior for Responses path
26
+ out["temperature"] = 1.0
27
+ out["tool_choice"] = "auto"
28
+
29
+ # If user didn't set extra_headers, propagate from llm config
30
+ if llm.extra_headers is not None and "extra_headers" not in out:
31
+ out["extra_headers"] = dict(llm.extra_headers)
32
+
33
+ # Store defaults to False (stateless) unless explicitly provided
34
+ if store is not None:
35
+ out["store"] = bool(store)
36
+ else:
37
+ out.setdefault("store", False)
38
+
39
+ # Include encrypted reasoning only when the user enables it on the LLM,
40
+ # and only for stateless calls (store=False). Respect user choice.
41
+ include_list = list(include) if include is not None else []
42
+
43
+ if not out.get("store", False) and llm.enable_encrypted_reasoning:
44
+ if "reasoning.encrypted_content" not in include_list:
45
+ include_list.append("reasoning.encrypted_content")
46
+ if include_list:
47
+ out["include"] = include_list
48
+
49
+ # Include reasoning effort only if explicitly set
50
+ if llm.reasoning_effort:
51
+ out["reasoning"] = {"effort": llm.reasoning_effort}
52
+ # Optionally include summary if explicitly set (requires verified org)
53
+ if llm.reasoning_summary:
54
+ out["reasoning"]["summary"] = llm.reasoning_summary
55
+
56
+ # Send prompt_cache_retention only if model supports it
57
+ if (
58
+ get_features(llm.model).supports_prompt_cache_retention
59
+ and llm.prompt_cache_retention
60
+ ):
61
+ out["prompt_cache_retention"] = llm.prompt_cache_retention
62
+
63
+ # Pass through user-provided extra_body unchanged
64
+ if llm.litellm_extra_body:
65
+ out["extra_body"] = llm.litellm_extra_body
66
+
67
+ return out
@@ -0,0 +1,10 @@
1
+ from openhands.sdk.llm.router.base import RouterLLM
2
+ from openhands.sdk.llm.router.impl.multimodal import MultimodalRouter
3
+ from openhands.sdk.llm.router.impl.random import RandomRouter
4
+
5
+
6
+ __all__ = [
7
+ "RouterLLM",
8
+ "RandomRouter",
9
+ "MultimodalRouter",
10
+ ]
@@ -0,0 +1,117 @@
1
+ from abc import abstractmethod
2
+ from collections.abc import Sequence
3
+
4
+ from pydantic import (
5
+ Field,
6
+ field_validator,
7
+ model_validator,
8
+ )
9
+
10
+ from openhands.sdk.llm.llm import LLM
11
+ from openhands.sdk.llm.llm_response import LLMResponse
12
+ from openhands.sdk.llm.message import Message
13
+ from openhands.sdk.llm.streaming import TokenCallbackType
14
+ from openhands.sdk.logger import get_logger
15
+ from openhands.sdk.tool.tool import ToolDefinition
16
+
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ class RouterLLM(LLM):
22
+ """
23
+ Base class for multiple LLM acting as a unified LLM.
24
+ This class provides a foundation for implementing model routing by
25
+ inheriting from LLM, allowing routers to work with multiple underlying
26
+ LLM models while presenting a unified LLM interface to consumers.
27
+ Key features:
28
+ - Works with multiple LLMs configured via llms_for_routing
29
+ - Delegates all other operations/properties to the selected LLM
30
+ - Provides routing interface through select_llm() method
31
+ """
32
+
33
+ router_name: str = Field(default="base_router", description="Name of the router")
34
+ llms_for_routing: dict[str, LLM] = Field(
35
+ default_factory=dict
36
+ ) # Mapping of LLM name to LLM instance for routing
37
+ active_llm: LLM | None = Field(
38
+ default=None, description="Currently selected LLM instance"
39
+ )
40
+
41
+ @field_validator("llms_for_routing")
42
+ @classmethod
43
+ def validate_llms_not_empty(cls, v):
44
+ if not v:
45
+ raise ValueError(
46
+ "llms_for_routing cannot be empty - at least one LLM must be provided"
47
+ )
48
+ return v
49
+
50
+ def completion(
51
+ self,
52
+ messages: list[Message],
53
+ tools: Sequence[ToolDefinition] | None = None,
54
+ return_metrics: bool = False,
55
+ add_security_risk_prediction: bool = False,
56
+ on_token: TokenCallbackType | None = None,
57
+ **kwargs,
58
+ ) -> LLMResponse:
59
+ """
60
+ This method intercepts completion calls and routes them to the appropriate
61
+ underlying LLM based on the routing logic implemented in select_llm().
62
+ """
63
+ # Select appropriate LLM
64
+ selected_model = self.select_llm(messages)
65
+ self.active_llm = self.llms_for_routing[selected_model]
66
+
67
+ logger.info(f"RouterLLM routing to {selected_model}...")
68
+
69
+ # Delegate to selected LLM
70
+ return self.active_llm.completion(
71
+ messages=messages,
72
+ tools=tools,
73
+ _return_metrics=return_metrics,
74
+ add_security_risk_prediction=add_security_risk_prediction,
75
+ on_token=on_token,
76
+ **kwargs,
77
+ )
78
+
79
+ @abstractmethod
80
+ def select_llm(self, messages: list[Message]) -> str:
81
+ """Select which LLM to use based on messages and events.
82
+
83
+ This method implements the core routing logic for the RouterLLM.
84
+ Subclasses should analyze the provided messages to determine which
85
+ LLM from llms_for_routing is most appropriate for handling the request.
86
+
87
+ Args:
88
+ messages: List of messages in the conversation that can be used
89
+ to inform the routing decision.
90
+
91
+ Returns:
92
+ The key/name of the LLM to use from llms_for_routing dictionary.
93
+ """
94
+
95
+ def __getattr__(self, name):
96
+ """Delegate other attributes/methods to the active LLM."""
97
+ fallback_llm = next(iter(self.llms_for_routing.values()))
98
+ logger.info(f"RouterLLM: No active LLM, using first LLM for attribute '{name}'")
99
+ return getattr(fallback_llm, name)
100
+
101
+ def __str__(self) -> str:
102
+ """String representation of the router."""
103
+ return f"{self.__class__.__name__}(llms={list(self.llms_for_routing.keys())})"
104
+
105
+ @model_validator(mode="before")
106
+ @classmethod
107
+ def set_placeholder_model(cls, data):
108
+ """Guarantee `model` exists before LLM base validation runs."""
109
+ if not isinstance(data, dict):
110
+ return data
111
+ d = dict(data)
112
+
113
+ # In router, we don't need a model name to be specified
114
+ if "model" not in d or not d["model"]:
115
+ d["model"] = d.get("router_name", "router")
116
+
117
+ return d
@@ -0,0 +1,76 @@
1
+ from typing import ClassVar
2
+
3
+ from pydantic import model_validator
4
+
5
+ from openhands.sdk.llm.message import Message
6
+ from openhands.sdk.llm.router.base import RouterLLM
7
+ from openhands.sdk.logger import get_logger
8
+
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class MultimodalRouter(RouterLLM):
14
+ """
15
+ A RouterLLM implementation that routes requests based on multimodal content
16
+ (e.g., images) and token limits. If any message contains multimodal content
17
+ or if the token limit of the secondary model is exceeded, it routes to the
18
+ primary model. Otherwise, it routes to the secondary model.
19
+
20
+ Note: The primary model is expected to support multimodal content, while
21
+ the secondary model is typically a text-only model with a lower context window.
22
+ """
23
+
24
+ router_name: str = "multimodal_router"
25
+
26
+ PRIMARY_MODEL_KEY: ClassVar[str] = "primary"
27
+ SECONDARY_MODEL_KEY: ClassVar[str] = "secondary"
28
+
29
+ def select_llm(self, messages: list[Message]) -> str:
30
+ """Select LLM based on multimodal content and token limits."""
31
+ route_to_primary = False
32
+
33
+ # Check for multimodal content in messages
34
+ for message in messages:
35
+ if message.contains_image:
36
+ logger.info(
37
+ "Multimodal content detected in messages. "
38
+ "Routing to the primary model."
39
+ )
40
+ route_to_primary = True
41
+
42
+ # Check if `messages` exceeds context window of the secondary model
43
+ # Assuming the secondary model has a lower context window limit
44
+ # compared to the primary model
45
+ secondary_llm = self.llms_for_routing.get(self.SECONDARY_MODEL_KEY)
46
+ if secondary_llm and (
47
+ secondary_llm.max_input_tokens
48
+ and secondary_llm.get_token_count(messages) > secondary_llm.max_input_tokens
49
+ ):
50
+ logger.warning(
51
+ f"Messages having {secondary_llm.get_token_count(messages)} tokens, exceeded secondary model's max input tokens ({secondary_llm.max_input_tokens} tokens). " # noqa: E501
52
+ "Routing to the primary model."
53
+ )
54
+ route_to_primary = True
55
+
56
+ if route_to_primary:
57
+ logger.info("Routing to the primary model...")
58
+ return self.PRIMARY_MODEL_KEY
59
+ else:
60
+ logger.info("Routing to the secondary model...")
61
+ return self.SECONDARY_MODEL_KEY
62
+
63
+ @model_validator(mode="after")
64
+ def _validate_llms_for_routing(self) -> "MultimodalRouter":
65
+ """Ensure required models are present in llms_for_routing."""
66
+ if self.PRIMARY_MODEL_KEY not in self.llms_for_routing:
67
+ raise ValueError(
68
+ f"Primary LLM key '{self.PRIMARY_MODEL_KEY}' not found"
69
+ " in llms_for_routing."
70
+ )
71
+ if self.SECONDARY_MODEL_KEY not in self.llms_for_routing:
72
+ raise ValueError(
73
+ f"Secondary LLM key '{self.SECONDARY_MODEL_KEY}' not found"
74
+ " in llms_for_routing."
75
+ )
76
+ return self
@@ -0,0 +1,22 @@
1
+ import random
2
+
3
+ from openhands.sdk.llm.message import Message
4
+ from openhands.sdk.llm.router.base import RouterLLM
5
+ from openhands.sdk.logger import get_logger
6
+
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ class RandomRouter(RouterLLM):
12
+ """
13
+ A simple implementation of RouterLLM that randomly selects an LLM from
14
+ llms_for_routing for each completion request.
15
+ """
16
+
17
+ router_name: str = "random_router"
18
+
19
+ def select_llm(self, messages: list[Message]) -> str: # noqa: ARG002
20
+ selected_llm_name = random.choice(list(self.llms_for_routing.keys()))
21
+ logger.info(f"Randomly selected LLM: {selected_llm_name}")
22
+ return selected_llm_name
@@ -0,0 +1,9 @@
1
+ from collections.abc import Callable
2
+
3
+ from litellm.types.utils import ModelResponseStream
4
+
5
+
6
+ # Type alias for stream chunks
7
+ LLMStreamChunk = ModelResponseStream
8
+
9
+ TokenCallbackType = Callable[[LLMStreamChunk], None]