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,656 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any, ClassVar, Literal
|
|
5
|
+
|
|
6
|
+
from litellm import ChatCompletionMessageToolCall, ResponseFunctionToolCall
|
|
7
|
+
from litellm.types.responses.main import (
|
|
8
|
+
GenericResponseOutputItem,
|
|
9
|
+
OutputFunctionToolCall,
|
|
10
|
+
)
|
|
11
|
+
from litellm.types.utils import Message as LiteLLMMessage
|
|
12
|
+
from openai.types.responses.response_output_message import ResponseOutputMessage
|
|
13
|
+
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
15
|
+
|
|
16
|
+
from openhands.sdk.logger import get_logger
|
|
17
|
+
from openhands.sdk.utils import DEFAULT_TEXT_CONTENT_LIMIT, maybe_truncate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MessageToolCall(BaseModel):
|
|
24
|
+
"""Transport-agnostic tool call representation.
|
|
25
|
+
|
|
26
|
+
One canonical id is used for linking across actions/observations and
|
|
27
|
+
for Responses function_call_output call_id.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
id: str = Field(..., description="Canonical tool call id")
|
|
31
|
+
name: str = Field(..., description="Tool/function name")
|
|
32
|
+
arguments: str = Field(..., description="JSON string of arguments")
|
|
33
|
+
origin: Literal["completion", "responses"] = Field(
|
|
34
|
+
..., description="Originating API family"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_chat_tool_call(
|
|
39
|
+
cls, tool_call: ChatCompletionMessageToolCall
|
|
40
|
+
) -> "MessageToolCall":
|
|
41
|
+
"""Create a MessageToolCall from a Chat Completions tool call."""
|
|
42
|
+
if not tool_call.type == "function":
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Unsupported tool call type for {tool_call=}, expected 'function' "
|
|
45
|
+
f"not {tool_call.type}'"
|
|
46
|
+
)
|
|
47
|
+
if tool_call.function is None:
|
|
48
|
+
raise ValueError(f"tool_call.function is None for {tool_call=}")
|
|
49
|
+
if tool_call.function.name is None:
|
|
50
|
+
raise ValueError(f"tool_call.function.name is None for {tool_call=}")
|
|
51
|
+
|
|
52
|
+
return cls(
|
|
53
|
+
id=tool_call.id,
|
|
54
|
+
name=tool_call.function.name,
|
|
55
|
+
arguments=tool_call.function.arguments,
|
|
56
|
+
origin="completion",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_responses_function_call(
|
|
61
|
+
cls, item: ResponseFunctionToolCall | OutputFunctionToolCall
|
|
62
|
+
) -> "MessageToolCall":
|
|
63
|
+
"""Create a MessageToolCall from a typed OpenAI Responses function_call item.
|
|
64
|
+
|
|
65
|
+
Note: OpenAI Responses function_call.arguments is already a JSON string.
|
|
66
|
+
"""
|
|
67
|
+
call_id = item.call_id or item.id or ""
|
|
68
|
+
name = item.name or ""
|
|
69
|
+
arguments_str = item.arguments or ""
|
|
70
|
+
|
|
71
|
+
if not call_id:
|
|
72
|
+
raise ValueError(f"Responses function_call missing call_id/id: {item!r}")
|
|
73
|
+
if not name:
|
|
74
|
+
raise ValueError(f"Responses function_call missing name: {item!r}")
|
|
75
|
+
|
|
76
|
+
return cls(
|
|
77
|
+
id=str(call_id),
|
|
78
|
+
name=str(name),
|
|
79
|
+
arguments=arguments_str,
|
|
80
|
+
origin="responses",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def to_chat_dict(self) -> dict[str, Any]:
|
|
84
|
+
"""Serialize to OpenAI Chat Completions tool_calls format."""
|
|
85
|
+
return {
|
|
86
|
+
"id": self.id,
|
|
87
|
+
"type": "function",
|
|
88
|
+
"function": {
|
|
89
|
+
"name": self.name,
|
|
90
|
+
"arguments": self.arguments,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def to_responses_dict(self) -> dict[str, Any]:
|
|
95
|
+
"""Serialize to OpenAI Responses 'function_call' input item format."""
|
|
96
|
+
# Responses requires ids to begin with 'fc'
|
|
97
|
+
resp_id = self.id if str(self.id).startswith("fc") else f"fc_{self.id}"
|
|
98
|
+
# Responses requires arguments to be a JSON string
|
|
99
|
+
args_str = (
|
|
100
|
+
self.arguments
|
|
101
|
+
if isinstance(self.arguments, str)
|
|
102
|
+
else json.dumps(self.arguments)
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
"type": "function_call",
|
|
106
|
+
"id": resp_id,
|
|
107
|
+
"call_id": resp_id,
|
|
108
|
+
"name": self.name,
|
|
109
|
+
"arguments": args_str,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ThinkingBlock(BaseModel):
|
|
114
|
+
"""Anthropic thinking block for extended thinking feature.
|
|
115
|
+
|
|
116
|
+
This represents the raw thinking blocks returned by Anthropic models
|
|
117
|
+
when extended thinking is enabled. These blocks must be preserved
|
|
118
|
+
and passed back to the API for tool use scenarios.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
type: Literal["thinking"] = "thinking"
|
|
122
|
+
thinking: str = Field(..., description="The thinking content")
|
|
123
|
+
signature: str | None = Field(
|
|
124
|
+
default=None, description="Cryptographic signature for the thinking block"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class RedactedThinkingBlock(BaseModel):
|
|
129
|
+
"""Redacted thinking block for previous responses without extended thinking.
|
|
130
|
+
|
|
131
|
+
This is used as a placeholder for assistant messages that were generated
|
|
132
|
+
before extended thinking was enabled.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
type: Literal["redacted_thinking"] = "redacted_thinking"
|
|
136
|
+
data: str = Field(..., description="The redacted thinking content")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ReasoningItemModel(BaseModel):
|
|
140
|
+
"""OpenAI Responses reasoning item (non-stream, subset we consume).
|
|
141
|
+
|
|
142
|
+
Do not log or render encrypted_content.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
id: str | None = Field(default=None)
|
|
146
|
+
summary: list[str] = Field(default_factory=list)
|
|
147
|
+
content: list[str] | None = Field(default=None)
|
|
148
|
+
encrypted_content: str | None = Field(default=None)
|
|
149
|
+
status: str | None = Field(default=None)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class BaseContent(BaseModel):
|
|
153
|
+
cache_prompt: bool = False
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
def to_llm_dict(self) -> list[dict[str, str | dict[str, str]]]:
|
|
157
|
+
"""Convert to LLM API format. Always returns a list of dictionaries.
|
|
158
|
+
|
|
159
|
+
Subclasses should implement this method to return a list of dictionaries,
|
|
160
|
+
even if they only have a single item.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TextContent(BaseContent):
|
|
165
|
+
type: Literal["text"] = "text"
|
|
166
|
+
text: str
|
|
167
|
+
# We use populate_by_name since mcp.types.TextContent
|
|
168
|
+
# alias meta -> _meta, but .model_dumps() will output "meta"
|
|
169
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(
|
|
170
|
+
extra="forbid", populate_by_name=True
|
|
171
|
+
)
|
|
172
|
+
enable_truncation: bool = True
|
|
173
|
+
|
|
174
|
+
def to_llm_dict(self) -> list[dict[str, str | dict[str, str]]]:
|
|
175
|
+
"""Convert to LLM API format."""
|
|
176
|
+
text = self.text
|
|
177
|
+
if self.enable_truncation and len(text) > DEFAULT_TEXT_CONTENT_LIMIT:
|
|
178
|
+
logger.warning(
|
|
179
|
+
f"TextContent text length ({len(text)}) exceeds limit "
|
|
180
|
+
f"({DEFAULT_TEXT_CONTENT_LIMIT}), truncating"
|
|
181
|
+
)
|
|
182
|
+
text = maybe_truncate(text, DEFAULT_TEXT_CONTENT_LIMIT)
|
|
183
|
+
|
|
184
|
+
data: dict[str, str | dict[str, str]] = {
|
|
185
|
+
"type": self.type,
|
|
186
|
+
"text": text,
|
|
187
|
+
}
|
|
188
|
+
if self.cache_prompt:
|
|
189
|
+
data["cache_control"] = {"type": "ephemeral"}
|
|
190
|
+
return [data]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ImageContent(BaseContent):
|
|
194
|
+
type: Literal["image"] = "image"
|
|
195
|
+
image_urls: list[str]
|
|
196
|
+
|
|
197
|
+
def to_llm_dict(self) -> list[dict[str, str | dict[str, str]]]:
|
|
198
|
+
"""Convert to LLM API format."""
|
|
199
|
+
images: list[dict[str, str | dict[str, str]]] = []
|
|
200
|
+
for url in self.image_urls:
|
|
201
|
+
images.append({"type": "image_url", "image_url": {"url": url}})
|
|
202
|
+
if self.cache_prompt and images:
|
|
203
|
+
images[-1]["cache_control"] = {"type": "ephemeral"}
|
|
204
|
+
return images
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Message(BaseModel):
|
|
208
|
+
# NOTE: this is not the same as EventSource
|
|
209
|
+
# These are the roles in the LLM's APIs
|
|
210
|
+
role: Literal["user", "system", "assistant", "tool"]
|
|
211
|
+
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
|
+
# - tool calls (from LLM)
|
|
217
|
+
tool_calls: list[MessageToolCall] | None = None
|
|
218
|
+
# - tool execution result (to LLM)
|
|
219
|
+
tool_call_id: str | None = None
|
|
220
|
+
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
|
+
# reasoning content (from reasoning models like o1, Claude thinking, DeepSeek R1)
|
|
237
|
+
reasoning_content: str | None = Field(
|
|
238
|
+
default=None,
|
|
239
|
+
description="Intermediate reasoning/thinking content from reasoning models",
|
|
240
|
+
)
|
|
241
|
+
# Anthropic-specific thinking blocks (not normalized by LiteLLM)
|
|
242
|
+
thinking_blocks: Sequence[ThinkingBlock | RedactedThinkingBlock] = Field(
|
|
243
|
+
default_factory=list,
|
|
244
|
+
description="Raw Anthropic thinking blocks for extended thinking feature",
|
|
245
|
+
)
|
|
246
|
+
# OpenAI Responses reasoning item (when provided via Responses API output)
|
|
247
|
+
responses_reasoning_item: ReasoningItemModel | None = Field(
|
|
248
|
+
default=None,
|
|
249
|
+
description="OpenAI Responses reasoning item from model output",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def contains_image(self) -> bool:
|
|
254
|
+
return any(isinstance(content, ImageContent) for content in self.content)
|
|
255
|
+
|
|
256
|
+
@field_validator("content", mode="before")
|
|
257
|
+
@classmethod
|
|
258
|
+
def _coerce_content(cls, v: Any) -> Sequence[TextContent | ImageContent] | Any:
|
|
259
|
+
# Accept None → []
|
|
260
|
+
if v is None:
|
|
261
|
+
return []
|
|
262
|
+
# Accept a single string → [TextContent(...)]
|
|
263
|
+
if isinstance(v, str):
|
|
264
|
+
return [TextContent(text=v)]
|
|
265
|
+
return v
|
|
266
|
+
|
|
267
|
+
def to_chat_dict(self) -> dict[str, Any]:
|
|
268
|
+
"""Serialize message for OpenAI Chat Completions.
|
|
269
|
+
|
|
270
|
+
Chooses the appropriate content serializer and then injects threading keys:
|
|
271
|
+
- Assistant tool call turn: role == "assistant" and self.tool_calls
|
|
272
|
+
- Tool result turn: role == "tool" and self.tool_call_id (with name)
|
|
273
|
+
"""
|
|
274
|
+
if not self.force_string_serializer and (
|
|
275
|
+
self.cache_enabled or self.vision_enabled or self.function_calling_enabled
|
|
276
|
+
):
|
|
277
|
+
message_dict = self._list_serializer()
|
|
278
|
+
else:
|
|
279
|
+
# some providers, like HF and Groq/llama, don't support a list here, but a
|
|
280
|
+
# single string
|
|
281
|
+
message_dict = self._string_serializer()
|
|
282
|
+
|
|
283
|
+
# Assistant function_call(s)
|
|
284
|
+
if self.role == "assistant" and self.tool_calls:
|
|
285
|
+
message_dict["tool_calls"] = [tc.to_chat_dict() for tc in self.tool_calls]
|
|
286
|
+
self._remove_content_if_empty(message_dict)
|
|
287
|
+
|
|
288
|
+
# Tool result (observation) threading
|
|
289
|
+
if self.role == "tool" and self.tool_call_id is not None:
|
|
290
|
+
assert self.name is not None, (
|
|
291
|
+
"name is required when tool_call_id is not None"
|
|
292
|
+
)
|
|
293
|
+
message_dict["tool_call_id"] = self.tool_call_id
|
|
294
|
+
message_dict["name"] = self.name
|
|
295
|
+
|
|
296
|
+
# Required for model like kimi-k2-thinking
|
|
297
|
+
if self.send_reasoning_content and self.reasoning_content:
|
|
298
|
+
message_dict["reasoning_content"] = self.reasoning_content
|
|
299
|
+
|
|
300
|
+
return message_dict
|
|
301
|
+
|
|
302
|
+
def _string_serializer(self) -> dict[str, Any]:
|
|
303
|
+
# convert content to a single string
|
|
304
|
+
content = "\n".join(
|
|
305
|
+
item.text for item in self.content if isinstance(item, TextContent)
|
|
306
|
+
)
|
|
307
|
+
message_dict: dict[str, Any] = {"content": content, "role": self.role}
|
|
308
|
+
|
|
309
|
+
# tool call keys are added in to_chat_dict to centralize behavior
|
|
310
|
+
return message_dict
|
|
311
|
+
|
|
312
|
+
def _list_serializer(self) -> dict[str, Any]:
|
|
313
|
+
content: list[dict[str, Any]] = []
|
|
314
|
+
role_tool_with_prompt_caching = False
|
|
315
|
+
|
|
316
|
+
# Add thinking blocks first (for Anthropic extended thinking)
|
|
317
|
+
# Only add thinking blocks for assistant messages
|
|
318
|
+
thinking_blocks_dicts = []
|
|
319
|
+
if self.role == "assistant":
|
|
320
|
+
thinking_blocks = list(
|
|
321
|
+
self.thinking_blocks
|
|
322
|
+
) # Copy to avoid modifying original
|
|
323
|
+
for thinking_block in thinking_blocks:
|
|
324
|
+
thinking_dict = thinking_block.model_dump()
|
|
325
|
+
thinking_blocks_dicts.append(thinking_dict)
|
|
326
|
+
|
|
327
|
+
for item in self.content:
|
|
328
|
+
# All content types now return list[dict[str, Any]]
|
|
329
|
+
item_dicts = item.to_llm_dict()
|
|
330
|
+
|
|
331
|
+
# We have to remove cache_prompt for tool content and move it up to the
|
|
332
|
+
# message level
|
|
333
|
+
# See discussion here for details: https://github.com/BerriAI/litellm/issues/6422#issuecomment-2438765472
|
|
334
|
+
if self.role == "tool" and item.cache_prompt:
|
|
335
|
+
role_tool_with_prompt_caching = True
|
|
336
|
+
for d in item_dicts:
|
|
337
|
+
d.pop("cache_control", None)
|
|
338
|
+
|
|
339
|
+
# Handle vision-enabled filtering for ImageContent
|
|
340
|
+
if isinstance(item, ImageContent) and self.vision_enabled:
|
|
341
|
+
content.extend(item_dicts)
|
|
342
|
+
elif not isinstance(item, ImageContent):
|
|
343
|
+
# Add non-image content (TextContent, etc.)
|
|
344
|
+
content.extend(item_dicts)
|
|
345
|
+
|
|
346
|
+
message_dict: dict[str, Any] = {"content": content, "role": self.role}
|
|
347
|
+
if role_tool_with_prompt_caching:
|
|
348
|
+
message_dict["cache_control"] = {"type": "ephemeral"}
|
|
349
|
+
|
|
350
|
+
if thinking_blocks_dicts:
|
|
351
|
+
message_dict["thinking_blocks"] = thinking_blocks_dicts
|
|
352
|
+
|
|
353
|
+
# tool call keys are added in to_chat_dict to centralize behavior
|
|
354
|
+
return message_dict
|
|
355
|
+
|
|
356
|
+
def _remove_content_if_empty(self, message_dict: dict[str, Any]) -> None:
|
|
357
|
+
"""Remove empty text content entries from assistant tool-call messages.
|
|
358
|
+
|
|
359
|
+
Mutates the provided message_dict in-place:
|
|
360
|
+
- If content is a string of only whitespace, drop the 'content' key
|
|
361
|
+
- If content is a list, remove any text items with empty text; if the list
|
|
362
|
+
becomes empty, drop the 'content' key
|
|
363
|
+
"""
|
|
364
|
+
if "content" not in message_dict:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
content = message_dict["content"]
|
|
368
|
+
|
|
369
|
+
if isinstance(content, str):
|
|
370
|
+
if content.strip() == "":
|
|
371
|
+
message_dict.pop("content", None)
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if isinstance(content, list):
|
|
375
|
+
normalized: list[Any] = []
|
|
376
|
+
for item in content:
|
|
377
|
+
if not isinstance(item, dict):
|
|
378
|
+
normalized.append(item)
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
if item.get("type") == "text":
|
|
382
|
+
text_value = item.get("text", "")
|
|
383
|
+
if isinstance(text_value, str):
|
|
384
|
+
if text_value.strip() == "":
|
|
385
|
+
continue
|
|
386
|
+
else:
|
|
387
|
+
raise ValueError(
|
|
388
|
+
f"Text content item has non-string text value: "
|
|
389
|
+
f"{text_value!r}"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
normalized.append(item)
|
|
393
|
+
|
|
394
|
+
if normalized:
|
|
395
|
+
message_dict["content"] = normalized
|
|
396
|
+
else:
|
|
397
|
+
message_dict.pop("content", None)
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
# Any other content shape is left as-is
|
|
401
|
+
|
|
402
|
+
def to_responses_value(self, *, vision_enabled: bool) -> str | list[dict[str, Any]]:
|
|
403
|
+
"""Return serialized form.
|
|
404
|
+
|
|
405
|
+
Either an instructions string (for system) or input items (for other roles)."""
|
|
406
|
+
if self.role == "system":
|
|
407
|
+
parts: list[str] = []
|
|
408
|
+
for c in self.content:
|
|
409
|
+
if isinstance(c, TextContent) and c.text:
|
|
410
|
+
parts.append(c.text)
|
|
411
|
+
return "\n".join(parts)
|
|
412
|
+
return self.to_responses_dict(vision_enabled=vision_enabled)
|
|
413
|
+
|
|
414
|
+
def to_responses_dict(self, *, vision_enabled: bool) -> list[dict[str, Any]]:
|
|
415
|
+
"""Serialize message for OpenAI Responses (input parameter).
|
|
416
|
+
|
|
417
|
+
Produces a list of "input" items for the Responses API:
|
|
418
|
+
- system: returns [], system content is expected in 'instructions'
|
|
419
|
+
- user: one 'message' item with content parts -> input_text / input_image
|
|
420
|
+
(when vision enabled)
|
|
421
|
+
- assistant: emits prior assistant content as input_text,
|
|
422
|
+
and function_call items for tool_calls
|
|
423
|
+
- tool: emits function_call_output items (one per TextContent)
|
|
424
|
+
with matching call_id
|
|
425
|
+
"""
|
|
426
|
+
items: list[dict[str, Any]] = []
|
|
427
|
+
|
|
428
|
+
if self.role == "system":
|
|
429
|
+
return items
|
|
430
|
+
|
|
431
|
+
if self.role == "user":
|
|
432
|
+
content_items: list[dict[str, Any]] = []
|
|
433
|
+
for c in self.content:
|
|
434
|
+
if isinstance(c, TextContent):
|
|
435
|
+
content_items.append({"type": "input_text", "text": c.text})
|
|
436
|
+
elif isinstance(c, ImageContent) and vision_enabled:
|
|
437
|
+
for url in c.image_urls:
|
|
438
|
+
content_items.append(
|
|
439
|
+
{"type": "input_image", "image_url": url, "detail": "auto"}
|
|
440
|
+
)
|
|
441
|
+
items.append(
|
|
442
|
+
{
|
|
443
|
+
"type": "message",
|
|
444
|
+
"role": "user",
|
|
445
|
+
"content": content_items
|
|
446
|
+
or [
|
|
447
|
+
{
|
|
448
|
+
"type": "input_text",
|
|
449
|
+
"text": "",
|
|
450
|
+
}
|
|
451
|
+
],
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
return items
|
|
455
|
+
|
|
456
|
+
if self.role == "assistant":
|
|
457
|
+
# Include prior turn's reasoning item exactly as received (if any)
|
|
458
|
+
# Send reasoning first, followed by content and tool calls
|
|
459
|
+
if self.responses_reasoning_item is not None:
|
|
460
|
+
ri = self.responses_reasoning_item
|
|
461
|
+
# Only send back if we have an id; required by the param schema
|
|
462
|
+
if ri.id is not None:
|
|
463
|
+
reasoning_item: dict[str, Any] = {
|
|
464
|
+
"type": "reasoning",
|
|
465
|
+
"id": ri.id,
|
|
466
|
+
# Always include summary exactly as received (can be empty)
|
|
467
|
+
"summary": [
|
|
468
|
+
{"type": "summary_text", "text": s}
|
|
469
|
+
for s in (ri.summary or [])
|
|
470
|
+
],
|
|
471
|
+
}
|
|
472
|
+
# Optional content passthrough
|
|
473
|
+
if ri.content:
|
|
474
|
+
reasoning_item["content"] = [
|
|
475
|
+
{"type": "reasoning_text", "text": t} for t in ri.content
|
|
476
|
+
]
|
|
477
|
+
# Optional fields as received
|
|
478
|
+
if ri.encrypted_content:
|
|
479
|
+
reasoning_item["encrypted_content"] = ri.encrypted_content
|
|
480
|
+
if ri.status:
|
|
481
|
+
reasoning_item["status"] = ri.status
|
|
482
|
+
items.append(reasoning_item)
|
|
483
|
+
|
|
484
|
+
# Emit prior assistant content as a single message item using output_text
|
|
485
|
+
content_items: list[dict[str, Any]] = []
|
|
486
|
+
for c in self.content:
|
|
487
|
+
if isinstance(c, TextContent) and c.text:
|
|
488
|
+
content_items.append({"type": "output_text", "text": c.text})
|
|
489
|
+
if content_items:
|
|
490
|
+
items.append(
|
|
491
|
+
{
|
|
492
|
+
"type": "message",
|
|
493
|
+
"role": "assistant",
|
|
494
|
+
"content": content_items,
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
# Emit assistant tool calls so subsequent function_call_output
|
|
498
|
+
# can match call_id
|
|
499
|
+
if self.tool_calls:
|
|
500
|
+
for tc in self.tool_calls:
|
|
501
|
+
items.append(tc.to_responses_dict())
|
|
502
|
+
|
|
503
|
+
return items
|
|
504
|
+
|
|
505
|
+
if self.role == "tool":
|
|
506
|
+
if self.tool_call_id is not None:
|
|
507
|
+
# Responses requires function_call_output.call_id
|
|
508
|
+
# to match a previous function_call id
|
|
509
|
+
resp_call_id = (
|
|
510
|
+
self.tool_call_id
|
|
511
|
+
if str(self.tool_call_id).startswith("fc")
|
|
512
|
+
else f"fc_{self.tool_call_id}"
|
|
513
|
+
)
|
|
514
|
+
for c in self.content:
|
|
515
|
+
if isinstance(c, TextContent):
|
|
516
|
+
items.append(
|
|
517
|
+
{
|
|
518
|
+
"type": "function_call_output",
|
|
519
|
+
"call_id": resp_call_id,
|
|
520
|
+
"output": c.text,
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
return items
|
|
524
|
+
|
|
525
|
+
return items
|
|
526
|
+
|
|
527
|
+
@classmethod
|
|
528
|
+
def from_llm_chat_message(cls, message: LiteLLMMessage) -> "Message":
|
|
529
|
+
"""Convert a LiteLLMMessage (Chat Completions) to our Message class.
|
|
530
|
+
|
|
531
|
+
Provider-agnostic mapping for reasoning:
|
|
532
|
+
- Prefer `message.reasoning_content` if present (LiteLLM normalized field)
|
|
533
|
+
- Extract `thinking_blocks` from content array (Anthropic-specific)
|
|
534
|
+
"""
|
|
535
|
+
assert message.role != "function", "Function role is not supported"
|
|
536
|
+
|
|
537
|
+
rc = getattr(message, "reasoning_content", None)
|
|
538
|
+
thinking_blocks = getattr(message, "thinking_blocks", None)
|
|
539
|
+
|
|
540
|
+
# Convert to list of ThinkingBlock or RedactedThinkingBlock
|
|
541
|
+
if thinking_blocks is not None:
|
|
542
|
+
thinking_blocks = [
|
|
543
|
+
ThinkingBlock(**tb)
|
|
544
|
+
if tb.get("type") == "thinking"
|
|
545
|
+
else RedactedThinkingBlock(**tb)
|
|
546
|
+
for tb in thinking_blocks
|
|
547
|
+
]
|
|
548
|
+
else:
|
|
549
|
+
thinking_blocks = []
|
|
550
|
+
|
|
551
|
+
tool_calls = None
|
|
552
|
+
|
|
553
|
+
if message.tool_calls:
|
|
554
|
+
# Validate tool calls - filter out non-function types
|
|
555
|
+
if any(tc.type != "function" for tc in message.tool_calls):
|
|
556
|
+
logger.warning(
|
|
557
|
+
"LLM returned tool calls but some are not of type 'function' - "
|
|
558
|
+
"ignoring those"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
function_tool_calls = [
|
|
562
|
+
tc for tc in message.tool_calls if tc.type == "function"
|
|
563
|
+
]
|
|
564
|
+
|
|
565
|
+
if len(function_tool_calls) > 0:
|
|
566
|
+
tool_calls = [
|
|
567
|
+
MessageToolCall.from_chat_tool_call(tc)
|
|
568
|
+
for tc in function_tool_calls
|
|
569
|
+
]
|
|
570
|
+
else:
|
|
571
|
+
# If no function tool calls remain after filtering, raise an error
|
|
572
|
+
raise ValueError(
|
|
573
|
+
"LLM returned tool calls but none are of type 'function'"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
return Message(
|
|
577
|
+
role=message.role,
|
|
578
|
+
content=[TextContent(text=message.content)]
|
|
579
|
+
if isinstance(message.content, str)
|
|
580
|
+
else [],
|
|
581
|
+
tool_calls=tool_calls,
|
|
582
|
+
reasoning_content=rc,
|
|
583
|
+
thinking_blocks=thinking_blocks,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
@classmethod
|
|
587
|
+
def from_llm_responses_output(
|
|
588
|
+
cls,
|
|
589
|
+
output: Any,
|
|
590
|
+
) -> "Message":
|
|
591
|
+
"""Convert OpenAI Responses API output items into a single assistant Message.
|
|
592
|
+
|
|
593
|
+
Policy (non-stream):
|
|
594
|
+
- Collect assistant text by concatenating output_text parts from message items
|
|
595
|
+
- Normalize function_call items to MessageToolCall list
|
|
596
|
+
"""
|
|
597
|
+
assistant_text_parts: list[str] = []
|
|
598
|
+
tool_calls: list[MessageToolCall] = []
|
|
599
|
+
responses_reasoning_item: ReasoningItemModel | None = None
|
|
600
|
+
|
|
601
|
+
for item in output or []:
|
|
602
|
+
if (
|
|
603
|
+
isinstance(item, GenericResponseOutputItem)
|
|
604
|
+
or isinstance(item, ResponseOutputMessage)
|
|
605
|
+
) and item.type == "message":
|
|
606
|
+
for part in item.content or []:
|
|
607
|
+
if part.type == "output_text" and part.text:
|
|
608
|
+
assistant_text_parts.append(part.text)
|
|
609
|
+
elif (
|
|
610
|
+
isinstance(item, (OutputFunctionToolCall, ResponseFunctionToolCall))
|
|
611
|
+
and item.type == "function_call"
|
|
612
|
+
):
|
|
613
|
+
tc = MessageToolCall.from_responses_function_call(item)
|
|
614
|
+
tool_calls.append(tc)
|
|
615
|
+
elif isinstance(item, ResponseReasoningItem) and item.type == "reasoning":
|
|
616
|
+
# Parse OpenAI typed Responses "reasoning" output item
|
|
617
|
+
# (Pydantic BaseModel)
|
|
618
|
+
rid = item.id
|
|
619
|
+
summaries = item.summary or []
|
|
620
|
+
contents = item.content or []
|
|
621
|
+
enc = item.encrypted_content
|
|
622
|
+
status = item.status
|
|
623
|
+
|
|
624
|
+
summary_list: list[str] = [s.text for s in summaries]
|
|
625
|
+
content_texts: list[str] = [c.text for c in contents]
|
|
626
|
+
content_list: list[str] | None = content_texts or None
|
|
627
|
+
|
|
628
|
+
responses_reasoning_item = ReasoningItemModel(
|
|
629
|
+
id=rid,
|
|
630
|
+
summary=summary_list,
|
|
631
|
+
content=content_list,
|
|
632
|
+
encrypted_content=enc,
|
|
633
|
+
status=status,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
assistant_text = "\n".join(assistant_text_parts).strip()
|
|
637
|
+
return Message(
|
|
638
|
+
role="assistant",
|
|
639
|
+
content=[TextContent(text=assistant_text)] if assistant_text else [],
|
|
640
|
+
tool_calls=tool_calls or None,
|
|
641
|
+
responses_reasoning_item=responses_reasoning_item,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def content_to_str(contents: Sequence[TextContent | ImageContent]) -> list[str]:
|
|
646
|
+
"""Convert a list of TextContent and ImageContent to a list of strings.
|
|
647
|
+
|
|
648
|
+
This is primarily used for display purposes.
|
|
649
|
+
"""
|
|
650
|
+
text_parts = []
|
|
651
|
+
for content_item in contents:
|
|
652
|
+
if isinstance(content_item, TextContent):
|
|
653
|
+
text_parts.append(content_item.text)
|
|
654
|
+
elif isinstance(content_item, ImageContent):
|
|
655
|
+
text_parts.append(f"[Image: {len(content_item.image_urls)} URLs]")
|
|
656
|
+
return text_parts
|