letta-nightly 0.11.7.dev20250916104104__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/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/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/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +9 -4
- letta/server/rest_api/routers/v1/archives.py +113 -0
- letta/server/rest_api/routers/v1/jobs.py +7 -2
- letta/server/rest_api/routers/v1/runs.py +9 -1
- letta/server/rest_api/routers/v1/tools.py +7 -26
- letta/services/agent_manager.py +17 -9
- letta/services/agent_serialization_manager.py +11 -3
- letta/services/archive_manager.py +73 -0
- letta/services/helpers/agent_manager_helper.py +6 -1
- letta/services/job_manager.py +18 -2
- letta/services/mcp_manager.py +198 -82
- letta/services/telemetry_manager.py +2 -0
- letta/services/tool_executor/composio_tool_executor.py +1 -1
- letta/services/tool_sandbox/base.py +2 -3
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/METADATA +5 -2
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/RECORD +44 -41
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/licenses/LICENSE +0 -0
letta/schemas/mcp.py
CHANGED
@@ -13,6 +13,8 @@ from letta.functions.mcp_client.types import (
|
|
13
13
|
)
|
14
14
|
from letta.orm.mcp_oauth import OAuthSessionStatus
|
15
15
|
from letta.schemas.letta_base import LettaBase
|
16
|
+
from letta.schemas.secret import Secret, SecretDict
|
17
|
+
from letta.settings import settings
|
16
18
|
|
17
19
|
|
18
20
|
class BaseMCPServer(LettaBase):
|
@@ -29,6 +31,9 @@ class MCPServer(BaseMCPServer):
|
|
29
31
|
token: Optional[str] = Field(None, description="The access token or API key for the MCP server (used for authentication)")
|
30
32
|
custom_headers: Optional[Dict[str, str]] = Field(None, description="Custom authentication headers as key-value pairs")
|
31
33
|
|
34
|
+
token_enc: Optional[str] = Field(None, description="Encrypted token")
|
35
|
+
custom_headers_enc: Optional[str] = Field(None, description="Encrypted custom headers")
|
36
|
+
|
32
37
|
# stdio config
|
33
38
|
stdio_config: Optional[StdioServerConfig] = Field(
|
34
39
|
None, description="The configuration for the server (MCP 'local' client will run this command)"
|
@@ -41,18 +46,76 @@ class MCPServer(BaseMCPServer):
|
|
41
46
|
last_updated_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")
|
42
47
|
metadata_: Optional[Dict[str, Any]] = Field(default_factory=dict, description="A dictionary of additional metadata for the tool.")
|
43
48
|
|
49
|
+
def get_token_secret(self) -> Secret:
|
50
|
+
"""Get the token as a Secret object, preferring encrypted over plaintext."""
|
51
|
+
return Secret.from_db(self.token_enc, self.token)
|
52
|
+
|
53
|
+
def get_custom_headers_secret(self) -> SecretDict:
|
54
|
+
"""Get custom headers as a SecretDict object, preferring encrypted over plaintext."""
|
55
|
+
return SecretDict.from_db(self.custom_headers_enc, self.custom_headers)
|
56
|
+
|
57
|
+
def set_token_secret(self, secret: Secret) -> None:
|
58
|
+
"""Set token from a Secret object, updating both encrypted and plaintext fields."""
|
59
|
+
secret_dict = secret.to_dict()
|
60
|
+
self.token_enc = secret_dict["encrypted"]
|
61
|
+
# Only set plaintext during migration phase
|
62
|
+
if not secret._was_encrypted:
|
63
|
+
self.token = secret_dict["plaintext"]
|
64
|
+
else:
|
65
|
+
self.token = None
|
66
|
+
|
67
|
+
def set_custom_headers_secret(self, secret: SecretDict) -> None:
|
68
|
+
"""Set custom headers from a SecretDict object, updating both fields."""
|
69
|
+
secret_dict = secret.to_dict()
|
70
|
+
self.custom_headers_enc = secret_dict["encrypted"]
|
71
|
+
# Only set plaintext during migration phase
|
72
|
+
if not secret._was_encrypted:
|
73
|
+
self.custom_headers = secret_dict["plaintext"]
|
74
|
+
else:
|
75
|
+
self.custom_headers = None
|
76
|
+
|
77
|
+
def model_dump(self, to_orm: bool = False, **kwargs):
|
78
|
+
"""Override model_dump to handle encryption when saving to database."""
|
79
|
+
data = super().model_dump(to_orm=to_orm, **kwargs)
|
80
|
+
|
81
|
+
if to_orm and settings.encryption_key:
|
82
|
+
# Encrypt token if present
|
83
|
+
if self.token is not None:
|
84
|
+
token_secret = Secret.from_plaintext(self.token)
|
85
|
+
secret_dict = token_secret.to_dict()
|
86
|
+
data["token_enc"] = secret_dict["encrypted"]
|
87
|
+
# Keep plaintext for dual-write during migration
|
88
|
+
data["token"] = secret_dict["plaintext"]
|
89
|
+
|
90
|
+
# Encrypt custom headers if present
|
91
|
+
if self.custom_headers is not None:
|
92
|
+
headers_secret = SecretDict.from_plaintext(self.custom_headers)
|
93
|
+
secret_dict = headers_secret.to_dict()
|
94
|
+
data["custom_headers_enc"] = secret_dict["encrypted"]
|
95
|
+
# Keep plaintext for dual-write during migration
|
96
|
+
data["custom_headers"] = secret_dict["plaintext"]
|
97
|
+
|
98
|
+
return data
|
99
|
+
|
44
100
|
def to_config(
|
45
101
|
self,
|
46
102
|
environment_variables: Optional[Dict[str, str]] = None,
|
47
103
|
resolve_variables: bool = True,
|
48
104
|
) -> Union[SSEServerConfig, StdioServerConfig, StreamableHTTPServerConfig]:
|
105
|
+
# Get decrypted values for use in config
|
106
|
+
token_secret = self.get_token_secret()
|
107
|
+
token_plaintext = token_secret.get_plaintext()
|
108
|
+
|
109
|
+
headers_secret = self.get_custom_headers_secret()
|
110
|
+
headers_plaintext = headers_secret.get_plaintext()
|
111
|
+
|
49
112
|
if self.server_type == MCPServerType.SSE:
|
50
113
|
config = SSEServerConfig(
|
51
114
|
server_name=self.server_name,
|
52
115
|
server_url=self.server_url,
|
53
|
-
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if
|
54
|
-
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {
|
55
|
-
custom_headers=
|
116
|
+
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if token_plaintext and not headers_plaintext else None,
|
117
|
+
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {token_plaintext}" if token_plaintext and not headers_plaintext else None,
|
118
|
+
custom_headers=headers_plaintext,
|
56
119
|
)
|
57
120
|
if resolve_variables:
|
58
121
|
config.resolve_environment_variables(environment_variables)
|
@@ -70,9 +133,9 @@ class MCPServer(BaseMCPServer):
|
|
70
133
|
config = StreamableHTTPServerConfig(
|
71
134
|
server_name=self.server_name,
|
72
135
|
server_url=self.server_url,
|
73
|
-
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if
|
74
|
-
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {
|
75
|
-
custom_headers=
|
136
|
+
auth_header=MCP_AUTH_HEADER_AUTHORIZATION if token_plaintext and not headers_plaintext else None,
|
137
|
+
auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {token_plaintext}" if token_plaintext and not headers_plaintext else None,
|
138
|
+
custom_headers=headers_plaintext,
|
76
139
|
)
|
77
140
|
if resolve_variables:
|
78
141
|
config.resolve_environment_variables(environment_variables)
|
@@ -138,11 +201,18 @@ class MCPOAuthSession(BaseMCPOAuth):
|
|
138
201
|
expires_at: Optional[datetime] = Field(None, description="Token expiry time")
|
139
202
|
scope: Optional[str] = Field(None, description="OAuth scope")
|
140
203
|
|
204
|
+
# Encrypted token fields (for internal use)
|
205
|
+
access_token_enc: Optional[str] = Field(None, description="Encrypted OAuth access token")
|
206
|
+
refresh_token_enc: Optional[str] = Field(None, description="Encrypted OAuth refresh token")
|
207
|
+
|
141
208
|
# Client configuration
|
142
209
|
client_id: Optional[str] = Field(None, description="OAuth client ID")
|
143
210
|
client_secret: Optional[str] = Field(None, description="OAuth client secret")
|
144
211
|
redirect_uri: Optional[str] = Field(None, description="OAuth redirect URI")
|
145
212
|
|
213
|
+
# Encrypted client secret (for internal use)
|
214
|
+
client_secret_enc: Optional[str] = Field(None, description="Encrypted OAuth client secret")
|
215
|
+
|
146
216
|
# Session state
|
147
217
|
status: OAuthSessionStatus = Field(default=OAuthSessionStatus.PENDING, description="Session status")
|
148
218
|
|
@@ -150,6 +220,76 @@ class MCPOAuthSession(BaseMCPOAuth):
|
|
150
220
|
created_at: datetime = Field(default_factory=datetime.now, description="Session creation time")
|
151
221
|
updated_at: datetime = Field(default_factory=datetime.now, description="Last update time")
|
152
222
|
|
223
|
+
def get_access_token_secret(self) -> Secret:
|
224
|
+
"""Get the access token as a Secret object, preferring encrypted over plaintext."""
|
225
|
+
return Secret.from_db(self.access_token_enc, self.access_token)
|
226
|
+
|
227
|
+
def get_refresh_token_secret(self) -> Secret:
|
228
|
+
"""Get the refresh token as a Secret object, preferring encrypted over plaintext."""
|
229
|
+
return Secret.from_db(self.refresh_token_enc, self.refresh_token)
|
230
|
+
|
231
|
+
def get_client_secret_secret(self) -> Secret:
|
232
|
+
"""Get the client secret as a Secret object, preferring encrypted over plaintext."""
|
233
|
+
return Secret.from_db(self.client_secret_enc, self.client_secret)
|
234
|
+
|
235
|
+
def set_access_token_secret(self, secret: Secret) -> None:
|
236
|
+
"""Set access token from a Secret object."""
|
237
|
+
secret_dict = secret.to_dict()
|
238
|
+
self.access_token_enc = secret_dict["encrypted"]
|
239
|
+
if not secret._was_encrypted:
|
240
|
+
self.access_token = secret_dict["plaintext"]
|
241
|
+
else:
|
242
|
+
self.access_token = None
|
243
|
+
|
244
|
+
def set_refresh_token_secret(self, secret: Secret) -> None:
|
245
|
+
"""Set refresh token from a Secret object."""
|
246
|
+
secret_dict = secret.to_dict()
|
247
|
+
self.refresh_token_enc = secret_dict["encrypted"]
|
248
|
+
if not secret._was_encrypted:
|
249
|
+
self.refresh_token = secret_dict["plaintext"]
|
250
|
+
else:
|
251
|
+
self.refresh_token = None
|
252
|
+
|
253
|
+
def set_client_secret_secret(self, secret: Secret) -> None:
|
254
|
+
"""Set client secret from a Secret object."""
|
255
|
+
secret_dict = secret.to_dict()
|
256
|
+
self.client_secret_enc = secret_dict["encrypted"]
|
257
|
+
if not secret._was_encrypted:
|
258
|
+
self.client_secret = secret_dict["plaintext"]
|
259
|
+
else:
|
260
|
+
self.client_secret = None
|
261
|
+
|
262
|
+
def model_dump(self, to_orm: bool = False, **kwargs):
|
263
|
+
"""Override model_dump to handle encryption when saving to database."""
|
264
|
+
data = super().model_dump(to_orm=to_orm, **kwargs)
|
265
|
+
|
266
|
+
if to_orm and settings.encryption_key:
|
267
|
+
# Encrypt access token if present
|
268
|
+
if self.access_token is not None:
|
269
|
+
token_secret = Secret.from_plaintext(self.access_token)
|
270
|
+
secret_dict = token_secret.to_dict()
|
271
|
+
data["access_token_enc"] = secret_dict["encrypted"]
|
272
|
+
# Keep plaintext for dual-write during migration
|
273
|
+
data["access_token"] = secret_dict["plaintext"]
|
274
|
+
|
275
|
+
# Encrypt refresh token if present
|
276
|
+
if self.refresh_token is not None:
|
277
|
+
token_secret = Secret.from_plaintext(self.refresh_token)
|
278
|
+
secret_dict = token_secret.to_dict()
|
279
|
+
data["refresh_token_enc"] = secret_dict["encrypted"]
|
280
|
+
# Keep plaintext for dual-write during migration
|
281
|
+
data["refresh_token"] = secret_dict["plaintext"]
|
282
|
+
|
283
|
+
# Encrypt client secret if present
|
284
|
+
if self.client_secret is not None:
|
285
|
+
secret = Secret.from_plaintext(self.client_secret)
|
286
|
+
secret_dict = secret.to_dict()
|
287
|
+
data["client_secret_enc"] = secret_dict["encrypted"]
|
288
|
+
# Keep plaintext for dual-write during migration
|
289
|
+
data["client_secret"] = secret_dict["plaintext"]
|
290
|
+
|
291
|
+
return data
|
292
|
+
|
153
293
|
|
154
294
|
class MCPOAuthSessionCreate(BaseMCPOAuth):
|
155
295
|
"""Create a new OAuth session."""
|
letta/schemas/provider_trace.py
CHANGED
@@ -19,7 +19,6 @@ class ProviderTraceCreate(BaseModel):
|
|
19
19
|
request_json: dict[str, Any] = Field(..., description="JSON content of the provider request")
|
20
20
|
response_json: dict[str, Any] = Field(..., description="JSON content of the provider response")
|
21
21
|
step_id: str = Field(None, description="ID of the step that this trace is associated with")
|
22
|
-
organization_id: str = Field(..., description="The unique identifier of the organization.")
|
23
22
|
|
24
23
|
|
25
24
|
class ProviderTrace(BaseProviderTrace):
|
@@ -39,5 +38,4 @@ class ProviderTrace(BaseProviderTrace):
|
|
39
38
|
request_json: Dict[str, Any] = Field(..., description="JSON content of the provider request")
|
40
39
|
response_json: Dict[str, Any] = Field(..., description="JSON content of the provider response")
|
41
40
|
step_id: Optional[str] = Field(None, description="ID of the step that this trace is associated with")
|
42
|
-
organization_id: str = Field(..., description="The unique identifier of the organization.")
|
43
41
|
created_at: datetime = Field(default_factory=get_utc_time, description="The timestamp when the object was created.")
|
letta/schemas/run.py
CHANGED
@@ -4,6 +4,7 @@ from pydantic import Field
|
|
4
4
|
|
5
5
|
from letta.schemas.enums import JobType
|
6
6
|
from letta.schemas.job import Job, JobBase, LettaRequestConfig
|
7
|
+
from letta.schemas.letta_stop_reason import StopReasonType
|
7
8
|
|
8
9
|
|
9
10
|
class RunBase(JobBase):
|
@@ -29,6 +30,7 @@ class Run(RunBase):
|
|
29
30
|
id: str = RunBase.generate_id_field()
|
30
31
|
user_id: Optional[str] = Field(None, description="The unique identifier of the user associated with the run.")
|
31
32
|
request_config: Optional[LettaRequestConfig] = Field(None, description="The request configuration for the run.")
|
33
|
+
stop_reason: Optional[StopReasonType] = Field(None, description="The reason why the run was stopped.")
|
32
34
|
|
33
35
|
@classmethod
|
34
36
|
def from_job(cls, job: Job) -> "Run":
|
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
|
@@ -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,
|