openhands-sdk 1.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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