letta-nightly 0.11.7.dev20250915104130__py3-none-any.whl → 0.11.7.dev20250917104122__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 +10 -2
- letta/adapters/letta_llm_request_adapter.py +0 -1
- letta/adapters/letta_llm_stream_adapter.py +0 -1
- letta/agent.py +1 -1
- letta/agents/letta_agent.py +1 -4
- letta/agents/letta_agent_v2.py +2 -1
- letta/agents/voice_agent.py +1 -1
- letta/functions/function_sets/multi_agent.py +1 -1
- letta/functions/helpers.py +1 -1
- letta/helpers/converters.py +8 -2
- letta/helpers/crypto_utils.py +144 -0
- letta/llm_api/llm_api_tools.py +0 -1
- letta/llm_api/llm_client_base.py +0 -2
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +5 -1
- letta/orm/job.py +3 -1
- letta/orm/mcp_oauth.py +6 -0
- letta/orm/mcp_server.py +7 -1
- letta/orm/sqlalchemy_base.py +2 -1
- letta/prompts/gpt_system.py +13 -15
- letta/prompts/system_prompts/__init__.py +27 -0
- letta/prompts/{system/memgpt_chat.txt → system_prompts/memgpt_chat.py} +2 -0
- letta/prompts/{system/memgpt_generate_tool.txt → system_prompts/memgpt_generate_tool.py} +4 -2
- letta/prompts/{system/memgpt_v2_chat.txt → system_prompts/memgpt_v2_chat.py} +2 -0
- letta/prompts/{system/react.txt → system_prompts/react.py} +2 -0
- letta/prompts/{system/sleeptime_doc_ingest.txt → system_prompts/sleeptime_doc_ingest.py} +2 -0
- letta/prompts/{system/sleeptime_v2.txt → system_prompts/sleeptime_v2.py} +2 -0
- letta/prompts/{system/summary_system_prompt.txt → system_prompts/summary_system_prompt.py} +2 -0
- letta/prompts/{system/voice_chat.txt → system_prompts/voice_chat.py} +2 -0
- letta/prompts/{system/voice_sleeptime.txt → system_prompts/voice_sleeptime.py} +2 -0
- letta/prompts/{system/workflow.txt → system_prompts/workflow.py} +2 -0
- letta/schemas/agent.py +10 -7
- letta/schemas/job.py +10 -0
- letta/schemas/mcp.py +146 -6
- letta/schemas/provider_trace.py +0 -2
- letta/schemas/run.py +2 -0
- letta/schemas/secret.py +378 -0
- letta/serialize_schemas/marshmallow_agent.py +4 -0
- letta/server/rest_api/dependencies.py +37 -0
- letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +4 -3
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +115 -107
- letta/server/rest_api/routers/v1/archives.py +113 -0
- letta/server/rest_api/routers/v1/blocks.py +44 -20
- letta/server/rest_api/routers/v1/embeddings.py +3 -3
- letta/server/rest_api/routers/v1/folders.py +107 -47
- letta/server/rest_api/routers/v1/groups.py +52 -32
- letta/server/rest_api/routers/v1/identities.py +110 -21
- letta/server/rest_api/routers/v1/internal_templates.py +28 -13
- letta/server/rest_api/routers/v1/jobs.py +19 -14
- letta/server/rest_api/routers/v1/llms.py +6 -8
- letta/server/rest_api/routers/v1/messages.py +14 -14
- letta/server/rest_api/routers/v1/organizations.py +1 -1
- letta/server/rest_api/routers/v1/providers.py +40 -16
- letta/server/rest_api/routers/v1/runs.py +28 -20
- letta/server/rest_api/routers/v1/sandbox_configs.py +25 -25
- letta/server/rest_api/routers/v1/sources.py +44 -45
- letta/server/rest_api/routers/v1/steps.py +27 -25
- letta/server/rest_api/routers/v1/tags.py +11 -7
- letta/server/rest_api/routers/v1/telemetry.py +11 -6
- letta/server/rest_api/routers/v1/tools.py +78 -80
- letta/server/rest_api/routers/v1/users.py +1 -1
- letta/server/rest_api/routers/v1/voice.py +6 -5
- letta/server/rest_api/utils.py +1 -18
- letta/services/agent_manager.py +17 -9
- letta/services/agent_serialization_manager.py +11 -3
- letta/services/archive_manager.py +73 -0
- letta/services/file_manager.py +6 -0
- letta/services/group_manager.py +2 -1
- letta/services/helpers/agent_manager_helper.py +6 -1
- letta/services/identity_manager.py +67 -0
- letta/services/job_manager.py +18 -2
- letta/services/mcp_manager.py +198 -82
- letta/services/provider_manager.py +14 -1
- letta/services/source_manager.py +11 -1
- letta/services/telemetry_manager.py +2 -0
- letta/services/tool_executor/composio_tool_executor.py +1 -1
- letta/services/tool_manager.py +46 -9
- letta/services/tool_sandbox/base.py +2 -3
- letta/utils.py +4 -2
- {letta_nightly-0.11.7.dev20250915104130.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/METADATA +5 -2
- {letta_nightly-0.11.7.dev20250915104130.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/RECORD +85 -94
- letta/prompts/system/memgpt_base.txt +0 -54
- letta/prompts/system/memgpt_chat_compressed.txt +0 -13
- letta/prompts/system/memgpt_chat_fstring.txt +0 -51
- letta/prompts/system/memgpt_convo_only.txt +0 -12
- letta/prompts/system/memgpt_doc.txt +0 -50
- letta/prompts/system/memgpt_gpt35_extralong.txt +0 -53
- letta/prompts/system/memgpt_intuitive_knowledge.txt +0 -31
- letta/prompts/system/memgpt_memory_only.txt +0 -29
- letta/prompts/system/memgpt_modified_chat.txt +0 -23
- letta/prompts/system/memgpt_modified_o1.txt +0 -31
- letta/prompts/system/memgpt_offline_memory.txt +0 -23
- letta/prompts/system/memgpt_offline_memory_chat.txt +0 -35
- letta/prompts/system/memgpt_sleeptime_chat.txt +0 -52
- letta/prompts/system/sleeptime.txt +0 -37
- {letta_nightly-0.11.7.dev20250915104130.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20250915104130.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20250915104130.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/licenses/LICENSE +0 -0
letta/schemas/secret.py
ADDED
@@ -0,0 +1,378 @@
|
|
1
|
+
import json
|
2
|
+
from typing import Any, Dict, Optional
|
3
|
+
|
4
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
5
|
+
|
6
|
+
from letta.helpers.crypto_utils import CryptoUtils
|
7
|
+
from letta.log import get_logger
|
8
|
+
|
9
|
+
logger = get_logger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
class Secret(BaseModel):
|
13
|
+
"""
|
14
|
+
A wrapper class for encrypted credentials that keeps values encrypted in memory.
|
15
|
+
|
16
|
+
This class ensures that sensitive data remains encrypted as much as possible
|
17
|
+
while passing through the codebase, only decrypting when absolutely necessary.
|
18
|
+
|
19
|
+
TODO: Once we deprecate plaintext columns in the database:
|
20
|
+
- Remove the dual-write logic in to_dict()
|
21
|
+
- Remove the from_db() method's plaintext_value parameter
|
22
|
+
- Remove the _was_encrypted flag (no longer needed for migration)
|
23
|
+
- Simplify get_plaintext() to only handle encrypted values
|
24
|
+
"""
|
25
|
+
|
26
|
+
# Store the encrypted value
|
27
|
+
_encrypted_value: Optional[str] = PrivateAttr(default=None)
|
28
|
+
# Cache the decrypted value to avoid repeated decryption
|
29
|
+
_plaintext_cache: Optional[str] = PrivateAttr(default=None)
|
30
|
+
# Flag to indicate if the value was originally encrypted
|
31
|
+
_was_encrypted: bool = PrivateAttr(default=False)
|
32
|
+
|
33
|
+
model_config = ConfigDict(frozen=True)
|
34
|
+
|
35
|
+
@classmethod
|
36
|
+
def from_plaintext(cls, value: Optional[str]) -> "Secret":
|
37
|
+
"""
|
38
|
+
Create a Secret from a plaintext value, encrypting it if possible.
|
39
|
+
|
40
|
+
Args:
|
41
|
+
value: The plaintext value to encrypt
|
42
|
+
|
43
|
+
Returns:
|
44
|
+
A Secret instance with the encrypted value, or plaintext if encryption unavailable
|
45
|
+
"""
|
46
|
+
if value is None:
|
47
|
+
instance = cls()
|
48
|
+
instance._encrypted_value = None
|
49
|
+
instance._was_encrypted = False
|
50
|
+
return instance
|
51
|
+
|
52
|
+
# Try to encrypt, but fall back to plaintext if no encryption key
|
53
|
+
try:
|
54
|
+
encrypted = CryptoUtils.encrypt(value)
|
55
|
+
instance = cls()
|
56
|
+
instance._encrypted_value = encrypted
|
57
|
+
instance._was_encrypted = False
|
58
|
+
return instance
|
59
|
+
except ValueError as e:
|
60
|
+
# No encryption key available, store as plaintext
|
61
|
+
if "No encryption key configured" in str(e):
|
62
|
+
logger.warning(
|
63
|
+
"No encryption key configured. Storing Secret value as plaintext. "
|
64
|
+
"Set LETTA_ENCRYPTION_KEY environment variable to enable encryption."
|
65
|
+
)
|
66
|
+
instance = cls()
|
67
|
+
instance._encrypted_value = value # Store plaintext
|
68
|
+
instance._plaintext_cache = value # Cache it
|
69
|
+
instance._was_encrypted = False
|
70
|
+
return instance
|
71
|
+
raise # Re-raise if it's a different error
|
72
|
+
|
73
|
+
@classmethod
|
74
|
+
def from_encrypted(cls, encrypted_value: Optional[str]) -> "Secret":
|
75
|
+
"""
|
76
|
+
Create a Secret from an already encrypted value.
|
77
|
+
|
78
|
+
Args:
|
79
|
+
encrypted_value: The encrypted value
|
80
|
+
|
81
|
+
Returns:
|
82
|
+
A Secret instance
|
83
|
+
"""
|
84
|
+
instance = cls()
|
85
|
+
instance._encrypted_value = encrypted_value
|
86
|
+
instance._was_encrypted = True
|
87
|
+
return instance
|
88
|
+
|
89
|
+
@classmethod
|
90
|
+
def from_db(cls, encrypted_value: Optional[str], plaintext_value: Optional[str]) -> "Secret":
|
91
|
+
"""
|
92
|
+
Create a Secret from database values during migration phase.
|
93
|
+
|
94
|
+
Prefers encrypted value if available, falls back to plaintext.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
encrypted_value: The encrypted value from the database
|
98
|
+
plaintext_value: The plaintext value from the database
|
99
|
+
|
100
|
+
Returns:
|
101
|
+
A Secret instance
|
102
|
+
"""
|
103
|
+
if encrypted_value is not None:
|
104
|
+
return cls.from_encrypted(encrypted_value)
|
105
|
+
elif plaintext_value is not None:
|
106
|
+
return cls.from_plaintext(plaintext_value)
|
107
|
+
else:
|
108
|
+
return cls.from_plaintext(None)
|
109
|
+
|
110
|
+
def get_encrypted(self) -> Optional[str]:
|
111
|
+
"""
|
112
|
+
Get the encrypted value.
|
113
|
+
|
114
|
+
Returns:
|
115
|
+
The encrypted value, or None if the secret is empty
|
116
|
+
"""
|
117
|
+
return self._encrypted_value
|
118
|
+
|
119
|
+
def get_plaintext(self) -> Optional[str]:
|
120
|
+
"""
|
121
|
+
Get the decrypted plaintext value.
|
122
|
+
|
123
|
+
This should only be called when the plaintext is actually needed,
|
124
|
+
such as when making an external API call.
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
The decrypted plaintext value
|
128
|
+
"""
|
129
|
+
if self._encrypted_value is None:
|
130
|
+
return None
|
131
|
+
|
132
|
+
# Use cached value if available, but only if it looks like plaintext
|
133
|
+
# or we're confident we can decrypt it
|
134
|
+
if self._plaintext_cache is not None:
|
135
|
+
# If we have a cache but the stored value looks encrypted and we have no key,
|
136
|
+
# we should not use the cache
|
137
|
+
if CryptoUtils.is_encrypted(self._encrypted_value) and not CryptoUtils.is_encryption_available():
|
138
|
+
self._plaintext_cache = None # Clear invalid cache
|
139
|
+
else:
|
140
|
+
return self._plaintext_cache
|
141
|
+
|
142
|
+
# Decrypt and cache
|
143
|
+
try:
|
144
|
+
plaintext = CryptoUtils.decrypt(self._encrypted_value)
|
145
|
+
# Cache the decrypted value (PrivateAttr fields can be mutated even with frozen=True)
|
146
|
+
self._plaintext_cache = plaintext
|
147
|
+
return plaintext
|
148
|
+
except ValueError as e:
|
149
|
+
error_msg = str(e)
|
150
|
+
|
151
|
+
# Handle missing encryption key
|
152
|
+
if "No encryption key configured" in error_msg:
|
153
|
+
# Check if the value looks encrypted
|
154
|
+
if CryptoUtils.is_encrypted(self._encrypted_value):
|
155
|
+
# Value was encrypted, but now we have no key - can't decrypt
|
156
|
+
logger.warning(
|
157
|
+
"Cannot decrypt Secret value - no encryption key configured. "
|
158
|
+
"The value was encrypted and requires the original key to decrypt."
|
159
|
+
)
|
160
|
+
# Return None to indicate we can't get the plaintext
|
161
|
+
return None
|
162
|
+
else:
|
163
|
+
# Value is plaintext (stored when no key was available)
|
164
|
+
logger.debug("Secret value is plaintext (stored without encryption)")
|
165
|
+
self._plaintext_cache = self._encrypted_value
|
166
|
+
return self._encrypted_value
|
167
|
+
|
168
|
+
# Handle decryption failure (might be plaintext stored as such)
|
169
|
+
elif "Failed to decrypt data" in error_msg:
|
170
|
+
# Check if it might be plaintext
|
171
|
+
if not CryptoUtils.is_encrypted(self._encrypted_value):
|
172
|
+
# It's plaintext that was stored when no key was available
|
173
|
+
logger.debug("Secret value appears to be plaintext (stored without encryption)")
|
174
|
+
self._plaintext_cache = self._encrypted_value
|
175
|
+
return self._encrypted_value
|
176
|
+
# Otherwise, it's corrupted or wrong key
|
177
|
+
logger.error("Failed to decrypt Secret value - data may be corrupted or wrong key")
|
178
|
+
raise
|
179
|
+
|
180
|
+
# Migration case: handle legacy plaintext
|
181
|
+
elif not self._was_encrypted:
|
182
|
+
if self._encrypted_value and not CryptoUtils.is_encrypted(self._encrypted_value):
|
183
|
+
self._plaintext_cache = self._encrypted_value
|
184
|
+
return self._encrypted_value
|
185
|
+
return None
|
186
|
+
|
187
|
+
# Re-raise for other errors
|
188
|
+
raise
|
189
|
+
|
190
|
+
def is_empty(self) -> bool:
|
191
|
+
"""Check if the secret is empty/None."""
|
192
|
+
return self._encrypted_value is None
|
193
|
+
|
194
|
+
def __str__(self) -> str:
|
195
|
+
"""String representation that doesn't expose the actual value."""
|
196
|
+
if self.is_empty():
|
197
|
+
return "<Secret: empty>"
|
198
|
+
return "<Secret: ****>"
|
199
|
+
|
200
|
+
def __repr__(self) -> str:
|
201
|
+
"""Representation that doesn't expose the actual value."""
|
202
|
+
return self.__str__()
|
203
|
+
|
204
|
+
def to_dict(self) -> Dict[str, Any]:
|
205
|
+
"""
|
206
|
+
Convert to dictionary for database storage.
|
207
|
+
|
208
|
+
Returns both encrypted and plaintext values for dual-write during migration.
|
209
|
+
"""
|
210
|
+
return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self._was_encrypted else None}
|
211
|
+
|
212
|
+
def __eq__(self, other: Any) -> bool:
|
213
|
+
"""
|
214
|
+
Compare two secrets by their plaintext values.
|
215
|
+
|
216
|
+
Note: This decrypts both values, so use sparingly.
|
217
|
+
"""
|
218
|
+
if not isinstance(other, Secret):
|
219
|
+
return False
|
220
|
+
return self.get_plaintext() == other.get_plaintext()
|
221
|
+
|
222
|
+
|
223
|
+
class SecretDict(BaseModel):
|
224
|
+
"""
|
225
|
+
A wrapper for dictionaries containing sensitive key-value pairs.
|
226
|
+
|
227
|
+
Used for custom headers and other key-value configurations.
|
228
|
+
|
229
|
+
TODO: Once we deprecate plaintext columns in the database:
|
230
|
+
- Remove the dual-write logic in to_dict()
|
231
|
+
- Remove the from_db() method's plaintext_value parameter
|
232
|
+
- Remove the _was_encrypted flag (no longer needed for migration)
|
233
|
+
- Simplify get_plaintext() to only handle encrypted JSON values
|
234
|
+
"""
|
235
|
+
|
236
|
+
_encrypted_value: Optional[str] = PrivateAttr(default=None)
|
237
|
+
_plaintext_cache: Optional[Dict[str, str]] = PrivateAttr(default=None)
|
238
|
+
_was_encrypted: bool = PrivateAttr(default=False)
|
239
|
+
|
240
|
+
model_config = ConfigDict(frozen=True)
|
241
|
+
|
242
|
+
@classmethod
|
243
|
+
def from_plaintext(cls, value: Optional[Dict[str, str]]) -> "SecretDict":
|
244
|
+
"""Create a SecretDict from a plaintext dictionary."""
|
245
|
+
if value is None:
|
246
|
+
instance = cls()
|
247
|
+
instance._encrypted_value = None
|
248
|
+
instance._was_encrypted = False
|
249
|
+
return instance
|
250
|
+
|
251
|
+
# Serialize to JSON then try to encrypt
|
252
|
+
json_str = json.dumps(value)
|
253
|
+
try:
|
254
|
+
encrypted = CryptoUtils.encrypt(json_str)
|
255
|
+
instance = cls()
|
256
|
+
instance._encrypted_value = encrypted
|
257
|
+
instance._was_encrypted = False
|
258
|
+
return instance
|
259
|
+
except ValueError as e:
|
260
|
+
# No encryption key available, store as plaintext JSON
|
261
|
+
if "No encryption key configured" in str(e):
|
262
|
+
logger.warning(
|
263
|
+
"No encryption key configured. Storing SecretDict value as plaintext JSON. "
|
264
|
+
"Set LETTA_ENCRYPTION_KEY environment variable to enable encryption."
|
265
|
+
)
|
266
|
+
instance = cls()
|
267
|
+
instance._encrypted_value = json_str # Store JSON string
|
268
|
+
instance._plaintext_cache = value # Cache the dict
|
269
|
+
instance._was_encrypted = False
|
270
|
+
return instance
|
271
|
+
raise # Re-raise if it's a different error
|
272
|
+
|
273
|
+
@classmethod
|
274
|
+
def from_encrypted(cls, encrypted_value: Optional[str]) -> "SecretDict":
|
275
|
+
"""Create a SecretDict from an encrypted value."""
|
276
|
+
instance = cls()
|
277
|
+
instance._encrypted_value = encrypted_value
|
278
|
+
instance._was_encrypted = True
|
279
|
+
return instance
|
280
|
+
|
281
|
+
@classmethod
|
282
|
+
def from_db(cls, encrypted_value: Optional[str], plaintext_value: Optional[Dict[str, str]]) -> "SecretDict":
|
283
|
+
"""Create a SecretDict from database values during migration phase."""
|
284
|
+
if encrypted_value is not None:
|
285
|
+
return cls.from_encrypted(encrypted_value)
|
286
|
+
elif plaintext_value is not None:
|
287
|
+
return cls.from_plaintext(plaintext_value)
|
288
|
+
else:
|
289
|
+
return cls.from_plaintext(None)
|
290
|
+
|
291
|
+
def get_encrypted(self) -> Optional[str]:
|
292
|
+
"""Get the encrypted value."""
|
293
|
+
return self._encrypted_value
|
294
|
+
|
295
|
+
def get_plaintext(self) -> Optional[Dict[str, str]]:
|
296
|
+
"""Get the decrypted dictionary."""
|
297
|
+
if self._encrypted_value is None:
|
298
|
+
return None
|
299
|
+
|
300
|
+
# Use cached value if available, but only if it looks like plaintext
|
301
|
+
# or we're confident we can decrypt it
|
302
|
+
if self._plaintext_cache is not None:
|
303
|
+
# If we have a cache but the stored value looks encrypted and we have no key,
|
304
|
+
# we should not use the cache
|
305
|
+
if CryptoUtils.is_encrypted(self._encrypted_value) and not CryptoUtils.is_encryption_available():
|
306
|
+
self._plaintext_cache = None # Clear invalid cache
|
307
|
+
else:
|
308
|
+
return self._plaintext_cache
|
309
|
+
|
310
|
+
try:
|
311
|
+
decrypted_json = CryptoUtils.decrypt(self._encrypted_value)
|
312
|
+
plaintext_dict = json.loads(decrypted_json)
|
313
|
+
# Cache the decrypted value (PrivateAttr fields can be mutated even with frozen=True)
|
314
|
+
self._plaintext_cache = plaintext_dict
|
315
|
+
return plaintext_dict
|
316
|
+
except ValueError as e:
|
317
|
+
error_msg = str(e)
|
318
|
+
|
319
|
+
# Handle missing encryption key
|
320
|
+
if "No encryption key configured" in error_msg:
|
321
|
+
# Check if the value looks encrypted
|
322
|
+
if CryptoUtils.is_encrypted(self._encrypted_value):
|
323
|
+
# Value was encrypted, but now we have no key - can't decrypt
|
324
|
+
logger.warning(
|
325
|
+
"Cannot decrypt SecretDict value - no encryption key configured. "
|
326
|
+
"The value was encrypted and requires the original key to decrypt."
|
327
|
+
)
|
328
|
+
# Return None to indicate we can't get the plaintext
|
329
|
+
return None
|
330
|
+
else:
|
331
|
+
# Value is plaintext JSON (stored when no key was available)
|
332
|
+
logger.debug("SecretDict value is plaintext JSON (stored without encryption)")
|
333
|
+
try:
|
334
|
+
plaintext_dict = json.loads(self._encrypted_value)
|
335
|
+
self._plaintext_cache = plaintext_dict
|
336
|
+
return plaintext_dict
|
337
|
+
except json.JSONDecodeError:
|
338
|
+
logger.error("Failed to parse SecretDict plaintext as JSON")
|
339
|
+
return None
|
340
|
+
|
341
|
+
# Handle decryption failure (might be plaintext JSON)
|
342
|
+
elif "Failed to decrypt data" in error_msg:
|
343
|
+
# Check if it might be plaintext JSON
|
344
|
+
if not CryptoUtils.is_encrypted(self._encrypted_value):
|
345
|
+
# It's plaintext JSON that was stored when no key was available
|
346
|
+
logger.debug("SecretDict value appears to be plaintext JSON (stored without encryption)")
|
347
|
+
try:
|
348
|
+
plaintext_dict = json.loads(self._encrypted_value)
|
349
|
+
self._plaintext_cache = plaintext_dict
|
350
|
+
return plaintext_dict
|
351
|
+
except json.JSONDecodeError:
|
352
|
+
logger.error("Failed to parse SecretDict plaintext as JSON")
|
353
|
+
return None
|
354
|
+
# Otherwise, it's corrupted or wrong key
|
355
|
+
logger.error("Failed to decrypt SecretDict value - data may be corrupted or wrong key")
|
356
|
+
raise
|
357
|
+
|
358
|
+
# Migration case: handle legacy plaintext
|
359
|
+
elif not self._was_encrypted:
|
360
|
+
if self._encrypted_value:
|
361
|
+
try:
|
362
|
+
plaintext_dict = json.loads(self._encrypted_value)
|
363
|
+
self._plaintext_cache = plaintext_dict
|
364
|
+
return plaintext_dict
|
365
|
+
except json.JSONDecodeError:
|
366
|
+
pass
|
367
|
+
return None
|
368
|
+
|
369
|
+
# Re-raise for other errors
|
370
|
+
raise
|
371
|
+
|
372
|
+
def is_empty(self) -> bool:
|
373
|
+
"""Check if the secret dict is empty/None."""
|
374
|
+
return self._encrypted_value is None
|
375
|
+
|
376
|
+
def to_dict(self) -> Dict[str, Any]:
|
377
|
+
"""Convert to dictionary for database storage."""
|
378
|
+
return {"encrypted": self.get_encrypted(), "plaintext": self.get_plaintext() if not self._was_encrypted else None}
|
@@ -40,6 +40,7 @@ class MarshmallowAgentSchema(BaseSchema):
|
|
40
40
|
core_memory = fields.List(fields.Nested(SerializedBlockSchema))
|
41
41
|
tools = fields.List(fields.Nested(SerializedToolSchema))
|
42
42
|
tool_exec_environment_variables = fields.List(fields.Nested(SerializedAgentEnvironmentVariableSchema))
|
43
|
+
secrets = fields.List(fields.Nested(SerializedAgentEnvironmentVariableSchema))
|
43
44
|
tags = fields.List(fields.Nested(SerializedAgentTagSchema))
|
44
45
|
|
45
46
|
def __init__(self, *args, session: sessionmaker, actor: User, max_steps: Optional[int] = None, **kwargs):
|
@@ -214,6 +215,9 @@ class MarshmallowAgentSchema(BaseSchema):
|
|
214
215
|
for env_var in data.get("tool_exec_environment_variables", []):
|
215
216
|
# need to be re-set at load time
|
216
217
|
env_var["value"] = ""
|
218
|
+
for env_var in data.get("secrets", []):
|
219
|
+
# need to be re-set at load time
|
220
|
+
env_var["value"] = ""
|
217
221
|
return data
|
218
222
|
|
219
223
|
@pre_load
|
@@ -0,0 +1,37 @@
|
|
1
|
+
from typing import TYPE_CHECKING, Optional
|
2
|
+
|
3
|
+
from fastapi import Header
|
4
|
+
from pydantic import BaseModel
|
5
|
+
|
6
|
+
if TYPE_CHECKING:
|
7
|
+
from letta.server.server import SyncServer
|
8
|
+
|
9
|
+
|
10
|
+
class HeaderParams(BaseModel):
|
11
|
+
"""Common header parameters used across REST API endpoints."""
|
12
|
+
|
13
|
+
actor_id: Optional[str] = None
|
14
|
+
user_agent: Optional[str] = None
|
15
|
+
project_id: Optional[str] = None
|
16
|
+
|
17
|
+
|
18
|
+
def get_headers(
|
19
|
+
actor_id: Optional[str] = Header(None, alias="user_id"),
|
20
|
+
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
21
|
+
project_id: Optional[str] = Header(None, alias="X-Project-Id"),
|
22
|
+
) -> HeaderParams:
|
23
|
+
"""Dependency injection function to extract common headers from requests."""
|
24
|
+
return HeaderParams(
|
25
|
+
actor_id=actor_id,
|
26
|
+
user_agent=user_agent,
|
27
|
+
project_id=project_id,
|
28
|
+
)
|
29
|
+
|
30
|
+
|
31
|
+
# TODO: why does this double up the interface?
|
32
|
+
async def get_letta_server() -> "SyncServer":
|
33
|
+
# Check if a global server is already instantiated
|
34
|
+
from letta.server.rest_api.app import server
|
35
|
+
|
36
|
+
# assert isinstance(server, SyncServer)
|
37
|
+
return server
|
@@ -11,9 +11,10 @@ from letta.log import get_logger
|
|
11
11
|
from letta.schemas.message import Message, MessageCreate
|
12
12
|
from letta.schemas.user import User
|
13
13
|
from letta.server.rest_api.chat_completions_interface import ChatCompletionsStreamingInterface
|
14
|
+
from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server
|
14
15
|
|
15
16
|
# TODO this belongs in a controller!
|
16
|
-
from letta.server.rest_api.utils import
|
17
|
+
from letta.server.rest_api.utils import get_user_message_from_chat_completions_request, sse_async_generator
|
17
18
|
from letta.utils import safe_create_task
|
18
19
|
|
19
20
|
if TYPE_CHECKING:
|
@@ -39,13 +40,13 @@ async def create_chat_completions(
|
|
39
40
|
agent_id: str,
|
40
41
|
completion_request: CompletionCreateParams = Body(...),
|
41
42
|
server: "SyncServer" = Depends(get_letta_server),
|
42
|
-
|
43
|
+
headers: HeaderParams = Depends(get_headers),
|
43
44
|
):
|
44
45
|
# Validate and process fields
|
45
46
|
if not completion_request["stream"]:
|
46
47
|
raise HTTPException(status_code=400, detail="Must be streaming request: `stream` was set to `False` in the request.")
|
47
48
|
|
48
|
-
actor = server.user_manager.get_user_or_default(user_id=
|
49
|
+
actor = server.user_manager.get_user_or_default(user_id=headers.actor_id)
|
49
50
|
|
50
51
|
letta_agent = server.load_agent(agent_id=agent_id, actor=actor)
|
51
52
|
llm_config = letta_agent.agent_state.llm_config
|
@@ -1,4 +1,5 @@
|
|
1
1
|
from letta.server.rest_api.routers.v1.agents import router as agents_router
|
2
|
+
from letta.server.rest_api.routers.v1.archives import router as archives_router
|
2
3
|
from letta.server.rest_api.routers.v1.blocks import router as blocks_router
|
3
4
|
from letta.server.rest_api.routers.v1.embeddings import router as embeddings_router
|
4
5
|
from letta.server.rest_api.routers.v1.folders import router as folders_router
|
@@ -20,6 +21,7 @@ from letta.server.rest_api.routers.v1.tools import router as tools_router
|
|
20
21
|
from letta.server.rest_api.routers.v1.voice import router as voice_router
|
21
22
|
|
22
23
|
ROUTERS = [
|
24
|
+
archives_router,
|
23
25
|
tools_router,
|
24
26
|
sources_router,
|
25
27
|
folders_router,
|