From 4deaafdb49a18f0bf2d18212d069b038fa89d9c1 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Thu, 20 Mar 2025 11:06:45 -0700 Subject: [PATCH] chore: Various bug fixes (#1350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sarah Wooders Co-authored-by: cthomas Co-authored-by: tarunkumark Co-authored-by: Kevin Lin Co-authored-by: Charles Packer Co-authored-by: Miao Co-authored-by: Krishnakumar R (KK) <65895020+kk-src@users.noreply.github.com> Co-authored-by: Shubham Naik Co-authored-by: Shubham Naik Co-authored-by: Will Sargent Co-authored-by: Shubham Naik Co-authored-by: mlong93 <35275280+mlong93@users.noreply.github.com> Co-authored-by: Mindy Long Co-authored-by: Stephan Fitzpatrick Co-authored-by: dboyliao Co-authored-by: Jyotirmaya Mahanta Co-authored-by: Nicholas <102550462+ndisalvio3@users.noreply.github.com> Co-authored-by: Tristan Morris Co-authored-by: Daniel Shin <88547237+kyuds@users.noreply.github.com> Co-authored-by: Jindřich Šíma <67415662+JindrichSima@users.noreply.github.com> Co-authored-by: Azin Asgarian <31479845+azinasg@users.noreply.github.com> Co-authored-by: Connor Shorten Co-authored-by: Lucas Mohallem Ferraz Co-authored-by: kyuds --- CONTRIBUTING.md | 4 +- Dockerfile | 1 + letta/__init__.py | 2 +- letta/client/client.py | 2 +- letta/embeddings.py | 13 - letta/llm_api/anthropic.py | 1 + letta/llm_api/aws_bedrock.py | 2 +- letta/llm_api/azure_openai.py | 67 ++--- letta/llm_api/llm_api_tools.py | 1 - letta/orm/sqlalchemy_base.py | 69 ++++- letta/schemas/message.py | 6 + letta/schemas/providers.py | 250 ++++++++++++++++++ letta/server/rest_api/app.py | 12 +- .../server/rest_api/optimistic_json_parser.py | 10 +- letta/services/block_manager.py | 14 +- letta/services/message_manager.py | 38 +++ poetry.lock | 52 +--- pyproject.toml | 2 +- tests/test_optimistic_json_parser.py | 34 ++- 19 files changed, 436 insertions(+), 144 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7b7d3a52..760be895f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,8 +60,8 @@ alembic upgrade head Now when you want to use `letta`, make sure you first activate the `poetry` environment using poetry shell: ```shell -$ poetry shell -(pyletta-py3.12) $ letta run +$ eval $(poetry env activate) +(letta-py3.12) $ letta run ``` Alternatively, you can use `poetry run` (which will activate the `poetry` environment for the `letta run` command only): diff --git a/Dockerfile b/Dockerfile index 14650051d..593164b0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,6 +67,7 @@ ENV LETTA_ENVIRONMENT=${LETTA_ENVIRONMENT} \ COMPOSIO_DISABLE_VERSION_CHECK=true \ LETTA_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" + WORKDIR /app # Copy virtual environment and app from builder diff --git a/letta/__init__.py b/letta/__init__.py index 285a2eba4..1982a4d55 100644 --- a/letta/__init__.py +++ b/letta/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.6.41" +__version__ = "0.6.43" # import clients from letta.client.client import LocalClient, RESTClient, create_client diff --git a/letta/client/client.py b/letta/client/client.py index 4405a167d..ac716499a 100644 --- a/letta/client/client.py +++ b/letta/client/client.py @@ -2937,7 +2937,6 @@ class LocalClient(AbstractClient): Args: func (callable): The function to create a tool for. - name: (str): Name of the tool (must be unique per-user.) tags (Optional[List[str]], optional): Tags for the tool. Defaults to None. description (str, optional): The description. return_char_limit (int): The character limit for the tool's return value. Defaults to FUNCTION_RETURN_CHAR_LIMIT. @@ -2950,6 +2949,7 @@ class LocalClient(AbstractClient): # parse source code/schema source_code = parse_source_code(func) source_type = "python" + name = func.__name__ # Initialize name using function's __name__ if not tags: tags = [] diff --git a/letta/embeddings.py b/letta/embeddings.py index 4dca8aab6..776671b51 100644 --- a/letta/embeddings.py +++ b/letta/embeddings.py @@ -246,19 +246,6 @@ def embedding_model(config: EmbeddingConfig, user_id: Optional[uuid.UUID] = None model_settings.azure_api_version is not None, ] ) - # from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding - - ## https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#embeddings - # model = "text-embedding-ada-002" - # deployment = credentials.azure_embedding_deployment if credentials.azure_embedding_deployment is not None else model - # return AzureOpenAIEmbedding( - # model=model, - # deployment_name=deployment, - # api_key=credentials.azure_key, - # azure_endpoint=credentials.azure_endpoint, - # api_version=credentials.azure_version, - # ) - return AzureOpenAIEmbedding( api_endpoint=model_settings.azure_base_url, api_key=model_settings.azure_api_key, diff --git a/letta/llm_api/anthropic.py b/letta/llm_api/anthropic.py index 35884fb9d..f7b2566d1 100644 --- a/letta/llm_api/anthropic.py +++ b/letta/llm_api/anthropic.py @@ -844,6 +844,7 @@ def anthropic_chat_completions_process_stream( total_tokens=prompt_tokens, ), ) + log_event(name="llm_request_sent", attributes=chat_completion_request.model_dump()) if stream_interface: diff --git a/letta/llm_api/aws_bedrock.py b/letta/llm_api/aws_bedrock.py index 31da4619c..688efc636 100644 --- a/letta/llm_api/aws_bedrock.py +++ b/letta/llm_api/aws_bedrock.py @@ -72,7 +72,7 @@ def bedrock_get_model_details(region_name: str, model_id: str) -> Dict[str, Any] response = bedrock.get_foundation_model(modelIdentifier=model_id) return response["modelDetails"] except ClientError as e: - print(f"Error getting model details: {str(e)}") + logger.exception(f"Error getting model details: {str(e)}") raise e diff --git a/letta/llm_api/azure_openai.py b/letta/llm_api/azure_openai.py index 368850ecc..c25560629 100644 --- a/letta/llm_api/azure_openai.py +++ b/letta/llm_api/azure_openai.py @@ -1,14 +1,13 @@ from collections import defaultdict import requests +from openai import AzureOpenAI -from letta.llm_api.helpers import make_post_request +from letta.llm_api.openai import prepare_openai_payload from letta.schemas.llm_config import LLMConfig from letta.schemas.openai.chat_completion_response import ChatCompletionResponse from letta.schemas.openai.chat_completions import ChatCompletionRequest -from letta.schemas.openai.embedding_response import EmbeddingResponse from letta.settings import ModelSettings -from letta.tracing import log_event def get_azure_chat_completions_endpoint(base_url: str, model: str, api_version: str): @@ -33,20 +32,20 @@ def get_azure_deployment_list_endpoint(base_url: str): def azure_openai_get_deployed_model_list(base_url: str, api_key: str, api_version: str) -> list: """https://learn.microsoft.com/en-us/rest/api/azureopenai/models/list?view=rest-azureopenai-2023-05-15&tabs=HTTP""" + client = AzureOpenAI(api_key=api_key, api_version=api_version, azure_endpoint=base_url) + + try: + models_list = client.models.list() + except requests.RequestException as e: + raise RuntimeError(f"Failed to retrieve model list: {e}") + + all_available_models = [model.to_dict() for model in models_list.data] + # https://xxx.openai.azure.com/openai/models?api-version=xxx headers = {"Content-Type": "application/json"} if api_key is not None: headers["api-key"] = f"{api_key}" - # 1. Get all available models - url = get_azure_model_list_endpoint(base_url, api_version) - try: - response = requests.get(url, headers=headers) - response.raise_for_status() - except requests.RequestException as e: - raise RuntimeError(f"Failed to retrieve model list: {e}") - all_available_models = response.json().get("data", []) - # 2. Get all the deployed models url = get_azure_deployment_list_endpoint(base_url) try: @@ -102,42 +101,18 @@ def azure_openai_get_embeddings_model_list(base_url: str, api_key: str, api_vers def azure_openai_chat_completions_request( - model_settings: ModelSettings, llm_config: LLMConfig, api_key: str, chat_completion_request: ChatCompletionRequest + model_settings: ModelSettings, llm_config: LLMConfig, chat_completion_request: ChatCompletionRequest ) -> ChatCompletionResponse: """https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions""" - assert api_key is not None, "Missing required field when calling Azure OpenAI" + assert model_settings.azure_api_key is not None, "Missing required api key field when calling Azure OpenAI" + assert model_settings.azure_api_version is not None, "Missing required api version field when calling Azure OpenAI" + assert model_settings.azure_base_url is not None, "Missing required base url field when calling Azure OpenAI" - headers = {"Content-Type": "application/json", "api-key": f"{api_key}"} - data = chat_completion_request.model_dump(exclude_none=True) + data = prepare_openai_payload(chat_completion_request) + client = AzureOpenAI( + api_key=model_settings.azure_api_key, api_version=model_settings.azure_api_version, azure_endpoint=model_settings.azure_base_url + ) + chat_completion = client.chat.completions.create(**data) - # If functions == None, strip from the payload - if "functions" in data and data["functions"] is None: - data.pop("functions") - data.pop("function_call", None) # extra safe, should exist always (default="auto") - - if "tools" in data and data["tools"] is None: - data.pop("tools") - data.pop("tool_choice", None) # extra safe, should exist always (default="auto") - - url = get_azure_chat_completions_endpoint(model_settings.azure_base_url, llm_config.model, model_settings.azure_api_version) - log_event(name="llm_request_sent", attributes=data) - response_json = make_post_request(url, headers, data) - # NOTE: azure openai does not include "content" in the response when it is None, so we need to add it - if "content" not in response_json["choices"][0].get("message"): - response_json["choices"][0]["message"]["content"] = None - log_event(name="llm_response_received", attributes=response_json) - response = ChatCompletionResponse(**response_json) # convert to 'dot-dict' style which is the openai python client default - return response - - -def azure_openai_embeddings_request( - resource_name: str, deployment_id: str, api_version: str, api_key: str, data: dict -) -> EmbeddingResponse: - """https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#embeddings""" - - url = f"https://{resource_name}.openai.azure.com/openai/deployments/{deployment_id}/embeddings?api-version={api_version}" - headers = {"Content-Type": "application/json", "api-key": f"{api_key}"} - - response_json = make_post_request(url, headers, data) - return EmbeddingResponse(**response_json) + return ChatCompletionResponse(**chat_completion.model_dump()) diff --git a/letta/llm_api/llm_api_tools.py b/letta/llm_api/llm_api_tools.py index b272c764d..f661f9db4 100644 --- a/letta/llm_api/llm_api_tools.py +++ b/letta/llm_api/llm_api_tools.py @@ -306,7 +306,6 @@ def create( response = azure_openai_chat_completions_request( model_settings=model_settings, llm_config=llm_config, - api_key=model_settings.azure_api_key, chat_completion_request=chat_completion_request, ) diff --git a/letta/orm/sqlalchemy_base.py b/letta/orm/sqlalchemy_base.py index 5d91f3d7a..b5dda2e17 100644 --- a/letta/orm/sqlalchemy_base.py +++ b/letta/orm/sqlalchemy_base.py @@ -286,7 +286,45 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base): Raises: NoResultFound: if the object is not found """ - logger.debug(f"Reading {cls.__name__} with ID: {identifier} with actor={actor}") + # this is ok because read_multiple will check if the + identifiers = [] if identifier is None else [identifier] + found = cls.read_multiple(db_session, identifiers, actor, access, access_type, **kwargs) + if len(found) == 0: + # for backwards compatibility. + conditions = [] + if identifier: + conditions.append(f"id={identifier}") + if actor: + conditions.append(f"access level in {access} for {actor}") + if hasattr(cls, "is_deleted"): + conditions.append("is_deleted=False") + raise NoResultFound(f"{cls.__name__} not found with {', '.join(conditions if conditions else ['no conditions'])}") + return found[0] + + @classmethod + @handle_db_timeout + def read_multiple( + cls, + db_session: "Session", + identifiers: List[str] = [], + actor: Optional["User"] = None, + access: Optional[List[Literal["read", "write", "admin"]]] = ["read"], + access_type: AccessType = AccessType.ORGANIZATION, + **kwargs, + ) -> List["SqlalchemyBase"]: + """The primary accessor for ORM record(s) + Args: + db_session: the database session to use when retrieving the record + identifiers: a list of identifiers of the records to read, can be the id string or the UUID object for backwards compatibility + actor: if specified, results will be scoped only to records the user is able to access + access: if actor is specified, records will be filtered to the minimum permission level for the actor + kwargs: additional arguments to pass to the read, used for more complex objects + Returns: + The matching object + Raises: + NoResultFound: if the object is not found + """ + logger.debug(f"Reading {cls.__name__} with ID(s): {identifiers} with actor={actor}") # Start the query query = select(cls) @@ -294,9 +332,9 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base): query_conditions = [] # If an identifier is provided, add it to the query conditions - if identifier is not None: - query = query.where(cls.id == identifier) - query_conditions.append(f"id='{identifier}'") + if len(identifiers) > 0: + query = query.where(cls.id.in_(identifiers)) + query_conditions.append(f"id='{identifiers}'") if kwargs: query = query.filter_by(**kwargs) @@ -309,12 +347,29 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base): if hasattr(cls, "is_deleted"): query = query.where(cls.is_deleted == False) query_conditions.append("is_deleted=False") - if found := db_session.execute(query).scalar(): - return found + + results = db_session.execute(query).scalars().all() + if results: # if empty list a.k.a. no results + if len(identifiers) > 0: + # find which identifiers were not found + # only when identifier length is greater than 0 (so it was used in the actual query) + identifier_set = set(identifiers) + results_set = set(map(lambda obj: obj.id, results)) + + # we log a warning message if any of the queried IDs were not found. + # TODO: should we error out instead? + if identifier_set != results_set: + # Construct a detailed error message based on query conditions + conditions_str = ", ".join(query_conditions) if query_conditions else "no specific conditions" + logger.warning( + f"{cls.__name__} not found with {conditions_str}. Queried ids: {identifier_set}, Found ids: {results_set}" + ) + return results # Construct a detailed error message based on query conditions conditions_str = ", ".join(query_conditions) if query_conditions else "no specific conditions" - raise NoResultFound(f"{cls.__name__} not found with {conditions_str}") + logger.warning(f"{cls.__name__} not found with {conditions_str}") + return [] @handle_db_timeout def create(self, db_session: "Session", actor: Optional["User"] = None) -> "SqlalchemyBase": diff --git a/letta/schemas/message.py b/letta/schemas/message.py index 3a34b8e82..16995a648 100644 --- a/letta/schemas/message.py +++ b/letta/schemas/message.py @@ -748,6 +748,12 @@ class Message(BaseMessage): else: raise ValueError(self.role) + # Validate that parts is never empty before returning + if "parts" not in google_ai_message or not google_ai_message["parts"]: + # If parts is empty, add a default text part + google_ai_message["parts"] = [{"text": "empty message"}] + warnings.warn(f"Empty 'parts' detected in message with role '{self.role}'. Added default empty text part.") + return google_ai_message def to_cohere_dict( diff --git a/letta/schemas/providers.py b/letta/schemas/providers.py index 9084c7291..e35c74c5e 100644 --- a/letta/schemas/providers.py +++ b/letta/schemas/providers.py @@ -207,9 +207,259 @@ class OpenAIProvider(Provider): def get_model_context_window_size(self, model_name: str): if model_name in LLM_MAX_TOKENS: return LLM_MAX_TOKENS[model_name] + else: + return LLM_MAX_TOKENS["DEFAULT"] + + +class xAIProvider(OpenAIProvider): + """https://docs.x.ai/docs/api-reference""" + + name: str = "xai" + api_key: str = Field(..., description="API key for the xAI/Grok API.") + base_url: str = Field("https://api.x.ai/v1", description="Base URL for the xAI/Grok API.") + + def get_model_context_window_size(self, model_name: str) -> Optional[int]: + # xAI doesn't return context window in the model listing, + # so these are hardcoded from their website + if model_name == "grok-2-1212": + return 131072 else: return None + def list_llm_models(self) -> List[LLMConfig]: + from letta.llm_api.openai import openai_get_model_list + + response = openai_get_model_list(self.base_url, api_key=self.api_key) + + if "data" in response: + data = response["data"] + else: + data = response + + configs = [] + for model in data: + assert "id" in model, f"xAI/Grok model missing 'id' field: {model}" + model_name = model["id"] + + # In case xAI starts supporting it in the future: + if "context_length" in model: + context_window_size = model["context_length"] + else: + context_window_size = self.get_model_context_window_size(model_name) + + if not context_window_size: + warnings.warn(f"Couldn't find context window size for model {model_name}") + continue + + configs.append( + LLMConfig( + model=model_name, + model_endpoint_type="xai", + model_endpoint=self.base_url, + context_window=context_window_size, + handle=self.get_handle(model_name), + ) + ) + + return configs + + def list_embedding_models(self) -> List[EmbeddingConfig]: + # No embeddings supported + return [] + + +class DeepSeekProvider(OpenAIProvider): + """ + DeepSeek ChatCompletions API is similar to OpenAI's reasoning API, + but with slight differences: + * For example, DeepSeek's API requires perfect interleaving of user/assistant + * It also does not support native function calling + """ + + name: str = "deepseek" + base_url: str = Field("https://api.deepseek.com/v1", description="Base URL for the DeepSeek API.") + api_key: str = Field(..., description="API key for the DeepSeek API.") + + def get_model_context_window_size(self, model_name: str) -> Optional[int]: + # DeepSeek doesn't return context window in the model listing, + # so these are hardcoded from their website + if model_name == "deepseek-reasoner": + return 64000 + elif model_name == "deepseek-chat": + return 64000 + else: + return None + + def list_llm_models(self) -> List[LLMConfig]: + from letta.llm_api.openai import openai_get_model_list + + response = openai_get_model_list(self.base_url, api_key=self.api_key) + + if "data" in response: + data = response["data"] + else: + data = response + + configs = [] + for model in data: + assert "id" in model, f"DeepSeek model missing 'id' field: {model}" + model_name = model["id"] + + # In case DeepSeek starts supporting it in the future: + if "context_length" in model: + # Context length is returned in OpenRouter as "context_length" + context_window_size = model["context_length"] + else: + context_window_size = self.get_model_context_window_size(model_name) + + if not context_window_size: + warnings.warn(f"Couldn't find context window size for model {model_name}") + continue + + # Not used for deepseek-reasoner, but otherwise is true + put_inner_thoughts_in_kwargs = False if model_name == "deepseek-reasoner" else True + + configs.append( + LLMConfig( + model=model_name, + model_endpoint_type="deepseek", + model_endpoint=self.base_url, + context_window=context_window_size, + handle=self.get_handle(model_name), + put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs, + ) + ) + + return configs + + def list_embedding_models(self) -> List[EmbeddingConfig]: + # No embeddings supported + return [] + + +class LMStudioOpenAIProvider(OpenAIProvider): + name: str = "lmstudio-openai" + base_url: str = Field(..., description="Base URL for the LMStudio OpenAI API.") + api_key: Optional[str] = Field(None, description="API key for the LMStudio API.") + + def list_llm_models(self) -> List[LLMConfig]: + from letta.llm_api.openai import openai_get_model_list + + # For LMStudio, we want to hit 'GET /api/v0/models' instead of 'GET /v1/models' + MODEL_ENDPOINT_URL = f"{self.base_url.strip('/v1')}/api/v0" + response = openai_get_model_list(MODEL_ENDPOINT_URL) + + """ + Example response: + + { + "object": "list", + "data": [ + { + "id": "qwen2-vl-7b-instruct", + "object": "model", + "type": "vlm", + "publisher": "mlx-community", + "arch": "qwen2_vl", + "compatibility_type": "mlx", + "quantization": "4bit", + "state": "not-loaded", + "max_context_length": 32768 + }, + ... + """ + if "data" not in response: + warnings.warn(f"LMStudio OpenAI model query response missing 'data' field: {response}") + return [] + + configs = [] + for model in response["data"]: + assert "id" in model, f"Model missing 'id' field: {model}" + model_name = model["id"] + + if "type" not in model: + warnings.warn(f"LMStudio OpenAI model missing 'type' field: {model}") + continue + elif model["type"] not in ["vlm", "llm"]: + continue + + if "max_context_length" in model: + context_window_size = model["max_context_length"] + else: + warnings.warn(f"LMStudio OpenAI model missing 'max_context_length' field: {model}") + continue + + configs.append( + LLMConfig( + model=model_name, + model_endpoint_type="openai", + model_endpoint=self.base_url, + context_window=context_window_size, + handle=self.get_handle(model_name), + ) + ) + + return configs + + def list_embedding_models(self) -> List[EmbeddingConfig]: + from letta.llm_api.openai import openai_get_model_list + + # For LMStudio, we want to hit 'GET /api/v0/models' instead of 'GET /v1/models' + MODEL_ENDPOINT_URL = f"{self.base_url.strip('/v1')}/api/v0" + response = openai_get_model_list(MODEL_ENDPOINT_URL) + + """ + Example response: + { + "object": "list", + "data": [ + { + "id": "text-embedding-nomic-embed-text-v1.5", + "object": "model", + "type": "embeddings", + "publisher": "nomic-ai", + "arch": "nomic-bert", + "compatibility_type": "gguf", + "quantization": "Q4_0", + "state": "not-loaded", + "max_context_length": 2048 + } + ... + """ + if "data" not in response: + warnings.warn(f"LMStudio OpenAI model query response missing 'data' field: {response}") + return [] + + configs = [] + for model in response["data"]: + assert "id" in model, f"Model missing 'id' field: {model}" + model_name = model["id"] + + if "type" not in model: + warnings.warn(f"LMStudio OpenAI model missing 'type' field: {model}") + continue + elif model["type"] not in ["embeddings"]: + continue + + if "max_context_length" in model: + context_window_size = model["max_context_length"] + else: + warnings.warn(f"LMStudio OpenAI model missing 'max_context_length' field: {model}") + continue + + configs.append( + EmbeddingConfig( + embedding_model=model_name, + embedding_endpoint_type="openai", + embedding_endpoint=self.base_url, + embedding_dim=context_window_size, + embedding_chunk_size=300, # NOTE: max is 2048 + handle=self.get_handle(model_name), + ), + ) + + return configs + class xAIProvider(OpenAIProvider): """https://docs.x.ai/docs/api-reference""" diff --git a/letta/server/rest_api/app.py b/letta/server/rest_api/app.py index c2b1c137b..bbbe69172 100644 --- a/letta/server/rest_api/app.py +++ b/letta/server/rest_api/app.py @@ -43,16 +43,6 @@ interface: StreamingServerInterface = StreamingServerInterface server = SyncServer(default_interface_factory=lambda: interface()) logger = get_logger(__name__) -# TODO: remove -password = None -## TODO(ethan): eventuall remove -# if password := settings.server_pass: -# # if the pass was specified in the environment, use it -# print(f"Using existing admin server password from environment.") -# else: -# # Autogenerate a password for this session and dump it to stdout -# password = secrets.token_urlsafe(16) -# #typer.secho(f"Generated admin server password for this session: {password}", fg=typer.colors.GREEN) import logging import platform @@ -287,7 +277,7 @@ def create_application() -> "FastAPI": app.include_router(openai_chat_completions_router, prefix=OPENAI_API_PREFIX) # /api/auth endpoints - app.include_router(setup_auth_router(server, interface, password), prefix=API_PREFIX) + app.include_router(setup_auth_router(server, interface, random_password), prefix=API_PREFIX) # / static files mount_static_files(app) diff --git a/letta/server/rest_api/optimistic_json_parser.py b/letta/server/rest_api/optimistic_json_parser.py index 9379b4e6e..452d29e91 100644 --- a/letta/server/rest_api/optimistic_json_parser.py +++ b/letta/server/rest_api/optimistic_json_parser.py @@ -32,7 +32,7 @@ class OptimisticJSONParser: self.on_extra_token = self.default_on_extra_token def default_on_extra_token(self, text, data, reminding): - pass + print(f"Parsed JSON with extra tokens: {data}, remaining: {reminding}") def parse(self, input_str): """ @@ -130,8 +130,8 @@ class OptimisticJSONParser: if end == -1: # Incomplete string if not self.strict: - return input_str[1:], "" - return json.loads(f'"{input_str[1:]}"'), "" + return input_str[1:], "" # Lenient mode returns partial string + raise decode_error # Raise error for incomplete string in strict mode str_val = input_str[: end + 1] input_str = input_str[end + 1 :] @@ -152,8 +152,8 @@ class OptimisticJSONParser: num_str = input_str[:idx] remainder = input_str[idx:] - # If it's only a sign or just '.', return as-is with empty remainder - if not num_str or num_str in {"-", "."}: + # If not strict, and it's only a sign or just '.', return as-is with empty remainder + if not self.strict and (not num_str or num_str in {"-", "."}): return num_str, "" try: diff --git a/letta/services/block_manager.py b/letta/services/block_manager.py index ff9b85072..cc09c0b16 100644 --- a/letta/services/block_manager.py +++ b/letta/services/block_manager.py @@ -106,12 +106,14 @@ class BlockManager: @enforce_types def get_all_blocks_by_ids(self, block_ids: List[str], actor: Optional[PydanticUser] = None) -> List[PydanticBlock]: - # TODO: We can do this much more efficiently by listing, instead of executing individual queries per block_id - blocks = [] - for block_id in block_ids: - block = self.get_block_by_id(block_id, actor=actor) - blocks.append(block) - return blocks + """Retrieve blocks by their names.""" + with self.session_maker() as session: + blocks = list( + map(lambda obj: obj.to_pydantic(), BlockModel.read_multiple(db_session=session, identifiers=block_ids, actor=actor)) + ) + # backwards compatibility. previous implementation added None for every block not found. + blocks.extend([None for _ in range(len(block_ids) - len(blocks))]) + return blocks @enforce_types def add_default_blocks(self, actor: PydanticUser): diff --git a/letta/services/message_manager.py b/letta/services/message_manager.py index 96b6f14cf..a22c3ec28 100644 --- a/letta/services/message_manager.py +++ b/letta/services/message_manager.py @@ -129,6 +129,44 @@ class MessageManager: # raise error if message type got modified raise ValueError(f"Message type got modified: {letta_message_update.message_type}") + @enforce_types + def update_message_by_letta_message( + self, message_id: str, letta_message_update: LettaMessageUpdateUnion, actor: PydanticUser + ) -> PydanticMessage: + """ + Updated the underlying messages table giving an update specified to the user-facing LettaMessage + """ + message = self.get_message_by_id(message_id=message_id, actor=actor) + if letta_message_update.message_type == "assistant_message": + # modify the tool call for send_message + # TODO: fix this if we add parallel tool calls + # TODO: note this only works if the AssistantMessage is generated by the standard send_message + assert ( + message.tool_calls[0].function.name == "send_message" + ), f"Expected the first tool call to be send_message, but got {message.tool_calls[0].function.name}" + original_args = json.loads(message.tool_calls[0].function.arguments) + original_args["message"] = letta_message_update.content # override the assistant message + update_tool_call = message.tool_calls[0].__deepcopy__() + update_tool_call.function.arguments = json.dumps(original_args) + + update_message = MessageUpdate(tool_calls=[update_tool_call]) + elif letta_message_update.message_type == "reasoning_message": + update_message = MessageUpdate(content=letta_message_update.reasoning) + elif letta_message_update.message_type == "user_message" or letta_message_update.message_type == "system_message": + update_message = MessageUpdate(content=letta_message_update.content) + else: + raise ValueError(f"Unsupported message type for modification: {letta_message_update.message_type}") + + message = self.update_message_by_id(message_id=message_id, message_update=update_message, actor=actor) + + # convert back to LettaMessage + for letta_msg in message.to_letta_message(use_assistant_message=True): + if letta_msg.message_type == letta_message_update.message_type: + return letta_msg + + # raise error if message type got modified + raise ValueError(f"Message type got modified: {letta_message_update.message_type}") + @enforce_types def update_message_by_id(self, message_id: str, message_update: MessageUpdate, actor: PydanticUser) -> PydanticMessage: """ diff --git a/poetry.lock b/poetry.lock index 06871d79a..6bf92fce2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -500,10 +500,6 @@ files = [ {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, @@ -516,14 +512,8 @@ files = [ {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, @@ -534,24 +524,8 @@ files = [ {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, - {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, - {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, @@ -561,10 +535,6 @@ files = [ {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, @@ -576,10 +546,6 @@ files = [ {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, @@ -592,10 +558,6 @@ files = [ {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, @@ -608,10 +570,6 @@ files = [ {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, @@ -1035,9 +993,9 @@ isort = ">=4.3.21,<6.0" jinja2 = ">=2.10.1,<4.0" packaging = "*" pydantic = [ + {version = ">=1.10.0,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, {version = ">=1.10.0,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.9.0,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, - {version = ">=1.10.0,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, ] pyyaml = ">=6.0.1" toml = {version = ">=0.10.0,<1.0.0", markers = "python_version < \"3.11\""} @@ -3035,8 +2993,8 @@ psutil = ">=5.9.1" pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pyzmq = ">=25.0.0" requests = [ - {version = ">=2.26.0", markers = "python_version <= \"3.11\""}, {version = ">=2.32.2", markers = "python_version > \"3.11\""}, + {version = ">=2.26.0", markers = "python_version <= \"3.11\""}, ] setuptools = ">=70.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} @@ -3899,9 +3857,9 @@ files = [ [package.dependencies] numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -4387,7 +4345,6 @@ files = [ {file = "psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4"}, {file = "psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067"}, {file = "psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e"}, - {file = "psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2"}, {file = "psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b"}, {file = "psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442"}, {file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"}, @@ -4447,7 +4404,6 @@ files = [ {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, @@ -5192,8 +5148,8 @@ grpcio = ">=1.41.0" grpcio-tools = ">=1.41.0" httpx = {version = ">=0.20.0", extras = ["http2"]} numpy = [ - {version = ">=1.21", markers = "python_version >= \"3.10\" and python_version < \"3.12\""}, {version = ">=1.26", markers = "python_version == \"3.12\""}, + {version = ">=1.21", markers = "python_version >= \"3.10\" and python_version < \"3.12\""}, ] portalocker = ">=2.7.0,<3.0.0" pydantic = ">=1.10.8" diff --git a/pyproject.toml b/pyproject.toml index 49d16bc0b..0f40f8633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "letta" -version = "0.6.42" +version = "0.6.43" packages = [ {include = "letta"}, ] diff --git a/tests/test_optimistic_json_parser.py b/tests/test_optimistic_json_parser.py index 4f1888544..f7741f7ce 100644 --- a/tests/test_optimistic_json_parser.py +++ b/tests/test_optimistic_json_parser.py @@ -96,7 +96,7 @@ def test_parse_number_cases(strict_parser): def test_parse_boolean_true(strict_parser): assert strict_parser.parse("true") is True, "Should parse 'true'." # Check leftover - assert strict_parser.last_parse_reminding == "", "No extra tokens expected." + assert strict_parser.last_parse_reminding == None, "No extra tokens expected." def test_parse_boolean_false(strict_parser): @@ -246,3 +246,35 @@ def test_multiple_parse_calls(strict_parser): result_2 = strict_parser.parse(input_2) assert result_2 == [2, 3] assert strict_parser.last_parse_reminding.strip() == "trailing2" + + +def test_parse_incomplete_string_streaming_strict(strict_parser): + """ + Test how a strict parser handles an incomplete string received in chunks. + """ + # Simulate streaming chunks + chunk1 = '{"message": "This is an incomplete' + chunk2 = " string with a newline\\n" + chunk3 = 'and more text"}' + + with pytest.raises(json.JSONDecodeError, match="Unterminated string"): + strict_parser.parse(chunk1) + + incomplete_json = chunk1 + chunk2 + with pytest.raises(json.JSONDecodeError, match="Unterminated string"): + strict_parser.parse(incomplete_json) + + complete_json = incomplete_json + chunk3 + result = strict_parser.parse(complete_json) + expected = {"message": "This is an incomplete string with a newline\nand more text"} + assert result == expected, "Should parse complete JSON correctly" + + +def test_unescaped_control_characters_strict(strict_parser): + """ + Test parsing JSON containing unescaped control characters in strict mode. + """ + input_str = '{"message": "This has a newline\nand tab\t"}' + + with pytest.raises(json.JSONDecodeError, match="Invalid control character"): + strict_parser.parse(input_str)