From 3e92fecb03557270dbfdd3a69f2f1b2d19f1ce1b Mon Sep 17 00:00:00 2001 From: Kian Jones <11655409+kianjones9@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:20:04 -0800 Subject: [PATCH] feat: query param validation block label, name, and search (#6397) * add block label, name, and search query param validation * finishing touches on blocks * remove default for blocks * query changes to api spec * openapi changes * change descriptions --- fern/openapi.json | 112 ++++++++++++------ letta/server/rest_api/routers/v1/blocks.py | 34 +++--- .../rest_api/routers/v1/internal_blocks.py | 34 +++--- letta/validators.py | 102 +++++++++++++++- 4 files changed, 206 insertions(+), 76 deletions(-) diff --git a/fern/openapi.json b/fern/openapi.json index ee4b023a..8e3c002d 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -10045,16 +10045,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_-]+$" }, { "type": "null" } ], - "description": "Labels to include (e.g. human, persona)", + "description": "Label to include (alphanumeric, hyphens, underscores only)", + "examples": ["human", "persona", "the_label_of-a-block"], "title": "Label" }, - "description": "Labels to include (e.g. human, persona)" + "description": "Label to include (alphanumeric, hyphens, underscores only)" }, { "name": "templates_only", @@ -10075,16 +10079,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-zA-Z0-9 _-]+$" }, { "type": "null" } ], - "description": "Name of the block", + "description": "Name filter (alphanumeric, spaces, hyphens, underscores)", + "examples": ["My Agent", "test_tool", "default-config"], "title": "Name" }, - "description": "Name of the block" + "description": "Name filter (alphanumeric, spaces, hyphens, underscores)" }, { "name": "identity_id", @@ -10093,16 +10101,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 45, + "maxLength": 45, + "pattern": "^identity-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" }, { "type": "null" } ], - "description": "Search agents by identifier id", + "description": "The ID of the identity in the format 'identity-'", + "examples": ["identity-123e4567-e89b-42d3-8456-426614174000"], "title": "Identity Id" }, - "description": "Search agents by identifier id" + "description": "The ID of the identity in the format 'identity-'" }, { "name": "identifier_keys", @@ -10231,16 +10243,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_-]+$" }, { "type": "null" } ], - "description": "Search blocks by label. If provided, returns blocks that match this label. This is a full-text search on labels.", + "description": "Search blocks by label. If provided, returns blocks whose label matches the search query. This is a full-text search on block labels.", + "examples": ["human", "persona", "the_label_of-a-block"], "title": "Label Search" }, - "description": "Search blocks by label. If provided, returns blocks that match this label. This is a full-text search on labels." + "description": "Search blocks by label. If provided, returns blocks whose label matches the search query. This is a full-text search on block labels." }, { "name": "description_search", @@ -10249,16 +10265,18 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 200 }, { "type": "null" } ], - "description": "Search blocks by description. If provided, returns blocks that match this description. This is a full-text search on block descriptions.", + "description": "Search blocks by description. If provided, returns blocks whose description matches the search query. This is a full-text search on block descriptions.", "title": "Description Search" }, - "description": "Search blocks by description. If provided, returns blocks that match this description. This is a full-text search on block descriptions." + "description": "Search blocks by description. If provided, returns blocks whose description matches the search query. This is a full-text search on block descriptions." }, { "name": "value_search", @@ -10267,16 +10285,18 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 200 }, { "type": "null" } ], - "description": "Search blocks by value. If provided, returns blocks that match this value.", + "description": "Search blocks by value. If provided, returns blocks whose value matches the search query. This is a full-text search on block values.", "title": "Value Search" }, - "description": "Search blocks by value. If provided, returns blocks that match this value." + "description": "Search blocks by value. If provided, returns blocks whose value matches the search query. This is a full-text search on block values." }, { "name": "connected_to_agents_count_gt", @@ -11939,16 +11959,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_-]+$" }, { "type": "null" } ], - "description": "Labels to include (e.g. human, persona)", + "description": "Label to include (alphanumeric, hyphens, underscores only)", + "examples": ["human", "persona", "the_label_of-a-block"], "title": "Label" }, - "description": "Labels to include (e.g. human, persona)" + "description": "Label to include (alphanumeric, hyphens, underscores only)" }, { "name": "templates_only", @@ -11969,16 +11993,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-zA-Z0-9 _-]+$" }, { "type": "null" } ], - "description": "Name of the block", + "description": "Name filter (alphanumeric, spaces, hyphens, underscores)", + "examples": ["My Agent", "test_tool", "default-config"], "title": "Name" }, - "description": "Name of the block" + "description": "Name filter (alphanumeric, spaces, hyphens, underscores)" }, { "name": "identity_id", @@ -11987,16 +12015,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 45, + "maxLength": 45, + "pattern": "^identity-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" }, { "type": "null" } ], - "description": "Search agents by identifier id", + "description": "The ID of the identity in the format 'identity-'", + "examples": ["identity-123e4567-e89b-42d3-8456-426614174000"], "title": "Identity Id" }, - "description": "Search agents by identifier id" + "description": "The ID of the identity in the format 'identity-'" }, { "name": "identifier_keys", @@ -12125,16 +12157,20 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_-]+$" }, { "type": "null" } ], - "description": "Search blocks by label. If provided, returns blocks that match this label. This is a full-text search on labels.", + "description": "Search blocks by label. If provided, returns blocks whose label matches the search query. This is a full-text search on block labels.", + "examples": ["human", "persona", "the_label_of-a-block"], "title": "Label Search" }, - "description": "Search blocks by label. If provided, returns blocks that match this label. This is a full-text search on labels." + "description": "Search blocks by label. If provided, returns blocks whose label matches the search query. This is a full-text search on block labels." }, { "name": "description_search", @@ -12143,16 +12179,18 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 200 }, { "type": "null" } ], - "description": "Search blocks by description. If provided, returns blocks that match this description. This is a full-text search on block descriptions.", + "description": "Search blocks by description. If provided, returns blocks whose description matches the search query. This is a full-text search on block descriptions.", "title": "Description Search" }, - "description": "Search blocks by description. If provided, returns blocks that match this description. This is a full-text search on block descriptions." + "description": "Search blocks by description. If provided, returns blocks whose description matches the search query. This is a full-text search on block descriptions." }, { "name": "value_search", @@ -12161,16 +12199,18 @@ "schema": { "anyOf": [ { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 200 }, { "type": "null" } ], - "description": "Search blocks by value. If provided, returns blocks that match this value.", + "description": "Search blocks by value. If provided, returns blocks whose value matches the search query. This is a full-text search on block values.", "title": "Value Search" }, - "description": "Search blocks by value. If provided, returns blocks that match this value." + "description": "Search blocks by value. If provided, returns blocks whose value matches the search query. This is a full-text search on block values." }, { "name": "connected_to_agents_count_gt", diff --git a/letta/server/rest_api/routers/v1/blocks.py b/letta/server/rest_api/routers/v1/blocks.py index 10b984bb..536e243d 100644 --- a/letta/server/rest_api/routers/v1/blocks.py +++ b/letta/server/rest_api/routers/v1/blocks.py @@ -8,7 +8,15 @@ from letta.schemas.block import BaseBlock, Block, BlockResponse, BlockUpdate, Cr from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server from letta.server.server import SyncServer from letta.utils import is_1_0_sdk_version -from letta.validators import BlockId +from letta.validators import ( + BlockDescriptionSearchQuery, + BlockId, + BlockLabelQuery, + BlockLabelSearchQuery, + BlockNameQuery, + BlockValueSearchQuery, + IdentityIdQuery, +) if TYPE_CHECKING: pass @@ -19,10 +27,10 @@ router = APIRouter(prefix="/blocks", tags=["blocks"]) @router.get("/", response_model=List[BlockResponse], operation_id="list_blocks") async def list_blocks( # query parameters - label: Optional[str] = Query(None, description="Labels to include (e.g. human, persona)"), + label: BlockLabelQuery = None, templates_only: bool = Query(False, description="Whether to include only templates"), - name: Optional[str] = Query(None, description="Name of the block"), - identity_id: Optional[str] = Query(None, description="Search agents by identifier id"), + name: BlockNameQuery = None, + identity_id: IdentityIdQuery = None, identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"), project_id: Optional[str] = Query(None, description="Search blocks by project id"), limit: Optional[int] = Query(50, description="Number of blocks to return"), @@ -38,21 +46,9 @@ async def list_blocks( "asc", description="Sort order for blocks by creation time. 'asc' for oldest first, 'desc' for newest first" ), order_by: Literal["created_at"] = Query("created_at", description="Field to sort by"), - label_search: Optional[str] = Query( - None, - description=("Search blocks by label. If provided, returns blocks that match this label. This is a full-text search on labels."), - ), - description_search: Optional[str] = Query( - None, - description=( - "Search blocks by description. If provided, returns blocks that match this description. " - "This is a full-text search on block descriptions." - ), - ), - value_search: Optional[str] = Query( - None, - description=("Search blocks by value. If provided, returns blocks that match this value."), - ), + label_search: BlockLabelSearchQuery = None, + description_search: BlockDescriptionSearchQuery = None, + value_search: BlockValueSearchQuery = None, connected_to_agents_count_gt: Optional[int] = Query( None, description=( diff --git a/letta/server/rest_api/routers/v1/internal_blocks.py b/letta/server/rest_api/routers/v1/internal_blocks.py index 052266b9..b39629db 100644 --- a/letta/server/rest_api/routers/v1/internal_blocks.py +++ b/letta/server/rest_api/routers/v1/internal_blocks.py @@ -7,7 +7,15 @@ from letta.schemas.block import Block, CreateBlock from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server from letta.server.server import SyncServer from letta.utils import is_1_0_sdk_version -from letta.validators import BlockId +from letta.validators import ( + BlockDescriptionSearchQuery, + BlockId, + BlockLabelQuery, + BlockLabelSearchQuery, + BlockNameQuery, + BlockValueSearchQuery, + IdentityIdQuery, +) if TYPE_CHECKING: pass @@ -18,10 +26,10 @@ router = APIRouter(prefix="/_internal_blocks", tags=["_internal_blocks"]) @router.get("/", response_model=List[Block], operation_id="list_internal_blocks") async def list_blocks( # query parameters - label: Optional[str] = Query(None, description="Labels to include (e.g. human, persona)"), + label: BlockLabelQuery = None, templates_only: bool = Query(False, description="Whether to include only templates"), - name: Optional[str] = Query(None, description="Name of the block"), - identity_id: Optional[str] = Query(None, description="Search agents by identifier id"), + name: BlockNameQuery = None, + identity_id: IdentityIdQuery = None, identifier_keys: Optional[List[str]] = Query(None, description="Search agents by identifier keys"), project_id: Optional[str] = Query(None, description="Search blocks by project id"), limit: Optional[int] = Query(50, description="Number of blocks to return"), @@ -37,21 +45,9 @@ async def list_blocks( "asc", description="Sort order for blocks by creation time. 'asc' for oldest first, 'desc' for newest first" ), order_by: Literal["created_at"] = Query("created_at", description="Field to sort by"), - label_search: Optional[str] = Query( - None, - description=("Search blocks by label. If provided, returns blocks that match this label. This is a full-text search on labels."), - ), - description_search: Optional[str] = Query( - None, - description=( - "Search blocks by description. If provided, returns blocks that match this description. " - "This is a full-text search on block descriptions." - ), - ), - value_search: Optional[str] = Query( - None, - description=("Search blocks by value. If provided, returns blocks that match this value."), - ), + label_search: BlockLabelSearchQuery = None, + description_search: BlockDescriptionSearchQuery = None, + value_search: BlockValueSearchQuery = None, connected_to_agents_count_gt: Optional[int] = Query( None, description=( diff --git a/letta/validators.py b/letta/validators.py index ed738eba..7206664c 100644 --- a/letta/validators.py +++ b/letta/validators.py @@ -1,9 +1,9 @@ import inspect import re from functools import wraps -from typing import Annotated +from typing import Annotated, Optional -from fastapi import Path +from fastapi import Path, Query from letta.errors import LettaInvalidArgumentError from letta.schemas.enums import PrimitiveType # PrimitiveType is now in schemas.enums @@ -124,3 +124,101 @@ def raise_on_invalid_id(param_name: str, expected_prefix: PrimitiveType): return wrapper return decorator + + +# ============================================================================= +# Query Parameter Validators +# ============================================================================= +# Format validators for common query parameters to match frontend constraints + + +def _create_id_query_validator(primitive: str): + """ + Creates a Query validator for ID parameters with format validation. + + Args: + primitive: The primitive type prefix (e.g., "agent", "tool") + + Returns: + A Query validator with pattern matching + """ + return Query( + description=f"The ID of the {primitive} in the format '{primitive}-'", + pattern=PRIMITIVE_ID_PATTERNS[primitive].pattern, + examples=[f"{primitive}-123e4567-e89b-42d3-8456-426614174000"], + min_length=len(primitive) + 1 + 36, + max_length=len(primitive) + 1 + 36, + ) + + +# Query parameter ID validators with format checking +AgentIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.AGENT.value)] +ToolIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.TOOL.value)] +SourceIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.SOURCE.value)] +BlockIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.BLOCK.value)] +MessageIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.MESSAGE.value)] +RunIdQuery = Annotated[Optional[str], _create_id_query_validator(PrimitiveType.RUN.value)] +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)] + + +# ============================================================================= +# String Field Validators +# ============================================================================= +# Format validators for common string fields + +# Label validator: alphanumeric, hyphens, underscores, max 50 chars +BlockLabelQuery = Annotated[ + Optional[str], + Query( + description="Label to include (alphanumeric, hyphens, underscores only)", + pattern=r"^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + examples=["human", "persona", "the_label_of-a-block"], + ), +] + + +# Name validator: similar to label but allows spaces, max 100 chars +BlockNameQuery = Annotated[ + Optional[str], + Query( + description="Name filter (alphanumeric, spaces, hyphens, underscores)", + pattern=r"^[a-zA-Z0-9 _-]+$", + min_length=1, + max_length=100, + examples=["My Agent", "test_tool", "default-config"], + ), +] + +# Search query validator: general text search, max 200 chars +BlockLabelSearchQuery = Annotated[ + Optional[str], + Query( + description="Search blocks by label. If provided, returns blocks whose label matches the search query. This is a full-text search on block labels.", + pattern=r"^[a-zA-Z0-9_-]+$", + min_length=1, + max_length=50, + examples=["human", "persona", "the_label_of-a-block"], + ), +] + +BlockValueSearchQuery = Annotated[ + Optional[str], + Query( + description="Search blocks by value. If provided, returns blocks whose value matches the search query. This is a full-text search on block values.", + min_length=1, + max_length=200, + ), +] + +BlockDescriptionSearchQuery = Annotated[ + Optional[str], + Query( + description="Search blocks by description. If provided, returns blocks whose description matches the search query. This is a full-text search on block descriptions.", + min_length=1, + max_length=200, + ), +]