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,85 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from cachetools import LRUCache
|
|
4
|
+
|
|
5
|
+
from openhands.sdk.logger import get_logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemoryLRUCache(LRUCache):
|
|
12
|
+
"""LRU cache with both entry count and memory size limits.
|
|
13
|
+
|
|
14
|
+
This cache enforces two limits:
|
|
15
|
+
1. Maximum number of entries (maxsize)
|
|
16
|
+
2. Maximum memory usage in bytes (max_memory)
|
|
17
|
+
|
|
18
|
+
When either limit is exceeded, the least recently used items are evicted.
|
|
19
|
+
|
|
20
|
+
Note: Memory tracking is based on string length for simplicity and accuracy.
|
|
21
|
+
For non-string values, sys.getsizeof is used as a rough approximation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, max_memory: int, max_size: int, *args, **kwargs):
|
|
25
|
+
# Ensure minimum maxsize of 1 to avoid LRUCache issues
|
|
26
|
+
maxsize = max(1, max_size)
|
|
27
|
+
super().__init__(maxsize=maxsize, *args, **kwargs)
|
|
28
|
+
self.max_memory = max_memory
|
|
29
|
+
self.current_memory = 0
|
|
30
|
+
|
|
31
|
+
def _get_size(self, value: Any) -> int:
|
|
32
|
+
"""Calculate size of value for memory tracking.
|
|
33
|
+
|
|
34
|
+
For strings (the common case in FileStore), we use len() which gives
|
|
35
|
+
accurate character count. For other types, we use sys.getsizeof() as
|
|
36
|
+
a rough approximation.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
# For strings, len() gives character count which is what we care about
|
|
40
|
+
# This is much more accurate than sys.getsizeof for our use case
|
|
41
|
+
return len(value)
|
|
42
|
+
elif isinstance(value, bytes):
|
|
43
|
+
return len(value)
|
|
44
|
+
else:
|
|
45
|
+
# For other types, fall back to sys.getsizeof
|
|
46
|
+
# This is mainly for edge cases and won't be accurate for nested
|
|
47
|
+
# structures, but it's better than nothing
|
|
48
|
+
try:
|
|
49
|
+
import sys
|
|
50
|
+
|
|
51
|
+
return sys.getsizeof(value)
|
|
52
|
+
except Exception:
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
def __setitem__(self, key: Any, value: Any) -> None:
|
|
56
|
+
new_size = self._get_size(value)
|
|
57
|
+
|
|
58
|
+
# Don't cache items that are larger than max_memory
|
|
59
|
+
# This prevents cache thrashing where one huge item evicts everything
|
|
60
|
+
if new_size > self.max_memory:
|
|
61
|
+
logger.debug(
|
|
62
|
+
f"Item too large for cache ({new_size} bytes > "
|
|
63
|
+
f"{self.max_memory} bytes), skipping cache"
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Update memory accounting if key exists
|
|
68
|
+
if key in self:
|
|
69
|
+
old_value = self[key]
|
|
70
|
+
self.current_memory -= self._get_size(old_value)
|
|
71
|
+
|
|
72
|
+
self.current_memory += new_size
|
|
73
|
+
|
|
74
|
+
# Evict items until we're under memory limit
|
|
75
|
+
while self.current_memory > self.max_memory and len(self) > 0:
|
|
76
|
+
self.popitem()
|
|
77
|
+
|
|
78
|
+
super().__setitem__(key, value)
|
|
79
|
+
|
|
80
|
+
def __delitem__(self, key: Any) -> None:
|
|
81
|
+
if key in self:
|
|
82
|
+
old_value = self[key]
|
|
83
|
+
self.current_memory -= self._get_size(old_value)
|
|
84
|
+
|
|
85
|
+
super().__delitem__(key)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
|
|
4
|
+
from openhands.sdk.io.cache import MemoryLRUCache
|
|
5
|
+
from openhands.sdk.logger import get_logger
|
|
6
|
+
from openhands.sdk.observability.laminar import observe
|
|
7
|
+
|
|
8
|
+
from .base import FileStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalFileStore(FileStore):
|
|
15
|
+
root: str
|
|
16
|
+
cache: MemoryLRUCache
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
root: str,
|
|
21
|
+
cache_limit_size: int = 500,
|
|
22
|
+
cache_memory_size: int = 20 * 1024 * 1024,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Initialize a LocalFileStore with caching.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
root: Root directory for file storage.
|
|
28
|
+
cache_limit_size: Maximum number of cached entries (default: 500).
|
|
29
|
+
cache_memory_size: Maximum cache memory in bytes (default: 20MB).
|
|
30
|
+
|
|
31
|
+
Note:
|
|
32
|
+
The cache assumes exclusive access to files. External modifications
|
|
33
|
+
to files will not be detected and may result in stale cache reads.
|
|
34
|
+
"""
|
|
35
|
+
if root.startswith("~"):
|
|
36
|
+
root = os.path.expanduser(root)
|
|
37
|
+
root = os.path.abspath(os.path.normpath(root))
|
|
38
|
+
self.root = root
|
|
39
|
+
os.makedirs(self.root, exist_ok=True)
|
|
40
|
+
self.cache = MemoryLRUCache(cache_memory_size, cache_limit_size)
|
|
41
|
+
|
|
42
|
+
def get_full_path(self, path: str) -> str:
|
|
43
|
+
# strip leading slash to keep relative under root
|
|
44
|
+
if path.startswith("/"):
|
|
45
|
+
path = path[1:]
|
|
46
|
+
# normalize path separators to handle both Unix (/) and Windows (\) styles
|
|
47
|
+
normalized_path = path.replace("\\", "/")
|
|
48
|
+
full = os.path.abspath(
|
|
49
|
+
os.path.normpath(os.path.join(self.root, normalized_path))
|
|
50
|
+
)
|
|
51
|
+
# ensure sandboxing
|
|
52
|
+
if os.path.commonpath([self.root, full]) != self.root:
|
|
53
|
+
raise ValueError(f"path escapes filestore root: {path}")
|
|
54
|
+
|
|
55
|
+
return full
|
|
56
|
+
|
|
57
|
+
@observe(name="LocalFileStore.write", span_type="TOOL")
|
|
58
|
+
def write(self, path: str, contents: str | bytes) -> None:
|
|
59
|
+
full_path = self.get_full_path(path)
|
|
60
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
61
|
+
if isinstance(contents, str):
|
|
62
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
63
|
+
f.write(contents)
|
|
64
|
+
self.cache[full_path] = contents
|
|
65
|
+
else:
|
|
66
|
+
with open(full_path, "wb") as f:
|
|
67
|
+
f.write(contents)
|
|
68
|
+
# Don't cache binary content - LocalFileStore is meant for JSON data
|
|
69
|
+
# If binary data is written and then read, it will error on read
|
|
70
|
+
|
|
71
|
+
def read(self, path: str) -> str:
|
|
72
|
+
full_path = self.get_full_path(path)
|
|
73
|
+
|
|
74
|
+
if full_path in self.cache:
|
|
75
|
+
return self.cache[full_path]
|
|
76
|
+
|
|
77
|
+
if not os.path.exists(full_path):
|
|
78
|
+
raise FileNotFoundError(path)
|
|
79
|
+
|
|
80
|
+
with open(full_path, encoding="utf-8") as f:
|
|
81
|
+
result = f.read()
|
|
82
|
+
|
|
83
|
+
self.cache[full_path] = result
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
@observe(name="LocalFileStore.list", span_type="TOOL")
|
|
87
|
+
def list(self, path: str) -> list[str]:
|
|
88
|
+
full_path = self.get_full_path(path)
|
|
89
|
+
if not os.path.exists(full_path):
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
# If path is a file, return the file itself (S3-consistent behavior)
|
|
93
|
+
if os.path.isfile(full_path):
|
|
94
|
+
return [path]
|
|
95
|
+
|
|
96
|
+
# Otherwise it's a directory, return its contents
|
|
97
|
+
files = [os.path.join(path, f) for f in os.listdir(full_path)]
|
|
98
|
+
files = [f + "/" if os.path.isdir(self.get_full_path(f)) else f for f in files]
|
|
99
|
+
return files
|
|
100
|
+
|
|
101
|
+
@observe(name="LocalFileStore.delete", span_type="TOOL")
|
|
102
|
+
def delete(self, path: str) -> None:
|
|
103
|
+
try:
|
|
104
|
+
full_path = self.get_full_path(path)
|
|
105
|
+
if not os.path.exists(full_path):
|
|
106
|
+
logger.debug(f"Local path does not exist: {full_path}")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if os.path.isfile(full_path):
|
|
110
|
+
os.remove(full_path)
|
|
111
|
+
del self.cache[full_path]
|
|
112
|
+
logger.debug(f"Removed local file: {full_path}")
|
|
113
|
+
elif os.path.isdir(full_path):
|
|
114
|
+
shutil.rmtree(full_path)
|
|
115
|
+
self.cache.clear()
|
|
116
|
+
logger.debug(f"Removed local directory: {full_path}")
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Error clearing local file store: {str(e)}")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from openhands.sdk.io.base import FileStore
|
|
4
|
+
from openhands.sdk.logger import get_logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InMemoryFileStore(FileStore):
|
|
11
|
+
files: dict[str, str]
|
|
12
|
+
|
|
13
|
+
def __init__(self, files: dict[str, str] | None = None) -> None:
|
|
14
|
+
self.files = {}
|
|
15
|
+
if files is not None:
|
|
16
|
+
self.files = files
|
|
17
|
+
|
|
18
|
+
def write(self, path: str, contents: str | bytes) -> None:
|
|
19
|
+
if isinstance(contents, bytes):
|
|
20
|
+
contents = contents.decode("utf-8")
|
|
21
|
+
self.files[path] = contents
|
|
22
|
+
|
|
23
|
+
def read(self, path: str) -> str:
|
|
24
|
+
if path not in self.files:
|
|
25
|
+
raise FileNotFoundError(path)
|
|
26
|
+
return self.files[path]
|
|
27
|
+
|
|
28
|
+
def list(self, path: str) -> list[str]:
|
|
29
|
+
files = []
|
|
30
|
+
for file in self.files:
|
|
31
|
+
if not file.startswith(path):
|
|
32
|
+
continue
|
|
33
|
+
suffix = file.removeprefix(path)
|
|
34
|
+
parts = suffix.split("/")
|
|
35
|
+
if parts[0] == "":
|
|
36
|
+
parts.pop(0)
|
|
37
|
+
if len(parts) == 1:
|
|
38
|
+
files.append(file)
|
|
39
|
+
else:
|
|
40
|
+
dir_path = os.path.join(path, parts[0])
|
|
41
|
+
if not dir_path.endswith("/"):
|
|
42
|
+
dir_path += "/"
|
|
43
|
+
if dir_path not in files:
|
|
44
|
+
files.append(dir_path)
|
|
45
|
+
return files
|
|
46
|
+
|
|
47
|
+
def delete(self, path: str) -> None:
|
|
48
|
+
try:
|
|
49
|
+
keys_to_delete = [key for key in self.files.keys() if key.startswith(path)]
|
|
50
|
+
for key in keys_to_delete:
|
|
51
|
+
del self.files[key]
|
|
52
|
+
logger.debug(f"Cleared in-memory file store: {path}")
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Error clearing in-memory file store: {str(e)}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from openhands.sdk.llm.llm import LLM
|
|
2
|
+
from openhands.sdk.llm.llm_registry import LLMRegistry, RegistryEvent
|
|
3
|
+
from openhands.sdk.llm.llm_response import LLMResponse
|
|
4
|
+
from openhands.sdk.llm.message import (
|
|
5
|
+
ImageContent,
|
|
6
|
+
Message,
|
|
7
|
+
MessageToolCall,
|
|
8
|
+
ReasoningItemModel,
|
|
9
|
+
RedactedThinkingBlock,
|
|
10
|
+
TextContent,
|
|
11
|
+
ThinkingBlock,
|
|
12
|
+
content_to_str,
|
|
13
|
+
)
|
|
14
|
+
from openhands.sdk.llm.router import RouterLLM
|
|
15
|
+
from openhands.sdk.llm.streaming import LLMStreamChunk, TokenCallbackType
|
|
16
|
+
from openhands.sdk.llm.utils.metrics import Metrics, MetricsSnapshot
|
|
17
|
+
from openhands.sdk.llm.utils.unverified_models import (
|
|
18
|
+
UNVERIFIED_MODELS_EXCLUDING_BEDROCK,
|
|
19
|
+
get_unverified_models,
|
|
20
|
+
)
|
|
21
|
+
from openhands.sdk.llm.utils.verified_models import VERIFIED_MODELS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"LLMResponse",
|
|
26
|
+
"LLM",
|
|
27
|
+
"LLMRegistry",
|
|
28
|
+
"RouterLLM",
|
|
29
|
+
"RegistryEvent",
|
|
30
|
+
"Message",
|
|
31
|
+
"MessageToolCall",
|
|
32
|
+
"TextContent",
|
|
33
|
+
"ImageContent",
|
|
34
|
+
"ThinkingBlock",
|
|
35
|
+
"RedactedThinkingBlock",
|
|
36
|
+
"ReasoningItemModel",
|
|
37
|
+
"content_to_str",
|
|
38
|
+
"LLMStreamChunk",
|
|
39
|
+
"TokenCallbackType",
|
|
40
|
+
"Metrics",
|
|
41
|
+
"MetricsSnapshot",
|
|
42
|
+
"VERIFIED_MODELS",
|
|
43
|
+
"UNVERIFIED_MODELS_EXCLUDING_BEDROCK",
|
|
44
|
+
"get_unverified_models",
|
|
45
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from .classifier import is_context_window_exceeded, looks_like_auth_error
|
|
2
|
+
from .mapping import map_provider_exception
|
|
3
|
+
from .types import (
|
|
4
|
+
FunctionCallConversionError,
|
|
5
|
+
FunctionCallNotExistsError,
|
|
6
|
+
FunctionCallValidationError,
|
|
7
|
+
LLMAuthenticationError,
|
|
8
|
+
LLMBadRequestError,
|
|
9
|
+
LLMContextWindowExceedError,
|
|
10
|
+
LLMError,
|
|
11
|
+
LLMMalformedActionError,
|
|
12
|
+
LLMNoActionError,
|
|
13
|
+
LLMNoResponseError,
|
|
14
|
+
LLMRateLimitError,
|
|
15
|
+
LLMResponseError,
|
|
16
|
+
LLMServiceUnavailableError,
|
|
17
|
+
LLMTimeoutError,
|
|
18
|
+
OperationCancelled,
|
|
19
|
+
UserCancelledError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Types
|
|
25
|
+
"LLMError",
|
|
26
|
+
"LLMMalformedActionError",
|
|
27
|
+
"LLMNoActionError",
|
|
28
|
+
"LLMResponseError",
|
|
29
|
+
"FunctionCallConversionError",
|
|
30
|
+
"FunctionCallValidationError",
|
|
31
|
+
"FunctionCallNotExistsError",
|
|
32
|
+
"LLMNoResponseError",
|
|
33
|
+
"LLMContextWindowExceedError",
|
|
34
|
+
"LLMAuthenticationError",
|
|
35
|
+
"LLMRateLimitError",
|
|
36
|
+
"LLMTimeoutError",
|
|
37
|
+
"LLMServiceUnavailableError",
|
|
38
|
+
"LLMBadRequestError",
|
|
39
|
+
"UserCancelledError",
|
|
40
|
+
"OperationCancelled",
|
|
41
|
+
# Helpers
|
|
42
|
+
"is_context_window_exceeded",
|
|
43
|
+
"looks_like_auth_error",
|
|
44
|
+
"map_provider_exception",
|
|
45
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from litellm.exceptions import BadRequestError, ContextWindowExceededError, OpenAIError
|
|
4
|
+
|
|
5
|
+
from .types import LLMContextWindowExceedError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Minimal, provider-agnostic context-window detection
|
|
9
|
+
LONG_PROMPT_PATTERNS: list[str] = [
|
|
10
|
+
"contextwindowexceedederror",
|
|
11
|
+
"prompt is too long",
|
|
12
|
+
"input length and `max_tokens` exceed context limit",
|
|
13
|
+
"please reduce the length of",
|
|
14
|
+
"the request exceeds the available context size",
|
|
15
|
+
"context length exceeded",
|
|
16
|
+
"input exceeds the context window",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_context_window_exceeded(exception: Exception) -> bool:
|
|
21
|
+
if isinstance(exception, (ContextWindowExceededError, LLMContextWindowExceedError)):
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
if not isinstance(exception, (BadRequestError, OpenAIError)):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
s = str(exception).lower()
|
|
28
|
+
return any(p in s for p in LONG_PROMPT_PATTERNS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
AUTH_PATTERNS: list[str] = [
|
|
32
|
+
"invalid api key",
|
|
33
|
+
"unauthorized",
|
|
34
|
+
"missing api key",
|
|
35
|
+
"invalid authentication",
|
|
36
|
+
"access denied",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def looks_like_auth_error(exception: Exception) -> bool:
|
|
41
|
+
if not isinstance(exception, (BadRequestError, OpenAIError)):
|
|
42
|
+
return False
|
|
43
|
+
s = str(exception).lower()
|
|
44
|
+
if any(p in s for p in AUTH_PATTERNS):
|
|
45
|
+
return True
|
|
46
|
+
# Some providers include explicit status codes in message text
|
|
47
|
+
for code in ("status 401", "status 403"):
|
|
48
|
+
if code in s:
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from litellm.exceptions import (
|
|
4
|
+
APIConnectionError,
|
|
5
|
+
BadRequestError,
|
|
6
|
+
InternalServerError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ServiceUnavailableError,
|
|
9
|
+
Timeout as LiteLLMTimeout,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from .classifier import is_context_window_exceeded, looks_like_auth_error
|
|
13
|
+
from .types import (
|
|
14
|
+
LLMAuthenticationError,
|
|
15
|
+
LLMBadRequestError,
|
|
16
|
+
LLMContextWindowExceedError,
|
|
17
|
+
LLMRateLimitError,
|
|
18
|
+
LLMServiceUnavailableError,
|
|
19
|
+
LLMTimeoutError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def map_provider_exception(exception: Exception) -> Exception:
|
|
24
|
+
"""
|
|
25
|
+
Map provider/LiteLLM exceptions to SDK-typed exceptions.
|
|
26
|
+
|
|
27
|
+
Returns original exception if no mapping applies.
|
|
28
|
+
"""
|
|
29
|
+
# Context window exceeded first (highest priority)
|
|
30
|
+
if is_context_window_exceeded(exception):
|
|
31
|
+
return LLMContextWindowExceedError(str(exception))
|
|
32
|
+
|
|
33
|
+
# Auth-like errors often appear as BadRequest/OpenAIError with specific text
|
|
34
|
+
if looks_like_auth_error(exception):
|
|
35
|
+
return LLMAuthenticationError(str(exception))
|
|
36
|
+
|
|
37
|
+
if isinstance(exception, RateLimitError):
|
|
38
|
+
return LLMRateLimitError(str(exception))
|
|
39
|
+
|
|
40
|
+
if isinstance(exception, LiteLLMTimeout):
|
|
41
|
+
return LLMTimeoutError(str(exception))
|
|
42
|
+
|
|
43
|
+
# Connectivity and service-side availability issues → service unavailable
|
|
44
|
+
if isinstance(
|
|
45
|
+
exception, (APIConnectionError, ServiceUnavailableError, InternalServerError)
|
|
46
|
+
):
|
|
47
|
+
return LLMServiceUnavailableError(str(exception))
|
|
48
|
+
|
|
49
|
+
# Generic client-side 4xx errors
|
|
50
|
+
if isinstance(exception, BadRequestError):
|
|
51
|
+
return LLMBadRequestError(str(exception))
|
|
52
|
+
|
|
53
|
+
# Unknown: let caller re-raise original
|
|
54
|
+
return exception
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
class LLMError(Exception):
|
|
2
|
+
message: str
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str) -> None:
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.message = message
|
|
7
|
+
|
|
8
|
+
def __str__(self) -> str:
|
|
9
|
+
return self.message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# General response parsing/validation errors
|
|
13
|
+
class LLMMalformedActionError(LLMError):
|
|
14
|
+
def __init__(self, message: str = "Malformed response") -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LLMNoActionError(LLMError):
|
|
19
|
+
def __init__(self, message: str = "Agent must return an action") -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LLMResponseError(LLMError):
|
|
24
|
+
def __init__(
|
|
25
|
+
self, message: str = "Failed to retrieve action from LLM response"
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Function-calling conversion/validation
|
|
31
|
+
class FunctionCallConversionError(LLMError):
|
|
32
|
+
def __init__(self, message: str) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FunctionCallValidationError(LLMError):
|
|
37
|
+
def __init__(self, message: str) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FunctionCallNotExistsError(LLMError):
|
|
42
|
+
def __init__(self, message: str) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Provider/transport related
|
|
47
|
+
class LLMNoResponseError(LLMError):
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
message: str = (
|
|
51
|
+
"LLM did not return a response. This is only seen in Gemini models so far."
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LLMContextWindowExceedError(LLMError):
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
message: str = (
|
|
61
|
+
"Conversation history longer than LLM context window limit. "
|
|
62
|
+
"Consider enabling a condenser or shortening inputs."
|
|
63
|
+
),
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__(message)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LLMAuthenticationError(LLMError):
|
|
69
|
+
def __init__(self, message: str = "Invalid or missing API credentials") -> None:
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LLMRateLimitError(LLMError):
|
|
74
|
+
def __init__(self, message: str = "Rate limit exceeded") -> None:
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LLMTimeoutError(LLMError):
|
|
79
|
+
def __init__(self, message: str = "LLM request timed out") -> None:
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LLMServiceUnavailableError(LLMError):
|
|
84
|
+
def __init__(self, message: str = "LLM service unavailable") -> None:
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LLMBadRequestError(LLMError):
|
|
89
|
+
def __init__(self, message: str = "Bad request to LLM provider") -> None:
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Other
|
|
94
|
+
class UserCancelledError(Exception):
|
|
95
|
+
def __init__(self, message: str = "User cancelled the request") -> None:
|
|
96
|
+
super().__init__(message)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class OperationCancelled(Exception):
|
|
100
|
+
def __init__(self, message: str = "Operation was cancelled") -> None:
|
|
101
|
+
super().__init__(message)
|