letta-nightly 0.5.0.dev20241022104124__py3-none-any.whl → 0.5.1.dev20241023193051__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 (35) hide show
  1. letta/__init__.py +8 -3
  2. letta/agent_store/db.py +4 -2
  3. letta/cli/cli_config.py +2 -2
  4. letta/client/client.py +13 -0
  5. letta/constants.py +7 -4
  6. letta/embeddings.py +34 -16
  7. letta/llm_api/azure_openai.py +44 -4
  8. letta/llm_api/openai.py +7 -1
  9. letta/metadata.py +1 -145
  10. letta/orm/__all__.py +0 -0
  11. letta/orm/__init__.py +0 -0
  12. letta/orm/base.py +75 -0
  13. letta/orm/enums.py +8 -0
  14. letta/orm/errors.py +6 -0
  15. letta/orm/mixins.py +67 -0
  16. letta/orm/organization.py +28 -0
  17. letta/orm/sqlalchemy_base.py +204 -0
  18. letta/orm/user.py +25 -0
  19. letta/schemas/organization.py +3 -3
  20. letta/schemas/user.py +13 -6
  21. letta/server/rest_api/interface.py +47 -9
  22. letta/server/rest_api/routers/v1/organizations.py +5 -6
  23. letta/server/rest_api/routers/v1/users.py +6 -7
  24. letta/server/server.py +51 -85
  25. letta/services/__init__.py +0 -0
  26. letta/services/organization_manager.py +76 -0
  27. letta/services/user_manager.py +99 -0
  28. letta/settings.py +5 -0
  29. {letta_nightly-0.5.0.dev20241022104124.dist-info → letta_nightly-0.5.1.dev20241023193051.dist-info}/METADATA +2 -1
  30. {letta_nightly-0.5.0.dev20241022104124.dist-info → letta_nightly-0.5.1.dev20241023193051.dist-info}/RECORD +33 -23
  31. letta/base.py +0 -3
  32. letta/client/admin.py +0 -171
  33. {letta_nightly-0.5.0.dev20241022104124.dist-info → letta_nightly-0.5.1.dev20241023193051.dist-info}/LICENSE +0 -0
  34. {letta_nightly-0.5.0.dev20241022104124.dist-info → letta_nightly-0.5.1.dev20241023193051.dist-info}/WHEEL +0 -0
  35. {letta_nightly-0.5.0.dev20241022104124.dist-info → letta_nightly-0.5.1.dev20241023193051.dist-info}/entry_points.txt +0 -0
letta/__init__.py CHANGED
@@ -1,7 +1,6 @@
1
- __version__ = "0.5.0"
1
+ __version__ = "0.5.1"
2
2
 
3
3
  # import clients
4
- from letta.client.admin import Admin
5
4
  from letta.client.client import LocalClient, RESTClient, create_client
6
5
 
7
6
  # imports for easier access
@@ -13,7 +12,13 @@ from letta.schemas.file import FileMetadata
13
12
  from letta.schemas.job import Job
14
13
  from letta.schemas.letta_message import LettaMessage
15
14
  from letta.schemas.llm_config import LLMConfig
16
- from letta.schemas.memory import ArchivalMemorySummary, Memory, RecallMemorySummary
15
+ from letta.schemas.memory import (
16
+ ArchivalMemorySummary,
17
+ BasicBlockMemory,
18
+ ChatMemory,
19
+ Memory,
20
+ RecallMemorySummary,
21
+ )
17
22
  from letta.schemas.message import Message
18
23
  from letta.schemas.openai.chat_completion_response import UsageStatistics
19
24
  from letta.schemas.organization import Organization
letta/agent_store/db.py CHANGED
@@ -25,10 +25,10 @@ from sqlalchemy_json import MutableJson
25
25
  from tqdm import tqdm
26
26
 
27
27
  from letta.agent_store.storage import StorageConnector, TableType
28
- from letta.base import Base
29
28
  from letta.config import LettaConfig
30
29
  from letta.constants import MAX_EMBEDDING_DIM
31
30
  from letta.metadata import EmbeddingConfigColumn, FileMetadataModel, ToolCallColumn
31
+ from letta.orm.base import Base
32
32
 
33
33
  # from letta.schemas.message import Message, Passage, Record, RecordType, ToolCall
34
34
  from letta.schemas.message import Message
@@ -509,8 +509,10 @@ class SQLLiteStorageConnector(SQLStorageConnector):
509
509
 
510
510
  self.session_maker = db_context
511
511
 
512
+ # Need this in order to allow UUIDs to be stored successfully in the sqlite database
512
513
  # import sqlite3
513
-
514
+ # import uuid
515
+ #
514
516
  # sqlite3.register_adapter(uuid.UUID, lambda u: u.bytes_le)
515
517
  # sqlite3.register_converter("UUID", lambda b: uuid.UUID(bytes_le=b))
516
518
 
letta/cli/cli_config.py CHANGED
@@ -105,7 +105,7 @@ def add_tool(
105
105
  """Add or update a tool from a Python file."""
106
106
  from letta.client.client import create_client
107
107
 
108
- client = create_client(base_url=os.getenv("MEMGPT_BASE_URL"), token=os.getenv("MEMGPT_SERVER_PASS"))
108
+ client = create_client()
109
109
 
110
110
  # 1. Parse the Python file
111
111
  with open(filename, "r", encoding="utf-8") as file:
@@ -145,7 +145,7 @@ def list_tools():
145
145
  """List all available tools."""
146
146
  from letta.client.client import create_client
147
147
 
148
- client = create_client(base_url=os.getenv("MEMGPT_BASE_URL"), token=os.getenv("MEMGPT_SERVER_PASS"))
148
+ client = create_client()
149
149
 
150
150
  tools = client.list_tools()
151
151
  for tool in tools:
letta/client/client.py CHANGED
@@ -1777,6 +1777,19 @@ class LocalClient(AbstractClient):
1777
1777
  """
1778
1778
  self.server.delete_agent(user_id=self.user_id, agent_id=agent_id)
1779
1779
 
1780
+ def get_agent_by_name(self, agent_name: str, user_id: str) -> AgentState:
1781
+ """
1782
+ Get an agent by its name
1783
+
1784
+ Args:
1785
+ agent_name (str): Name of the agent
1786
+
1787
+ Returns:
1788
+ agent_state (AgentState): State of the agent
1789
+ """
1790
+ self.interface.clear()
1791
+ return self.server.get_agent(agent_name=agent_name, user_id=user_id, agent_id=None)
1792
+
1780
1793
  def get_agent(self, agent_id: str) -> AgentState:
1781
1794
  """
1782
1795
  Get an agent's state by its ID.
letta/constants.py CHANGED
@@ -4,10 +4,13 @@ from logging import CRITICAL, DEBUG, ERROR, INFO, NOTSET, WARN, WARNING
4
4
  LETTA_DIR = os.path.join(os.path.expanduser("~"), ".letta")
5
5
 
6
6
  # Defaults
7
- DEFAULT_USER_ID = "user-00000000"
8
- DEFAULT_ORG_ID = "org-00000000"
9
- DEFAULT_USER_NAME = "default"
10
- DEFAULT_ORG_NAME = "default"
7
+ DEFAULT_USER_ID = "user-00000000-0000-4000-8000-000000000000"
8
+ # This UUID follows the UUID4 rules:
9
+ # The 13th character (4) indicates it's version 4.
10
+ # The first character of the third segment (8) ensures the variant is correctly set.
11
+ DEFAULT_ORG_ID = "organization-00000000-0000-4000-8000-000000000000"
12
+ DEFAULT_USER_NAME = "default_user"
13
+ DEFAULT_ORG_NAME = "default_org"
11
14
 
12
15
 
13
16
  # String in the error message for when the context window is too large
letta/embeddings.py CHANGED
@@ -21,7 +21,6 @@ from letta.constants import (
21
21
  EMBEDDING_TO_TOKENIZER_MAP,
22
22
  MAX_EMBEDDING_DIM,
23
23
  )
24
- from letta.credentials import LettaCredentials
25
24
  from letta.schemas.embedding_config import EmbeddingConfig
26
25
  from letta.utils import is_valid_url, printd
27
26
 
@@ -138,6 +137,18 @@ class EmbeddingEndpoint:
138
137
  return self._call_api(text)
139
138
 
140
139
 
140
+ class AzureOpenAIEmbedding:
141
+ def __init__(self, api_endpoint: str, api_key: str, api_version: str, model: str):
142
+ from openai import AzureOpenAI
143
+
144
+ self.client = AzureOpenAI(api_key=api_key, api_version=api_version, azure_endpoint=api_endpoint)
145
+ self.model = model
146
+
147
+ def get_text_embedding(self, text: str):
148
+ embeddings = self.client.embeddings.create(input=[text], model=self.model).data[0].embedding
149
+ return embeddings
150
+
151
+
141
152
  def default_embedding_model():
142
153
  # default to hugging face model running local
143
154
  # warning: this is a terrible model
@@ -161,8 +172,8 @@ def embedding_model(config: EmbeddingConfig, user_id: Optional[uuid.UUID] = None
161
172
 
162
173
  endpoint_type = config.embedding_endpoint_type
163
174
 
164
- # TODO refactor to pass credentials through args
165
- credentials = LettaCredentials.load()
175
+ # TODO: refactor to pass in settings from server
176
+ from letta.settings import model_settings
166
177
 
167
178
  if endpoint_type == "openai":
168
179
  from llama_index.embeddings.openai import OpenAIEmbedding
@@ -170,7 +181,7 @@ def embedding_model(config: EmbeddingConfig, user_id: Optional[uuid.UUID] = None
170
181
  additional_kwargs = {"user_id": user_id} if user_id else {}
171
182
  model = OpenAIEmbedding(
172
183
  api_base=config.embedding_endpoint,
173
- api_key=credentials.openai_key,
184
+ api_key=model_settings.openai_api_key,
174
185
  additional_kwargs=additional_kwargs,
175
186
  )
176
187
  return model
@@ -178,22 +189,29 @@ def embedding_model(config: EmbeddingConfig, user_id: Optional[uuid.UUID] = None
178
189
  elif endpoint_type == "azure":
179
190
  assert all(
180
191
  [
181
- credentials.azure_key is not None,
182
- credentials.azure_embedding_endpoint is not None,
183
- credentials.azure_version is not None,
192
+ model_settings.azure_api_key is not None,
193
+ model_settings.azure_base_url is not None,
194
+ model_settings.azure_api_version is not None,
184
195
  ]
185
196
  )
186
- from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding
197
+ # from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding
198
+
199
+ ## https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#embeddings
200
+ # model = "text-embedding-ada-002"
201
+ # deployment = credentials.azure_embedding_deployment if credentials.azure_embedding_deployment is not None else model
202
+ # return AzureOpenAIEmbedding(
203
+ # model=model,
204
+ # deployment_name=deployment,
205
+ # api_key=credentials.azure_key,
206
+ # azure_endpoint=credentials.azure_endpoint,
207
+ # api_version=credentials.azure_version,
208
+ # )
187
209
 
188
- # https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#embeddings
189
- model = "text-embedding-ada-002"
190
- deployment = credentials.azure_embedding_deployment if credentials.azure_embedding_deployment is not None else model
191
210
  return AzureOpenAIEmbedding(
192
- model=model,
193
- deployment_name=deployment,
194
- api_key=credentials.azure_key,
195
- azure_endpoint=credentials.azure_endpoint,
196
- api_version=credentials.azure_version,
211
+ api_endpoint=model_settings.azure_base_url,
212
+ api_key=model_settings.azure_api_key,
213
+ api_version=model_settings.azure_api_version,
214
+ model=config.embedding_model,
197
215
  )
198
216
 
199
217
  elif endpoint_type == "hugging-face":
@@ -1,3 +1,5 @@
1
+ from collections import defaultdict
2
+
1
3
  import requests
2
4
 
3
5
  from letta.llm_api.helpers import make_post_request
@@ -20,7 +22,14 @@ def get_azure_model_list_endpoint(base_url: str, api_version: str):
20
22
  return f"{base_url}/openai/models?api-version={api_version}"
21
23
 
22
24
 
23
- def azure_openai_get_model_list(base_url: str, api_key: str, api_version: str) -> list:
25
+ def get_azure_deployment_list_endpoint(base_url: str):
26
+ # Please note that it has to be 2023-03-15-preview
27
+ # That's the only api version that works with this deployments endpoint
28
+ # TODO: Use the Azure Client library here instead
29
+ return f"{base_url}/openai/deployments?api-version=2023-03-15-preview"
30
+
31
+
32
+ def azure_openai_get_deployed_model_list(base_url: str, api_key: str, api_version: str) -> list:
24
33
  """https://learn.microsoft.com/en-us/rest/api/azureopenai/models/list?view=rest-azureopenai-2023-05-15&tabs=HTTP"""
25
34
 
26
35
  # https://xxx.openai.azure.com/openai/models?api-version=xxx
@@ -28,18 +37,48 @@ def azure_openai_get_model_list(base_url: str, api_key: str, api_version: str) -
28
37
  if api_key is not None:
29
38
  headers["api-key"] = f"{api_key}"
30
39
 
40
+ # 1. Get all available models
31
41
  url = get_azure_model_list_endpoint(base_url, api_version)
32
42
  try:
33
43
  response = requests.get(url, headers=headers)
34
44
  response.raise_for_status()
35
45
  except requests.RequestException as e:
36
46
  raise RuntimeError(f"Failed to retrieve model list: {e}")
47
+ all_available_models = response.json().get("data", [])
37
48
 
38
- return response.json().get("data", [])
49
+ # 2. Get all the deployed models
50
+ url = get_azure_deployment_list_endpoint(base_url)
51
+ try:
52
+ response = requests.get(url, headers=headers)
53
+ response.raise_for_status()
54
+ except requests.RequestException as e:
55
+ raise RuntimeError(f"Failed to retrieve model list: {e}")
56
+
57
+ deployed_models = response.json().get("data", [])
58
+ deployed_model_names = set([m["id"] for m in deployed_models])
59
+
60
+ # 3. Only return the models in available models if they have been deployed
61
+ deployed_models = [m for m in all_available_models if m["id"] in deployed_model_names]
62
+
63
+ # 4. Remove redundant deployments, only include the ones with the latest deployment
64
+ # Create a dictionary to store the latest model for each ID
65
+ latest_models = defaultdict()
66
+
67
+ # Iterate through the models and update the dictionary with the most recent model
68
+ for model in deployed_models:
69
+ model_id = model["id"]
70
+ updated_at = model["created_at"]
71
+
72
+ # If the model ID is new or the current model has a more recent created_at, update the dictionary
73
+ if model_id not in latest_models or updated_at > latest_models[model_id]["created_at"]:
74
+ latest_models[model_id] = model
75
+
76
+ # Extract the unique models
77
+ return list(latest_models.values())
39
78
 
40
79
 
41
80
  def azure_openai_get_chat_completion_model_list(base_url: str, api_key: str, api_version: str) -> list:
42
- model_list = azure_openai_get_model_list(base_url, api_key, api_version)
81
+ model_list = azure_openai_get_deployed_model_list(base_url, api_key, api_version)
43
82
  # Extract models that support text generation
44
83
  model_options = [m for m in model_list if m.get("capabilities").get("chat_completion") == True]
45
84
  return model_options
@@ -53,10 +92,11 @@ def azure_openai_get_embeddings_model_list(base_url: str, api_key: str, api_vers
53
92
 
54
93
  return m.get("capabilities").get("embeddings") == True and valid_name
55
94
 
56
- model_list = azure_openai_get_model_list(base_url, api_key, api_version)
95
+ model_list = azure_openai_get_deployed_model_list(base_url, api_key, api_version)
57
96
  # Extract models that support embeddings
58
97
 
59
98
  model_options = [m for m in model_list if valid_embedding_model(m)]
99
+
60
100
  return model_options
61
101
 
62
102
 
letta/llm_api/openai.py CHANGED
@@ -314,11 +314,17 @@ def openai_chat_completions_process_stream(
314
314
  for _ in range(len(tool_calls_delta))
315
315
  ]
316
316
 
317
+ # There may be many tool calls in a tool calls delta (e.g. parallel tool calls)
317
318
  for tool_call_delta in tool_calls_delta:
318
319
  if tool_call_delta.id is not None:
319
320
  # TODO assert that we're not overwriting?
320
321
  # TODO += instead of =?
321
- accum_message.tool_calls[tool_call_delta.index].id = tool_call_delta.id
322
+ if tool_call_delta.index not in range(len(accum_message.tool_calls)):
323
+ warnings.warn(
324
+ f"Tool call index out of range ({tool_call_delta.index})\ncurrent tool calls: {accum_message.tool_calls}\ncurrent delta: {tool_call_delta}"
325
+ )
326
+ else:
327
+ accum_message.tool_calls[tool_call_delta.index].id = tool_call_delta.id
322
328
  if tool_call_delta.function is not None:
323
329
  if tool_call_delta.function.name is not None:
324
330
  # TODO assert that we're not overwriting?
letta/metadata.py CHANGED
@@ -15,13 +15,12 @@ from sqlalchemy import (
15
15
  String,
16
16
  TypeDecorator,
17
17
  asc,
18
- desc,
19
18
  or_,
20
19
  )
21
20
  from sqlalchemy.sql import func
22
21
 
23
- from letta.base import Base
24
22
  from letta.config import LettaConfig
23
+ from letta.orm.base import Base
25
24
  from letta.schemas.agent import AgentState
26
25
  from letta.schemas.api_key import APIKey
27
26
  from letta.schemas.block import Block, Human, Persona
@@ -31,10 +30,7 @@ from letta.schemas.file import FileMetadata
31
30
  from letta.schemas.job import Job
32
31
  from letta.schemas.llm_config import LLMConfig
33
32
  from letta.schemas.memory import Memory
34
-
35
- # from letta.schemas.message import Message, Passage, Record, RecordType, ToolCall
36
33
  from letta.schemas.openai.chat_completions import ToolCall, ToolCallFunction
37
- from letta.schemas.organization import Organization
38
34
  from letta.schemas.source import Source
39
35
  from letta.schemas.tool import Tool
40
36
  from letta.schemas.user import User
@@ -155,40 +151,6 @@ class ToolCallColumn(TypeDecorator):
155
151
  return value
156
152
 
157
153
 
158
- class UserModel(Base):
159
- __tablename__ = "users"
160
- __table_args__ = {"extend_existing": True}
161
-
162
- id = Column(String, primary_key=True)
163
- org_id = Column(String)
164
- name = Column(String, nullable=False)
165
- created_at = Column(DateTime(timezone=True))
166
-
167
- # TODO: what is this?
168
- policies_accepted = Column(Boolean, nullable=False, default=False)
169
-
170
- def __repr__(self) -> str:
171
- return f"<User(id='{self.id}' name='{self.name}')>"
172
-
173
- def to_record(self) -> User:
174
- return User(id=self.id, name=self.name, created_at=self.created_at, org_id=self.org_id)
175
-
176
-
177
- class OrganizationModel(Base):
178
- __tablename__ = "organizations"
179
- __table_args__ = {"extend_existing": True}
180
-
181
- id = Column(String, primary_key=True)
182
- name = Column(String, nullable=False)
183
- created_at = Column(DateTime(timezone=True))
184
-
185
- def __repr__(self) -> str:
186
- return f"<Organization(id='{self.id}' name='{self.name}')>"
187
-
188
- def to_record(self) -> Organization:
189
- return Organization(id=self.id, name=self.name, created_at=self.created_at)
190
-
191
-
192
154
  # TODO: eventually store providers?
193
155
  # class Provider(Base):
194
156
  # __tablename__ = "providers"
@@ -513,15 +475,6 @@ class MetadataStore:
513
475
  tokens = [r.to_record() for r in results]
514
476
  return tokens
515
477
 
516
- @enforce_types
517
- def get_user_from_api_key(self, api_key: str) -> Optional[User]:
518
- """Get the user associated with a given API key"""
519
- token = self.get_api_key(api_key=api_key)
520
- if token is None:
521
- raise ValueError(f"Provided token does not exist")
522
- else:
523
- return self.get_user(user_id=token.user_id)
524
-
525
478
  @enforce_types
526
479
  def create_agent(self, agent: AgentState):
527
480
  # insert into agent table
@@ -543,22 +496,6 @@ class MetadataStore:
543
496
  session.add(SourceModel(**vars(source)))
544
497
  session.commit()
545
498
 
546
- @enforce_types
547
- def create_user(self, user: User):
548
- with self.session_maker() as session:
549
- if session.query(UserModel).filter(UserModel.id == user.id).count() > 0:
550
- raise ValueError(f"User with id {user.id} already exists")
551
- session.add(UserModel(**vars(user)))
552
- session.commit()
553
-
554
- @enforce_types
555
- def create_organization(self, organization: Organization):
556
- with self.session_maker() as session:
557
- if session.query(OrganizationModel).filter(OrganizationModel.id == organization.id).count() > 0:
558
- raise ValueError(f"Organization with id {organization.id} already exists")
559
- session.add(OrganizationModel(**vars(organization)))
560
- session.commit()
561
-
562
499
  @enforce_types
563
500
  def create_block(self, block: Block):
564
501
  with self.session_maker() as session:
@@ -597,12 +534,6 @@ class MetadataStore:
597
534
  session.query(AgentModel).filter(AgentModel.id == agent.id).update(fields)
598
535
  session.commit()
599
536
 
600
- @enforce_types
601
- def update_user(self, user: User):
602
- with self.session_maker() as session:
603
- session.query(UserModel).filter(UserModel.id == user.id).update(vars(user))
604
- session.commit()
605
-
606
537
  @enforce_types
607
538
  def update_source(self, source: Source):
608
539
  with self.session_maker() as session:
@@ -681,33 +612,6 @@ class MetadataStore:
681
612
 
682
613
  session.commit()
683
614
 
684
- @enforce_types
685
- def delete_user(self, user_id: str):
686
- with self.session_maker() as session:
687
- # delete from users table
688
- session.query(UserModel).filter(UserModel.id == user_id).delete()
689
-
690
- # delete associated agents
691
- session.query(AgentModel).filter(AgentModel.user_id == user_id).delete()
692
-
693
- # delete associated sources
694
- session.query(SourceModel).filter(SourceModel.user_id == user_id).delete()
695
-
696
- # delete associated mappings
697
- session.query(AgentSourceMappingModel).filter(AgentSourceMappingModel.user_id == user_id).delete()
698
-
699
- session.commit()
700
-
701
- @enforce_types
702
- def delete_organization(self, org_id: str):
703
- with self.session_maker() as session:
704
- # delete from organizations table
705
- session.query(OrganizationModel).filter(OrganizationModel.id == org_id).delete()
706
-
707
- # TODO: delete associated data
708
-
709
- session.commit()
710
-
711
615
  @enforce_types
712
616
  def list_tools(self, cursor: Optional[str] = None, limit: Optional[int] = 50, user_id: Optional[str] = None) -> List[ToolModel]:
713
617
  with self.session_maker() as session:
@@ -753,54 +657,6 @@ class MetadataStore:
753
657
  assert len(results) == 1, f"Expected 1 result, got {len(results)}" # should only be one result
754
658
  return results[0].to_record()
755
659
 
756
- @enforce_types
757
- def get_user(self, user_id: str) -> Optional[User]:
758
- with self.session_maker() as session:
759
- results = session.query(UserModel).filter(UserModel.id == user_id).all()
760
- if len(results) == 0:
761
- return None
762
- assert len(results) == 1, f"Expected 1 result, got {len(results)}"
763
- return results[0].to_record()
764
-
765
- @enforce_types
766
- def get_organization(self, org_id: str) -> Optional[Organization]:
767
- with self.session_maker() as session:
768
- results = session.query(OrganizationModel).filter(OrganizationModel.id == org_id).all()
769
- if len(results) == 0:
770
- return None
771
- assert len(results) == 1, f"Expected 1 result, got {len(results)}"
772
- return results[0].to_record()
773
-
774
- @enforce_types
775
- def list_organizations(self, cursor: Optional[str] = None, limit: Optional[int] = 50):
776
- with self.session_maker() as session:
777
- query = session.query(OrganizationModel).order_by(desc(OrganizationModel.id))
778
- if cursor:
779
- query = query.filter(OrganizationModel.id < cursor)
780
- results = query.limit(limit).all()
781
- if not results:
782
- return None, []
783
- organization_records = [r.to_record() for r in results]
784
- next_cursor = organization_records[-1].id
785
- assert isinstance(next_cursor, str)
786
-
787
- return next_cursor, organization_records
788
-
789
- @enforce_types
790
- def get_all_users(self, cursor: Optional[str] = None, limit: Optional[int] = 50):
791
- with self.session_maker() as session:
792
- query = session.query(UserModel).order_by(desc(UserModel.id))
793
- if cursor:
794
- query = query.filter(UserModel.id < cursor)
795
- results = query.limit(limit).all()
796
- if not results:
797
- return None, []
798
- user_records = [r.to_record() for r in results]
799
- next_cursor = user_records[-1].id
800
- assert isinstance(next_cursor, str)
801
-
802
- return next_cursor, user_records
803
-
804
660
  @enforce_types
805
661
  def get_source(
806
662
  self, source_id: Optional[str] = None, user_id: Optional[str] = None, source_name: Optional[str] = None
letta/orm/__all__.py ADDED
File without changes
letta/orm/__init__.py ADDED
File without changes
letta/orm/base.py ADDED
@@ -0,0 +1,75 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from uuid import UUID
4
+
5
+ from sqlalchemy import UUID as SQLUUID
6
+ from sqlalchemy import Boolean, DateTime, func, text
7
+ from sqlalchemy.orm import (
8
+ DeclarativeBase,
9
+ Mapped,
10
+ declarative_mixin,
11
+ declared_attr,
12
+ mapped_column,
13
+ )
14
+
15
+
16
+ class Base(DeclarativeBase):
17
+ """absolute base for sqlalchemy classes"""
18
+
19
+
20
+ @declarative_mixin
21
+ class CommonSqlalchemyMetaMixins(Base):
22
+ __abstract__ = True
23
+
24
+ created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
25
+ updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now(), server_onupdate=func.now())
26
+ is_deleted: Mapped[bool] = mapped_column(Boolean, server_default=text("FALSE"))
27
+
28
+ @declared_attr
29
+ def _created_by_id(cls):
30
+ return cls._user_by_id()
31
+
32
+ @declared_attr
33
+ def _last_updated_by_id(cls):
34
+ return cls._user_by_id()
35
+
36
+ @classmethod
37
+ def _user_by_id(cls):
38
+ """a flexible non-constrained record of a user.
39
+ This way users can get added, deleted etc without history freaking out
40
+ """
41
+ return mapped_column(SQLUUID(), nullable=True)
42
+
43
+ @property
44
+ def last_updated_by_id(self) -> Optional[str]:
45
+ return self._user_id_getter("last_updated")
46
+
47
+ @last_updated_by_id.setter
48
+ def last_updated_by_id(self, value: str) -> None:
49
+ self._user_id_setter("last_updated", value)
50
+
51
+ @property
52
+ def created_by_id(self) -> Optional[str]:
53
+ return self._user_id_getter("created")
54
+
55
+ @created_by_id.setter
56
+ def created_by_id(self, value: str) -> None:
57
+ self._user_id_setter("created", value)
58
+
59
+ def _user_id_getter(self, prop: str) -> Optional[str]:
60
+ """returns the user id for the specified property"""
61
+ full_prop = f"_{prop}_by_id"
62
+ prop_value = getattr(self, full_prop, None)
63
+ if not prop_value:
64
+ return
65
+ return f"user-{prop_value}"
66
+
67
+ def _user_id_setter(self, prop: str, value: str) -> None:
68
+ """returns the user id for the specified property"""
69
+ full_prop = f"_{prop}_by_id"
70
+ if not value:
71
+ setattr(self, full_prop, None)
72
+ return
73
+ prefix, id_ = value.split("-", 1)
74
+ assert prefix == "user", f"{prefix} is not a valid id prefix for a user id"
75
+ setattr(self, full_prop, UUID(id_))
letta/orm/enums.py ADDED
@@ -0,0 +1,8 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ToolSourceType(str, Enum):
5
+ """Defines what a tool was derived from"""
6
+
7
+ python = "python"
8
+ json = "json"
letta/orm/errors.py ADDED
@@ -0,0 +1,6 @@
1
+ class NoResultFound(Exception):
2
+ """A record or records cannot be found given the provided search params"""
3
+
4
+
5
+ class MalformedIdError(Exception):
6
+ """An id not in the right format, most likely violating uuid4 format."""
letta/orm/mixins.py ADDED
@@ -0,0 +1,67 @@
1
+ from typing import Optional
2
+ from uuid import UUID
3
+
4
+ from sqlalchemy import ForeignKey, String
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+ from letta.orm.base import Base
8
+ from letta.orm.errors import MalformedIdError
9
+
10
+
11
+ def is_valid_uuid4(uuid_string: str) -> bool:
12
+ """Check if a string is a valid UUID4."""
13
+ try:
14
+ uuid_obj = UUID(uuid_string)
15
+ return uuid_obj.version == 4
16
+ except ValueError:
17
+ return False
18
+
19
+
20
+ def _relation_getter(instance: "Base", prop: str) -> Optional[str]:
21
+ """Get relation and return id with prefix as a string."""
22
+ prefix = prop.replace("_", "")
23
+ formatted_prop = f"_{prop}_id"
24
+ try:
25
+ id_ = getattr(instance, formatted_prop) # Get the string id directly
26
+ return f"{prefix}-{id_}"
27
+ except AttributeError:
28
+ return None
29
+
30
+
31
+ def _relation_setter(instance: "Base", prop: str, value: str) -> None:
32
+ """Set relation using the id with prefix, ensuring the id is a valid UUIDv4."""
33
+ formatted_prop = f"_{prop}_id"
34
+ prefix = prop.replace("_", "")
35
+ if not value:
36
+ setattr(instance, formatted_prop, None)
37
+ return
38
+ try:
39
+ found_prefix, id_ = value.split("-", 1)
40
+ except ValueError as e:
41
+ raise MalformedIdError(f"{value} is not a valid ID.") from e
42
+
43
+ # Ensure prefix matches
44
+ assert found_prefix == prefix, f"{found_prefix} is not a valid id prefix, expecting {prefix}"
45
+
46
+ # Validate that the id is a valid UUID4 string
47
+ if not is_valid_uuid4(id_):
48
+ raise MalformedIdError(f"Hash segment of {value} is not a valid UUID4")
49
+
50
+ setattr(instance, formatted_prop, id_) # Store id as a string
51
+
52
+
53
+ class OrganizationMixin(Base):
54
+ """Mixin for models that belong to an organization."""
55
+
56
+ __abstract__ = True
57
+
58
+ # Changed _organization_id to store string (still a valid UUID4 string)
59
+ _organization_id: Mapped[str] = mapped_column(String, ForeignKey("organization._id"))
60
+
61
+ @property
62
+ def organization_id(self) -> str:
63
+ return _relation_getter(self, "organization")
64
+
65
+ @organization_id.setter
66
+ def organization_id(self, value: str) -> None:
67
+ _relation_setter(self, "organization", value)