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.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- 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
|