letta-nightly 0.6.27.dev20250219104103__py3-none-any.whl → 0.6.28.dev20250220163833__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.
- letta/__init__.py +1 -1
- letta/agent.py +13 -1
- letta/client/client.py +2 -0
- letta/constants.py +2 -0
- letta/functions/schema_generator.py +6 -6
- letta/helpers/converters.py +153 -0
- letta/helpers/tool_rule_solver.py +11 -1
- letta/llm_api/anthropic.py +10 -5
- letta/llm_api/aws_bedrock.py +1 -1
- letta/llm_api/azure_openai_constants.py +1 -0
- letta/llm_api/deepseek.py +303 -0
- letta/llm_api/llm_api_tools.py +81 -1
- letta/llm_api/openai.py +13 -0
- letta/local_llm/chat_completion_proxy.py +15 -2
- letta/local_llm/lmstudio/api.py +75 -1
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +14 -5
- letta/orm/custom_columns.py +31 -110
- letta/orm/identity.py +39 -0
- letta/orm/organization.py +2 -0
- letta/schemas/agent.py +13 -1
- letta/schemas/identity.py +44 -0
- letta/schemas/llm_config.py +2 -0
- letta/schemas/message.py +1 -1
- letta/schemas/openai/chat_completion_response.py +2 -0
- letta/schemas/providers.py +72 -1
- letta/schemas/tool_rule.py +9 -1
- letta/serialize_schemas/__init__.py +1 -0
- letta/serialize_schemas/agent.py +36 -0
- letta/serialize_schemas/base.py +12 -0
- letta/serialize_schemas/custom_fields.py +69 -0
- letta/serialize_schemas/message.py +15 -0
- letta/server/db.py +111 -0
- letta/server/rest_api/app.py +8 -0
- letta/server/rest_api/interface.py +114 -9
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +7 -1
- letta/server/rest_api/routers/v1/identities.py +111 -0
- letta/server/server.py +13 -116
- letta/services/agent_manager.py +54 -6
- letta/services/block_manager.py +1 -1
- letta/services/helpers/agent_manager_helper.py +15 -0
- letta/services/identity_manager.py +140 -0
- letta/services/job_manager.py +1 -1
- letta/services/message_manager.py +1 -1
- letta/services/organization_manager.py +1 -1
- letta/services/passage_manager.py +1 -1
- letta/services/provider_manager.py +1 -1
- letta/services/sandbox_config_manager.py +1 -1
- letta/services/source_manager.py +1 -1
- letta/services/step_manager.py +1 -1
- letta/services/tool_manager.py +1 -1
- letta/services/user_manager.py +1 -1
- letta/settings.py +3 -0
- letta/tracing.py +205 -0
- letta/utils.py +4 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/METADATA +9 -2
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/RECORD +61 -48
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/WHEEL +0 -0
- {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/entry_points.txt +0 -0
letta/__init__.py
CHANGED
letta/agent.py
CHANGED
|
@@ -60,6 +60,7 @@ from letta.services.tool_manager import ToolManager
|
|
|
60
60
|
from letta.settings import summarizer_settings
|
|
61
61
|
from letta.streaming_interface import StreamingRefreshCLIInterface
|
|
62
62
|
from letta.system import get_heartbeat, get_token_limit_warning, package_function_response, package_summarize_message, package_user_message
|
|
63
|
+
from letta.tracing import trace_method
|
|
63
64
|
from letta.utils import (
|
|
64
65
|
count_tokens,
|
|
65
66
|
get_friendly_error_msg,
|
|
@@ -309,6 +310,7 @@ class Agent(BaseAgent):
|
|
|
309
310
|
# Return updated messages
|
|
310
311
|
return messages
|
|
311
312
|
|
|
313
|
+
@trace_method("Get AI Reply")
|
|
312
314
|
def _get_ai_reply(
|
|
313
315
|
self,
|
|
314
316
|
message_sequence: List[Message],
|
|
@@ -399,6 +401,7 @@ class Agent(BaseAgent):
|
|
|
399
401
|
log_telemetry(self.logger, "_handle_ai_response finish catch-all exception")
|
|
400
402
|
raise Exception("Retries exhausted and no valid response received.")
|
|
401
403
|
|
|
404
|
+
@trace_method("Handle AI Response")
|
|
402
405
|
def _handle_ai_response(
|
|
403
406
|
self,
|
|
404
407
|
response_message: ChatCompletionMessage, # TODO should we eventually move the Message creation outside of this function?
|
|
@@ -492,7 +495,10 @@ class Agent(BaseAgent):
|
|
|
492
495
|
try:
|
|
493
496
|
raw_function_args = function_call.arguments
|
|
494
497
|
function_args = parse_json(raw_function_args)
|
|
495
|
-
|
|
498
|
+
if not isinstance(function_args, dict):
|
|
499
|
+
raise ValueError(f"Function arguments are not a dictionary: {function_args} (raw={raw_function_args})")
|
|
500
|
+
except Exception as e:
|
|
501
|
+
print(e)
|
|
496
502
|
error_msg = f"Error parsing JSON for function '{function_name}' arguments: {function_call.arguments}"
|
|
497
503
|
function_response = "None" # more like "never ran?"
|
|
498
504
|
messages = self._handle_function_error_response(
|
|
@@ -627,9 +633,15 @@ class Agent(BaseAgent):
|
|
|
627
633
|
elif self.tool_rules_solver.is_terminal_tool(function_name):
|
|
628
634
|
heartbeat_request = False
|
|
629
635
|
|
|
636
|
+
# if continue tool rule, then must request a heartbeat
|
|
637
|
+
# TODO: dont even include heartbeats in the args
|
|
638
|
+
if self.tool_rules_solver.is_continue_tool(function_name):
|
|
639
|
+
heartbeat_request = True
|
|
640
|
+
|
|
630
641
|
log_telemetry(self.logger, "_handle_ai_response finish")
|
|
631
642
|
return messages, heartbeat_request, function_failed
|
|
632
643
|
|
|
644
|
+
@trace_method("Agent Step")
|
|
633
645
|
def step(
|
|
634
646
|
self,
|
|
635
647
|
messages: Union[Message, List[Message]],
|
letta/client/client.py
CHANGED
|
@@ -2351,6 +2351,7 @@ class LocalClient(AbstractClient):
|
|
|
2351
2351
|
tool_rules: Optional[List[BaseToolRule]] = None,
|
|
2352
2352
|
include_base_tools: Optional[bool] = True,
|
|
2353
2353
|
include_multi_agent_tools: bool = False,
|
|
2354
|
+
include_base_tool_rules: bool = True,
|
|
2354
2355
|
# metadata
|
|
2355
2356
|
metadata: Optional[Dict] = {"human:": DEFAULT_HUMAN, "persona": DEFAULT_PERSONA},
|
|
2356
2357
|
description: Optional[str] = None,
|
|
@@ -2402,6 +2403,7 @@ class LocalClient(AbstractClient):
|
|
|
2402
2403
|
"tool_rules": tool_rules,
|
|
2403
2404
|
"include_base_tools": include_base_tools,
|
|
2404
2405
|
"include_multi_agent_tools": include_multi_agent_tools,
|
|
2406
|
+
"include_base_tool_rules": include_base_tool_rules,
|
|
2405
2407
|
"system": system,
|
|
2406
2408
|
"agent_type": agent_type,
|
|
2407
2409
|
"llm_config": llm_config if llm_config else self._default_llm_config,
|
letta/constants.py
CHANGED
|
@@ -86,6 +86,8 @@ NON_USER_MSG_PREFIX = "[This is an automated system message hidden from the user
|
|
|
86
86
|
# The max amount of tokens supported by the underlying model (eg 8k for gpt-4 and Mistral 7B)
|
|
87
87
|
LLM_MAX_TOKENS = {
|
|
88
88
|
"DEFAULT": 8192,
|
|
89
|
+
"deepseek-chat": 64000,
|
|
90
|
+
"deepseek-reasoner": 64000,
|
|
89
91
|
## OpenAI models: https://platform.openai.com/docs/models/overview
|
|
90
92
|
# "o1-preview
|
|
91
93
|
"chatgpt-4o-latest": 128000,
|
|
@@ -394,12 +394,12 @@ def generate_schema(function, name: Optional[str] = None, description: Optional[
|
|
|
394
394
|
# append the heartbeat
|
|
395
395
|
# TODO: don't hard-code
|
|
396
396
|
# TODO: if terminal, don't include this
|
|
397
|
-
if function.__name__ not in ["send_message"]:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
397
|
+
# if function.__name__ not in ["send_message"]:
|
|
398
|
+
schema["parameters"]["properties"]["request_heartbeat"] = {
|
|
399
|
+
"type": "boolean",
|
|
400
|
+
"description": "Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function.",
|
|
401
|
+
}
|
|
402
|
+
schema["parameters"]["required"].append("request_heartbeat")
|
|
403
403
|
|
|
404
404
|
return schema
|
|
405
405
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall
|
|
6
|
+
from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction
|
|
7
|
+
from sqlalchemy import Dialect
|
|
8
|
+
|
|
9
|
+
from letta.schemas.embedding_config import EmbeddingConfig
|
|
10
|
+
from letta.schemas.enums import ToolRuleType
|
|
11
|
+
from letta.schemas.llm_config import LLMConfig
|
|
12
|
+
from letta.schemas.tool_rule import ChildToolRule, ConditionalToolRule, ContinueToolRule, InitToolRule, TerminalToolRule, ToolRule
|
|
13
|
+
|
|
14
|
+
# --------------------------
|
|
15
|
+
# LLMConfig Serialization
|
|
16
|
+
# --------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def serialize_llm_config(config: Union[Optional[LLMConfig], Dict]) -> Optional[Dict]:
|
|
20
|
+
"""Convert an LLMConfig object into a JSON-serializable dictionary."""
|
|
21
|
+
if config and isinstance(config, LLMConfig):
|
|
22
|
+
return config.model_dump()
|
|
23
|
+
return config
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def deserialize_llm_config(data: Optional[Dict]) -> Optional[LLMConfig]:
|
|
27
|
+
"""Convert a dictionary back into an LLMConfig object."""
|
|
28
|
+
return LLMConfig(**data) if data else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --------------------------
|
|
32
|
+
# EmbeddingConfig Serialization
|
|
33
|
+
# --------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def serialize_embedding_config(config: Union[Optional[EmbeddingConfig], Dict]) -> Optional[Dict]:
|
|
37
|
+
"""Convert an EmbeddingConfig object into a JSON-serializable dictionary."""
|
|
38
|
+
if config and isinstance(config, EmbeddingConfig):
|
|
39
|
+
return config.model_dump()
|
|
40
|
+
return config
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def deserialize_embedding_config(data: Optional[Dict]) -> Optional[EmbeddingConfig]:
|
|
44
|
+
"""Convert a dictionary back into an EmbeddingConfig object."""
|
|
45
|
+
return EmbeddingConfig(**data) if data else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------
|
|
49
|
+
# ToolRule Serialization
|
|
50
|
+
# --------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def serialize_tool_rules(tool_rules: Optional[List[ToolRule]]) -> List[Dict[str, Any]]:
|
|
54
|
+
"""Convert a list of ToolRules into a JSON-serializable format."""
|
|
55
|
+
|
|
56
|
+
if not tool_rules:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
data = [{**rule.model_dump(), "type": rule.type.value} for rule in tool_rules] # Convert Enum to string for JSON compatibility
|
|
60
|
+
|
|
61
|
+
# Validate ToolRule structure
|
|
62
|
+
for rule_data in data:
|
|
63
|
+
if rule_data["type"] == ToolRuleType.constrain_child_tools.value and "children" not in rule_data:
|
|
64
|
+
raise ValueError(f"Invalid ToolRule serialization: 'children' field missing for rule {rule_data}")
|
|
65
|
+
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def deserialize_tool_rules(data: Optional[List[Dict]]) -> List[Union[ChildToolRule, InitToolRule, TerminalToolRule, ConditionalToolRule]]:
|
|
70
|
+
"""Convert a list of dictionaries back into ToolRule objects."""
|
|
71
|
+
if not data:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
return [deserialize_tool_rule(rule_data) for rule_data in data]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def deserialize_tool_rule(data: Dict) -> Union[ChildToolRule, InitToolRule, TerminalToolRule, ConditionalToolRule, ContinueToolRule]:
|
|
78
|
+
"""Deserialize a dictionary to the appropriate ToolRule subclass based on 'type'."""
|
|
79
|
+
rule_type = ToolRuleType(data.get("type"))
|
|
80
|
+
|
|
81
|
+
if rule_type == ToolRuleType.run_first:
|
|
82
|
+
return InitToolRule(**data)
|
|
83
|
+
elif rule_type == ToolRuleType.exit_loop:
|
|
84
|
+
return TerminalToolRule(**data)
|
|
85
|
+
elif rule_type == ToolRuleType.constrain_child_tools:
|
|
86
|
+
return ChildToolRule(**data)
|
|
87
|
+
elif rule_type == ToolRuleType.conditional:
|
|
88
|
+
return ConditionalToolRule(**data)
|
|
89
|
+
elif rule_type == ToolRuleType.continue_loop:
|
|
90
|
+
return ContinueToolRule(**data)
|
|
91
|
+
raise ValueError(f"Unknown ToolRule type: {rule_type}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------
|
|
95
|
+
# ToolCall Serialization
|
|
96
|
+
# --------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def serialize_tool_calls(tool_calls: Optional[List[Union[OpenAIToolCall, dict]]]) -> List[Dict]:
|
|
100
|
+
"""Convert a list of OpenAI ToolCall objects into JSON-serializable format."""
|
|
101
|
+
if not tool_calls:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
serialized_calls = []
|
|
105
|
+
for call in tool_calls:
|
|
106
|
+
if isinstance(call, OpenAIToolCall):
|
|
107
|
+
serialized_calls.append(call.model_dump())
|
|
108
|
+
elif isinstance(call, dict):
|
|
109
|
+
serialized_calls.append(call) # Already a dictionary, leave it as-is
|
|
110
|
+
else:
|
|
111
|
+
raise TypeError(f"Unexpected tool call type: {type(call)}")
|
|
112
|
+
|
|
113
|
+
return serialized_calls
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def deserialize_tool_calls(data: Optional[List[Dict]]) -> List[OpenAIToolCall]:
|
|
117
|
+
"""Convert a JSON list back into OpenAIToolCall objects."""
|
|
118
|
+
if not data:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
calls = []
|
|
122
|
+
for item in data:
|
|
123
|
+
func_data = item.pop("function", None)
|
|
124
|
+
tool_call_function = OpenAIFunction(**func_data) if func_data else None
|
|
125
|
+
calls.append(OpenAIToolCall(function=tool_call_function, **item))
|
|
126
|
+
|
|
127
|
+
return calls
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------
|
|
131
|
+
# Vector Serialization
|
|
132
|
+
# --------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def serialize_vector(vector: Optional[Union[List[float], np.ndarray]]) -> Optional[bytes]:
|
|
136
|
+
"""Convert a NumPy array or list into a base64-encoded byte string."""
|
|
137
|
+
if vector is None:
|
|
138
|
+
return None
|
|
139
|
+
if isinstance(vector, list):
|
|
140
|
+
vector = np.array(vector, dtype=np.float32)
|
|
141
|
+
|
|
142
|
+
return base64.b64encode(vector.tobytes())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def deserialize_vector(data: Optional[bytes], dialect: Dialect) -> Optional[np.ndarray]:
|
|
146
|
+
"""Convert a base64-encoded byte string back into a NumPy array."""
|
|
147
|
+
if not data:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if dialect.name == "sqlite":
|
|
151
|
+
data = base64.b64decode(data)
|
|
152
|
+
|
|
153
|
+
return np.frombuffer(data, dtype=np.float32)
|
|
@@ -4,7 +4,7 @@ from typing import List, Optional, Union
|
|
|
4
4
|
from pydantic import BaseModel, Field
|
|
5
5
|
|
|
6
6
|
from letta.schemas.enums import ToolRuleType
|
|
7
|
-
from letta.schemas.tool_rule import BaseToolRule, ChildToolRule, ConditionalToolRule, InitToolRule, TerminalToolRule
|
|
7
|
+
from letta.schemas.tool_rule import BaseToolRule, ChildToolRule, ConditionalToolRule, ContinueToolRule, InitToolRule, TerminalToolRule
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class ToolRuleValidationError(Exception):
|
|
@@ -18,6 +18,9 @@ class ToolRulesSolver(BaseModel):
|
|
|
18
18
|
init_tool_rules: List[InitToolRule] = Field(
|
|
19
19
|
default_factory=list, description="Initial tool rules to be used at the start of tool execution."
|
|
20
20
|
)
|
|
21
|
+
continue_tool_rules: List[ContinueToolRule] = Field(
|
|
22
|
+
default_factory=list, description="Continue tool rules to be used to continue tool execution."
|
|
23
|
+
)
|
|
21
24
|
tool_rules: List[Union[ChildToolRule, ConditionalToolRule]] = Field(
|
|
22
25
|
default_factory=list, description="Standard tool rules for controlling execution sequence and allowed transitions."
|
|
23
26
|
)
|
|
@@ -43,6 +46,9 @@ class ToolRulesSolver(BaseModel):
|
|
|
43
46
|
elif rule.type == ToolRuleType.exit_loop:
|
|
44
47
|
assert isinstance(rule, TerminalToolRule)
|
|
45
48
|
self.terminal_tool_rules.append(rule)
|
|
49
|
+
elif rule.type == ToolRuleType.continue_loop:
|
|
50
|
+
assert isinstance(rule, ContinueToolRule)
|
|
51
|
+
self.continue_tool_rules.append(rule)
|
|
46
52
|
|
|
47
53
|
def update_tool_usage(self, tool_name: str):
|
|
48
54
|
"""Update the internal state to track the last tool called."""
|
|
@@ -80,6 +86,10 @@ class ToolRulesSolver(BaseModel):
|
|
|
80
86
|
"""Check if the tool has children tools"""
|
|
81
87
|
return any(rule.tool_name == tool_name for rule in self.tool_rules)
|
|
82
88
|
|
|
89
|
+
def is_continue_tool(self, tool_name):
|
|
90
|
+
"""Check if the tool is defined as a continue tool in the tool rules."""
|
|
91
|
+
return any(rule.tool_name == tool_name for rule in self.continue_tool_rules)
|
|
92
|
+
|
|
83
93
|
def validate_conditional_tool(self, rule: ConditionalToolRule):
|
|
84
94
|
"""
|
|
85
95
|
Validate a conditional tool rule
|
letta/llm_api/anthropic.py
CHANGED
|
@@ -519,6 +519,7 @@ def _prepare_anthropic_request(
|
|
|
519
519
|
prefix_fill: bool = True,
|
|
520
520
|
# if true, put COT inside the tool calls instead of inside the content
|
|
521
521
|
put_inner_thoughts_in_kwargs: bool = False,
|
|
522
|
+
bedrock: bool = False,
|
|
522
523
|
) -> dict:
|
|
523
524
|
"""Prepare the request data for Anthropic API format."""
|
|
524
525
|
|
|
@@ -606,10 +607,11 @@ def _prepare_anthropic_request(
|
|
|
606
607
|
# NOTE: cannot prefill with tools for opus:
|
|
607
608
|
# Your API request included an `assistant` message in the final position, which would pre-fill the `assistant` response. When using tools with "claude-3-opus-20240229"
|
|
608
609
|
if prefix_fill and not put_inner_thoughts_in_kwargs and "opus" not in data["model"]:
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
610
|
+
if not bedrock: # not support for bedrock
|
|
611
|
+
data["messages"].append(
|
|
612
|
+
# Start the thinking process for the assistant
|
|
613
|
+
{"role": "assistant", "content": f"<{inner_thoughts_xml_tag}>"},
|
|
614
|
+
)
|
|
613
615
|
|
|
614
616
|
# Validate max_tokens
|
|
615
617
|
assert "max_tokens" in data, data
|
|
@@ -651,13 +653,16 @@ def anthropic_bedrock_chat_completions_request(
|
|
|
651
653
|
inner_thoughts_xml_tag: Optional[str] = "thinking",
|
|
652
654
|
) -> ChatCompletionResponse:
|
|
653
655
|
"""Make a chat completion request to Anthropic via AWS Bedrock."""
|
|
654
|
-
data = _prepare_anthropic_request(data, inner_thoughts_xml_tag)
|
|
656
|
+
data = _prepare_anthropic_request(data, inner_thoughts_xml_tag, bedrock=True)
|
|
655
657
|
|
|
656
658
|
# Get the client
|
|
657
659
|
client = get_bedrock_client()
|
|
658
660
|
|
|
659
661
|
# Make the request
|
|
660
662
|
try:
|
|
663
|
+
# bedrock does not support certain args
|
|
664
|
+
data["tool_choice"] = {"type": "any"}
|
|
665
|
+
|
|
661
666
|
response = client.messages.create(**data)
|
|
662
667
|
return convert_anthropic_response_to_chatcompletion(response=response, inner_thoughts_xml_tag=inner_thoughts_xml_tag)
|
|
663
668
|
except PermissionDeniedError:
|
letta/llm_api/aws_bedrock.py
CHANGED
|
@@ -10,7 +10,7 @@ def has_valid_aws_credentials() -> bool:
|
|
|
10
10
|
"""
|
|
11
11
|
Check if AWS credentials are properly configured.
|
|
12
12
|
"""
|
|
13
|
-
valid_aws_credentials = os.getenv("
|
|
13
|
+
valid_aws_credentials = os.getenv("AWS_ACCESS_KEY") and os.getenv("AWS_SECRET_ACCESS_KEY") and os.getenv("AWS_REGION")
|
|
14
14
|
return valid_aws_credentials
|
|
15
15
|
|
|
16
16
|
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from letta.schemas.llm_config import LLMConfig
|
|
7
|
+
from letta.schemas.message import Message as _Message
|
|
8
|
+
from letta.schemas.openai.chat_completion_request import AssistantMessage, ChatCompletionRequest, ChatMessage
|
|
9
|
+
from letta.schemas.openai.chat_completion_request import FunctionCall as ToolFunctionChoiceFunctionCall
|
|
10
|
+
from letta.schemas.openai.chat_completion_request import Tool, ToolFunctionChoice, ToolMessage, UserMessage, cast_message_to_subtype
|
|
11
|
+
from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
|
|
12
|
+
from letta.schemas.openai.openai import Function, ToolCall
|
|
13
|
+
from letta.utils import get_tool_call_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def merge_tool_message(previous_message: ChatMessage, tool_message: ToolMessage) -> ChatMessage:
|
|
17
|
+
"""
|
|
18
|
+
Merge `ToolMessage` objects into the previous message.
|
|
19
|
+
"""
|
|
20
|
+
previous_message.content += (
|
|
21
|
+
f"<ToolMessage> content: {tool_message.content}, role: {tool_message.role}, tool_call_id: {tool_message.tool_call_id}</ToolMessage>"
|
|
22
|
+
)
|
|
23
|
+
return previous_message
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle_assistant_message(assistant_message: AssistantMessage) -> AssistantMessage:
|
|
27
|
+
"""
|
|
28
|
+
For `AssistantMessage` objects, remove the `tool_calls` field and add them to the `content` field.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
if "tool_calls" in assistant_message.dict().keys():
|
|
32
|
+
assistant_message.content = "".join(
|
|
33
|
+
[
|
|
34
|
+
# f"<ToolCall> name: {tool_call.function.name}, function: {tool_call.function}</ToolCall>"
|
|
35
|
+
f"<ToolCall> {json.dumps(tool_call.function.dict())} </ToolCall>"
|
|
36
|
+
for tool_call in assistant_message.tool_calls
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
del assistant_message.tool_calls
|
|
40
|
+
return assistant_message
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def map_messages_to_deepseek_format(messages: List[ChatMessage]) -> List[_Message]:
|
|
44
|
+
"""
|
|
45
|
+
Deepeek API has the following constraints: messages must be interleaved between user and assistant messages, ending on a user message.
|
|
46
|
+
Tools are currently unstable for V3 and not supported for R1 in the API: https://api-docs.deepseek.com/guides/function_calling.
|
|
47
|
+
|
|
48
|
+
This function merges ToolMessages into AssistantMessages and removes ToolCalls from AssistantMessages, and adds a dummy user message
|
|
49
|
+
at the end.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
deepseek_messages = []
|
|
53
|
+
for idx, message in enumerate(messages):
|
|
54
|
+
# First message is the system prompt, add it
|
|
55
|
+
if idx == 0 and message.role == "system":
|
|
56
|
+
deepseek_messages.append(message)
|
|
57
|
+
continue
|
|
58
|
+
if message.role == "user":
|
|
59
|
+
if deepseek_messages[-1].role == "assistant" or deepseek_messages[-1].role == "system":
|
|
60
|
+
# User message, add it
|
|
61
|
+
deepseek_messages.append(UserMessage(content=message.content))
|
|
62
|
+
else:
|
|
63
|
+
# add to the content of the previous message
|
|
64
|
+
deepseek_messages[-1].content += message.content
|
|
65
|
+
elif message.role == "assistant":
|
|
66
|
+
if deepseek_messages[-1].role == "user":
|
|
67
|
+
# Assistant message, remove tool calls and add them to the content
|
|
68
|
+
deepseek_messages.append(handle_assistant_message(message))
|
|
69
|
+
else:
|
|
70
|
+
# add to the content of the previous message
|
|
71
|
+
deepseek_messages[-1].content += message.content
|
|
72
|
+
elif message.role == "tool" and deepseek_messages[-1].role == "assistant":
|
|
73
|
+
# Tool message, add it to the last assistant message
|
|
74
|
+
merged_message = merge_tool_message(deepseek_messages[-1], message)
|
|
75
|
+
deepseek_messages[-1] = merged_message
|
|
76
|
+
else:
|
|
77
|
+
print(f"Skipping message: {message}")
|
|
78
|
+
|
|
79
|
+
# This needs to end on a user message, add a dummy message if the last was assistant
|
|
80
|
+
if deepseek_messages[-1].role == "assistant":
|
|
81
|
+
deepseek_messages.append(UserMessage(content=""))
|
|
82
|
+
return deepseek_messages
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_deepseek_chat_completions_request(
|
|
86
|
+
llm_config: LLMConfig,
|
|
87
|
+
messages: List[_Message],
|
|
88
|
+
user_id: Optional[str],
|
|
89
|
+
functions: Optional[list],
|
|
90
|
+
function_call: Optional[str],
|
|
91
|
+
use_tool_naming: bool,
|
|
92
|
+
max_tokens: Optional[int],
|
|
93
|
+
) -> ChatCompletionRequest:
|
|
94
|
+
# if functions and llm_config.put_inner_thoughts_in_kwargs:
|
|
95
|
+
# # Special case for LM Studio backend since it needs extra guidance to force out the thoughts first
|
|
96
|
+
# # TODO(fix)
|
|
97
|
+
# inner_thoughts_desc = (
|
|
98
|
+
# INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST if ":1234" in llm_config.model_endpoint else INNER_THOUGHTS_KWARG_DESCRIPTION
|
|
99
|
+
# )
|
|
100
|
+
# functions = add_inner_thoughts_to_functions(
|
|
101
|
+
# functions=functions,
|
|
102
|
+
# inner_thoughts_key=INNER_THOUGHTS_KWARG,
|
|
103
|
+
# inner_thoughts_description=inner_thoughts_desc,
|
|
104
|
+
# )
|
|
105
|
+
|
|
106
|
+
openai_message_list = [cast_message_to_subtype(m.to_openai_dict(put_inner_thoughts_in_kwargs=False)) for m in messages]
|
|
107
|
+
|
|
108
|
+
if llm_config.model:
|
|
109
|
+
model = llm_config.model
|
|
110
|
+
else:
|
|
111
|
+
warnings.warn(f"Model type not set in llm_config: {llm_config.model_dump_json(indent=4)}")
|
|
112
|
+
model = None
|
|
113
|
+
if use_tool_naming:
|
|
114
|
+
if function_call is None:
|
|
115
|
+
tool_choice = None
|
|
116
|
+
elif function_call not in ["none", "auto", "required"]:
|
|
117
|
+
tool_choice = ToolFunctionChoice(type="function", function=ToolFunctionChoiceFunctionCall(name=function_call))
|
|
118
|
+
else:
|
|
119
|
+
tool_choice = function_call
|
|
120
|
+
|
|
121
|
+
def add_functions_to_system_message(system_message: ChatMessage):
|
|
122
|
+
system_message.content += f"<available functions> {''.join(json.dumps(f) for f in functions)} </available functions>"
|
|
123
|
+
system_message.content += f'Select best function to call simply respond with a single json block with the fields "name" and "arguments". Use double quotes around the arguments.'
|
|
124
|
+
|
|
125
|
+
if llm_config.model == "deepseek-reasoner": # R1 currently doesn't support function calling natively
|
|
126
|
+
add_functions_to_system_message(
|
|
127
|
+
openai_message_list[0]
|
|
128
|
+
) # Inject additional instructions to the system prompt with the available functions
|
|
129
|
+
|
|
130
|
+
openai_message_list = map_messages_to_deepseek_format(openai_message_list)
|
|
131
|
+
|
|
132
|
+
data = ChatCompletionRequest(
|
|
133
|
+
model=model,
|
|
134
|
+
messages=openai_message_list,
|
|
135
|
+
user=str(user_id),
|
|
136
|
+
max_completion_tokens=max_tokens,
|
|
137
|
+
temperature=llm_config.temperature,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
data = ChatCompletionRequest(
|
|
141
|
+
model=model,
|
|
142
|
+
messages=openai_message_list,
|
|
143
|
+
tools=[Tool(type="function", function=f) for f in functions] if functions else None,
|
|
144
|
+
tool_choice=tool_choice,
|
|
145
|
+
user=str(user_id),
|
|
146
|
+
max_completion_tokens=max_tokens,
|
|
147
|
+
temperature=llm_config.temperature,
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
data = ChatCompletionRequest(
|
|
151
|
+
model=model,
|
|
152
|
+
messages=openai_message_list,
|
|
153
|
+
functions=functions,
|
|
154
|
+
function_call=function_call,
|
|
155
|
+
user=str(user_id),
|
|
156
|
+
max_completion_tokens=max_tokens,
|
|
157
|
+
temperature=llm_config.temperature,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def convert_deepseek_response_to_chatcompletion(
|
|
164
|
+
response: ChatCompletionResponse,
|
|
165
|
+
) -> ChatCompletionResponse:
|
|
166
|
+
"""
|
|
167
|
+
Example response from DeepSeek:
|
|
168
|
+
|
|
169
|
+
ChatCompletion(
|
|
170
|
+
id='bc7f7d25-82e4-443a-b217-dfad2b66da8e',
|
|
171
|
+
choices=[
|
|
172
|
+
Choice(
|
|
173
|
+
finish_reason='stop',
|
|
174
|
+
index=0,
|
|
175
|
+
logprobs=None,
|
|
176
|
+
message=ChatCompletionMessage(
|
|
177
|
+
content='{"function": "send_message", "arguments": {"message": "Hey! Whales are such majestic creatures, aren\'t they? How\'s your day going? 🌊 "}}',
|
|
178
|
+
refusal=None,
|
|
179
|
+
role='assistant',
|
|
180
|
+
audio=None,
|
|
181
|
+
function_call=None,
|
|
182
|
+
tool_calls=None,
|
|
183
|
+
reasoning_content='Okay, the user said "hello whales". Hmm, that\'s an interesting greeting. Maybe they meant "hello there" or are they actually talking about whales? Let me check if I misheard. Whales are fascinating creatures. I should respond in a friendly way. Let me ask them how they\'re doing and mention whales to keep the conversation going.'
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
],
|
|
187
|
+
created=1738266449,
|
|
188
|
+
model='deepseek-reasoner',
|
|
189
|
+
object='chat.completion',
|
|
190
|
+
service_tier=None,
|
|
191
|
+
system_fingerprint='fp_7e73fd9a08',
|
|
192
|
+
usage=CompletionUsage(
|
|
193
|
+
completion_tokens=111,
|
|
194
|
+
prompt_tokens=1270,
|
|
195
|
+
total_tokens=1381,
|
|
196
|
+
completion_tokens_details=CompletionTokensDetails(
|
|
197
|
+
accepted_prediction_tokens=None,
|
|
198
|
+
audio_tokens=None,
|
|
199
|
+
reasoning_tokens=72,
|
|
200
|
+
rejected_prediction_tokens=None
|
|
201
|
+
),
|
|
202
|
+
prompt_tokens_details=PromptTokensDetails(
|
|
203
|
+
audio_tokens=None,
|
|
204
|
+
cached_tokens=1088
|
|
205
|
+
),
|
|
206
|
+
prompt_cache_hit_tokens=1088,
|
|
207
|
+
prompt_cache_miss_tokens=182
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def convert_dict_quotes(input_dict: dict):
|
|
213
|
+
"""
|
|
214
|
+
Convert a dictionary with single-quoted keys to double-quoted keys,
|
|
215
|
+
properly handling boolean values and nested structures.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
input_dict (dict): Input dictionary with single-quoted keys
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
str: JSON string with double-quoted keys
|
|
222
|
+
"""
|
|
223
|
+
# First convert the dictionary to a JSON string to handle booleans properly
|
|
224
|
+
json_str = json.dumps(input_dict)
|
|
225
|
+
|
|
226
|
+
# Function to handle complex string replacements
|
|
227
|
+
def replace_quotes(match):
|
|
228
|
+
key = match.group(1)
|
|
229
|
+
# Escape any existing double quotes in the key
|
|
230
|
+
key = key.replace('"', '\\"')
|
|
231
|
+
return f'"{key}":'
|
|
232
|
+
|
|
233
|
+
# Replace single-quoted keys with double-quoted keys
|
|
234
|
+
# This regex looks for single-quoted keys followed by a colon
|
|
235
|
+
def strip_json_block(text):
|
|
236
|
+
# Check if text starts with ```json or similar
|
|
237
|
+
if text.strip().startswith("```"):
|
|
238
|
+
# Split by \n to remove the first and last lines
|
|
239
|
+
lines = text.split("\n")[1:-1]
|
|
240
|
+
return "\n".join(lines)
|
|
241
|
+
return text
|
|
242
|
+
|
|
243
|
+
pattern = r"'([^']*)':"
|
|
244
|
+
converted_str = re.sub(pattern, replace_quotes, strip_json_block(json_str))
|
|
245
|
+
|
|
246
|
+
# Parse the string back to ensure valid JSON format
|
|
247
|
+
try:
|
|
248
|
+
json.loads(converted_str)
|
|
249
|
+
return converted_str
|
|
250
|
+
except json.JSONDecodeError as e:
|
|
251
|
+
raise ValueError(f"Failed to create valid JSON with double quotes: {str(e)}")
|
|
252
|
+
|
|
253
|
+
def extract_json_block(text):
|
|
254
|
+
# Find the first {
|
|
255
|
+
start = text.find("{")
|
|
256
|
+
if start == -1:
|
|
257
|
+
return text
|
|
258
|
+
|
|
259
|
+
# Track nested braces to find the matching closing brace
|
|
260
|
+
brace_count = 0
|
|
261
|
+
end = start
|
|
262
|
+
|
|
263
|
+
for i in range(start, len(text)):
|
|
264
|
+
if text[i] == "{":
|
|
265
|
+
brace_count += 1
|
|
266
|
+
elif text[i] == "}":
|
|
267
|
+
brace_count -= 1
|
|
268
|
+
if brace_count == 0:
|
|
269
|
+
end = i + 1
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
return text[start:end]
|
|
273
|
+
|
|
274
|
+
content = response.choices[0].message.content
|
|
275
|
+
try:
|
|
276
|
+
content_dict = json.loads(extract_json_block(content))
|
|
277
|
+
|
|
278
|
+
if type(content_dict["arguments"]) == str:
|
|
279
|
+
content_dict["arguments"] = json.loads(content_dict["arguments"])
|
|
280
|
+
|
|
281
|
+
tool_calls = [
|
|
282
|
+
ToolCall(
|
|
283
|
+
id=get_tool_call_id(),
|
|
284
|
+
type="function",
|
|
285
|
+
function=Function(
|
|
286
|
+
name=content_dict["name"],
|
|
287
|
+
arguments=convert_dict_quotes(content_dict["arguments"]),
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
]
|
|
291
|
+
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
|
292
|
+
print(e)
|
|
293
|
+
tool_calls = response.choices[0].message.tool_calls
|
|
294
|
+
raise ValueError(f"Failed to create valid JSON {content}")
|
|
295
|
+
|
|
296
|
+
# Move the "reasoning_content" into the "content" field
|
|
297
|
+
response.choices[0].message.content = response.choices[0].message.reasoning_content
|
|
298
|
+
response.choices[0].message.tool_calls = tool_calls
|
|
299
|
+
|
|
300
|
+
# Remove the "reasoning_content" field
|
|
301
|
+
response.choices[0].message.reasoning_content = None
|
|
302
|
+
|
|
303
|
+
return response
|