letta-nightly 0.6.27.dev20250219104103__py3-none-any.whl → 0.6.28.dev20250220163833__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.

Potentially problematic release.


This version of letta-nightly might be problematic. Click here for more details.

Files changed (61) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +13 -1
  3. letta/client/client.py +2 -0
  4. letta/constants.py +2 -0
  5. letta/functions/schema_generator.py +6 -6
  6. letta/helpers/converters.py +153 -0
  7. letta/helpers/tool_rule_solver.py +11 -1
  8. letta/llm_api/anthropic.py +10 -5
  9. letta/llm_api/aws_bedrock.py +1 -1
  10. letta/llm_api/azure_openai_constants.py +1 -0
  11. letta/llm_api/deepseek.py +303 -0
  12. letta/llm_api/llm_api_tools.py +81 -1
  13. letta/llm_api/openai.py +13 -0
  14. letta/local_llm/chat_completion_proxy.py +15 -2
  15. letta/local_llm/lmstudio/api.py +75 -1
  16. letta/orm/__init__.py +1 -0
  17. letta/orm/agent.py +14 -5
  18. letta/orm/custom_columns.py +31 -110
  19. letta/orm/identity.py +39 -0
  20. letta/orm/organization.py +2 -0
  21. letta/schemas/agent.py +13 -1
  22. letta/schemas/identity.py +44 -0
  23. letta/schemas/llm_config.py +2 -0
  24. letta/schemas/message.py +1 -1
  25. letta/schemas/openai/chat_completion_response.py +2 -0
  26. letta/schemas/providers.py +72 -1
  27. letta/schemas/tool_rule.py +9 -1
  28. letta/serialize_schemas/__init__.py +1 -0
  29. letta/serialize_schemas/agent.py +36 -0
  30. letta/serialize_schemas/base.py +12 -0
  31. letta/serialize_schemas/custom_fields.py +69 -0
  32. letta/serialize_schemas/message.py +15 -0
  33. letta/server/db.py +111 -0
  34. letta/server/rest_api/app.py +8 -0
  35. letta/server/rest_api/interface.py +114 -9
  36. letta/server/rest_api/routers/v1/__init__.py +2 -0
  37. letta/server/rest_api/routers/v1/agents.py +7 -1
  38. letta/server/rest_api/routers/v1/identities.py +111 -0
  39. letta/server/server.py +13 -116
  40. letta/services/agent_manager.py +54 -6
  41. letta/services/block_manager.py +1 -1
  42. letta/services/helpers/agent_manager_helper.py +15 -0
  43. letta/services/identity_manager.py +140 -0
  44. letta/services/job_manager.py +1 -1
  45. letta/services/message_manager.py +1 -1
  46. letta/services/organization_manager.py +1 -1
  47. letta/services/passage_manager.py +1 -1
  48. letta/services/provider_manager.py +1 -1
  49. letta/services/sandbox_config_manager.py +1 -1
  50. letta/services/source_manager.py +1 -1
  51. letta/services/step_manager.py +1 -1
  52. letta/services/tool_manager.py +1 -1
  53. letta/services/user_manager.py +1 -1
  54. letta/settings.py +3 -0
  55. letta/tracing.py +205 -0
  56. letta/utils.py +4 -0
  57. {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/METADATA +9 -2
  58. {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/RECORD +61 -48
  59. {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/LICENSE +0 -0
  60. {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/WHEEL +0 -0
  61. {letta_nightly-0.6.27.dev20250219104103.dist-info → letta_nightly-0.6.28.dev20250220163833.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import random
2
3
  import time
3
4
  from typing import List, Optional, Union
@@ -13,6 +14,7 @@ from letta.llm_api.anthropic import (
13
14
  )
14
15
  from letta.llm_api.aws_bedrock import has_valid_aws_credentials
15
16
  from letta.llm_api.azure_openai import azure_openai_chat_completions_request
17
+ from letta.llm_api.deepseek import build_deepseek_chat_completions_request, convert_deepseek_response_to_chatcompletion
16
18
  from letta.llm_api.google_ai import convert_tools_to_google_ai_format, google_ai_chat_completions_request
17
19
  from letta.llm_api.helpers import add_inner_thoughts_to_functions, unpack_all_inner_thoughts_from_kwargs
18
20
  from letta.llm_api.openai import (
@@ -29,8 +31,9 @@ from letta.schemas.openai.chat_completion_request import ChatCompletionRequest,
29
31
  from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
30
32
  from letta.settings import ModelSettings
31
33
  from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface
34
+ from letta.tracing import log_event, trace_method
32
35
 
33
- LLM_API_PROVIDER_OPTIONS = ["openai", "azure", "anthropic", "google_ai", "cohere", "local", "groq"]
36
+ LLM_API_PROVIDER_OPTIONS = ["openai", "azure", "anthropic", "google_ai", "cohere", "local", "groq", "deepseek"]
34
37
 
35
38
 
36
39
  def retry_with_exponential_backoff(
@@ -68,9 +71,28 @@ def retry_with_exponential_backoff(
68
71
  if http_err.response.status_code in error_codes:
69
72
  # Increment retries
70
73
  num_retries += 1
74
+ log_event(
75
+ "llm_retry_attempt",
76
+ {
77
+ "attempt": num_retries,
78
+ "delay": delay,
79
+ "status_code": http_err.response.status_code,
80
+ "error_type": type(http_err).__name__,
81
+ "error": str(http_err),
82
+ },
83
+ )
71
84
 
72
85
  # Check if max retries has been reached
73
86
  if num_retries > max_retries:
87
+ log_event(
88
+ "llm_max_retries_exceeded",
89
+ {
90
+ "max_retries": max_retries,
91
+ "status_code": http_err.response.status_code,
92
+ "error_type": type(http_err).__name__,
93
+ "error": str(http_err),
94
+ },
95
+ )
74
96
  raise RateLimitExceededError("Maximum number of retries exceeded", max_retries=max_retries)
75
97
 
76
98
  # Increment the delay
@@ -84,15 +106,21 @@ def retry_with_exponential_backoff(
84
106
  time.sleep(delay)
85
107
  else:
86
108
  # For other HTTP errors, re-raise the exception
109
+ log_event(
110
+ "llm_non_retryable_error",
111
+ {"status_code": http_err.response.status_code, "error_type": type(http_err).__name__, "error": str(http_err)},
112
+ )
87
113
  raise
88
114
 
89
115
  # Raise exceptions for any errors not specified
90
116
  except Exception as e:
117
+ log_event("llm_unexpected_error", {"error_type": type(e).__name__, "error": str(e)})
91
118
  raise e
92
119
 
93
120
  return wrapper
94
121
 
95
122
 
123
+ @trace_method("LLM Request")
96
124
  @retry_with_exponential_backoff
97
125
  def create(
98
126
  # agent_state: AgentState,
@@ -453,10 +481,62 @@ def create(
453
481
  ),
454
482
  )
455
483
 
484
+ elif llm_config.model_endpoint_type == "deepseek":
485
+ if model_settings.deepseek_api_key is None and llm_config.model_endpoint == "":
486
+ # only is a problem if we are *not* using an openai proxy
487
+ raise LettaConfigurationError(message="DeepSeek key is missing from letta config file", missing_fields=["deepseek_api_key"])
488
+
489
+ data = build_deepseek_chat_completions_request(
490
+ llm_config,
491
+ messages,
492
+ user_id,
493
+ functions,
494
+ function_call,
495
+ use_tool_naming,
496
+ llm_config.max_tokens,
497
+ )
498
+ if stream: # Client requested token streaming
499
+ data.stream = True
500
+ assert isinstance(stream_interface, AgentChunkStreamingInterface) or isinstance(
501
+ stream_interface, AgentRefreshStreamingInterface
502
+ ), type(stream_interface)
503
+ response = openai_chat_completions_process_stream(
504
+ url=llm_config.model_endpoint,
505
+ api_key=model_settings.deepseek_api_key,
506
+ chat_completion_request=data,
507
+ stream_interface=stream_interface,
508
+ )
509
+ else: # Client did not request token streaming (expect a blocking backend response)
510
+ data.stream = False
511
+ if isinstance(stream_interface, AgentChunkStreamingInterface):
512
+ stream_interface.stream_start()
513
+ try:
514
+ response = openai_chat_completions_request(
515
+ url=llm_config.model_endpoint,
516
+ api_key=model_settings.deepseek_api_key,
517
+ chat_completion_request=data,
518
+ )
519
+ finally:
520
+ if isinstance(stream_interface, AgentChunkStreamingInterface):
521
+ stream_interface.stream_end()
522
+ """
523
+ if llm_config.put_inner_thoughts_in_kwargs:
524
+ response = unpack_all_inner_thoughts_from_kwargs(response=response, inner_thoughts_key=INNER_THOUGHTS_KWARG)
525
+ """
526
+ response = convert_deepseek_response_to_chatcompletion(response)
527
+ return response
528
+
456
529
  # local model
457
530
  else:
458
531
  if stream:
459
532
  raise NotImplementedError(f"Streaming not yet implemented for {llm_config.model_endpoint_type}")
533
+
534
+ if "DeepSeek-R1".lower() in llm_config.model.lower(): # TODO: move this to the llm_config.
535
+ messages[0].content[0].text += f"<available functions> {''.join(json.dumps(f) for f in functions)} </available functions>"
536
+ messages[0].content[
537
+ 0
538
+ ].text += f'Select best function to call simply by responding with a single json block with the keys "function" and "params". Use double quotes around the arguments.'
539
+
460
540
  return get_chat_completion(
461
541
  model=llm_config.model,
462
542
  messages=messages,
letta/llm_api/openai.py CHANGED
@@ -166,6 +166,11 @@ def openai_chat_completions_process_stream(
166
166
  create_message_id: bool = True,
167
167
  create_message_datetime: bool = True,
168
168
  override_tool_call_id: bool = True,
169
+ # if we expect reasoning content in the response,
170
+ # then we should emit reasoning_content as "inner_thoughts"
171
+ # however, we don't necessarily want to put these
172
+ # expect_reasoning_content: bool = False,
173
+ expect_reasoning_content: bool = True,
169
174
  ) -> ChatCompletionResponse:
170
175
  """Process a streaming completion response, and return a ChatCompletionRequest at the end.
171
176
 
@@ -250,6 +255,7 @@ def openai_chat_completions_process_stream(
250
255
  chat_completion_chunk,
251
256
  message_id=chat_completion_response.id if create_message_id else chat_completion_chunk.id,
252
257
  message_date=chat_completion_response.created if create_message_datetime else chat_completion_chunk.created,
258
+ expect_reasoning_content=expect_reasoning_content,
253
259
  )
254
260
  elif isinstance(stream_interface, AgentRefreshStreamingInterface):
255
261
  stream_interface.process_refresh(chat_completion_response)
@@ -290,6 +296,13 @@ def openai_chat_completions_process_stream(
290
296
  else:
291
297
  accum_message.content += content_delta
292
298
 
299
+ if expect_reasoning_content and message_delta.reasoning_content is not None:
300
+ reasoning_content_delta = message_delta.reasoning_content
301
+ if accum_message.reasoning_content is None:
302
+ accum_message.reasoning_content = reasoning_content_delta
303
+ else:
304
+ accum_message.reasoning_content += reasoning_content_delta
305
+
293
306
  # TODO(charles) make sure this works for parallel tool calling?
294
307
  if message_delta.tool_calls is not None:
295
308
  tool_calls_delta = message_delta.tool_calls
@@ -14,7 +14,7 @@ from letta.local_llm.grammars.gbnf_grammar_generator import create_dynamic_model
14
14
  from letta.local_llm.koboldcpp.api import get_koboldcpp_completion
15
15
  from letta.local_llm.llamacpp.api import get_llamacpp_completion
16
16
  from letta.local_llm.llm_chat_completion_wrappers import simple_summary_wrapper
17
- from letta.local_llm.lmstudio.api import get_lmstudio_completion
17
+ from letta.local_llm.lmstudio.api import get_lmstudio_completion, get_lmstudio_completion_chatcompletions
18
18
  from letta.local_llm.ollama.api import get_ollama_completion
19
19
  from letta.local_llm.utils import count_tokens, get_available_wrappers
20
20
  from letta.local_llm.vllm.api import get_vllm_completion
@@ -141,11 +141,24 @@ def get_chat_completion(
141
141
  f"Failed to convert ChatCompletion messages into prompt string with wrapper {str(llm_wrapper)} - error: {str(e)}"
142
142
  )
143
143
 
144
+ # get the schema for the model
145
+
146
+ """
147
+ if functions_python is not None:
148
+ model_schema = generate_schema(functions)
149
+ else:
150
+ model_schema = None
151
+ """
152
+
153
+ # Run the LLM
144
154
  try:
155
+ result_reasoning = None
145
156
  if endpoint_type == "webui":
146
157
  result, usage = get_webui_completion(endpoint, auth_type, auth_key, prompt, context_window, grammar=grammar)
147
158
  elif endpoint_type == "webui-legacy":
148
159
  result, usage = get_webui_completion_legacy(endpoint, auth_type, auth_key, prompt, context_window, grammar=grammar)
160
+ elif endpoint_type == "lmstudio-chatcompletions":
161
+ result, usage, result_reasoning = get_lmstudio_completion_chatcompletions(endpoint, auth_type, auth_key, model, messages)
149
162
  elif endpoint_type == "lmstudio":
150
163
  result, usage = get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_window, api="completions")
151
164
  elif endpoint_type == "lmstudio-legacy":
@@ -214,7 +227,7 @@ def get_chat_completion(
214
227
  index=0,
215
228
  message=Message(
216
229
  role=chat_completion_result["role"],
217
- content=chat_completion_result["content"],
230
+ content=result_reasoning if result_reasoning is not None else chat_completion_result["content"],
218
231
  tool_calls=(
219
232
  [ToolCall(id=get_tool_call_id(), type="function", function=chat_completion_result["function_call"])]
220
233
  if "function_call" in chat_completion_result
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from urllib.parse import urljoin
2
3
 
3
4
  from letta.local_llm.settings.settings import get_completions_settings
@@ -6,6 +7,73 @@ from letta.utils import count_tokens
6
7
 
7
8
  LMSTUDIO_API_CHAT_SUFFIX = "/v1/chat/completions"
8
9
  LMSTUDIO_API_COMPLETIONS_SUFFIX = "/v1/completions"
10
+ LMSTUDIO_API_CHAT_COMPLETIONS_SUFFIX = "/v1/chat/completions"
11
+
12
+
13
+ def get_lmstudio_completion_chatcompletions(endpoint, auth_type, auth_key, model, messages):
14
+ """
15
+ This is the request we need to send
16
+
17
+ {
18
+ "model": "deepseek-r1-distill-qwen-7b",
19
+ "messages": [
20
+ { "role": "system", "content": "Always answer in rhymes. Today is Thursday" },
21
+ { "role": "user", "content": "What day is it today?" },
22
+ { "role": "user", "content": "What day is it today?" }],
23
+ "temperature": 0.7,
24
+ "max_tokens": -1,
25
+ "stream": false
26
+ """
27
+ from letta.utils import printd
28
+
29
+ URI = endpoint + LMSTUDIO_API_CHAT_COMPLETIONS_SUFFIX
30
+ request = {"model": model, "messages": messages}
31
+
32
+ response = post_json_auth_request(uri=URI, json_payload=request, auth_type=auth_type, auth_key=auth_key)
33
+
34
+ # Get the reasoning from the model
35
+ if response.status_code == 200:
36
+ result_full = response.json()
37
+ result_reasoning = result_full["choices"][0]["message"].get("reasoning_content")
38
+ result = result_full["choices"][0]["message"]["content"]
39
+ usage = result_full["usage"]
40
+
41
+ # See if result is json
42
+ try:
43
+ function_call = json.loads(result)
44
+ if "function" in function_call and "params" in function_call:
45
+ return result, usage, result_reasoning
46
+ else:
47
+ print("Did not get json on without json constraint, attempting with json decoding")
48
+ except Exception as e:
49
+ print(f"Did not get json on without json constraint, attempting with json decoding: {e}")
50
+
51
+ request["messages"].append({"role": "assistant", "content": result_reasoning})
52
+ request["messages"].append({"role": "user", "content": ""}) # last message must be user
53
+ # Now run with json decoding to get the function
54
+ request["response_format"] = {
55
+ "type": "json_schema",
56
+ "json_schema": {
57
+ "name": "function_call",
58
+ "strict": "true",
59
+ "schema": {
60
+ "type": "object",
61
+ "properties": {"function": {"type": "string"}, "params": {"type": "object"}},
62
+ "required": ["function", "params"],
63
+ },
64
+ },
65
+ }
66
+
67
+ response = post_json_auth_request(uri=URI, json_payload=request, auth_type=auth_type, auth_key=auth_key)
68
+ if response.status_code == 200:
69
+ result_full = response.json()
70
+ printd(f"JSON API response:\n{result_full}")
71
+ result = result_full["choices"][0]["message"]["content"]
72
+ # add usage with previous call, merge with prev usage
73
+ for key, value in result_full["usage"].items():
74
+ usage[key] += value
75
+
76
+ return result, usage, result_reasoning
9
77
 
10
78
 
11
79
  def get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_window, api="completions"):
@@ -24,7 +92,8 @@ def get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_windo
24
92
  # This controls how LM studio handles context overflow
25
93
  # In Letta we handle this ourselves, so this should be disabled
26
94
  # "context_overflow_policy": 0,
27
- "lmstudio": {"context_overflow_policy": 0}, # 0 = stop at limit
95
+ # "lmstudio": {"context_overflow_policy": 0}, # 0 = stop at limit
96
+ # "lmstudio": {"context_overflow_policy": "stopAtLimit"}, # https://github.com/letta-ai/letta/issues/1782
28
97
  "stream": False,
29
98
  "model": "local model",
30
99
  }
@@ -72,6 +141,11 @@ def get_lmstudio_completion(endpoint, auth_type, auth_key, prompt, context_windo
72
141
  elif api == "completions":
73
142
  result = result_full["choices"][0]["text"]
74
143
  usage = result_full.get("usage", None)
144
+ elif api == "chat/completions":
145
+ result = result_full["choices"][0]["content"]
146
+ result_full["choices"][0]["reasoning_content"]
147
+ usage = result_full.get("usage", None)
148
+
75
149
  else:
76
150
  # Example error: msg={"error":"Context length exceeded. Tokens in context: 8000, Context length: 8000"}
77
151
  if "context length" in str(response.text).lower():
letta/orm/__init__.py CHANGED
@@ -4,6 +4,7 @@ from letta.orm.base import Base
4
4
  from letta.orm.block import Block
5
5
  from letta.orm.blocks_agents import BlocksAgents
6
6
  from letta.orm.file import FileMetadata
7
+ from letta.orm.identity import Identity
7
8
  from letta.orm.job import Job
8
9
  from letta.orm.job_messages import JobMessage
9
10
  from letta.orm.message import Message
letta/orm/agent.py CHANGED
@@ -1,11 +1,12 @@
1
1
  import uuid
2
2
  from typing import TYPE_CHECKING, List, Optional
3
3
 
4
- from sqlalchemy import JSON, Boolean, Index, String
4
+ from sqlalchemy import JSON, Boolean, ForeignKey, Index, String
5
5
  from sqlalchemy.orm import Mapped, mapped_column, relationship
6
6
 
7
7
  from letta.orm.block import Block
8
8
  from letta.orm.custom_columns import EmbeddingConfigColumn, LLMConfigColumn, ToolRulesColumn
9
+ from letta.orm.identity import Identity
9
10
  from letta.orm.message import Message
10
11
  from letta.orm.mixins import OrganizationMixin
11
12
  from letta.orm.organization import Organization
@@ -15,10 +16,11 @@ from letta.schemas.agent import AgentType
15
16
  from letta.schemas.embedding_config import EmbeddingConfig
16
17
  from letta.schemas.llm_config import LLMConfig
17
18
  from letta.schemas.memory import Memory
18
- from letta.schemas.tool_rule import TerminalToolRule, ToolRule
19
+ from letta.schemas.tool_rule import ToolRule
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  from letta.orm.agents_tags import AgentsTags
23
+ from letta.orm.identity import Identity
22
24
  from letta.orm.organization import Organization
23
25
  from letta.orm.source import Source
24
26
  from letta.orm.tool import Tool
@@ -59,6 +61,14 @@ class Agent(SqlalchemyBase, OrganizationMixin):
59
61
  template_id: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The id of the template the agent belongs to.")
60
62
  base_template_id: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The base template id of the agent.")
61
63
 
64
+ # Identity
65
+ identity_id: Mapped[Optional[str]] = mapped_column(
66
+ String, ForeignKey("identities.id", ondelete="CASCADE"), nullable=True, doc="The id of the identity the agent belongs to."
67
+ )
68
+ identifier_key: Mapped[Optional[str]] = mapped_column(
69
+ String, nullable=True, doc="The identifier key of the identity the agent belongs to."
70
+ )
71
+
62
72
  # Tool rules
63
73
  tool_rules: Mapped[Optional[List[ToolRule]]] = mapped_column(ToolRulesColumn, doc="the tool rules for this agent.")
64
74
 
@@ -69,6 +79,7 @@ class Agent(SqlalchemyBase, OrganizationMixin):
69
79
 
70
80
  # relationships
71
81
  organization: Mapped["Organization"] = relationship("Organization", back_populates="agents")
82
+ identity: Mapped["Identity"] = relationship("Identity", back_populates="agents")
72
83
  tool_exec_environment_variables: Mapped[List["AgentEnvironmentVariable"]] = relationship(
73
84
  "AgentEnvironmentVariable",
74
85
  back_populates="agent",
@@ -119,14 +130,12 @@ class Agent(SqlalchemyBase, OrganizationMixin):
119
130
  viewonly=True, # Ensures SQLAlchemy doesn't attempt to manage this relationship
120
131
  doc="All passages derived created by this agent.",
121
132
  )
133
+ identity: Mapped[Optional["Identity"]] = relationship("Identity", back_populates="agents")
122
134
 
123
135
  def to_pydantic(self) -> PydanticAgentState:
124
136
  """converts to the basic pydantic model counterpart"""
125
137
  # add default rule for having send_message be a terminal tool
126
138
  tool_rules = self.tool_rules
127
- if not tool_rules:
128
- tool_rules = [TerminalToolRule(tool_name="send_message"), TerminalToolRule(tool_name="send_message_to_agent_async")]
129
-
130
139
  state = {
131
140
  "id": self.id,
132
141
  "organization_id": self.organization_id,
@@ -1,159 +1,80 @@
1
- import base64
2
- from typing import List, Union
3
-
4
- import numpy as np
5
- from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall
6
- from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction
7
1
  from sqlalchemy import JSON
8
2
  from sqlalchemy.types import BINARY, TypeDecorator
9
3
 
10
- from letta.schemas.embedding_config import EmbeddingConfig
11
- from letta.schemas.enums import ToolRuleType
12
- from letta.schemas.llm_config import LLMConfig
13
- from letta.schemas.tool_rule import ChildToolRule, ConditionalToolRule, InitToolRule, TerminalToolRule
4
+ from letta.helpers.converters import (
5
+ deserialize_embedding_config,
6
+ deserialize_llm_config,
7
+ deserialize_tool_calls,
8
+ deserialize_tool_rules,
9
+ deserialize_vector,
10
+ serialize_embedding_config,
11
+ serialize_llm_config,
12
+ serialize_tool_calls,
13
+ serialize_tool_rules,
14
+ serialize_vector,
15
+ )
14
16
 
15
17
 
16
- class EmbeddingConfigColumn(TypeDecorator):
17
- """Custom type for storing EmbeddingConfig as JSON."""
18
+ class LLMConfigColumn(TypeDecorator):
19
+ """Custom SQLAlchemy column type for storing LLMConfig as JSON."""
18
20
 
19
21
  impl = JSON
20
22
  cache_ok = True
21
23
 
22
- def load_dialect_impl(self, dialect):
23
- return dialect.type_descriptor(JSON())
24
-
25
24
  def process_bind_param(self, value, dialect):
26
- if value and isinstance(value, EmbeddingConfig):
27
- return value.model_dump()
28
- return value
25
+ return serialize_llm_config(value)
29
26
 
30
27
  def process_result_value(self, value, dialect):
31
- if value:
32
- return EmbeddingConfig(**value)
33
- return value
28
+ return deserialize_llm_config(value)
34
29
 
35
30
 
36
- class LLMConfigColumn(TypeDecorator):
37
- """Custom type for storing LLMConfig as JSON."""
31
+ class EmbeddingConfigColumn(TypeDecorator):
32
+ """Custom SQLAlchemy column type for storing EmbeddingConfig as JSON."""
38
33
 
39
34
  impl = JSON
40
35
  cache_ok = True
41
36
 
42
- def load_dialect_impl(self, dialect):
43
- return dialect.type_descriptor(JSON())
44
-
45
37
  def process_bind_param(self, value, dialect):
46
- if value and isinstance(value, LLMConfig):
47
- return value.model_dump()
48
- return value
38
+ return serialize_embedding_config(value)
49
39
 
50
40
  def process_result_value(self, value, dialect):
51
- if value:
52
- return LLMConfig(**value)
53
- return value
41
+ return deserialize_embedding_config(value)
54
42
 
55
43
 
56
44
  class ToolRulesColumn(TypeDecorator):
57
- """Custom type for storing a list of ToolRules as JSON"""
45
+ """Custom SQLAlchemy column type for storing a list of ToolRules as JSON."""
58
46
 
59
47
  impl = JSON
60
48
  cache_ok = True
61
49
 
62
- def load_dialect_impl(self, dialect):
63
- return dialect.type_descriptor(JSON())
64
-
65
50
  def process_bind_param(self, value, dialect):
66
- """Convert a list of ToolRules to JSON-serializable format."""
67
- if value:
68
- data = [rule.model_dump() for rule in value]
69
- for d in data:
70
- d["type"] = d["type"].value
71
-
72
- for d in data:
73
- assert not (d["type"] == "ToolRule" and "children" not in d), "ToolRule does not have children field"
74
- return data
75
- return value
76
-
77
- def process_result_value(self, value, dialect) -> List[Union[ChildToolRule, InitToolRule, TerminalToolRule]]:
78
- """Convert JSON back to a list of ToolRules."""
79
- if value:
80
- return [self.deserialize_tool_rule(rule_data) for rule_data in value]
81
- return value
82
-
83
- @staticmethod
84
- def deserialize_tool_rule(data: dict) -> Union[ChildToolRule, InitToolRule, TerminalToolRule, ConditionalToolRule]:
85
- """Deserialize a dictionary to the appropriate ToolRule subclass based on the 'type'."""
86
- rule_type = ToolRuleType(data.get("type")) # Remove 'type' field if it exists since it is a class var
87
- if rule_type == ToolRuleType.run_first or rule_type == "InitToolRule":
88
- data["type"] = ToolRuleType.run_first
89
- return InitToolRule(**data)
90
- elif rule_type == ToolRuleType.exit_loop or rule_type == "TerminalToolRule":
91
- data["type"] = ToolRuleType.exit_loop
92
- return TerminalToolRule(**data)
93
- elif rule_type == ToolRuleType.constrain_child_tools or rule_type == "ToolRule":
94
- data["type"] = ToolRuleType.constrain_child_tools
95
- rule = ChildToolRule(**data)
96
- return rule
97
- elif rule_type == ToolRuleType.conditional:
98
- rule = ConditionalToolRule(**data)
99
- return rule
100
- else:
101
- raise ValueError(f"Unknown tool rule type: {rule_type}")
51
+ return serialize_tool_rules(value)
52
+
53
+ def process_result_value(self, value, dialect):
54
+ return deserialize_tool_rules(value)
102
55
 
103
56
 
104
57
  class ToolCallColumn(TypeDecorator):
58
+ """Custom SQLAlchemy column type for storing OpenAI ToolCall objects as JSON."""
105
59
 
106
60
  impl = JSON
107
61
  cache_ok = True
108
62
 
109
- def load_dialect_impl(self, dialect):
110
- return dialect.type_descriptor(JSON())
111
-
112
63
  def process_bind_param(self, value, dialect):
113
- if value:
114
- values = []
115
- for v in value:
116
- if isinstance(v, OpenAIToolCall):
117
- values.append(v.model_dump())
118
- else:
119
- values.append(v)
120
- return values
121
-
122
- return value
64
+ return serialize_tool_calls(value)
123
65
 
124
66
  def process_result_value(self, value, dialect):
125
- if value:
126
- tools = []
127
- for tool_value in value:
128
- if "function" in tool_value:
129
- tool_call_function = OpenAIFunction(**tool_value["function"])
130
- del tool_value["function"]
131
- else:
132
- tool_call_function = None
133
- tools.append(OpenAIToolCall(function=tool_call_function, **tool_value))
134
- return tools
135
- return value
67
+ return deserialize_tool_calls(value)
136
68
 
137
69
 
138
70
  class CommonVector(TypeDecorator):
139
- """Common type for representing vectors in SQLite"""
71
+ """Custom SQLAlchemy column type for storing vectors in SQLite."""
140
72
 
141
73
  impl = BINARY
142
74
  cache_ok = True
143
75
 
144
- def load_dialect_impl(self, dialect):
145
- return dialect.type_descriptor(BINARY())
146
-
147
76
  def process_bind_param(self, value, dialect):
148
- if value is None:
149
- return value
150
- if isinstance(value, list):
151
- value = np.array(value, dtype=np.float32)
152
- return base64.b64encode(value.tobytes())
77
+ return serialize_vector(value)
153
78
 
154
79
  def process_result_value(self, value, dialect):
155
- if not value:
156
- return value
157
- if dialect.name == "sqlite":
158
- value = base64.b64decode(value)
159
- return np.frombuffer(value, dtype=np.float32)
80
+ return deserialize_vector(value, dialect)
letta/orm/identity.py ADDED
@@ -0,0 +1,39 @@
1
+ import uuid
2
+ from typing import List, Optional
3
+
4
+ from sqlalchemy import String, UniqueConstraint
5
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
6
+
7
+ from letta.orm.mixins import OrganizationMixin
8
+ from letta.orm.sqlalchemy_base import SqlalchemyBase
9
+ from letta.schemas.identity import Identity as PydanticIdentity
10
+
11
+
12
+ class Identity(SqlalchemyBase, OrganizationMixin):
13
+ """Identity ORM class"""
14
+
15
+ __tablename__ = "identities"
16
+ __pydantic_model__ = PydanticIdentity
17
+ __table_args__ = (UniqueConstraint("identifier_key", "project_id", "organization_id", name="unique_identifier_pid_org_id"),)
18
+
19
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"identity-{uuid.uuid4()}")
20
+ identifier_key: Mapped[str] = mapped_column(nullable=False, doc="External, user-generated identifier key of the identity.")
21
+ name: Mapped[str] = mapped_column(nullable=False, doc="The name of the identity.")
22
+ identity_type: Mapped[str] = mapped_column(nullable=False, doc="The type of the identity.")
23
+ project_id: Mapped[Optional[str]] = mapped_column(nullable=True, doc="The project id of the identity.")
24
+
25
+ # relationships
26
+ organization: Mapped["Organization"] = relationship("Organization", back_populates="identities")
27
+ agents: Mapped[List["Agent"]] = relationship("Agent", lazy="selectin", back_populates="identity")
28
+
29
+ def to_pydantic(self) -> PydanticIdentity:
30
+ state = {
31
+ "id": self.id,
32
+ "identifier_key": self.identifier_key,
33
+ "name": self.name,
34
+ "identity_type": self.identity_type,
35
+ "project_id": self.project_id,
36
+ "agents": [agent.to_pydantic() for agent in self.agents],
37
+ }
38
+
39
+ return self.__pydantic_model__(**state)
letta/orm/organization.py CHANGED
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
9
9
 
10
10
  from letta.orm.agent import Agent
11
11
  from letta.orm.file import FileMetadata
12
+ from letta.orm.identity import Identity
12
13
  from letta.orm.provider import Provider
13
14
  from letta.orm.sandbox_config import AgentEnvironmentVariable
14
15
  from letta.orm.tool import Tool
@@ -47,6 +48,7 @@ class Organization(SqlalchemyBase):
47
48
  )
48
49
  agent_passages: Mapped[List["AgentPassage"]] = relationship("AgentPassage", back_populates="organization", cascade="all, delete-orphan")
49
50
  providers: Mapped[List["Provider"]] = relationship("Provider", back_populates="organization", cascade="all, delete-orphan")
51
+ identities: Mapped[List["Identity"]] = relationship("Identity", back_populates="organization", cascade="all, delete-orphan")
50
52
 
51
53
  @property
52
54
  def passages(self) -> List[Union["SourcePassage", "AgentPassage"]]: