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.
Files changed (44) hide show
  1. letta/__init__.py +10 -2
  2. letta/adapters/letta_llm_request_adapter.py +0 -1
  3. letta/adapters/letta_llm_stream_adapter.py +0 -1
  4. letta/agent.py +1 -1
  5. letta/agents/letta_agent.py +1 -4
  6. letta/agents/letta_agent_v2.py +2 -1
  7. letta/agents/voice_agent.py +1 -1
  8. letta/helpers/converters.py +8 -2
  9. letta/helpers/crypto_utils.py +144 -0
  10. letta/llm_api/llm_api_tools.py +0 -1
  11. letta/llm_api/llm_client_base.py +0 -2
  12. letta/orm/__init__.py +1 -0
  13. letta/orm/agent.py +5 -1
  14. letta/orm/job.py +3 -1
  15. letta/orm/mcp_oauth.py +6 -0
  16. letta/orm/mcp_server.py +7 -1
  17. letta/orm/sqlalchemy_base.py +2 -1
  18. letta/schemas/agent.py +10 -7
  19. letta/schemas/job.py +10 -0
  20. letta/schemas/mcp.py +146 -6
  21. letta/schemas/provider_trace.py +0 -2
  22. letta/schemas/run.py +2 -0
  23. letta/schemas/secret.py +378 -0
  24. letta/serialize_schemas/marshmallow_agent.py +4 -0
  25. letta/server/rest_api/routers/v1/__init__.py +2 -0
  26. letta/server/rest_api/routers/v1/agents.py +9 -4
  27. letta/server/rest_api/routers/v1/archives.py +113 -0
  28. letta/server/rest_api/routers/v1/jobs.py +7 -2
  29. letta/server/rest_api/routers/v1/runs.py +9 -1
  30. letta/server/rest_api/routers/v1/tools.py +7 -26
  31. letta/services/agent_manager.py +17 -9
  32. letta/services/agent_serialization_manager.py +11 -3
  33. letta/services/archive_manager.py +73 -0
  34. letta/services/helpers/agent_manager_helper.py +6 -1
  35. letta/services/job_manager.py +18 -2
  36. letta/services/mcp_manager.py +198 -82
  37. letta/services/telemetry_manager.py +2 -0
  38. letta/services/tool_executor/composio_tool_executor.py +1 -1
  39. letta/services/tool_sandbox/base.py +2 -3
  40. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/METADATA +5 -2
  41. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/RECORD +44 -41
  42. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/WHEEL +0 -0
  43. {letta_nightly-0.11.7.dev20250916104104.dist-info → letta_nightly-0.11.7.dev20250917104122.dist-info}/entry_points.txt +0 -0
  44. {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 self.token and not self.custom_headers else None,
54
- auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {self.token}" if self.token and not self.custom_headers else None,
55
- custom_headers=self.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 self.token and not self.custom_headers else None,
74
- auth_token=f"{MCP_AUTH_TOKEN_BEARER_PREFIX} {self.token}" if self.token and not self.custom_headers else None,
75
- custom_headers=self.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."""
@@ -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":
@@ -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,