diff --git a/fern/openapi.json b/fern/openapi.json index 95d6a9c9..1dd96159 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -8535,14 +8535,17 @@ "required": true, "schema": { "type": "string", - "minLength": 41, + "minLength": 1, "maxLength": 41, - "pattern": "^conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "The ID of the conv in the format 'conv-'", - "examples": ["conv-123e4567-e89b-42d3-8456-426614174000"], + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], "title": "Conversation Id" }, - "description": "The ID of the conv in the format 'conv-'" + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" } ], "responses": { @@ -8580,14 +8583,17 @@ "required": true, "schema": { "type": "string", - "minLength": 41, + "minLength": 1, "maxLength": 41, - "pattern": "^conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "The ID of the conv in the format 'conv-'", - "examples": ["conv-123e4567-e89b-42d3-8456-426614174000"], + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], "title": "Conversation Id" }, - "description": "The ID of the conv in the format 'conv-'" + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" } ], "requestBody": { @@ -8637,14 +8643,17 @@ "required": true, "schema": { "type": "string", - "minLength": 41, + "minLength": 1, "maxLength": 41, - "pattern": "^conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "The ID of the conv in the format 'conv-'", - "examples": ["conv-123e4567-e89b-42d3-8456-426614174000"], + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], "title": "Conversation Id" }, - "description": "The ID of the conv in the format 'conv-'" + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" }, { "name": "before", @@ -8803,14 +8812,17 @@ "required": true, "schema": { "type": "string", - "minLength": 41, + "minLength": 1, "maxLength": 41, - "pattern": "^conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "The ID of the conv in the format 'conv-'", - "examples": ["conv-123e4567-e89b-42d3-8456-426614174000"], + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], "title": "Conversation Id" }, - "description": "The ID of the conv in the format 'conv-'" + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" } ], "requestBody": { @@ -8864,14 +8876,17 @@ "required": true, "schema": { "type": "string", - "minLength": 41, + "minLength": 1, "maxLength": 41, - "pattern": "^conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "The ID of the conv in the format 'conv-'", - "examples": ["conv-123e4567-e89b-42d3-8456-426614174000"], + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], "title": "Conversation Id" }, - "description": "The ID of the conv in the format 'conv-'" + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" } ], "requestBody": { @@ -8964,14 +8979,17 @@ "required": true, "schema": { "type": "string", - "minLength": 41, + "minLength": 1, "maxLength": 41, - "pattern": "^conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "The ID of the conv in the format 'conv-'", - "examples": ["conv-123e4567-e89b-42d3-8456-426614174000"], + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], "title": "Conversation Id" }, - "description": "The ID of the conv in the format 'conv-'" + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" } ], "responses": { @@ -9013,14 +9031,17 @@ "required": true, "schema": { "type": "string", - "minLength": 41, + "minLength": 1, "maxLength": 41, - "pattern": "^conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "The ID of the conv in the format 'conv-'", - "examples": ["conv-123e4567-e89b-42d3-8456-426614174000"], + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], "title": "Conversation Id" }, - "description": "The ID of the conv in the format 'conv-'" + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" } ], "requestBody": { diff --git a/letta/server/rest_api/dependencies.py b/letta/server/rest_api/dependencies.py index 1cc1d02f..b6f6b6cc 100644 --- a/letta/server/rest_api/dependencies.py +++ b/letta/server/rest_api/dependencies.py @@ -3,7 +3,10 @@ from typing import TYPE_CHECKING, Optional from fastapi import Header from pydantic import BaseModel +from letta.errors import LettaInvalidArgumentError from letta.otel.tracing import tracer +from letta.schemas.enums import PrimitiveType +from letta.validators import PRIMITIVE_ID_PATTERNS if TYPE_CHECKING: from letta.server.server import SyncServer @@ -42,6 +45,12 @@ def get_headers( ) -> HeaderParams: """Dependency injection function to extract common headers from requests.""" with tracer.start_as_current_span("dependency.get_headers"): + if actor_id is not None and PRIMITIVE_ID_PATTERNS[PrimitiveType.USER.value].match(actor_id) is None: + raise LettaInvalidArgumentError( + message=(f"Invalid user ID format: {actor_id}. Expected format: '{PrimitiveType.USER.value}-'"), + argument_name="user_id", + ) + return HeaderParams( actor_id=actor_id, user_agent=user_agent, diff --git a/letta/server/rest_api/routers/v1/users.py b/letta/server/rest_api/routers/v1/users.py index f8e5acd0..35d8a821 100644 --- a/letta/server/rest_api/routers/v1/users.py +++ b/letta/server/rest_api/routers/v1/users.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Body, Depends, Query from letta.schemas.user import User, UserCreate, UserUpdate from letta.server.rest_api.dependencies import get_letta_server +from letta.validators import UserIdQueryRequired if TYPE_CHECKING: from letta.schemas.user import User @@ -52,7 +53,7 @@ async def update_user( @router.delete("/", tags=["admin"], response_model=User, operation_id="delete_user") async def delete_user( - user_id: str = Query(..., description="The user_id key to be deleted."), + user_id: UserIdQueryRequired, server: "SyncServer" = Depends(get_letta_server), ): # TODO make a soft deletion, instead of a hard deletion diff --git a/letta/validators.py b/letta/validators.py index 4b2f16ee..4e8552c5 100644 --- a/letta/validators.py +++ b/letta/validators.py @@ -44,6 +44,31 @@ def _create_path_validator_factory(primitive: str): PATH_VALIDATORS = {primitive_type.value: _create_path_validator_factory(primitive_type.value) for primitive_type in PrimitiveType} +def _create_conversation_id_or_default_path_validator_factory(): + """Conversation IDs accept the usual primitive format or the special value 'default'.""" + + primitive = PrimitiveType.CONVERSATION.value + prefix_pattern = PRIMITIVE_ID_PATTERNS[primitive].pattern + # Make the full regex accept either the primitive ID format or 'default'. + # `prefix_pattern` already contains the ^...$ anchors. + conversation_or_default_pattern = f"^(default|{prefix_pattern[1:-1]})$" + + def factory(): + return Path( + description=(f"The conversation identifier. Either the special value 'default' or an ID in the format '{primitive}-'"), + pattern=conversation_or_default_pattern, + examples=["default", f"{primitive}-123e4567-e89b-42d3-8456-426614174000"], + min_length=1, + max_length=len(primitive) + 1 + 36, + ) + + return factory + + +# Override conversation ID path validation to also allow the special value 'default'. +PATH_VALIDATORS[PrimitiveType.CONVERSATION.value] = _create_conversation_id_or_default_path_validator_factory() + + # Type aliases for common ID types # These can be used directly in route handler signatures for cleaner code AgentId = Annotated[str, PATH_VALIDATORS[PrimitiveType.AGENT.value]()] @@ -139,7 +164,6 @@ def _create_id_query_validator(primitive: str): Args: primitive: The primitive type prefix (e.g., "agent", "tool") - Returns: A Query validator with pattern matching """ @@ -162,6 +186,8 @@ RunIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.R JobIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.JOB.value)] GroupIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.GROUP.value)] IdentityIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.IDENTITY.value)] +UserIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.USER.value)] +UserIdQueryRequired = Annotated[str, _create_id_query_validator(PrimitiveType.USER.value)] # ============================================================================= diff --git a/tests/test_utils.py b/tests/test_utils.py index 37114aa7..3e23a0b8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,10 @@ import pytest from letta.constants import MAX_FILENAME_LENGTH +from letta.errors import LettaInvalidArgumentError from letta.functions.ast_parsers import coerce_dict_args_by_annotations, get_function_annotations_from_source from letta.schemas.file import FileMetadata -from letta.server.rest_api.dependencies import HeaderParams +from letta.server.rest_api.dependencies import HeaderParams, get_headers from letta.services.file_processor.chunker.line_chunker import LineChunker from letta.services.helpers.agent_manager_helper import safe_format from letta.utils import is_1_0_sdk_version, sanitize_filename, validate_function_response @@ -11,6 +12,52 @@ from letta.utils import is_1_0_sdk_version, sanitize_filename, validate_function CORE_MEMORY_VAR = "My core memory is that I like to eat bananas" VARS_DICT = {"CORE_MEMORY": CORE_MEMORY_VAR} + +def test_get_headers_user_id_allows_none(): + headers = get_headers( + actor_id=None, + user_agent=None, + project_id=None, + letta_source=None, + sdk_version=None, + message_async=None, + letta_v1_agent=None, + letta_v1_agent_message_async=None, + modal_sandbox=None, + ) + assert isinstance(headers, HeaderParams) + + +def test_get_headers_user_id_rejects_invalid_format(): + with pytest.raises(LettaInvalidArgumentError, match="Invalid user ID format"): + get_headers( + actor_id="not-a-user-id", + user_agent=None, + project_id=None, + letta_source=None, + sdk_version=None, + message_async=None, + letta_v1_agent=None, + letta_v1_agent_message_async=None, + modal_sandbox=None, + ) + + +def test_get_headers_user_id_accepts_valid_format(): + headers = get_headers( + actor_id="user-123e4567-e89b-42d3-8456-426614174000", + user_agent=None, + project_id=None, + letta_source=None, + sdk_version=None, + message_async=None, + letta_v1_agent=None, + letta_v1_agent_message_async=None, + modal_sandbox=None, + ) + assert headers.actor_id == "user-123e4567-e89b-42d3-8456-426614174000" + + # ----------------------------------------------------------------------- # Example source code for testing multiple scenarios, including: # 1) A class-based custom type (which we won't handle properly). @@ -711,11 +758,13 @@ def test_sanitize_null_bytes_dict(): from letta.helpers.json_helpers import sanitize_null_bytes # Test nested dict with null bytes - result = sanitize_null_bytes({ - "key1": "value\x00with\x00nulls", - "key2": {"nested": "also\x00null"}, - "key3": 123, # non-string should be unchanged - }) + result = sanitize_null_bytes( + { + "key1": "value\x00with\x00nulls", + "key2": {"nested": "also\x00null"}, + "key3": 123, # non-string should be unchanged + } + ) assert result == { "key1": "valuewithnulls", "key2": {"nested": "alsonull"},