letta-nightly 0.12.0.dev20251009203644__py3-none-any.whl → 0.12.1.dev20251010012355__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/llm_api/openai_client.py +11 -0
- letta/schemas/environment_variables.py +24 -0
- letta/schemas/providers/base.py +43 -0
- letta/schemas/secret.py +103 -36
- letta/settings.py +3 -0
- {letta_nightly-0.12.0.dev20251009203644.dist-info → letta_nightly-0.12.1.dev20251010012355.dist-info}/METADATA +1 -1
- {letta_nightly-0.12.0.dev20251009203644.dist-info → letta_nightly-0.12.1.dev20251010012355.dist-info}/RECORD +14 -13
- {letta_nightly-0.12.0.dev20251009203644.dist-info → letta_nightly-0.12.1.dev20251010012355.dist-info}/WHEEL +0 -0
- {letta_nightly-0.12.0.dev20251009203644.dist-info → letta_nightly-0.12.1.dev20251010012355.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.12.0.dev20251009203644.dist-info → letta_nightly-0.12.1.dev20251010012355.dist-info}/licenses/LICENSE +0 -0
letta/__init__.py
CHANGED
@@ -2,7 +2,7 @@ from typing import AsyncGenerator, List
|
|
2
2
|
|
3
3
|
from letta.adapters.letta_llm_stream_adapter import LettaLLMStreamAdapter
|
4
4
|
from letta.helpers.datetime_helpers import get_utc_timestamp_ns
|
5
|
-
from letta.interfaces.
|
5
|
+
from letta.interfaces.anthropic_parallel_tool_call_streaming_interface import SimpleAnthropicStreamingInterface
|
6
6
|
from letta.interfaces.gemini_streaming_interface import SimpleGeminiStreamingInterface
|
7
7
|
from letta.interfaces.openai_streaming_interface import SimpleOpenAIResponsesStreamingInterface, SimpleOpenAIStreamingInterface
|
8
8
|
from letta.schemas.enums import ProviderType
|
letta/agents/letta_agent_v2.py
CHANGED
@@ -99,16 +99,16 @@ class LettaAgentV2(BaseAgentV2):
|
|
99
99
|
self.step_manager = StepManager()
|
100
100
|
self.telemetry_manager = TelemetryManager()
|
101
101
|
|
102
|
-
|
103
|
-
if summarizer_settings.enable_summarization and model_settings.openai_api_key:
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
102
|
+
## TODO: Expand to more
|
103
|
+
# if summarizer_settings.enable_summarization and model_settings.openai_api_key:
|
104
|
+
# self.summarization_agent = EphemeralSummaryAgent(
|
105
|
+
# target_block_label="conversation_summary",
|
106
|
+
# agent_id=self.agent_state.id,
|
107
|
+
# block_manager=self.block_manager,
|
108
|
+
# message_manager=self.message_manager,
|
109
|
+
# agent_manager=self.agent_manager,
|
110
|
+
# actor=self.actor,
|
111
|
+
# )
|
112
112
|
|
113
113
|
# Initialize summarizer for context window management
|
114
114
|
self.summarizer = Summarizer(
|
@@ -117,7 +117,7 @@ class LettaAgentV2(BaseAgentV2):
|
|
117
117
|
if self.agent_state.agent_type == AgentType.voice_convo_agent
|
118
118
|
else summarizer_settings.mode
|
119
119
|
),
|
120
|
-
summarizer_agent=self.summarization_agent,
|
120
|
+
summarizer_agent=None, # self.summarization_agent,
|
121
121
|
message_buffer_limit=summarizer_settings.message_buffer_limit,
|
122
122
|
message_buffer_min=summarizer_settings.message_buffer_min,
|
123
123
|
partial_evict_summarizer_percentage=summarizer_settings.partial_evict_summarizer_percentage,
|
@@ -0,0 +1,487 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
from collections.abc import AsyncGenerator
|
4
|
+
from datetime import datetime, timezone
|
5
|
+
from enum import Enum
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
from anthropic import AsyncStream
|
9
|
+
from anthropic.types.beta import (
|
10
|
+
BetaInputJSONDelta,
|
11
|
+
BetaRawContentBlockDeltaEvent,
|
12
|
+
BetaRawContentBlockStartEvent,
|
13
|
+
BetaRawContentBlockStopEvent,
|
14
|
+
BetaRawMessageDeltaEvent,
|
15
|
+
BetaRawMessageStartEvent,
|
16
|
+
BetaRawMessageStopEvent,
|
17
|
+
BetaRawMessageStreamEvent,
|
18
|
+
BetaRedactedThinkingBlock,
|
19
|
+
BetaSignatureDelta,
|
20
|
+
BetaTextBlock,
|
21
|
+
BetaTextDelta,
|
22
|
+
BetaThinkingBlock,
|
23
|
+
BetaThinkingDelta,
|
24
|
+
BetaToolUseBlock,
|
25
|
+
)
|
26
|
+
|
27
|
+
from letta.log import get_logger
|
28
|
+
from letta.schemas.letta_message import (
|
29
|
+
ApprovalRequestMessage,
|
30
|
+
AssistantMessage,
|
31
|
+
HiddenReasoningMessage,
|
32
|
+
LettaMessage,
|
33
|
+
ReasoningMessage,
|
34
|
+
ToolCallDelta,
|
35
|
+
ToolCallMessage,
|
36
|
+
)
|
37
|
+
from letta.schemas.letta_message_content import ReasoningContent, RedactedReasoningContent, TextContent
|
38
|
+
from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
|
39
|
+
from letta.schemas.message import Message
|
40
|
+
from letta.schemas.openai.chat_completion_response import FunctionCall, ToolCall
|
41
|
+
from letta.server.rest_api.json_parser import JSONParser, PydanticJSONParser
|
42
|
+
|
43
|
+
logger = get_logger(__name__)
|
44
|
+
|
45
|
+
|
46
|
+
# TODO: These modes aren't used right now - but can be useful we do multiple sequential tool calling within one Claude message
|
47
|
+
class EventMode(Enum):
|
48
|
+
TEXT = "TEXT"
|
49
|
+
TOOL_USE = "TOOL_USE"
|
50
|
+
THINKING = "THINKING"
|
51
|
+
REDACTED_THINKING = "REDACTED_THINKING"
|
52
|
+
|
53
|
+
|
54
|
+
# TODO: There's a duplicate version of this in anthropic_streaming_interface
|
55
|
+
class SimpleAnthropicStreamingInterface:
|
56
|
+
"""
|
57
|
+
A simpler version of AnthropicStreamingInterface focused on streaming assistant text and
|
58
|
+
tool call deltas. Updated to support parallel tool calling by collecting completed
|
59
|
+
ToolUse blocks (from content_block stop events) and exposing all finalized tool calls
|
60
|
+
via get_tool_call_objects().
|
61
|
+
|
62
|
+
Notes:
|
63
|
+
- We keep emitting the stream (text and tool-call deltas) as before for latency.
|
64
|
+
- We no longer rely on accumulating partial JSON to build the final tool call; instead
|
65
|
+
we read the finalized ToolUse input from the stop event and store it.
|
66
|
+
- Multiple tool calls within a single message (parallel tool use) are collected and
|
67
|
+
can be returned to the agent as a list.
|
68
|
+
"""
|
69
|
+
|
70
|
+
def __init__(
|
71
|
+
self,
|
72
|
+
requires_approval_tools: list = [],
|
73
|
+
run_id: str | None = None,
|
74
|
+
step_id: str | None = None,
|
75
|
+
):
|
76
|
+
self.json_parser: JSONParser = PydanticJSONParser()
|
77
|
+
self.run_id = run_id
|
78
|
+
self.step_id = step_id
|
79
|
+
|
80
|
+
# Premake IDs for database writes
|
81
|
+
self.letta_message_id = Message.generate_id()
|
82
|
+
|
83
|
+
self.anthropic_mode = None
|
84
|
+
self.message_id = None
|
85
|
+
self.accumulated_inner_thoughts = []
|
86
|
+
self.tool_call_id = None
|
87
|
+
self.tool_call_name = None
|
88
|
+
self.accumulated_tool_call_args = ""
|
89
|
+
self.previous_parse = {}
|
90
|
+
|
91
|
+
# usage trackers
|
92
|
+
self.input_tokens = 0
|
93
|
+
self.output_tokens = 0
|
94
|
+
self.model = None
|
95
|
+
|
96
|
+
# reasoning object trackers
|
97
|
+
self.reasoning_messages = []
|
98
|
+
|
99
|
+
# assistant object trackers
|
100
|
+
self.assistant_messages: list[AssistantMessage] = []
|
101
|
+
|
102
|
+
# Buffer to hold tool call messages until inner thoughts are complete
|
103
|
+
self.tool_call_buffer = []
|
104
|
+
self.inner_thoughts_complete = False
|
105
|
+
|
106
|
+
# Buffer to handle partial XML tags across chunks
|
107
|
+
self.partial_tag_buffer = ""
|
108
|
+
|
109
|
+
self.requires_approval_tools = requires_approval_tools
|
110
|
+
# Collected finalized tool calls (supports parallel tool use)
|
111
|
+
self.collected_tool_calls: list[ToolCall] = []
|
112
|
+
# Track active tool_use blocks by stream index for parallel tool calling
|
113
|
+
# { index: {"id": str, "name": str, "args": str} }
|
114
|
+
self.active_tool_uses: dict[int, dict[str, str]] = {}
|
115
|
+
# Maintain start order and indexed collection for stable ordering
|
116
|
+
self._tool_use_start_order: list[int] = []
|
117
|
+
self._collected_indexed: list[tuple[int, ToolCall]] = []
|
118
|
+
|
119
|
+
def get_tool_call_objects(self) -> list[ToolCall]:
|
120
|
+
"""Return all finalized tool calls collected during this message (parallel supported)."""
|
121
|
+
# Prefer indexed ordering if available
|
122
|
+
if self._collected_indexed:
|
123
|
+
return [
|
124
|
+
call
|
125
|
+
for _, call in sorted(
|
126
|
+
self._collected_indexed,
|
127
|
+
key=lambda x: self._tool_use_start_order.index(x[0]) if x[0] in self._tool_use_start_order else x[0],
|
128
|
+
)
|
129
|
+
]
|
130
|
+
return self.collected_tool_calls
|
131
|
+
|
132
|
+
# This exists for legacy compatibility
|
133
|
+
def get_tool_call_object(self) -> Optional[ToolCall]:
|
134
|
+
tool_calls = self.get_tool_call_objects()
|
135
|
+
if tool_calls:
|
136
|
+
return tool_calls[0]
|
137
|
+
return None
|
138
|
+
|
139
|
+
def get_reasoning_content(self) -> list[TextContent | ReasoningContent | RedactedReasoningContent]:
|
140
|
+
def _process_group(
|
141
|
+
group: list[ReasoningMessage | HiddenReasoningMessage | AssistantMessage],
|
142
|
+
group_type: str,
|
143
|
+
) -> TextContent | ReasoningContent | RedactedReasoningContent:
|
144
|
+
if group_type == "reasoning":
|
145
|
+
reasoning_text = "".join(chunk.reasoning for chunk in group).strip()
|
146
|
+
is_native = any(chunk.source == "reasoner_model" for chunk in group)
|
147
|
+
signature = next((chunk.signature for chunk in group if chunk.signature is not None), None)
|
148
|
+
if is_native:
|
149
|
+
return ReasoningContent(is_native=is_native, reasoning=reasoning_text, signature=signature)
|
150
|
+
else:
|
151
|
+
return TextContent(text=reasoning_text)
|
152
|
+
elif group_type == "redacted":
|
153
|
+
redacted_text = "".join(chunk.hidden_reasoning for chunk in group if chunk.hidden_reasoning is not None)
|
154
|
+
return RedactedReasoningContent(data=redacted_text)
|
155
|
+
elif group_type == "text":
|
156
|
+
concat = ""
|
157
|
+
for chunk in group:
|
158
|
+
if isinstance(chunk.content, list):
|
159
|
+
concat += "".join([c.text for c in chunk.content])
|
160
|
+
else:
|
161
|
+
concat += chunk.content
|
162
|
+
return TextContent(text=concat)
|
163
|
+
else:
|
164
|
+
raise ValueError("Unexpected group type")
|
165
|
+
|
166
|
+
merged = []
|
167
|
+
current_group = []
|
168
|
+
current_group_type = None # "reasoning" or "redacted"
|
169
|
+
|
170
|
+
for msg in self.reasoning_messages:
|
171
|
+
# Determine the type of the current message
|
172
|
+
if isinstance(msg, HiddenReasoningMessage):
|
173
|
+
msg_type = "redacted"
|
174
|
+
elif isinstance(msg, ReasoningMessage):
|
175
|
+
msg_type = "reasoning"
|
176
|
+
elif isinstance(msg, AssistantMessage):
|
177
|
+
msg_type = "text"
|
178
|
+
else:
|
179
|
+
raise ValueError("Unexpected message type")
|
180
|
+
|
181
|
+
# Initialize group type if not set
|
182
|
+
if current_group_type is None:
|
183
|
+
current_group_type = msg_type
|
184
|
+
|
185
|
+
# If the type changes, process the current group
|
186
|
+
if msg_type != current_group_type:
|
187
|
+
merged.append(_process_group(current_group, current_group_type))
|
188
|
+
current_group = []
|
189
|
+
current_group_type = msg_type
|
190
|
+
|
191
|
+
current_group.append(msg)
|
192
|
+
|
193
|
+
# Process the final group, if any.
|
194
|
+
if current_group:
|
195
|
+
merged.append(_process_group(current_group, current_group_type))
|
196
|
+
|
197
|
+
return merged
|
198
|
+
|
199
|
+
def get_content(self) -> list[TextContent | ReasoningContent | RedactedReasoningContent]:
|
200
|
+
return self.get_reasoning_content()
|
201
|
+
|
202
|
+
async def process(
|
203
|
+
self,
|
204
|
+
stream: AsyncStream[BetaRawMessageStreamEvent],
|
205
|
+
ttft_span: Optional["Span"] = None,
|
206
|
+
) -> AsyncGenerator[LettaMessage | LettaStopReason, None]:
|
207
|
+
prev_message_type = None
|
208
|
+
message_index = 0
|
209
|
+
event = None
|
210
|
+
try:
|
211
|
+
async with stream:
|
212
|
+
async for event in stream:
|
213
|
+
try:
|
214
|
+
async for message in self._process_event(event, ttft_span, prev_message_type, message_index):
|
215
|
+
new_message_type = message.message_type
|
216
|
+
if new_message_type != prev_message_type:
|
217
|
+
if prev_message_type != None:
|
218
|
+
message_index += 1
|
219
|
+
prev_message_type = new_message_type
|
220
|
+
# print(f"Yielding message: {message}")
|
221
|
+
yield message
|
222
|
+
except asyncio.CancelledError as e:
|
223
|
+
import traceback
|
224
|
+
|
225
|
+
logger.info("Cancelled stream attempt but overriding %s: %s", e, traceback.format_exc())
|
226
|
+
async for message in self._process_event(event, ttft_span, prev_message_type, message_index):
|
227
|
+
new_message_type = message.message_type
|
228
|
+
if new_message_type != prev_message_type:
|
229
|
+
if prev_message_type != None:
|
230
|
+
message_index += 1
|
231
|
+
prev_message_type = new_message_type
|
232
|
+
yield message
|
233
|
+
|
234
|
+
# Don't raise the exception here
|
235
|
+
continue
|
236
|
+
|
237
|
+
except Exception as e:
|
238
|
+
import traceback
|
239
|
+
|
240
|
+
logger.error("Error processing stream: %s\n%s", e, traceback.format_exc())
|
241
|
+
if ttft_span:
|
242
|
+
ttft_span.add_event(
|
243
|
+
name="stop_reason",
|
244
|
+
attributes={"stop_reason": StopReasonType.error.value, "error": str(e), "stacktrace": traceback.format_exc()},
|
245
|
+
)
|
246
|
+
yield LettaStopReason(stop_reason=StopReasonType.error)
|
247
|
+
raise e
|
248
|
+
finally:
|
249
|
+
logger.info("AnthropicStreamingInterface: Stream processing complete.")
|
250
|
+
|
251
|
+
async def _process_event(
|
252
|
+
self,
|
253
|
+
event: BetaRawMessageStreamEvent,
|
254
|
+
ttft_span: Optional["Span"] = None,
|
255
|
+
prev_message_type: Optional[str] = None,
|
256
|
+
message_index: int = 0,
|
257
|
+
) -> AsyncGenerator[LettaMessage | LettaStopReason, None]:
|
258
|
+
"""Process a single event from the Anthropic stream and yield any resulting messages.
|
259
|
+
|
260
|
+
Args:
|
261
|
+
event: The event to process
|
262
|
+
|
263
|
+
Yields:
|
264
|
+
Messages generated from processing this event
|
265
|
+
"""
|
266
|
+
if isinstance(event, BetaRawContentBlockStartEvent):
|
267
|
+
content = event.content_block
|
268
|
+
|
269
|
+
if isinstance(content, BetaTextBlock):
|
270
|
+
self.anthropic_mode = EventMode.TEXT
|
271
|
+
# TODO: Can capture citations, etc.
|
272
|
+
|
273
|
+
elif isinstance(content, BetaToolUseBlock):
|
274
|
+
# New tool_use block started at this index
|
275
|
+
self.anthropic_mode = EventMode.TOOL_USE
|
276
|
+
self.active_tool_uses[event.index] = {"id": content.id, "name": content.name, "args": ""}
|
277
|
+
if event.index not in self._tool_use_start_order:
|
278
|
+
self._tool_use_start_order.append(event.index)
|
279
|
+
|
280
|
+
# Emit an initial tool call delta for this new block
|
281
|
+
name = content.name
|
282
|
+
call_id = content.id
|
283
|
+
# Initialize arguments from the start event's input (often {}) to avoid undefined in UIs
|
284
|
+
if name in self.requires_approval_tools:
|
285
|
+
if prev_message_type and prev_message_type != "approval_request_message":
|
286
|
+
message_index += 1
|
287
|
+
tool_call_msg = ApprovalRequestMessage(
|
288
|
+
id=self.letta_message_id,
|
289
|
+
# Do not emit placeholder arguments here to avoid UI duplicates
|
290
|
+
tool_call=ToolCallDelta(name=name, tool_call_id=call_id),
|
291
|
+
date=datetime.now(timezone.utc).isoformat(),
|
292
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
293
|
+
run_id=self.run_id,
|
294
|
+
step_id=self.step_id,
|
295
|
+
)
|
296
|
+
else:
|
297
|
+
if prev_message_type and prev_message_type != "tool_call_message":
|
298
|
+
message_index += 1
|
299
|
+
tool_call_msg = ToolCallMessage(
|
300
|
+
id=self.letta_message_id,
|
301
|
+
# Do not emit placeholder arguments here to avoid UI duplicates
|
302
|
+
tool_call=ToolCallDelta(name=name, tool_call_id=call_id),
|
303
|
+
date=datetime.now(timezone.utc).isoformat(),
|
304
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
305
|
+
run_id=self.run_id,
|
306
|
+
step_id=self.step_id,
|
307
|
+
)
|
308
|
+
prev_message_type = tool_call_msg.message_type
|
309
|
+
yield tool_call_msg
|
310
|
+
|
311
|
+
elif isinstance(content, BetaThinkingBlock):
|
312
|
+
self.anthropic_mode = EventMode.THINKING
|
313
|
+
# TODO: Can capture signature, etc.
|
314
|
+
|
315
|
+
elif isinstance(content, BetaRedactedThinkingBlock):
|
316
|
+
self.anthropic_mode = EventMode.REDACTED_THINKING
|
317
|
+
|
318
|
+
if prev_message_type and prev_message_type != "hidden_reasoning_message":
|
319
|
+
message_index += 1
|
320
|
+
|
321
|
+
hidden_reasoning_message = HiddenReasoningMessage(
|
322
|
+
id=self.letta_message_id,
|
323
|
+
state="redacted",
|
324
|
+
hidden_reasoning=content.data,
|
325
|
+
date=datetime.now(timezone.utc).isoformat(),
|
326
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
327
|
+
run_id=self.run_id,
|
328
|
+
step_id=self.step_id,
|
329
|
+
)
|
330
|
+
|
331
|
+
self.reasoning_messages.append(hidden_reasoning_message)
|
332
|
+
prev_message_type = hidden_reasoning_message.message_type
|
333
|
+
yield hidden_reasoning_message
|
334
|
+
|
335
|
+
elif isinstance(event, BetaRawContentBlockDeltaEvent):
|
336
|
+
delta = event.delta
|
337
|
+
|
338
|
+
if isinstance(delta, BetaTextDelta):
|
339
|
+
# Safety check
|
340
|
+
if not self.anthropic_mode == EventMode.TEXT:
|
341
|
+
raise RuntimeError(f"Streaming integrity failed - received BetaTextDelta object while not in TEXT EventMode: {delta}")
|
342
|
+
|
343
|
+
if prev_message_type and prev_message_type != "assistant_message":
|
344
|
+
message_index += 1
|
345
|
+
|
346
|
+
assistant_msg = AssistantMessage(
|
347
|
+
id=self.letta_message_id,
|
348
|
+
# content=[TextContent(text=delta.text)],
|
349
|
+
content=delta.text,
|
350
|
+
date=datetime.now(timezone.utc).isoformat(),
|
351
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
352
|
+
run_id=self.run_id,
|
353
|
+
step_id=self.step_id,
|
354
|
+
)
|
355
|
+
# self.assistant_messages.append(assistant_msg)
|
356
|
+
self.reasoning_messages.append(assistant_msg)
|
357
|
+
prev_message_type = assistant_msg.message_type
|
358
|
+
yield assistant_msg
|
359
|
+
|
360
|
+
elif isinstance(delta, BetaInputJSONDelta):
|
361
|
+
# Append partial JSON for the specific tool_use block at this index
|
362
|
+
if not self.anthropic_mode == EventMode.TOOL_USE:
|
363
|
+
raise RuntimeError(
|
364
|
+
f"Streaming integrity failed - received BetaInputJSONDelta object while not in TOOL_USE EventMode: {delta}"
|
365
|
+
)
|
366
|
+
|
367
|
+
ctx = self.active_tool_uses.get(event.index)
|
368
|
+
if ctx is None:
|
369
|
+
# Defensive: initialize if missing
|
370
|
+
self.active_tool_uses[event.index] = {"id": self.tool_call_id or "", "name": self.tool_call_name or "", "args": ""}
|
371
|
+
ctx = self.active_tool_uses[event.index]
|
372
|
+
|
373
|
+
# Append only non-empty partials
|
374
|
+
if delta.partial_json:
|
375
|
+
ctx["args"] += delta.partial_json
|
376
|
+
else:
|
377
|
+
# Skip streaming a no-op delta to prevent duplicate placeholders in UI
|
378
|
+
return
|
379
|
+
|
380
|
+
name = ctx.get("name")
|
381
|
+
call_id = ctx.get("id")
|
382
|
+
|
383
|
+
if name in self.requires_approval_tools:
|
384
|
+
if prev_message_type and prev_message_type != "approval_request_message":
|
385
|
+
message_index += 1
|
386
|
+
tool_call_msg = ApprovalRequestMessage(
|
387
|
+
id=self.letta_message_id,
|
388
|
+
tool_call=ToolCallDelta(name=name, tool_call_id=call_id, arguments=delta.partial_json),
|
389
|
+
date=datetime.now(timezone.utc).isoformat(),
|
390
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
391
|
+
run_id=self.run_id,
|
392
|
+
step_id=self.step_id,
|
393
|
+
)
|
394
|
+
else:
|
395
|
+
if prev_message_type and prev_message_type != "tool_call_message":
|
396
|
+
message_index += 1
|
397
|
+
tool_call_msg = ToolCallMessage(
|
398
|
+
id=self.letta_message_id,
|
399
|
+
tool_call=ToolCallDelta(name=name, tool_call_id=call_id, arguments=delta.partial_json),
|
400
|
+
date=datetime.now(timezone.utc).isoformat(),
|
401
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
402
|
+
run_id=self.run_id,
|
403
|
+
step_id=self.step_id,
|
404
|
+
)
|
405
|
+
|
406
|
+
yield tool_call_msg
|
407
|
+
|
408
|
+
elif isinstance(delta, BetaThinkingDelta):
|
409
|
+
# Safety check
|
410
|
+
if not self.anthropic_mode == EventMode.THINKING:
|
411
|
+
raise RuntimeError(
|
412
|
+
f"Streaming integrity failed - received BetaThinkingBlock object while not in THINKING EventMode: {delta}"
|
413
|
+
)
|
414
|
+
|
415
|
+
if prev_message_type and prev_message_type != "reasoning_message":
|
416
|
+
message_index += 1
|
417
|
+
reasoning_message = ReasoningMessage(
|
418
|
+
id=self.letta_message_id,
|
419
|
+
source="reasoner_model",
|
420
|
+
reasoning=delta.thinking,
|
421
|
+
date=datetime.now(timezone.utc).isoformat(),
|
422
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
423
|
+
run_id=self.run_id,
|
424
|
+
step_id=self.step_id,
|
425
|
+
)
|
426
|
+
self.reasoning_messages.append(reasoning_message)
|
427
|
+
prev_message_type = reasoning_message.message_type
|
428
|
+
yield reasoning_message
|
429
|
+
|
430
|
+
elif isinstance(delta, BetaSignatureDelta):
|
431
|
+
# Safety check
|
432
|
+
if not self.anthropic_mode == EventMode.THINKING:
|
433
|
+
raise RuntimeError(
|
434
|
+
f"Streaming integrity failed - received BetaSignatureDelta object while not in THINKING EventMode: {delta}"
|
435
|
+
)
|
436
|
+
|
437
|
+
if prev_message_type and prev_message_type != "reasoning_message":
|
438
|
+
message_index += 1
|
439
|
+
reasoning_message = ReasoningMessage(
|
440
|
+
id=self.letta_message_id,
|
441
|
+
source="reasoner_model",
|
442
|
+
reasoning="",
|
443
|
+
date=datetime.now(timezone.utc).isoformat(),
|
444
|
+
signature=delta.signature,
|
445
|
+
otid=Message.generate_otid_from_id(self.letta_message_id, message_index),
|
446
|
+
run_id=self.run_id,
|
447
|
+
step_id=self.step_id,
|
448
|
+
)
|
449
|
+
self.reasoning_messages.append(reasoning_message)
|
450
|
+
prev_message_type = reasoning_message.message_type
|
451
|
+
yield reasoning_message
|
452
|
+
|
453
|
+
elif isinstance(event, BetaRawMessageStartEvent):
|
454
|
+
self.message_id = event.message.id
|
455
|
+
self.input_tokens += event.message.usage.input_tokens
|
456
|
+
self.output_tokens += event.message.usage.output_tokens
|
457
|
+
self.model = event.message.model
|
458
|
+
|
459
|
+
elif isinstance(event, BetaRawMessageDeltaEvent):
|
460
|
+
self.output_tokens += event.usage.output_tokens
|
461
|
+
|
462
|
+
elif isinstance(event, BetaRawMessageStopEvent):
|
463
|
+
# Don't do anything here! We don't want to stop the stream.
|
464
|
+
pass
|
465
|
+
|
466
|
+
elif isinstance(event, BetaRawContentBlockStopEvent):
|
467
|
+
# Finalize the tool_use block at this index using accumulated deltas
|
468
|
+
ctx = self.active_tool_uses.pop(event.index, None)
|
469
|
+
if ctx is not None and ctx.get("id") and ctx.get("name") is not None:
|
470
|
+
raw_args = ctx.get("args", "")
|
471
|
+
try:
|
472
|
+
# Prefer strict JSON load, fallback to permissive parser
|
473
|
+
tool_input = json.loads(raw_args) if raw_args else {}
|
474
|
+
except json.JSONDecodeError:
|
475
|
+
try:
|
476
|
+
tool_input = self.json_parser.parse(raw_args) if raw_args else {}
|
477
|
+
except Exception:
|
478
|
+
tool_input = {}
|
479
|
+
|
480
|
+
arguments = json.dumps(tool_input)
|
481
|
+
finalized = ToolCall(id=ctx["id"], function=FunctionCall(arguments=arguments, name=ctx["name"]))
|
482
|
+
# Keep both raw list and indexed list for compatibility
|
483
|
+
self.collected_tool_calls.append(finalized)
|
484
|
+
self._collected_indexed.append((event.index, finalized))
|
485
|
+
|
486
|
+
# Reset mode when a content block ends
|
487
|
+
self.anthropic_mode = None
|
letta/llm_api/openai_client.py
CHANGED
@@ -420,6 +420,17 @@ class OpenAIClient(LLMClientBase):
|
|
420
420
|
logger.warning(f"Model type not set in llm_config: {llm_config.model_dump_json(indent=4)}")
|
421
421
|
model = None
|
422
422
|
|
423
|
+
# TODO: we may need to extend this to more models using proxy?
|
424
|
+
is_openrouter = (llm_config.model_endpoint and "openrouter.ai" in llm_config.model_endpoint) or (
|
425
|
+
llm_config.provider_name == "openrouter"
|
426
|
+
)
|
427
|
+
if is_openrouter:
|
428
|
+
try:
|
429
|
+
model = llm_config.handle.split("/", 1)[-1]
|
430
|
+
except:
|
431
|
+
# don't raise error since this isn't robust against edge cases
|
432
|
+
pass
|
433
|
+
|
423
434
|
# force function calling for reliability, see https://platform.openai.com/docs/api-reference/chat/create#chat-create-tool_choice
|
424
435
|
# TODO(matt) move into LLMConfig
|
425
436
|
# TODO: This vllm checking is very brittle and is a patch at most
|
@@ -3,6 +3,8 @@ from typing import Optional
|
|
3
3
|
from pydantic import Field
|
4
4
|
|
5
5
|
from letta.schemas.letta_base import LettaBase, OrmMetadataBase
|
6
|
+
from letta.schemas.secret import Secret
|
7
|
+
from letta.settings import settings
|
6
8
|
|
7
9
|
|
8
10
|
# Base Environment Variable
|
@@ -13,6 +15,28 @@ class EnvironmentVariableBase(OrmMetadataBase):
|
|
13
15
|
description: Optional[str] = Field(None, description="An optional description of the environment variable.")
|
14
16
|
organization_id: Optional[str] = Field(None, description="The ID of the organization this environment variable belongs to.")
|
15
17
|
|
18
|
+
# Encrypted field (stored as Secret object, serialized to string for DB)
|
19
|
+
# Secret class handles validation and serialization automatically via __get_pydantic_core_schema__
|
20
|
+
value_enc: Secret | None = Field(None, description="Encrypted value as Secret object")
|
21
|
+
|
22
|
+
def get_value_secret(self) -> Secret:
|
23
|
+
"""Get the value as a Secret object, preferring encrypted over plaintext."""
|
24
|
+
# If value_enc is already a Secret, return it
|
25
|
+
if self.value_enc is not None:
|
26
|
+
return self.value_enc
|
27
|
+
# Otherwise, create from plaintext value
|
28
|
+
return Secret.from_db(None, self.value)
|
29
|
+
|
30
|
+
def set_value_secret(self, secret: Secret) -> None:
|
31
|
+
"""Set value from a Secret object, directly storing the Secret."""
|
32
|
+
self.value_enc = secret
|
33
|
+
# Also update plaintext field for dual-write during migration
|
34
|
+
secret_dict = secret.to_dict()
|
35
|
+
if not secret.was_encrypted:
|
36
|
+
self.value = secret_dict["plaintext"]
|
37
|
+
else:
|
38
|
+
self.value = None
|
39
|
+
|
16
40
|
|
17
41
|
class EnvironmentVariableCreateBase(LettaBase):
|
18
42
|
key: str = Field(..., description="The name of the environment variable.")
|
letta/schemas/providers/base.py
CHANGED
@@ -8,6 +8,7 @@ from letta.schemas.enums import ProviderCategory, ProviderType
|
|
8
8
|
from letta.schemas.letta_base import LettaBase
|
9
9
|
from letta.schemas.llm_config import LLMConfig
|
10
10
|
from letta.schemas.llm_config_overrides import LLM_HANDLE_OVERRIDES
|
11
|
+
from letta.schemas.secret import Secret
|
11
12
|
from letta.settings import model_settings
|
12
13
|
|
13
14
|
|
@@ -28,8 +29,14 @@ class Provider(ProviderBase):
|
|
28
29
|
organization_id: str | None = Field(None, description="The organization id of the user")
|
29
30
|
updated_at: datetime | None = Field(None, description="The last update timestamp of the provider.")
|
30
31
|
|
32
|
+
# Encrypted fields (stored as Secret objects, serialized to strings for DB)
|
33
|
+
# Secret class handles validation and serialization automatically via __get_pydantic_core_schema__
|
34
|
+
api_key_enc: Secret | None = Field(None, description="Encrypted API key as Secret object")
|
35
|
+
access_key_enc: Secret | None = Field(None, description="Encrypted access key as Secret object")
|
36
|
+
|
31
37
|
@model_validator(mode="after")
|
32
38
|
def default_base_url(self):
|
39
|
+
# Set default base URL
|
33
40
|
if self.provider_type == ProviderType.openai and self.base_url is None:
|
34
41
|
self.base_url = model_settings.openai_api_base
|
35
42
|
return self
|
@@ -38,6 +45,42 @@ class Provider(ProviderBase):
|
|
38
45
|
if not self.id:
|
39
46
|
self.id = ProviderBase.generate_id(prefix=ProviderBase.__id_prefix__)
|
40
47
|
|
48
|
+
def get_api_key_secret(self) -> Secret:
|
49
|
+
"""Get the API key as a Secret object, preferring encrypted over plaintext."""
|
50
|
+
# If api_key_enc is already a Secret, return it
|
51
|
+
if self.api_key_enc is not None:
|
52
|
+
return self.api_key_enc
|
53
|
+
# Otherwise, create from plaintext api_key
|
54
|
+
return Secret.from_db(None, self.api_key)
|
55
|
+
|
56
|
+
def get_access_key_secret(self) -> Secret:
|
57
|
+
"""Get the access key as a Secret object, preferring encrypted over plaintext."""
|
58
|
+
# If access_key_enc is already a Secret, return it
|
59
|
+
if self.access_key_enc is not None:
|
60
|
+
return self.access_key_enc
|
61
|
+
# Otherwise, create from plaintext access_key
|
62
|
+
return Secret.from_db(None, self.access_key)
|
63
|
+
|
64
|
+
def set_api_key_secret(self, secret: Secret) -> None:
|
65
|
+
"""Set API key from a Secret object, directly storing the Secret."""
|
66
|
+
self.api_key_enc = secret
|
67
|
+
# Also update plaintext field for dual-write during migration
|
68
|
+
secret_dict = secret.to_dict()
|
69
|
+
if not secret.was_encrypted:
|
70
|
+
self.api_key = secret_dict["plaintext"]
|
71
|
+
else:
|
72
|
+
self.api_key = None
|
73
|
+
|
74
|
+
def set_access_key_secret(self, secret: Secret) -> None:
|
75
|
+
"""Set access key from a Secret object, directly storing the Secret."""
|
76
|
+
self.access_key_enc = secret
|
77
|
+
# Also update plaintext field for dual-write during migration
|
78
|
+
secret_dict = secret.to_dict()
|
79
|
+
if not secret.was_encrypted:
|
80
|
+
self.access_key = secret_dict["plaintext"]
|
81
|
+
else:
|
82
|
+
self.access_key = None
|
83
|
+
|
41
84
|
async def check_api_key(self):
|
42
85
|
"""Check if the API key is valid for the provider"""
|
43
86
|
raise NotImplementedError
|
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
|
"""
|
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:
|
@@ -1,4 +1,4 @@
|
|
1
|
-
letta/__init__.py,sha256=
|
1
|
+
letta/__init__.py,sha256=dpn9tUBW7H4GEY_umGCKhKfZA9qu8S1F1ZPBPFuJJIA,1620
|
2
2
|
letta/agent.py,sha256=xP13DDysFq-h3Ny7DiTN5ZC2txRripMNvPFt8dfFDbE,89498
|
3
3
|
letta/config.py,sha256=JFGY4TWW0Wm5fTbZamOwWqk5G8Nn-TXyhgByGoAqy2c,12375
|
4
4
|
letta/constants.py,sha256=Yxp2DGlVnesnevIQbXqfYadxvVuYYk_6-lsF7YmHixs,16255
|
@@ -10,7 +10,7 @@ letta/log.py,sha256=-hkEaSIru3cvO8ynHPWb91jemJHzgWjmVc3fqGJrjvU,2294
|
|
10
10
|
letta/main.py,sha256=A0e4EXoW9XdlCzJ8EyMQaukjNhu_8XI0_FIiwyHTENM,458
|
11
11
|
letta/memory.py,sha256=xE1gAm99_PN0mWi4EL5CEIE95y9XFD4G0rHKTkO17-w,4325
|
12
12
|
letta/pytest.ini,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
|
-
letta/settings.py,sha256=
|
13
|
+
letta/settings.py,sha256=r7yRYXiWsdJnBUvFmEPLVHuiBTuvtrDem-TtUsmNPM4,15891
|
14
14
|
letta/streaming_interface.py,sha256=rPMfwUcjqITWk2tVqFQm1hmP99tU2IOHg9gU2dgPSo8,16400
|
15
15
|
letta/streaming_utils.py,sha256=ZRFGFpQqn9ujCEbgZdLM7yTjiuNNvqQ47sNhV8ix-yQ,16553
|
16
16
|
letta/system.py,sha256=NClLhwB9Dw20L6IDPpfcnmxXqYQlA5E92xzJ0hGPLo0,8993
|
@@ -19,7 +19,7 @@ letta/adapters/letta_llm_adapter.py,sha256=Uo9l2lUI-n9mymWlK8lTResdWx0mc-F_ttmmK
|
|
19
19
|
letta/adapters/letta_llm_request_adapter.py,sha256=ApEaMOkvtH5l09NvVgm0r2qaqTnGSqwVqAFA7n5N1HA,4757
|
20
20
|
letta/adapters/letta_llm_stream_adapter.py,sha256=p31xPQ-pU4SYPImlC6frl3cnVltSdCzO-NDD2Q7W8L0,7859
|
21
21
|
letta/adapters/simple_llm_request_adapter.py,sha256=LmzawoEqqcqAeqvdmWikRUZRnb3BriMN71IyyOm-uJo,3734
|
22
|
-
letta/adapters/simple_llm_stream_adapter.py,sha256=
|
22
|
+
letta/adapters/simple_llm_stream_adapter.py,sha256=Rq-m48GQ7jdWaCAsDQd6li6HL0s_7VREi8pE2OLYIO0,8898
|
23
23
|
letta/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
24
24
|
letta/agents/agent_loop.py,sha256=IODUozq61N73V55stSN65QLBs9nCD9IHtFTJHr51E0c,1120
|
25
25
|
letta/agents/base_agent.py,sha256=TAc5quD9KRMKOurz_-ZHoAPZe13ZWFlE6sDjaPqN-fk,8518
|
@@ -30,7 +30,7 @@ letta/agents/exceptions.py,sha256=BQY4D4w32OYHM63CM19ko7dPwZiAzUs3NbKvzmCTcJg,31
|
|
30
30
|
letta/agents/helpers.py,sha256=Hz8dKczPxMmDh-ILiUAEHgT55hMgIHO1yV8Vo0PORcw,16837
|
31
31
|
letta/agents/letta_agent.py,sha256=4SSx6aORD28B1IogEoWeCTQr4jBaniX9jJolD4pssaQ,98156
|
32
32
|
letta/agents/letta_agent_batch.py,sha256=NizNeIHvFtG4XpZCIplOSF8GohwHvNEkAUvsFBSdtSs,27950
|
33
|
-
letta/agents/letta_agent_v2.py,sha256=
|
33
|
+
letta/agents/letta_agent_v2.py,sha256=vRUDrkFhRl-HRfoYMm3z_T6msjuMo7kt5itQdIHE09A,60121
|
34
34
|
letta/agents/letta_agent_v3.py,sha256=i8idcEKVcZxJk67T_g0zLFBcrlHfygcuN9V_xk4Sj0c,48081
|
35
35
|
letta/agents/voice_agent.py,sha256=DP7g7TPFhaIAxmpur9yyJVI8Sb_MnoWfpHm7Q-t7mJI,23177
|
36
36
|
letta/agents/voice_sleeptime_agent.py,sha256=_JzCbWBOKrmo1cTaqZFTrQudpJEapwAyrXYtAHUILGo,8675
|
@@ -88,6 +88,7 @@ letta/humans/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
88
88
|
letta/humans/examples/basic.txt,sha256=Lcp8YESTWvOJgO4Yf_yyQmgo5bKakeB1nIVrwEGG6PA,17
|
89
89
|
letta/humans/examples/cs_phd.txt,sha256=9C9ZAV_VuG7GB31ksy3-_NAyk8rjE6YtVOkhp08k1xw,297
|
90
90
|
letta/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
91
|
+
letta/interfaces/anthropic_parallel_tool_call_streaming_interface.py,sha256=c65ULgokOuO-zbg_ek4cnHytIxcNx9AfTYOOaAnCMLY,21820
|
91
92
|
letta/interfaces/anthropic_streaming_interface.py,sha256=VaqI_aLAOk8EbVmDLe8PMOb5LsKNhVqpgVbLe-WbjMg,44780
|
92
93
|
letta/interfaces/gemini_streaming_interface.py,sha256=1nJwGl3nKAL7yfDX0zzpWrrjjpqzRFo82JpsEF6u_cA,12406
|
93
94
|
letta/interfaces/openai_chat_completions_streaming_interface.py,sha256=3xHXh8cW79EkiMUTYfvcH_s92nkLjxXfvtVOVC3bfLo,5050
|
@@ -113,7 +114,7 @@ letta/llm_api/llm_client.py,sha256=iXiPbrhluP2DBczv9nkFlAXdwWGOkg0lNDA9LzLrG4o,3
|
|
113
114
|
letta/llm_api/llm_client_base.py,sha256=31I1ancGiAaNBRMGbVkaBDgJ0Tu1cc0XLlTnjUllqbM,10085
|
114
115
|
letta/llm_api/mistral.py,sha256=ruOTBt07Uzx7S30_eXhedVWngtpjtlzG6Ox1Iw0_mQs,662
|
115
116
|
letta/llm_api/openai.py,sha256=loY0N97Ot_TyNZhNyPCZsFv3NHOXZyLNiecvLvZT1hs,28771
|
116
|
-
letta/llm_api/openai_client.py,sha256=
|
117
|
+
letta/llm_api/openai_client.py,sha256=hKBU0PshJNeYXLvio3fvaDUEHjQbDoqJNSyOUE6wi3o,42070
|
117
118
|
letta/llm_api/together_client.py,sha256=HeDMDDa525yfDTKciODDfX_t93QBfFmX0n2P-FT1QTU,2284
|
118
119
|
letta/llm_api/xai_client.py,sha256=_hlZrNeGy86hix-vTUcDe_3TrVnn6wSBHv1tI2YtGAQ,3941
|
119
120
|
letta/llm_api/sample_response_jsons/aws_bedrock.json,sha256=RS3VqyxPB9hQQCPm42hWoga0bisKv_0e8ZF-c3Ag1FA,930
|
@@ -253,7 +254,7 @@ letta/schemas/block.py,sha256=SIHjoxgwl9JmJROmntznIZSA0OJBdbj4R58OUjg3RdU,7960
|
|
253
254
|
letta/schemas/embedding_config.py,sha256=ZaD40UeeAX6A9C6bQVhrKwNDiMEuOn7-5uHcj9_T_D0,3740
|
254
255
|
letta/schemas/embedding_config_overrides.py,sha256=lkTa4y-EQ2RnaEKtKDM0sEAk7EwNa67REw8DGNNtGQY,84
|
255
256
|
letta/schemas/enums.py,sha256=9G968IVdkrCzJ-cU0_GJZDosQdk7qMlvC8_a0ylh_v8,5704
|
256
|
-
letta/schemas/environment_variables.py,sha256=
|
257
|
+
letta/schemas/environment_variables.py,sha256=XX_fk_rDaBd3r5ORnynewNIml0he5JdWdGE3NS5cTyM,3533
|
257
258
|
letta/schemas/file.py,sha256=78aTJuYqhhTu95hKFmcJmCa5_wJMZ521aiRzPM2plAM,6242
|
258
259
|
letta/schemas/folder.py,sha256=OpTj9idfGx6CEKDySeDEu3ZNDYjl8jJ02CH96RWPAVk,3309
|
259
260
|
letta/schemas/group.py,sha256=X4ioNEUraBa_pGN1bNBNugkQieGMLvh6rXisFgcHwoo,8663
|
@@ -284,7 +285,7 @@ letta/schemas/response_format.py,sha256=b2onyfSDCxnNkSHd4NsfJg_4ni5qBIK_F6zeJoMv
|
|
284
285
|
letta/schemas/run.py,sha256=wjpDNsgme51WVr5EJ12Px6qhtOYbw-PMpJP32lche14,3630
|
285
286
|
letta/schemas/run_metrics.py,sha256=xbeObgpP-0guO3RRvEfEK5YNV6G7ol4whx3_ds88mIk,1147
|
286
287
|
letta/schemas/sandbox_config.py,sha256=iw3-QS7PNy649tdynTJUxBbaruprykYAuGO6q28w-gU,5974
|
287
|
-
letta/schemas/secret.py,sha256=
|
288
|
+
letta/schemas/secret.py,sha256=grMrOGFDRf5JHw9xtacD0KxCk89OGdTzO67zkFMYr8c,18737
|
288
289
|
letta/schemas/source.py,sha256=Uxsm8-XA3vuIt5Ihu_l2Aau7quuqmyIDg7qIryklUqY,3565
|
289
290
|
letta/schemas/source_metadata.py,sha256=_dGjuXhGcVMlc53ja9yuk16Uj64ggEzilRDgmkqYfNs,1334
|
290
291
|
letta/schemas/step.py,sha256=rPT20AvHk31iGZRblp1tXbAgKedE4JZQKhu3RkvymrE,3572
|
@@ -303,7 +304,7 @@ letta/schemas/openai/responses_request.py,sha256=7UlZt2XProhRZ9L_oUQ5HrD9wPxDzvB
|
|
303
304
|
letta/schemas/providers/__init__.py,sha256=3_8xegx1svx31qAupfpIZ4MmeW1_2FRmxZbhjfLnSZo,1405
|
304
305
|
letta/schemas/providers/anthropic.py,sha256=CWvCN2wAf6PyrgQy1dDv-MoYwYSaw3f-wzzNUaeXTkY,6835
|
305
306
|
letta/schemas/providers/azure.py,sha256=c0aSfKdVwS_4wWnqng4fe_jmXGmvICRES-8nmqzoEpg,7692
|
306
|
-
letta/schemas/providers/base.py,sha256=
|
307
|
+
letta/schemas/providers/base.py,sha256=_Zven0SepeI3kBwpTKA3oapXWBxoUk4FdBAxckgLvTI,12121
|
307
308
|
letta/schemas/providers/bedrock.py,sha256=_8HPjLQ3KKj2x2zINUVbqbHxToXDOhkbQA2A3bDFntU,3634
|
308
309
|
letta/schemas/providers/cerebras.py,sha256=tFPN_LTJL_rzRE0DvXHb5Ckyaw4534X2kmibhWjHlA8,3208
|
309
310
|
letta/schemas/providers/deepseek.py,sha256=FXu8qPedeQC2Cn_L-k-1sQjj4feB8mbmW9XMJYx5hjE,2623
|
@@ -476,8 +477,8 @@ letta/templates/sandbox_code_file.py.j2,sha256=eXga5J_04Z8-pGdwfOCDjcRnMceIqcF5i
|
|
476
477
|
letta/templates/sandbox_code_file_async.py.j2,sha256=lb7nh_P2W9VZHzU_9TxSCEMUod7SDziPXgvT75xVds0,2748
|
477
478
|
letta/templates/summary_request_text.j2,sha256=ZttQwXonW2lk4pJLYzLK0pmo4EO4EtUUIXjgXKiizuc,842
|
478
479
|
letta/types/__init__.py,sha256=hokKjCVFGEfR7SLMrtZsRsBfsC7yTIbgKPLdGg4K1eY,147
|
479
|
-
letta_nightly-0.12.
|
480
|
-
letta_nightly-0.12.
|
481
|
-
letta_nightly-0.12.
|
482
|
-
letta_nightly-0.12.
|
483
|
-
letta_nightly-0.12.
|
480
|
+
letta_nightly-0.12.1.dev20251010012355.dist-info/METADATA,sha256=aoPdv79qrEbohSKR-WbKaoRViQCJCcCzNcjjXtsTvSs,24468
|
481
|
+
letta_nightly-0.12.1.dev20251010012355.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
482
|
+
letta_nightly-0.12.1.dev20251010012355.dist-info/entry_points.txt,sha256=m-94Paj-kxiR6Ktu0us0_2qfhn29DzF2oVzqBE6cu8w,41
|
483
|
+
letta_nightly-0.12.1.dev20251010012355.dist-info/licenses/LICENSE,sha256=mExtuZ_GYJgDEI38GWdiEYZizZS4KkVt2SF1g_GPNhI,10759
|
484
|
+
letta_nightly-0.12.1.dev20251010012355.dist-info/RECORD,,
|
File without changes
|
File without changes
|