letta-nightly 0.12.0.dev20251009104148__py3-none-any.whl → 0.12.1.dev20251009224219__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.
- letta/__init__.py +1 -1
- letta/adapters/simple_llm_stream_adapter.py +1 -1
- letta/agents/letta_agent_v2.py +11 -11
- letta/interfaces/anthropic_parallel_tool_call_streaming_interface.py +487 -0
- letta/interfaces/anthropic_streaming_interface.py +21 -9
- letta/interfaces/gemini_streaming_interface.py +7 -5
- letta/interfaces/openai_streaming_interface.py +42 -30
- letta/llm_api/anthropic_client.py +35 -16
- letta/llm_api/openai_client.py +11 -0
- letta/schemas/environment_variables.py +24 -0
- letta/schemas/letta_message.py +29 -12
- letta/schemas/message.py +102 -21
- letta/schemas/providers/base.py +43 -0
- letta/schemas/secret.py +103 -36
- letta/server/rest_api/interface.py +85 -41
- letta/server/rest_api/routers/v1/providers.py +34 -0
- letta/server/rest_api/routers/v1/runs.py +1 -1
- letta/server/server.py +22 -0
- letta/settings.py +3 -0
- {letta_nightly-0.12.0.dev20251009104148.dist-info → letta_nightly-0.12.1.dev20251009224219.dist-info}/METADATA +1 -1
- {letta_nightly-0.12.0.dev20251009104148.dist-info → letta_nightly-0.12.1.dev20251009224219.dist-info}/RECORD +24 -23
- {letta_nightly-0.12.0.dev20251009104148.dist-info → letta_nightly-0.12.1.dev20251009224219.dist-info}/WHEEL +0 -0
- {letta_nightly-0.12.0.dev20251009104148.dist-info → letta_nightly-0.12.1.dev20251009224219.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.12.0.dev20251009104148.dist-info → letta_nightly-0.12.1.dev20251009224219.dist-info}/licenses/LICENSE +0 -0
letta/schemas/secret.py
CHANGED
@@ -2,6 +2,7 @@ import json
|
|
2
2
|
from typing import Any, Dict, Optional
|
3
3
|
|
4
4
|
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
5
|
+
from pydantic_core import core_schema
|
5
6
|
|
6
7
|
from letta.helpers.crypto_utils import CryptoUtils
|
7
8
|
from letta.log import get_logger
|
@@ -19,16 +20,16 @@ class Secret(BaseModel):
|
|
19
20
|
TODO: Once we deprecate plaintext columns in the database:
|
20
21
|
- Remove the dual-write logic in to_dict()
|
21
22
|
- Remove the from_db() method's plaintext_value parameter
|
22
|
-
- Remove the
|
23
|
+
- Remove the was_encrypted flag (no longer needed for migration)
|
23
24
|
- Simplify get_plaintext() to only handle encrypted values
|
24
25
|
"""
|
25
26
|
|
26
|
-
# Store the encrypted value
|
27
|
-
|
28
|
-
# Cache the decrypted value to avoid repeated decryption
|
27
|
+
# Store the encrypted value as a regular field
|
28
|
+
encrypted_value: Optional[str] = None
|
29
|
+
# Cache the decrypted value to avoid repeated decryption (not serialized for security)
|
29
30
|
_plaintext_cache: Optional[str] = PrivateAttr(default=None)
|
30
31
|
# Flag to indicate if the value was originally encrypted
|
31
|
-
|
32
|
+
was_encrypted: bool = False
|
32
33
|
|
33
34
|
model_config = ConfigDict(frozen=True)
|
34
35
|
|
@@ -44,18 +45,16 @@ class Secret(BaseModel):
|
|
44
45
|
A Secret instance with the encrypted value, or plaintext if encryption unavailable
|
45
46
|
"""
|
46
47
|
if value is None:
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
48
|
+
return cls.model_construct(encrypted_value=None, was_encrypted=False)
|
49
|
+
|
50
|
+
# Guard against double encryption - check if value is already encrypted
|
51
|
+
if CryptoUtils.is_encrypted(value):
|
52
|
+
logger.warning("Creating Secret from already-encrypted value. This can be dangerous.")
|
51
53
|
|
52
54
|
# Try to encrypt, but fall back to plaintext if no encryption key
|
53
55
|
try:
|
54
56
|
encrypted = CryptoUtils.encrypt(value)
|
55
|
-
|
56
|
-
instance._encrypted_value = encrypted
|
57
|
-
instance._was_encrypted = False
|
58
|
-
return instance
|
57
|
+
return cls.model_construct(encrypted_value=encrypted, was_encrypted=False)
|
59
58
|
except ValueError as e:
|
60
59
|
# No encryption key available, store as plaintext
|
61
60
|
if "No encryption key configured" in str(e):
|
@@ -63,10 +62,8 @@ class Secret(BaseModel):
|
|
63
62
|
"No encryption key configured. Storing Secret value as plaintext. "
|
64
63
|
"Set LETTA_ENCRYPTION_KEY environment variable to enable encryption."
|
65
64
|
)
|
66
|
-
instance = cls()
|
67
|
-
instance._encrypted_value = value # Store plaintext
|
65
|
+
instance = cls.model_construct(encrypted_value=value, was_encrypted=False)
|
68
66
|
instance._plaintext_cache = value # Cache it
|
69
|
-
instance._was_encrypted = False
|
70
67
|
return instance
|
71
68
|
raise # Re-raise if it's a different error
|
72
69
|
|
@@ -81,10 +78,7 @@ class Secret(BaseModel):
|
|
81
78
|
Returns:
|
82
79
|
A Secret instance
|
83
80
|
"""
|
84
|
-
|
85
|
-
instance._encrypted_value = encrypted_value
|
86
|
-
instance._was_encrypted = True
|
87
|
-
return instance
|
81
|
+
return cls.model_construct(encrypted_value=encrypted_value, was_encrypted=True)
|
88
82
|
|
89
83
|
@classmethod
|
90
84
|
def from_db(cls, encrypted_value: Optional[str], plaintext_value: Optional[str]) -> "Secret":
|
@@ -114,7 +108,7 @@ class Secret(BaseModel):
|
|
114
108
|
Returns:
|
115
109
|
The encrypted value, or None if the secret is empty
|
116
110
|
"""
|
117
|
-
return self.
|
111
|
+
return self.encrypted_value
|
118
112
|
|
119
113
|
def get_plaintext(self) -> Optional[str]:
|
120
114
|
"""
|
@@ -126,7 +120,7 @@ class Secret(BaseModel):
|
|
126
120
|
Returns:
|
127
121
|
The decrypted plaintext value
|
128
122
|
"""
|
129
|
-
if self.
|
123
|
+
if self.encrypted_value is None:
|
130
124
|
return None
|
131
125
|
|
132
126
|
# Use cached value if available, but only if it looks like plaintext
|
@@ -134,14 +128,14 @@ class Secret(BaseModel):
|
|
134
128
|
if self._plaintext_cache is not None:
|
135
129
|
# If we have a cache but the stored value looks encrypted and we have no key,
|
136
130
|
# we should not use the cache
|
137
|
-
if CryptoUtils.is_encrypted(self.
|
131
|
+
if CryptoUtils.is_encrypted(self.encrypted_value) and not CryptoUtils.is_encryption_available():
|
138
132
|
self._plaintext_cache = None # Clear invalid cache
|
139
133
|
else:
|
140
134
|
return self._plaintext_cache
|
141
135
|
|
142
136
|
# Decrypt and cache
|
143
137
|
try:
|
144
|
-
plaintext = CryptoUtils.decrypt(self.
|
138
|
+
plaintext = CryptoUtils.decrypt(self.encrypted_value)
|
145
139
|
# Cache the decrypted value (PrivateAttr fields can be mutated even with frozen=True)
|
146
140
|
self._plaintext_cache = plaintext
|
147
141
|
return plaintext
|
@@ -151,7 +145,7 @@ class Secret(BaseModel):
|
|
151
145
|
# Handle missing encryption key
|
152
146
|
if "No encryption key configured" in error_msg:
|
153
147
|
# Check if the value looks encrypted
|
154
|
-
if CryptoUtils.is_encrypted(self.
|
148
|
+
if CryptoUtils.is_encrypted(self.encrypted_value):
|
155
149
|
# Value was encrypted, but now we have no key - can't decrypt
|
156
150
|
logger.warning(
|
157
151
|
"Cannot decrypt Secret value - no encryption key configured. "
|
@@ -162,26 +156,26 @@ class Secret(BaseModel):
|
|
162
156
|
else:
|
163
157
|
# Value is plaintext (stored when no key was available)
|
164
158
|
logger.debug("Secret value is plaintext (stored without encryption)")
|
165
|
-
self._plaintext_cache = self.
|
166
|
-
return self.
|
159
|
+
self._plaintext_cache = self.encrypted_value
|
160
|
+
return self.encrypted_value
|
167
161
|
|
168
162
|
# Handle decryption failure (might be plaintext stored as such)
|
169
163
|
elif "Failed to decrypt data" in error_msg:
|
170
164
|
# Check if it might be plaintext
|
171
|
-
if not CryptoUtils.is_encrypted(self.
|
165
|
+
if not CryptoUtils.is_encrypted(self.encrypted_value):
|
172
166
|
# It's plaintext that was stored when no key was available
|
173
167
|
logger.debug("Secret value appears to be plaintext (stored without encryption)")
|
174
|
-
self._plaintext_cache = self.
|
175
|
-
return self.
|
168
|
+
self._plaintext_cache = self.encrypted_value
|
169
|
+
return self.encrypted_value
|
176
170
|
# Otherwise, it's corrupted or wrong key
|
177
171
|
logger.error("Failed to decrypt Secret value - data may be corrupted or wrong key")
|
178
172
|
raise
|
179
173
|
|
180
174
|
# Migration case: handle legacy plaintext
|
181
|
-
elif not self.
|
182
|
-
if self.
|
183
|
-
self._plaintext_cache = self.
|
184
|
-
return self.
|
175
|
+
elif not self.was_encrypted:
|
176
|
+
if self.encrypted_value and not CryptoUtils.is_encrypted(self.encrypted_value):
|
177
|
+
self._plaintext_cache = self.encrypted_value
|
178
|
+
return self.encrypted_value
|
185
179
|
return None
|
186
180
|
|
187
181
|
# Re-raise for other errors
|
@@ -189,7 +183,7 @@ class Secret(BaseModel):
|
|
189
183
|
|
190
184
|
def is_empty(self) -> bool:
|
191
185
|
"""Check if the secret is empty/None."""
|
192
|
-
return self.
|
186
|
+
return self.encrypted_value is None
|
193
187
|
|
194
188
|
def __str__(self) -> str:
|
195
189
|
"""String representation that doesn't expose the actual value."""
|
@@ -207,7 +201,7 @@ class Secret(BaseModel):
|
|
207
201
|
|
208
202
|
Returns both encrypted and plaintext values for dual-write during migration.
|
209
203
|
"""
|
210
|
-
return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self.
|
204
|
+
return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self.was_encrypted else None}
|
211
205
|
|
212
206
|
def __eq__(self, other: Any) -> bool:
|
213
207
|
"""
|
@@ -219,6 +213,79 @@ class Secret(BaseModel):
|
|
219
213
|
return False
|
220
214
|
return self.get_plaintext() == other.get_plaintext()
|
221
215
|
|
216
|
+
@classmethod
|
217
|
+
def __get_pydantic_core_schema__(cls, source_type: Any, handler) -> core_schema.CoreSchema:
|
218
|
+
"""
|
219
|
+
Customize Pydantic's validation and serialization behavior for Secret fields.
|
220
|
+
|
221
|
+
This allows Secret fields to automatically:
|
222
|
+
- Deserialize: Convert encrypted strings from DB → Secret objects
|
223
|
+
- Serialize: Convert Secret objects → encrypted strings for DB
|
224
|
+
"""
|
225
|
+
|
226
|
+
def validate_secret(value: Any) -> "Secret":
|
227
|
+
"""Convert various input types to Secret objects."""
|
228
|
+
if isinstance(value, Secret):
|
229
|
+
return value
|
230
|
+
elif isinstance(value, str):
|
231
|
+
# String from DB is assumed to be encrypted
|
232
|
+
return Secret.from_encrypted(value)
|
233
|
+
elif isinstance(value, dict):
|
234
|
+
# Dict might be from Pydantic serialization - check for encrypted_value key
|
235
|
+
if "encrypted_value" in value:
|
236
|
+
# This is a serialized Secret being deserialized
|
237
|
+
return cls(**value)
|
238
|
+
elif not value or value == {}:
|
239
|
+
# Empty dict means None
|
240
|
+
return Secret.from_plaintext(None)
|
241
|
+
else:
|
242
|
+
raise ValueError(f"Cannot convert dict to Secret: {value}")
|
243
|
+
elif value is None:
|
244
|
+
return Secret.from_plaintext(None)
|
245
|
+
else:
|
246
|
+
raise ValueError(f"Cannot convert {type(value)} to Secret")
|
247
|
+
|
248
|
+
def serialize_secret(secret: "Secret") -> Optional[str]:
|
249
|
+
"""Serialize Secret to encrypted string."""
|
250
|
+
if secret is None:
|
251
|
+
return None
|
252
|
+
return secret.get_encrypted()
|
253
|
+
|
254
|
+
python_schema = core_schema.chain_schema(
|
255
|
+
[
|
256
|
+
core_schema.no_info_plain_validator_function(validate_secret),
|
257
|
+
core_schema.is_instance_schema(cls),
|
258
|
+
]
|
259
|
+
)
|
260
|
+
|
261
|
+
return core_schema.json_or_python_schema(
|
262
|
+
json_schema=python_schema,
|
263
|
+
python_schema=python_schema,
|
264
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
265
|
+
serialize_secret,
|
266
|
+
when_used="always",
|
267
|
+
),
|
268
|
+
)
|
269
|
+
|
270
|
+
@classmethod
|
271
|
+
def __get_pydantic_json_schema__(cls, core_schema: core_schema.CoreSchema, handler) -> Dict[str, Any]:
|
272
|
+
"""
|
273
|
+
Define JSON schema representation for Secret fields.
|
274
|
+
In JSON schema (OpenAPI docs), Secret fields appear as nullable strings.
|
275
|
+
The actual encryption/decryption happens at runtime via __get_pydantic_core_schema__.
|
276
|
+
Args:
|
277
|
+
core_schema: The core schema for this type
|
278
|
+
handler: Handler for generating JSON schema
|
279
|
+
Returns:
|
280
|
+
A JSON schema dict representing this type as a nullable string
|
281
|
+
"""
|
282
|
+
# Return a simple string schema for JSON schema generation
|
283
|
+
return {
|
284
|
+
"type": "string",
|
285
|
+
"nullable": True,
|
286
|
+
"description": "Encrypted secret value (stored as encrypted string)",
|
287
|
+
}
|
288
|
+
|
222
289
|
|
223
290
|
class SecretDict(BaseModel):
|
224
291
|
"""
|
@@ -562,14 +562,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
562
562
|
|
563
563
|
if prev_message_type and prev_message_type != "tool_call_message":
|
564
564
|
message_index += 1
|
565
|
+
tool_call_delta = ToolCallDelta(
|
566
|
+
name=json_reasoning_content.get("name"),
|
567
|
+
arguments=json.dumps(json_reasoning_content.get("arguments")),
|
568
|
+
tool_call_id=None,
|
569
|
+
)
|
565
570
|
processed_chunk = ToolCallMessage(
|
566
571
|
id=message_id,
|
567
572
|
date=message_date,
|
568
|
-
tool_call=
|
569
|
-
|
570
|
-
arguments=json.dumps(json_reasoning_content.get("arguments")),
|
571
|
-
tool_call_id=None,
|
572
|
-
),
|
573
|
+
tool_call=tool_call_delta,
|
574
|
+
tool_calls=tool_call_delta,
|
573
575
|
name=name,
|
574
576
|
otid=Message.generate_otid_from_id(message_id, message_index),
|
575
577
|
)
|
@@ -703,14 +705,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
703
705
|
else:
|
704
706
|
if prev_message_type and prev_message_type != "tool_call_message":
|
705
707
|
message_index += 1
|
708
|
+
tc_delta = ToolCallDelta(
|
709
|
+
name=tool_call_delta.get("name"),
|
710
|
+
arguments=tool_call_delta.get("arguments"),
|
711
|
+
tool_call_id=tool_call_delta.get("id"),
|
712
|
+
)
|
706
713
|
processed_chunk = ToolCallMessage(
|
707
714
|
id=message_id,
|
708
715
|
date=message_date,
|
709
|
-
tool_call=
|
710
|
-
|
711
|
-
arguments=tool_call_delta.get("arguments"),
|
712
|
-
tool_call_id=tool_call_delta.get("id"),
|
713
|
-
),
|
716
|
+
tool_call=tc_delta,
|
717
|
+
tool_calls=tc_delta,
|
714
718
|
name=name,
|
715
719
|
otid=Message.generate_otid_from_id(message_id, message_index),
|
716
720
|
)
|
@@ -779,14 +783,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
779
783
|
else:
|
780
784
|
if prev_message_type and prev_message_type != "tool_call_message":
|
781
785
|
message_index += 1
|
786
|
+
tc_delta = ToolCallDelta(
|
787
|
+
name=self.function_name_buffer,
|
788
|
+
arguments=None,
|
789
|
+
tool_call_id=self.function_id_buffer,
|
790
|
+
)
|
782
791
|
processed_chunk = ToolCallMessage(
|
783
792
|
id=message_id,
|
784
793
|
date=message_date,
|
785
|
-
tool_call=
|
786
|
-
|
787
|
-
arguments=None,
|
788
|
-
tool_call_id=self.function_id_buffer,
|
789
|
-
),
|
794
|
+
tool_call=tc_delta,
|
795
|
+
tool_calls=tc_delta,
|
790
796
|
name=name,
|
791
797
|
otid=Message.generate_otid_from_id(message_id, message_index),
|
792
798
|
)
|
@@ -843,14 +849,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
843
849
|
combined_chunk = self.function_args_buffer + updates_main_json
|
844
850
|
if prev_message_type and prev_message_type != "tool_call_message":
|
845
851
|
message_index += 1
|
852
|
+
tc_delta = ToolCallDelta(
|
853
|
+
name=None,
|
854
|
+
arguments=combined_chunk,
|
855
|
+
tool_call_id=self.function_id_buffer,
|
856
|
+
)
|
846
857
|
processed_chunk = ToolCallMessage(
|
847
858
|
id=message_id,
|
848
859
|
date=message_date,
|
849
|
-
tool_call=
|
850
|
-
|
851
|
-
arguments=combined_chunk,
|
852
|
-
tool_call_id=self.function_id_buffer,
|
853
|
-
),
|
860
|
+
tool_call=tc_delta,
|
861
|
+
tool_calls=tc_delta,
|
854
862
|
name=name,
|
855
863
|
otid=Message.generate_otid_from_id(message_id, message_index),
|
856
864
|
)
|
@@ -861,14 +869,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
861
869
|
# If there's no buffer to clear, just output a new chunk with new data
|
862
870
|
if prev_message_type and prev_message_type != "tool_call_message":
|
863
871
|
message_index += 1
|
872
|
+
tc_delta = ToolCallDelta(
|
873
|
+
name=None,
|
874
|
+
arguments=updates_main_json,
|
875
|
+
tool_call_id=self.function_id_buffer,
|
876
|
+
)
|
864
877
|
processed_chunk = ToolCallMessage(
|
865
878
|
id=message_id,
|
866
879
|
date=message_date,
|
867
|
-
tool_call=
|
868
|
-
|
869
|
-
arguments=updates_main_json,
|
870
|
-
tool_call_id=self.function_id_buffer,
|
871
|
-
),
|
880
|
+
tool_call=tc_delta,
|
881
|
+
tool_calls=tc_delta,
|
872
882
|
name=name,
|
873
883
|
otid=Message.generate_otid_from_id(message_id, message_index),
|
874
884
|
)
|
@@ -992,14 +1002,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
992
1002
|
else:
|
993
1003
|
if prev_message_type and prev_message_type != "tool_call_message":
|
994
1004
|
message_index += 1
|
1005
|
+
tc_delta = ToolCallDelta(
|
1006
|
+
name=tool_call_delta.get("name"),
|
1007
|
+
arguments=tool_call_delta.get("arguments"),
|
1008
|
+
tool_call_id=tool_call_delta.get("id"),
|
1009
|
+
)
|
995
1010
|
processed_chunk = ToolCallMessage(
|
996
1011
|
id=message_id,
|
997
1012
|
date=message_date,
|
998
|
-
tool_call=
|
999
|
-
|
1000
|
-
arguments=tool_call_delta.get("arguments"),
|
1001
|
-
tool_call_id=tool_call_delta.get("id"),
|
1002
|
-
),
|
1013
|
+
tool_call=tc_delta,
|
1014
|
+
tool_calls=tc_delta,
|
1003
1015
|
name=name,
|
1004
1016
|
otid=Message.generate_otid_from_id(message_id, message_index),
|
1005
1017
|
)
|
@@ -1262,14 +1274,16 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
1262
1274
|
# Store the ID of the tool call so allow skipping the corresponding response
|
1263
1275
|
self.prev_assistant_message_id = function_call.id
|
1264
1276
|
else:
|
1277
|
+
tool_call_obj = ToolCall(
|
1278
|
+
name=function_call.function.name,
|
1279
|
+
arguments=function_call.function.arguments,
|
1280
|
+
tool_call_id=function_call.id,
|
1281
|
+
)
|
1265
1282
|
processed_chunk = ToolCallMessage(
|
1266
1283
|
id=msg_obj.id,
|
1267
1284
|
date=msg_obj.created_at,
|
1268
|
-
tool_call=
|
1269
|
-
|
1270
|
-
arguments=function_call.function.arguments,
|
1271
|
-
tool_call_id=function_call.id,
|
1272
|
-
),
|
1285
|
+
tool_call=tool_call_obj,
|
1286
|
+
tool_calls=tool_call_obj,
|
1273
1287
|
name=msg_obj.name,
|
1274
1288
|
otid=Message.generate_otid_from_id(msg_obj.id, chunk_index) if chunk_index is not None else None,
|
1275
1289
|
)
|
@@ -1303,14 +1317,29 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
1303
1317
|
# Skip this tool call receipt
|
1304
1318
|
return
|
1305
1319
|
else:
|
1320
|
+
from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
|
1321
|
+
|
1322
|
+
status = msg_obj.tool_returns[0].status if msg_obj.tool_returns else "success"
|
1323
|
+
stdout = msg_obj.tool_returns[0].stdout if msg_obj.tool_returns else []
|
1324
|
+
stderr = msg_obj.tool_returns[0].stderr if msg_obj.tool_returns else []
|
1325
|
+
|
1326
|
+
tool_return_obj = ToolReturnSchema(
|
1327
|
+
tool_return=msg,
|
1328
|
+
status=status,
|
1329
|
+
tool_call_id=msg_obj.tool_call_id,
|
1330
|
+
stdout=stdout,
|
1331
|
+
stderr=stderr,
|
1332
|
+
)
|
1333
|
+
|
1306
1334
|
new_message = ToolReturnMessage(
|
1307
1335
|
id=msg_obj.id,
|
1308
1336
|
date=msg_obj.created_at,
|
1309
1337
|
tool_return=msg,
|
1310
|
-
status=
|
1338
|
+
status=status,
|
1311
1339
|
tool_call_id=msg_obj.tool_call_id,
|
1312
|
-
stdout=
|
1313
|
-
stderr=
|
1340
|
+
stdout=stdout,
|
1341
|
+
stderr=stderr,
|
1342
|
+
tool_returns=[tool_return_obj],
|
1314
1343
|
name=msg_obj.name,
|
1315
1344
|
otid=Message.generate_otid_from_id(msg_obj.id, chunk_index) if chunk_index is not None else None,
|
1316
1345
|
)
|
@@ -1319,14 +1348,29 @@ class StreamingServerInterface(AgentChunkStreamingInterface):
|
|
1319
1348
|
msg = msg.replace("Error: ", "", 1)
|
1320
1349
|
# new_message = {"function_return": msg, "status": "error"}
|
1321
1350
|
assert msg_obj.tool_call_id is not None
|
1351
|
+
from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
|
1352
|
+
|
1353
|
+
status = msg_obj.tool_returns[0].status if msg_obj.tool_returns else "error"
|
1354
|
+
stdout = msg_obj.tool_returns[0].stdout if msg_obj.tool_returns else []
|
1355
|
+
stderr = msg_obj.tool_returns[0].stderr if msg_obj.tool_returns else []
|
1356
|
+
|
1357
|
+
tool_return_obj = ToolReturnSchema(
|
1358
|
+
tool_return=msg,
|
1359
|
+
status=status,
|
1360
|
+
tool_call_id=msg_obj.tool_call_id,
|
1361
|
+
stdout=stdout,
|
1362
|
+
stderr=stderr,
|
1363
|
+
)
|
1364
|
+
|
1322
1365
|
new_message = ToolReturnMessage(
|
1323
1366
|
id=msg_obj.id,
|
1324
1367
|
date=msg_obj.created_at,
|
1325
1368
|
tool_return=msg,
|
1326
|
-
status=
|
1369
|
+
status=status,
|
1327
1370
|
tool_call_id=msg_obj.tool_call_id,
|
1328
|
-
stdout=
|
1329
|
-
stderr=
|
1371
|
+
stdout=stdout,
|
1372
|
+
stderr=stderr,
|
1373
|
+
tool_returns=[tool_return_obj],
|
1330
1374
|
name=msg_obj.name,
|
1331
1375
|
otid=Message.generate_otid_from_id(msg_obj.id, chunk_index) if chunk_index is not None else None,
|
1332
1376
|
)
|
@@ -120,6 +120,40 @@ async def check_provider(
|
|
120
120
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"{e}")
|
121
121
|
|
122
122
|
|
123
|
+
@router.post("/{provider_id}/check", response_model=None, operation_id="check_existing_provider")
|
124
|
+
async def check_existing_provider(
|
125
|
+
provider_id: str,
|
126
|
+
headers: HeaderParams = Depends(get_headers),
|
127
|
+
server: "SyncServer" = Depends(get_letta_server),
|
128
|
+
):
|
129
|
+
"""
|
130
|
+
Verify the API key and additional parameters for an existing provider.
|
131
|
+
"""
|
132
|
+
try:
|
133
|
+
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
|
134
|
+
provider = await server.provider_manager.get_provider_async(provider_id=provider_id, actor=actor)
|
135
|
+
|
136
|
+
# Create a ProviderCheck from the existing provider
|
137
|
+
provider_check = ProviderCheck(
|
138
|
+
provider_type=provider.provider_type,
|
139
|
+
api_key=provider.api_key,
|
140
|
+
base_url=provider.base_url,
|
141
|
+
)
|
142
|
+
|
143
|
+
await server.provider_manager.check_provider_api_key(provider_check=provider_check)
|
144
|
+
return JSONResponse(
|
145
|
+
status_code=status.HTTP_200_OK, content={"message": f"Valid api key for provider_type={provider.provider_type.value}"}
|
146
|
+
)
|
147
|
+
except LLMAuthenticationError as e:
|
148
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"{e.message}")
|
149
|
+
except NoResultFound:
|
150
|
+
raise HTTPException(status_code=404, detail=f"Provider provider_id={provider_id} not found for user_id={actor.id}.")
|
151
|
+
except HTTPException:
|
152
|
+
raise
|
153
|
+
except Exception as e:
|
154
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"{e}")
|
155
|
+
|
156
|
+
|
123
157
|
@router.delete("/{provider_id}", response_model=None, operation_id="delete_provider")
|
124
158
|
async def delete_provider(
|
125
159
|
provider_id: str,
|
@@ -286,7 +286,7 @@ async def delete_run(
|
|
286
286
|
"""
|
287
287
|
actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id)
|
288
288
|
runs_manager = RunManager()
|
289
|
-
return await runs_manager.
|
289
|
+
return await runs_manager.delete_run(run_id=run_id, actor=actor)
|
290
290
|
|
291
291
|
|
292
292
|
@router.post(
|
letta/server/server.py
CHANGED
@@ -1239,6 +1239,16 @@ class SyncServer(object):
|
|
1239
1239
|
function_args=tool_args,
|
1240
1240
|
tool=tool,
|
1241
1241
|
)
|
1242
|
+
from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
|
1243
|
+
|
1244
|
+
tool_return_obj = ToolReturnSchema(
|
1245
|
+
tool_return=str(tool_execution_result.func_return),
|
1246
|
+
status=tool_execution_result.status,
|
1247
|
+
tool_call_id="null",
|
1248
|
+
stdout=tool_execution_result.stdout,
|
1249
|
+
stderr=tool_execution_result.stderr,
|
1250
|
+
)
|
1251
|
+
|
1242
1252
|
return ToolReturnMessage(
|
1243
1253
|
id="null",
|
1244
1254
|
tool_call_id="null",
|
@@ -1247,10 +1257,21 @@ class SyncServer(object):
|
|
1247
1257
|
tool_return=str(tool_execution_result.func_return),
|
1248
1258
|
stdout=tool_execution_result.stdout,
|
1249
1259
|
stderr=tool_execution_result.stderr,
|
1260
|
+
tool_returns=[tool_return_obj],
|
1250
1261
|
)
|
1251
1262
|
|
1252
1263
|
except Exception as e:
|
1253
1264
|
func_return = get_friendly_error_msg(function_name=tool.name, exception_name=type(e).__name__, exception_message=str(e))
|
1265
|
+
from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
|
1266
|
+
|
1267
|
+
tool_return_obj = ToolReturnSchema(
|
1268
|
+
tool_return=func_return,
|
1269
|
+
status="error",
|
1270
|
+
tool_call_id="null",
|
1271
|
+
stdout=[],
|
1272
|
+
stderr=[traceback.format_exc()],
|
1273
|
+
)
|
1274
|
+
|
1254
1275
|
return ToolReturnMessage(
|
1255
1276
|
id="null",
|
1256
1277
|
tool_call_id="null",
|
@@ -1259,6 +1280,7 @@ class SyncServer(object):
|
|
1259
1280
|
tool_return=func_return,
|
1260
1281
|
stdout=[],
|
1261
1282
|
stderr=[traceback.format_exc()],
|
1283
|
+
tool_returns=[tool_return_obj],
|
1262
1284
|
)
|
1263
1285
|
|
1264
1286
|
# MCP wrappers
|
letta/settings.py
CHANGED
@@ -329,6 +329,9 @@ class Settings(BaseSettings):
|
|
329
329
|
file_processing_timeout_minutes: int = 30
|
330
330
|
file_processing_timeout_error_message: str = "File processing timed out after {} minutes. Please try again."
|
331
331
|
|
332
|
+
# enabling letta_agent_v1 architecture
|
333
|
+
use_letta_v1_agent: bool = False
|
334
|
+
|
332
335
|
@property
|
333
336
|
def letta_pg_uri(self) -> str:
|
334
337
|
if self.pg_uri:
|