letta-nightly 0.12.1.dev20251023104211__py3-none-any.whl → 0.13.0.dev20251024223017__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.

Potentially problematic release.


This version of letta-nightly might be problematic. Click here for more details.

Files changed (159) hide show
  1. letta/__init__.py +2 -3
  2. letta/adapters/letta_llm_adapter.py +1 -0
  3. letta/adapters/simple_llm_request_adapter.py +8 -5
  4. letta/adapters/simple_llm_stream_adapter.py +22 -6
  5. letta/agents/agent_loop.py +10 -3
  6. letta/agents/base_agent.py +4 -1
  7. letta/agents/helpers.py +41 -9
  8. letta/agents/letta_agent.py +11 -10
  9. letta/agents/letta_agent_v2.py +47 -37
  10. letta/agents/letta_agent_v3.py +395 -300
  11. letta/agents/voice_agent.py +8 -6
  12. letta/agents/voice_sleeptime_agent.py +3 -3
  13. letta/constants.py +30 -7
  14. letta/errors.py +20 -0
  15. letta/functions/function_sets/base.py +55 -3
  16. letta/functions/mcp_client/types.py +33 -57
  17. letta/functions/schema_generator.py +135 -23
  18. letta/groups/sleeptime_multi_agent_v3.py +6 -11
  19. letta/groups/sleeptime_multi_agent_v4.py +227 -0
  20. letta/helpers/converters.py +78 -4
  21. letta/helpers/crypto_utils.py +6 -2
  22. letta/interfaces/anthropic_parallel_tool_call_streaming_interface.py +9 -11
  23. letta/interfaces/anthropic_streaming_interface.py +3 -4
  24. letta/interfaces/gemini_streaming_interface.py +4 -6
  25. letta/interfaces/openai_streaming_interface.py +63 -28
  26. letta/llm_api/anthropic_client.py +7 -4
  27. letta/llm_api/deepseek_client.py +6 -4
  28. letta/llm_api/google_ai_client.py +3 -12
  29. letta/llm_api/google_vertex_client.py +1 -1
  30. letta/llm_api/helpers.py +90 -61
  31. letta/llm_api/llm_api_tools.py +4 -1
  32. letta/llm_api/openai.py +12 -12
  33. letta/llm_api/openai_client.py +53 -16
  34. letta/local_llm/constants.py +4 -3
  35. letta/local_llm/json_parser.py +5 -2
  36. letta/local_llm/utils.py +2 -3
  37. letta/log.py +171 -7
  38. letta/orm/agent.py +43 -9
  39. letta/orm/archive.py +4 -0
  40. letta/orm/custom_columns.py +15 -0
  41. letta/orm/identity.py +11 -11
  42. letta/orm/mcp_server.py +9 -0
  43. letta/orm/message.py +6 -1
  44. letta/orm/run_metrics.py +7 -2
  45. letta/orm/sqlalchemy_base.py +2 -2
  46. letta/orm/tool.py +3 -0
  47. letta/otel/tracing.py +2 -0
  48. letta/prompts/prompt_generator.py +7 -2
  49. letta/schemas/agent.py +41 -10
  50. letta/schemas/agent_file.py +3 -0
  51. letta/schemas/archive.py +4 -2
  52. letta/schemas/block.py +2 -1
  53. letta/schemas/enums.py +36 -3
  54. letta/schemas/file.py +3 -3
  55. letta/schemas/folder.py +2 -1
  56. letta/schemas/group.py +2 -1
  57. letta/schemas/identity.py +18 -9
  58. letta/schemas/job.py +3 -1
  59. letta/schemas/letta_message.py +71 -12
  60. letta/schemas/letta_request.py +7 -3
  61. letta/schemas/letta_stop_reason.py +0 -25
  62. letta/schemas/llm_config.py +8 -2
  63. letta/schemas/mcp.py +80 -83
  64. letta/schemas/mcp_server.py +349 -0
  65. letta/schemas/memory.py +20 -8
  66. letta/schemas/message.py +212 -67
  67. letta/schemas/providers/anthropic.py +13 -6
  68. letta/schemas/providers/azure.py +6 -4
  69. letta/schemas/providers/base.py +8 -4
  70. letta/schemas/providers/bedrock.py +6 -2
  71. letta/schemas/providers/cerebras.py +7 -3
  72. letta/schemas/providers/deepseek.py +2 -1
  73. letta/schemas/providers/google_gemini.py +15 -6
  74. letta/schemas/providers/groq.py +2 -1
  75. letta/schemas/providers/lmstudio.py +9 -6
  76. letta/schemas/providers/mistral.py +2 -1
  77. letta/schemas/providers/openai.py +7 -2
  78. letta/schemas/providers/together.py +9 -3
  79. letta/schemas/providers/xai.py +7 -3
  80. letta/schemas/run.py +7 -2
  81. letta/schemas/run_metrics.py +2 -1
  82. letta/schemas/sandbox_config.py +2 -2
  83. letta/schemas/secret.py +3 -158
  84. letta/schemas/source.py +2 -2
  85. letta/schemas/step.py +2 -2
  86. letta/schemas/tool.py +24 -1
  87. letta/schemas/usage.py +0 -1
  88. letta/server/rest_api/app.py +123 -7
  89. letta/server/rest_api/dependencies.py +3 -0
  90. letta/server/rest_api/interface.py +7 -4
  91. letta/server/rest_api/redis_stream_manager.py +16 -1
  92. letta/server/rest_api/routers/v1/__init__.py +7 -0
  93. letta/server/rest_api/routers/v1/agents.py +332 -322
  94. letta/server/rest_api/routers/v1/archives.py +127 -40
  95. letta/server/rest_api/routers/v1/blocks.py +54 -6
  96. letta/server/rest_api/routers/v1/chat_completions.py +146 -0
  97. letta/server/rest_api/routers/v1/folders.py +27 -35
  98. letta/server/rest_api/routers/v1/groups.py +23 -35
  99. letta/server/rest_api/routers/v1/identities.py +24 -10
  100. letta/server/rest_api/routers/v1/internal_runs.py +107 -0
  101. letta/server/rest_api/routers/v1/internal_templates.py +162 -179
  102. letta/server/rest_api/routers/v1/jobs.py +15 -27
  103. letta/server/rest_api/routers/v1/mcp_servers.py +309 -0
  104. letta/server/rest_api/routers/v1/messages.py +23 -34
  105. letta/server/rest_api/routers/v1/organizations.py +6 -27
  106. letta/server/rest_api/routers/v1/providers.py +35 -62
  107. letta/server/rest_api/routers/v1/runs.py +30 -43
  108. letta/server/rest_api/routers/v1/sandbox_configs.py +6 -4
  109. letta/server/rest_api/routers/v1/sources.py +26 -42
  110. letta/server/rest_api/routers/v1/steps.py +16 -29
  111. letta/server/rest_api/routers/v1/tools.py +17 -13
  112. letta/server/rest_api/routers/v1/users.py +5 -17
  113. letta/server/rest_api/routers/v1/voice.py +18 -27
  114. letta/server/rest_api/streaming_response.py +5 -2
  115. letta/server/rest_api/utils.py +187 -25
  116. letta/server/server.py +27 -22
  117. letta/server/ws_api/server.py +5 -4
  118. letta/services/agent_manager.py +148 -26
  119. letta/services/agent_serialization_manager.py +6 -1
  120. letta/services/archive_manager.py +168 -15
  121. letta/services/block_manager.py +14 -4
  122. letta/services/file_manager.py +33 -29
  123. letta/services/group_manager.py +10 -0
  124. letta/services/helpers/agent_manager_helper.py +65 -11
  125. letta/services/identity_manager.py +105 -4
  126. letta/services/job_manager.py +11 -1
  127. letta/services/mcp/base_client.py +2 -2
  128. letta/services/mcp/oauth_utils.py +33 -8
  129. letta/services/mcp_manager.py +174 -78
  130. letta/services/mcp_server_manager.py +1331 -0
  131. letta/services/message_manager.py +109 -4
  132. letta/services/organization_manager.py +4 -4
  133. letta/services/passage_manager.py +9 -25
  134. letta/services/provider_manager.py +91 -15
  135. letta/services/run_manager.py +72 -15
  136. letta/services/sandbox_config_manager.py +45 -3
  137. letta/services/source_manager.py +15 -8
  138. letta/services/step_manager.py +24 -1
  139. letta/services/streaming_service.py +581 -0
  140. letta/services/summarizer/summarizer.py +1 -1
  141. letta/services/tool_executor/core_tool_executor.py +111 -0
  142. letta/services/tool_executor/files_tool_executor.py +5 -3
  143. letta/services/tool_executor/sandbox_tool_executor.py +2 -2
  144. letta/services/tool_executor/tool_execution_manager.py +1 -1
  145. letta/services/tool_manager.py +10 -3
  146. letta/services/tool_sandbox/base.py +61 -1
  147. letta/services/tool_sandbox/local_sandbox.py +1 -3
  148. letta/services/user_manager.py +2 -2
  149. letta/settings.py +49 -5
  150. letta/system.py +14 -5
  151. letta/utils.py +73 -1
  152. letta/validators.py +105 -0
  153. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/METADATA +4 -2
  154. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/RECORD +157 -151
  155. letta/schemas/letta_ping.py +0 -28
  156. letta/server/rest_api/routers/openai/chat_completions/__init__.py +0 -0
  157. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/WHEEL +0 -0
  158. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/entry_points.txt +0 -0
  159. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/licenses/LICENSE +0 -0
letta/schemas/message.py CHANGED
@@ -1,15 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from letta.log import get_logger
4
+
5
+ logger = get_logger(__name__)
6
+
3
7
  import copy
4
8
  import json
5
9
  import re
6
10
  import uuid
7
- import warnings
8
11
  from collections import OrderedDict
9
12
  from datetime import datetime, timezone
10
13
  from enum import Enum
11
14
  from typing import Annotated, Any, Dict, List, Literal, Optional, Union
12
15
 
16
+ from letta_client import LettaMessageUnion
13
17
  from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction
14
18
  from openai.types.responses import ResponseReasoningItem
15
19
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -18,19 +22,22 @@ from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, RE
18
22
  from letta.helpers.datetime_helpers import get_utc_time, is_utc_datetime
19
23
  from letta.helpers.json_helpers import json_dumps
20
24
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_VERTEX
21
- from letta.schemas.enums import MessageRole
25
+ from letta.schemas.enums import MessageRole, PrimitiveType
22
26
  from letta.schemas.letta_base import OrmMetadataBase
23
27
  from letta.schemas.letta_message import (
24
28
  ApprovalRequestMessage,
25
29
  ApprovalResponseMessage,
30
+ ApprovalReturn,
26
31
  AssistantMessage,
27
32
  HiddenReasoningMessage,
28
33
  LettaMessage,
34
+ LettaMessageReturnUnion,
29
35
  MessageType,
30
36
  ReasoningMessage,
31
37
  SystemMessage,
32
38
  ToolCall,
33
39
  ToolCallMessage,
40
+ ToolReturn as LettaToolReturn,
34
41
  ToolReturnMessage,
35
42
  UserMessage,
36
43
  )
@@ -68,7 +75,7 @@ def add_inner_thoughts_to_tool_call(
68
75
  updated_tool_call.function.arguments = json_dumps(ordered_args)
69
76
  return updated_tool_call
70
77
  except json.JSONDecodeError as e:
71
- warnings.warn(f"Failed to put inner thoughts in kwargs: {e}")
78
+ logger.warning(f"Failed to put inner thoughts in kwargs: {e}")
72
79
  raise e
73
80
 
74
81
 
@@ -116,9 +123,22 @@ class ApprovalCreate(MessageCreateBase):
116
123
  """Input to approve or deny a tool call request"""
117
124
 
118
125
  type: Literal[MessageCreateType.approval] = Field(default=MessageCreateType.approval, description="The message type to be created.")
119
- approve: bool = Field(..., description="Whether the tool has been approved")
120
- approval_request_id: str = Field(..., description="The message ID of the approval request")
121
- reason: Optional[str] = Field(None, description="An optional explanation for the provided approval status")
126
+ approvals: Optional[List[LettaMessageReturnUnion]] = Field(default=None, description="The list of approval responses")
127
+ approve: Optional[bool] = Field(None, description="Whether the tool has been approved", deprecated=True)
128
+ approval_request_id: Optional[str] = Field(None, description="The message ID of the approval request", deprecated=True)
129
+ reason: Optional[str] = Field(None, description="An optional explanation for the provided approval status", deprecated=True)
130
+
131
+ @model_validator(mode="after")
132
+ def migrate_deprecated_fields(self):
133
+ if not self.approvals and self.approve is not None and self.approval_request_id is not None:
134
+ self.approvals = [
135
+ ApprovalReturn(
136
+ tool_call_id=self.approval_request_id,
137
+ approve=self.approve,
138
+ reason=self.reason,
139
+ )
140
+ ]
141
+ return self
122
142
 
123
143
 
124
144
  MessageCreateUnion = Union[MessageCreate, ApprovalCreate]
@@ -153,7 +173,7 @@ class MessageUpdate(BaseModel):
153
173
 
154
174
 
155
175
  class BaseMessage(OrmMetadataBase):
156
- __id_prefix__ = "message"
176
+ __id_prefix__ = PrimitiveType.MESSAGE.value
157
177
 
158
178
 
159
179
  class Message(BaseMessage):
@@ -210,6 +230,7 @@ class Message(BaseMessage):
210
230
  )
211
231
  approve: Optional[bool] = Field(default=None, description="Whether tool call is approved.")
212
232
  denial_reason: Optional[str] = Field(default=None, description="The reason the tool call request was denied.")
233
+ approvals: Optional[List[ApprovalReturn | ToolReturn]] = Field(default=None, description="The list of approvals for this message.")
213
234
  # This overrides the optional base orm schema, created_at MUST exist on all messages objects
214
235
  created_at: datetime = Field(default_factory=get_utc_time, description="The timestamp when the object was created.")
215
236
 
@@ -313,7 +334,7 @@ class Message(BaseMessage):
313
334
  ),
314
335
  )
315
336
  elif self.role == MessageRole.tool:
316
- messages.extend(self._convert_tool_return_message())
337
+ messages.append(self._convert_tool_return_message())
317
338
  elif self.role == MessageRole.user:
318
339
  messages.append(self._convert_user_message())
319
340
  elif self.role == MessageRole.system:
@@ -322,20 +343,52 @@ class Message(BaseMessage):
322
343
  if self.content:
323
344
  messages.extend(self._convert_reasoning_messages(text_is_assistant_message=text_is_assistant_message))
324
345
  if self.tool_calls is not None:
325
- tool_calls = self._convert_tool_call_messages()
326
- assert len(tool_calls) == 1
327
- approval_request_message = ApprovalRequestMessage(**tool_calls[0].model_dump(exclude={"message_type"}))
328
- messages.append(approval_request_message)
346
+ messages.append(self._convert_approval_request_message())
329
347
  else:
330
- approval_response_message = ApprovalResponseMessage(
331
- id=self.id,
332
- date=self.created_at,
333
- otid=self.otid,
334
- approve=self.approve,
335
- approval_request_id=self.approval_request_id,
336
- reason=self.denial_reason,
337
- run_id=self.run_id,
338
- )
348
+ if self.approvals:
349
+ first_approval = [a for a in self.approvals if isinstance(a, ApprovalReturn)]
350
+
351
+ def maybe_convert_tool_return_message(maybe_tool_return):
352
+ if isinstance(maybe_tool_return, ToolReturn):
353
+ parsed_data = self._parse_tool_response(maybe_tool_return.func_response)
354
+ return LettaToolReturn(
355
+ tool_call_id=maybe_tool_return.tool_call_id,
356
+ status=maybe_tool_return.status,
357
+ tool_return=parsed_data["message"],
358
+ stdout=maybe_tool_return.stdout,
359
+ stderr=maybe_tool_return.stderr,
360
+ )
361
+ return maybe_tool_return
362
+
363
+ approval_response_message = ApprovalResponseMessage(
364
+ id=self.id,
365
+ date=self.created_at,
366
+ otid=self.otid,
367
+ approvals=[maybe_convert_tool_return_message(approval) for approval in self.approvals],
368
+ run_id=self.run_id,
369
+ # TODO: temporary populate these fields for backwards compatibility
370
+ approve=first_approval[0].approve if first_approval else None,
371
+ approval_request_id=first_approval[0].tool_call_id if first_approval else None,
372
+ reason=first_approval[0].reason if first_approval else None,
373
+ )
374
+ else:
375
+ approval_response_message = ApprovalResponseMessage(
376
+ id=self.id,
377
+ date=self.created_at,
378
+ otid=self.otid,
379
+ approve=self.approve,
380
+ approval_request_id=self.approval_request_id,
381
+ reason=self.denial_reason,
382
+ approvals=[
383
+ # TODO: temporary workaround to populate from legacy fields
384
+ ApprovalReturn(
385
+ tool_call_id=self.approval_request_id,
386
+ approve=self.approve,
387
+ reason=self.denial_reason,
388
+ )
389
+ ],
390
+ run_id=self.run_id,
391
+ )
339
392
  messages.append(approval_response_message)
340
393
  else:
341
394
  raise ValueError(f"Unknown role: {self.role}")
@@ -460,7 +513,7 @@ class Message(BaseMessage):
460
513
  )
461
514
 
462
515
  else:
463
- warnings.warn(f"Unrecognized content part in assistant message: {content_part}")
516
+ logger.warning(f"Unrecognized content part in assistant message: {content_part}")
464
517
 
465
518
  return messages
466
519
 
@@ -592,7 +645,7 @@ class Message(BaseMessage):
592
645
 
593
646
  return messages
594
647
 
595
- def _convert_tool_return_message(self) -> List[ToolReturnMessage]:
648
+ def _convert_tool_return_message(self) -> ToolReturnMessage:
596
649
  """Convert tool role message to ToolReturnMessage.
597
650
 
598
651
  The tool return is packaged as follows:
@@ -603,7 +656,7 @@ class Message(BaseMessage):
603
656
  }
604
657
 
605
658
  Returns:
606
- List[ToolReturnMessage]: Converted tool return messages
659
+ ToolReturnMessage: Converted tool return message
607
660
 
608
661
  Raises:
609
662
  ValueError: If message role is not 'tool', parsing fails, or no valid content exists
@@ -623,27 +676,47 @@ class Message(BaseMessage):
623
676
 
624
677
  return self._convert_legacy_tool_return()
625
678
 
626
- def _convert_explicit_tool_returns(self) -> List[ToolReturnMessage]:
627
- """Convert explicit tool returns to ToolReturnMessage list."""
628
- tool_returns = []
629
-
630
- for index, tool_return in enumerate(self.tool_returns):
679
+ def _convert_explicit_tool_returns(self) -> ToolReturnMessage:
680
+ """Convert explicit tool returns to a single ToolReturnMessage."""
681
+ # build list of all tool return objects
682
+ all_tool_returns = []
683
+ for tool_return in self.tool_returns:
631
684
  parsed_data = self._parse_tool_response(tool_return.func_response)
632
685
 
633
- tool_returns.append(
634
- self._create_tool_return_message(
635
- message_text=parsed_data["message"],
636
- status=parsed_data["status"],
637
- tool_call_id=tool_return.tool_call_id,
638
- stdout=tool_return.stdout,
639
- stderr=tool_return.stderr,
640
- otid_index=index,
641
- )
686
+ tool_return_obj = LettaToolReturn(
687
+ tool_return=parsed_data["message"],
688
+ status=parsed_data["status"],
689
+ tool_call_id=tool_return.tool_call_id,
690
+ stdout=tool_return.stdout,
691
+ stderr=tool_return.stderr,
642
692
  )
693
+ all_tool_returns.append(tool_return_obj)
694
+
695
+ if not all_tool_returns:
696
+ # this should not happen if tool_returns is non-empty, but handle gracefully
697
+ raise ValueError("No tool returns to convert")
698
+
699
+ first_tool_return = all_tool_returns[0]
643
700
 
644
- return tool_returns
701
+ return ToolReturnMessage(
702
+ id=self.id,
703
+ date=self.created_at,
704
+ # deprecated top-level fields populated from first tool return
705
+ tool_return=first_tool_return.tool_return,
706
+ status=first_tool_return.status,
707
+ tool_call_id=first_tool_return.tool_call_id,
708
+ stdout=first_tool_return.stdout,
709
+ stderr=first_tool_return.stderr,
710
+ tool_returns=all_tool_returns,
711
+ name=self.name,
712
+ otid=Message.generate_otid_from_id(self.id, 0),
713
+ sender_id=self.sender_id,
714
+ step_id=self.step_id,
715
+ is_err=self.is_err,
716
+ run_id=self.run_id,
717
+ )
645
718
 
646
- def _convert_legacy_tool_return(self) -> List[ToolReturnMessage]:
719
+ def _convert_legacy_tool_return(self) -> ToolReturnMessage:
647
720
  """Convert legacy single text content to ToolReturnMessage."""
648
721
  if not self._has_single_text_content():
649
722
  raise ValueError(f"No valid tool returns to convert: {self}")
@@ -651,16 +724,14 @@ class Message(BaseMessage):
651
724
  text_content = self.content[0].text
652
725
  parsed_data = self._parse_tool_response(text_content)
653
726
 
654
- return [
655
- self._create_tool_return_message(
656
- message_text=parsed_data["message"],
657
- status=parsed_data["status"],
658
- tool_call_id=self.tool_call_id,
659
- stdout=None,
660
- stderr=None,
661
- otid_index=0,
662
- )
663
- ]
727
+ return self._create_tool_return_message(
728
+ message_text=parsed_data["message"],
729
+ status=parsed_data["status"],
730
+ tool_call_id=self.tool_call_id,
731
+ stdout=None,
732
+ stderr=None,
733
+ otid_index=0,
734
+ )
664
735
 
665
736
  def _has_single_text_content(self) -> bool:
666
737
  """Check if message has exactly one text content item."""
@@ -709,9 +780,7 @@ class Message(BaseMessage):
709
780
  Returns:
710
781
  Configured ToolReturnMessage instance
711
782
  """
712
- from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
713
-
714
- tool_return_obj = ToolReturnSchema(
783
+ tool_return_obj = LettaToolReturn(
715
784
  tool_return=message_text,
716
785
  status=status,
717
786
  tool_call_id=tool_call_id,
@@ -746,6 +815,28 @@ class Message(BaseMessage):
746
815
  else:
747
816
  raise ValueError(f"Invalid status: {status}")
748
817
 
818
+ def _convert_approval_request_message(self) -> ApprovalRequestMessage:
819
+ """Convert approval request message to ApprovalRequestMessage"""
820
+
821
+ def _convert_tool_call(tool_call):
822
+ return ToolCall(
823
+ name=tool_call.function.name,
824
+ arguments=tool_call.function.arguments,
825
+ tool_call_id=tool_call.id,
826
+ )
827
+
828
+ return ApprovalRequestMessage(
829
+ id=self.id,
830
+ date=self.created_at,
831
+ otid=self.otid,
832
+ sender_id=self.sender_id,
833
+ step_id=self.step_id,
834
+ run_id=self.run_id,
835
+ tool_call=_convert_tool_call(self.tool_calls[0]), # backwards compatibility
836
+ tool_calls=[_convert_tool_call(tc) for tc in self.tool_calls],
837
+ name=self.name,
838
+ )
839
+
749
840
  def _convert_user_message(self) -> UserMessage:
750
841
  """Convert user role message to UserMessage"""
751
842
  # Extract text content
@@ -816,6 +907,12 @@ class Message(BaseMessage):
816
907
  [TextContent(text=openai_message_dict["content"])] if openai_message_dict["content"] else []
817
908
  )
818
909
 
910
+ # This is really hacky and this interface is poorly designed, we should auto derive tool_returns instead of passing it in
911
+ if not tool_returns:
912
+ tool_returns = []
913
+ if "tool_returns" in openai_message_dict:
914
+ tool_returns = [ToolReturn(**tr) for tr in openai_message_dict["tool_returns"]]
915
+
819
916
  # TODO(caren) bad assumption here that "reasoning_content" always comes before "redacted_reasoning_content"
820
917
  if "reasoning_content" in openai_message_dict and openai_message_dict["reasoning_content"]:
821
918
  content.append(
@@ -1099,7 +1196,7 @@ class Message(BaseMessage):
1099
1196
  if bool(re.match(r"^[^\s<|\\/>]+$", self.name)):
1100
1197
  openai_message["name"] = self.name
1101
1198
  else:
1102
- warnings.warn(f"Using OpenAI with invalid 'name' field (name={self.name} role={self.role}).")
1199
+ logger.warning(f"Using OpenAI with invalid 'name' field (name={self.name} role={self.role}).")
1103
1200
 
1104
1201
  if parse_content_parts and self.content is not None:
1105
1202
  for content in self.content:
@@ -1166,7 +1263,7 @@ class Message(BaseMessage):
1166
1263
  if bool(re.match(r"^[^\s<|\\/>]+$", self.name)):
1167
1264
  user_dict["name"] = self.name
1168
1265
  else:
1169
- warnings.warn(f"Using OpenAI with invalid 'name' field (name={self.name} role={self.role}).")
1266
+ logger.warning(f"Using OpenAI with invalid 'name' field (name={self.name} role={self.role}).")
1170
1267
 
1171
1268
  message_dicts.append(user_dict)
1172
1269
 
@@ -1414,18 +1511,38 @@ class Message(BaseMessage):
1414
1511
 
1415
1512
  elif self.role == "tool":
1416
1513
  # NOTE: Anthropic uses role "user" for "tool" responses
1417
- assert self.tool_call_id is not None, vars(self)
1418
- anthropic_message = {
1419
- "role": "user", # NOTE: diff
1420
- "content": [
1421
- # TODO support error types etc
1514
+ content = []
1515
+ for tool_return in self.tool_returns:
1516
+ if not tool_return.tool_call_id:
1517
+ raise TypeError("Anthropic API requires tool_use_id to be set.")
1518
+ content.append(
1422
1519
  {
1423
1520
  "type": "tool_result",
1424
- "tool_use_id": self.tool_call_id,
1425
- "content": text_content,
1521
+ "tool_use_id": tool_return.tool_call_id,
1522
+ "content": tool_return.func_response,
1426
1523
  }
1427
- ],
1428
- }
1524
+ )
1525
+ if content:
1526
+ anthropic_message = {
1527
+ "role": "user",
1528
+ "content": content,
1529
+ }
1530
+ else:
1531
+ if not self.tool_call_id:
1532
+ raise TypeError("Anthropic API requires tool_use_id to be set.")
1533
+
1534
+ # This is for legacy reasons
1535
+ anthropic_message = {
1536
+ "role": "user", # NOTE: diff
1537
+ "content": [
1538
+ # TODO support error types etc
1539
+ {
1540
+ "type": "tool_result",
1541
+ "tool_use_id": self.tool_call_id,
1542
+ "content": text_content,
1543
+ }
1544
+ ],
1545
+ }
1429
1546
 
1430
1547
  else:
1431
1548
  raise ValueError(self.role)
@@ -1483,7 +1600,7 @@ class Message(BaseMessage):
1483
1600
  text_content = None
1484
1601
 
1485
1602
  if self.role != "tool" and self.name is not None:
1486
- warnings.warn(f"Using Google AI with non-null 'name' field (name={self.name} role={self.role}), not yet supported.")
1603
+ logger.warning(f"Using Google AI with non-null 'name' field (name={self.name} role={self.role}), not yet supported.")
1487
1604
 
1488
1605
  if self.role == "system":
1489
1606
  # NOTE: Gemini API doesn't have a 'system' role, use 'user' instead
@@ -1603,7 +1720,7 @@ class Message(BaseMessage):
1603
1720
  assert self.tool_call_id is not None, vars(self)
1604
1721
 
1605
1722
  if self.name is None:
1606
- warnings.warn("Couldn't find function name on tool call, defaulting to tool ID instead.")
1723
+ logger.warning("Couldn't find function name on tool call, defaulting to tool ID instead.")
1607
1724
  function_name = self.tool_call_id
1608
1725
  else:
1609
1726
  function_name = self.name
@@ -1636,7 +1753,7 @@ class Message(BaseMessage):
1636
1753
  if "parts" not in google_ai_message or not google_ai_message["parts"]:
1637
1754
  # If parts is empty, add a default text part
1638
1755
  google_ai_message["parts"] = [{"text": "empty message"}]
1639
- warnings.warn(
1756
+ logger.warning(
1640
1757
  f"Empty 'parts' detected in message with role '{self.role}'. Added default empty text part. Full message:\n{vars(self)}"
1641
1758
  )
1642
1759
 
@@ -1697,6 +1814,9 @@ class Message(BaseMessage):
1697
1814
  # Filter last message if it is a lone approval request without a response - this only occurs for token counting
1698
1815
  if messages[-1].role == "approval" and messages[-1].tool_calls is not None and len(messages[-1].tool_calls) > 0:
1699
1816
  messages.remove(messages[-1])
1817
+ # Also filter pending tool call message if this turn invoked parallel tool calling
1818
+ if messages and messages[-1].role == "assistant" and messages[-1].tool_calls is not None and len(messages[-1].tool_calls) > 0:
1819
+ messages.remove(messages[-1])
1700
1820
 
1701
1821
  # Filter last message if it is a lone reasoning message without assistant message or tool call
1702
1822
  if (
@@ -1706,6 +1826,28 @@ class Message(BaseMessage):
1706
1826
  ):
1707
1827
  messages.remove(messages[-1])
1708
1828
 
1829
+ # Collapse adjacent tool call and approval messages
1830
+ messages = Message.collapse_tool_call_messages_for_llm_api(messages)
1831
+
1832
+ return messages
1833
+
1834
+ @staticmethod
1835
+ def collapse_tool_call_messages_for_llm_api(
1836
+ messages: List[Message],
1837
+ ) -> List[Message]:
1838
+ adjacent_tool_call_approval_messages = []
1839
+ for i in range(len(messages) - 1):
1840
+ if (
1841
+ messages[i].role == MessageRole.assistant
1842
+ and messages[i].tool_calls is not None
1843
+ and messages[i + 1].role == MessageRole.approval
1844
+ and messages[i + 1].tool_calls is not None
1845
+ ):
1846
+ adjacent_tool_call_approval_messages.append(i)
1847
+ for i in reversed(adjacent_tool_call_approval_messages):
1848
+ messages[i].content = messages[i].content + messages[i + 1].content
1849
+ messages[i].tool_calls = messages[i].tool_calls + messages[i + 1].tool_calls
1850
+ messages.remove(messages[i + 1])
1709
1851
  return messages
1710
1852
 
1711
1853
  @staticmethod
@@ -1713,6 +1855,9 @@ class Message(BaseMessage):
1713
1855
  """
1714
1856
  Convert message id to bits and change the list bit to the index
1715
1857
  """
1858
+ if index == -1:
1859
+ return message_id
1860
+
1716
1861
  if not 0 <= index < 128:
1717
1862
  raise ValueError("Index must be between 0 and 127")
1718
1863
 
@@ -1,12 +1,17 @@
1
- import warnings
2
1
  from typing import Literal
3
2
 
3
+ from letta.log import get_logger
4
+
5
+ logger = get_logger(__name__)
6
+
4
7
  import anthropic
5
8
  from pydantic import Field
6
9
 
10
+ from letta.errors import ErrorCode, LLMAuthenticationError, LLMError
7
11
  from letta.schemas.enums import ProviderCategory, ProviderType
8
12
  from letta.schemas.llm_config import LLMConfig
9
13
  from letta.schemas.providers.base import Provider
14
+ from letta.settings import model_settings
10
15
 
11
16
  # https://docs.anthropic.com/claude/docs/models-overview
12
17
  # Sadly hardcoded
@@ -98,8 +103,9 @@ class AnthropicProvider(Provider):
98
103
  base_url: str = "https://api.anthropic.com/v1"
99
104
 
100
105
  async def check_api_key(self):
101
- if self.api_key:
102
- anthropic_client = anthropic.Anthropic(api_key=self.api_key)
106
+ api_key = self.get_api_key_secret().get_plaintext()
107
+ if api_key:
108
+ anthropic_client = anthropic.Anthropic(api_key=api_key)
103
109
  try:
104
110
  # just use a cheap model to count some tokens - as of 5/7/2025 this is faster than fetching the list of models
105
111
  anthropic_client.messages.count_tokens(model=MODEL_LIST[-1]["name"], messages=[{"role": "user", "content": "a"}])
@@ -116,8 +122,9 @@ class AnthropicProvider(Provider):
116
122
 
117
123
  NOTE: currently there is no GET /models, so we need to hardcode
118
124
  """
119
- if self.api_key:
120
- anthropic_client = anthropic.AsyncAnthropic(api_key=self.api_key)
125
+ api_key = self.get_api_key_secret().get_plaintext()
126
+ if api_key:
127
+ anthropic_client = anthropic.AsyncAnthropic(api_key=api_key)
121
128
  elif model_settings.anthropic_api_key:
122
129
  anthropic_client = anthropic.AsyncAnthropic()
123
130
  else:
@@ -145,7 +152,7 @@ class AnthropicProvider(Provider):
145
152
  model["context_window"] = model_library[model["id"]]
146
153
  else:
147
154
  # On fallback, we can set 200k (generally safe), but we should warn the user
148
- warnings.warn(f"Couldn't find context window size for model {model['id']}, defaulting to 200,000")
155
+ logger.warning(f"Couldn't find context window size for model {model['id']}, defaulting to 200,000")
149
156
  model["context_window"] = 200000
150
157
 
151
158
  # Optional override: enable 1M context for Sonnet 4/4.5 when flag is set
@@ -60,7 +60,8 @@ class AzureProvider(Provider):
60
60
  def azure_openai_get_deployed_model_list(self) -> list:
61
61
  """https://learn.microsoft.com/en-us/rest/api/azureopenai/models/list?view=rest-azureopenai-2023-05-15&tabs=HTTP"""
62
62
 
63
- client = AzureOpenAI(api_key=self.api_key, api_version=self.api_version, azure_endpoint=self.base_url)
63
+ api_key = self.get_api_key_secret().get_plaintext()
64
+ client = AzureOpenAI(api_key=api_key, api_version=self.api_version, azure_endpoint=self.base_url)
64
65
 
65
66
  try:
66
67
  models_list = client.models.list()
@@ -71,8 +72,8 @@ class AzureProvider(Provider):
71
72
 
72
73
  # https://xxx.openai.azure.com/openai/models?api-version=xxx
73
74
  headers = {"Content-Type": "application/json"}
74
- if self.api_key is not None:
75
- headers["api-key"] = f"{self.api_key}"
75
+ if api_key is not None:
76
+ headers["api-key"] = f"{api_key}"
76
77
 
77
78
  # 2. Get all the deployed models
78
79
  url = self.get_azure_deployment_list_endpoint()
@@ -165,7 +166,8 @@ class AzureProvider(Provider):
165
166
  return AZURE_MODEL_TO_CONTEXT_LENGTH.get(model_name, llm_default)
166
167
 
167
168
  async def check_api_key(self):
168
- if not self.api_key:
169
+ api_key = self.get_api_key_secret().get_plaintext()
170
+ if not api_key:
169
171
  raise ValueError("No API key provided")
170
172
 
171
173
  try:
@@ -1,10 +1,14 @@
1
1
  from datetime import datetime
2
2
 
3
+ from letta.log import get_logger
4
+
5
+ logger = get_logger(__name__)
6
+
3
7
  from pydantic import BaseModel, Field, model_validator
4
8
 
5
9
  from letta.schemas.embedding_config import EmbeddingConfig
6
10
  from letta.schemas.embedding_config_overrides import EMBEDDING_HANDLE_OVERRIDES
7
- from letta.schemas.enums import ProviderCategory, ProviderType
11
+ from letta.schemas.enums import PrimitiveType, ProviderCategory, ProviderType
8
12
  from letta.schemas.letta_base import LettaBase
9
13
  from letta.schemas.llm_config import LLMConfig
10
14
  from letta.schemas.llm_config_overrides import LLM_HANDLE_OVERRIDES
@@ -13,7 +17,7 @@ from letta.settings import model_settings
13
17
 
14
18
 
15
19
  class ProviderBase(LettaBase):
16
- __id_prefix__ = "provider"
20
+ __id_prefix__ = PrimitiveType.PROVIDER.value
17
21
 
18
22
 
19
23
  class Provider(ProviderBase):
@@ -90,7 +94,7 @@ class Provider(ProviderBase):
90
94
  import asyncio
91
95
  import warnings
92
96
 
93
- warnings.warn("list_llm_models is deprecated, use list_llm_models_async instead", DeprecationWarning, stacklevel=2)
97
+ logger.warning("list_llm_models is deprecated, use list_llm_models_async instead", stacklevel=2)
94
98
 
95
99
  # Simplified asyncio handling - just use asyncio.run()
96
100
  # This works in most contexts and avoids complex event loop detection
@@ -115,7 +119,7 @@ class Provider(ProviderBase):
115
119
  import asyncio
116
120
  import warnings
117
121
 
118
- warnings.warn("list_embedding_models is deprecated, use list_embedding_models_async instead", DeprecationWarning, stacklevel=2)
122
+ logger.warning("list_embedding_models is deprecated, use list_embedding_models_async instead", stacklevel=2)
119
123
 
120
124
  # Simplified asyncio handling - just use asyncio.run()
121
125
  # This works in most contexts and avoids complex event loop detection
@@ -25,11 +25,15 @@ class BedrockProvider(Provider):
25
25
  from aioboto3.session import Session
26
26
 
27
27
  try:
28
+ # Decrypt credentials before using
29
+ access_key = self.get_access_key_secret().get_plaintext()
30
+ secret_key = self.get_api_key_secret().get_plaintext()
31
+
28
32
  session = Session()
29
33
  async with session.client(
30
34
  "bedrock",
31
- aws_access_key_id=self.access_key,
32
- aws_secret_access_key=self.api_key,
35
+ aws_access_key_id=access_key,
36
+ aws_secret_access_key=secret_key,
33
37
  region_name=self.region,
34
38
  ) as bedrock:
35
39
  response = await bedrock.list_inference_profiles()
@@ -1,6 +1,9 @@
1
- import warnings
2
1
  from typing import Literal
3
2
 
3
+ from letta.log import get_logger
4
+
5
+ logger = get_logger(__name__)
6
+
4
7
  from pydantic import Field
5
8
 
6
9
  from letta.schemas.enums import ProviderCategory, ProviderType
@@ -38,7 +41,8 @@ class CerebrasProvider(OpenAIProvider):
38
41
  async def list_llm_models_async(self) -> list[LLMConfig]:
39
42
  from letta.llm_api.openai import openai_get_model_list_async
40
43
 
41
- response = await openai_get_model_list_async(self.base_url, api_key=self.api_key)
44
+ api_key = self.get_api_key_secret().get_plaintext()
45
+ response = await openai_get_model_list_async(self.base_url, api_key=api_key)
42
46
 
43
47
  if "data" in response:
44
48
  data = response["data"]
@@ -57,7 +61,7 @@ class CerebrasProvider(OpenAIProvider):
57
61
  context_window_size = self.get_model_context_window_size(model_name)
58
62
 
59
63
  if not context_window_size:
60
- warnings.warn(f"Couldn't find context window size for model {model_name}")
64
+ logger.warning(f"Couldn't find context window size for model {model_name}")
61
65
  continue
62
66
 
63
67
  # Cerebras supports function calling
@@ -34,7 +34,8 @@ class DeepSeekProvider(OpenAIProvider):
34
34
  async def list_llm_models_async(self) -> list[LLMConfig]:
35
35
  from letta.llm_api.openai import openai_get_model_list_async
36
36
 
37
- response = await openai_get_model_list_async(self.base_url, api_key=self.api_key)
37
+ api_key = self.get_api_key_secret().get_plaintext()
38
+ response = await openai_get_model_list_async(self.base_url, api_key=api_key)
38
39
  data = response.get("data", response)
39
40
 
40
41
  configs = []