From 7838909894d04b7ddb82df1809ec48f612c2e907 Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:48:28 -0500 Subject: [PATCH 01/21] Add Claude Code support and vLLM "Tool Calling" support for LISA served models (#728) --- lib/serve/ecs-model/vllm/src/entrypoint.sh | 18 ++++++++ .../api/endpoints/v2/litellm_passthrough.py | 42 +++++++++++++++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/lib/serve/ecs-model/vllm/src/entrypoint.sh b/lib/serve/ecs-model/vllm/src/entrypoint.sh index c23a0452a..b17f1ae27 100644 --- a/lib/serve/ecs-model/vllm/src/entrypoint.sh +++ b/lib/serve/ecs-model/vllm/src/entrypoint.sh @@ -105,6 +105,10 @@ declare -a vars=("S3_BUCKET_MODELS" "LOCAL_MODEL_PATH" "MODEL_NAME" "S3_MOUNT_PO # VLLM_KEEP_ALIVE_ON_ENGINE_DEATH - Keep API server alive on engine error (true/false) # VLLM_SLEEP_WHEN_IDLE - Reduce CPU usage when idle (true/false) # +# TOOL CALLING / FUNCTION CALLING (opt-in): +# VLLM_ENABLE_AUTO_TOOL_CHOICE - Enable automatic tool choice routing (set to "true" to enable) +# VLLM_TOOL_CALL_PARSER - Tool call parser name (hermes/mistral/llama3_json/qwen/etc.) +# # ROCM SPECIFIC (AMD GPUs): # VLLM_ROCM_USE_AITER - Enable AITER ops on ROCm (true/false) # VLLM_ROCM_USE_SKINNY_GEMM - Use skinny GEMM on ROCm (true/false) @@ -241,6 +245,20 @@ if [[ "${VLLM_TRUST_REMOTE_CODE}" == "true" ]]; then echo " --trust-remote-code" fi +# Enable tool calling support (opt-in only) +# These flags are required for models that support function/tool calling with tool_choice: "auto" +# (e.g., Qwen, Mistral, Llama, etc.) +# See https://docs.vllm.ai/en/stable/features/tool_calling/ +if [[ "${VLLM_ENABLE_AUTO_TOOL_CHOICE}" == "true" ]]; then + ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --enable-auto-tool-choice" + echo " --enable-auto-tool-choice" +fi + +if [[ -n "${VLLM_TOOL_CALL_PARSER}" ]]; then + ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --tool-call-parser ${VLLM_TOOL_CALL_PARSER}" + echo " --tool-call-parser ${VLLM_TOOL_CALL_PARSER}" +fi + echo "Starting vLLM with args: ${ADDITIONAL_ARGS}" echo "vLLM environment variables:" env | grep -E "^(VLLM_|MAX_TOTAL_TOKENS)=" || echo "No vLLM environment variables set" diff --git a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py index bc6e0f2d4..8617dd559 100644 --- a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py +++ b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py @@ -90,6 +90,17 @@ "v1/mcp/server", ) +# Specific routes for anthropic (Claude Code compatibility) +# LiteLLM's request/response format: https://litellm-api.up.railway.app/#/ +ANTHROPIC_ROUTES = ( + # Anthropic Messages API + "v1/messages", + "v1/messages/count_tokens", + # Anthropic Messages API with prefix + "anthropic/v1/messages", + "anthropic/v1/messages/count_tokens", +) + # With the introduction of the LiteLLM database for model configurations, it forces a requirement to have a # LiteLLM-vended API key. Since we are not requiring LiteLLM keys for customers, we are using the LiteLLM key # required for the db and injecting that into all requests instead to overcome that requirement. @@ -316,12 +327,12 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: headers = dict(request.headers.items()) authorizer = Authorizer() - require_admin = not is_openai_route(api_path) + require_admin = not is_openai_route(api_path) and api_path not in ANTHROPIC_ROUTES jwt_data = await authorizer.authenticate_request(request) if not await authorizer.can_access(request, require_admin): raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, - message="Not authenticated in litellm_passthrough", + detail="Not authenticated in litellm_passthrough", ) # At this point in the request, we have already validated auth with IdP or persistent token. By using LiteLLM for @@ -449,6 +460,18 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if model_id and jwt_data: await apply_guardrails_to_request(params, model_id, jwt_data) + # Validate and cap max_tokens if needed for Claude Code requests + if api_path in ["v1/messages", "anthropic/v1/messages"]: + model_id = params.get("model") + + # Check for anthropic specific headers + if model_id and "anthropic-beta" in headers and "anthropic-version" in headers: + # reset max token parameter to null so LiteLLM handles the max_token value + if "max_tokens" in params: + params["max_tokens"] = None + if "max_completion_tokens" in params: + params["max_completion_tokens"] = None + if params.get("stream", False): # if a streaming request response = requests_request(method=http_method, url=litellm_path, json=params, headers=headers, stream=True) @@ -459,13 +482,16 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if guardrail_response: return guardrail_response - # Publish metrics for streaming chat completions (API users) - if api_path in ["chat/completions", "v1/chat/completions"] and response.status_code == 200: + # Publish metrics for streaming chat completions and messages (API users) + if ( + api_path in ["chat/completions", "v1/chat/completions", "v1/messages", "anthropic/v1/messages"] + and response.status_code == 200 + ): publish_metrics_event(request, params, response.status_code) # Normal streaming (no error or non-guardrail error) - # Use guardrail-aware generator for chat/completions endpoints - if api_path in ["chat/completions", "v1/chat/completions"]: + # Use guardrail-aware generator for chat/completions and messages endpoints + if api_path in ["chat/completions", "v1/chat/completions", "v1/messages", "anthropic/v1/messages"]: model_id = params.get("model", "") return StreamingResponse( generate_response_with_guardrail_handling(response.iter_lines(), model_id), @@ -489,8 +515,8 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if response.status_code != 200: logger.error(f"LiteLLM error response: {response.text}") - # Publish metrics for chat completions (API users) - if api_path in ["chat/completions", "v1/chat/completions"]: + # Publish metrics for chat completions and messages (API users) + if api_path in ["chat/completions", "v1/chat/completions", "v1/messages", "anthropic/v1/messages"]: publish_metrics_event(request, params, response.status_code) return JSONResponse(response.json(), status_code=response.status_code) From 8bbd0c01b52a7af44a2628227d6a05d634c72731 Mon Sep 17 00:00:00 2001 From: bedanley Date: Mon, 9 Feb 2026 18:46:19 -0700 Subject: [PATCH 02/21] Make exceptions consistent --- lambda/api_tokens/lambda_functions.py | 15 +- lambda/configuration/lambda_functions.py | 4 +- lambda/mcp_server/lambda_functions.py | 75 +++--- lambda/mcp_workbench/lambda_functions.py | 94 ++++---- lambda/models/lambda_functions.py | 19 +- lambda/prompt_templates/lambda_functions.py | 15 +- lambda/repository/lambda_functions.py | 10 +- .../services/bedrock_kb_repository_service.py | 11 +- lambda/user_preferences/lambda_functions.py | 5 +- lambda/utilities/auth.py | 10 +- lambda/utilities/exceptions.py | 26 +- lambda/utilities/fastapi_factory.py | 10 +- .../fastapi_middleware/exception_handlers.py | 3 +- .../input_validation_middleware.py | 2 +- lib/core/layers/fastapi/requirements.txt | 1 + .../src/mcpworkbench/server/auth.py | 3 +- .../src/mcpworkbench/server/mcp_server.py | 3 +- .../src/mcpworkbench/server/middleware.py | 3 +- .../src/api/endpoints/v1/embeddings.py | 3 +- .../src/api/endpoints/v1/generation.py | 3 +- .../rest-api/src/api/endpoints/v1/models.py | 9 +- .../api/endpoints/v2/litellm_passthrough.py | 212 ++++++---------- lib/serve/rest-api/src/api/routes.py | 23 +- lib/serve/rest-api/src/auth.py | 2 +- lib/serve/rest-api/src/handlers/models.py | 3 +- lib/serve/rest-api/src/main.py | 11 + lib/serve/rest-api/src/middleware/__init__.py | 4 + .../src/middleware/auth_middleware.py | 226 ++++++++++++++++++ .../src/middleware/exception_handlers.py | 6 +- .../src/middleware/input_validation.py | 17 +- .../src/middleware/request_middleware.py | 3 +- .../src/middleware/security_middleware.py | 19 +- lib/serve/rest-api/src/utils/guardrails.py | 3 +- lib/serve/rest-api/src/utils/route_utils.py | 194 +++++++++++++++ test/lambda/test_configuration_lambda.py | 29 ++- test/lambda/test_mcp_server_lambda.py | 16 +- test/lambda/test_mcp_workbench_lambda.py | 20 +- test/lambda/test_prompt_templates_lambda.py | 39 ++- test/lambda/test_user_preferences_lambda.py | 33 +-- 39 files changed, 803 insertions(+), 381 deletions(-) create mode 100644 lib/serve/rest-api/src/middleware/auth_middleware.py create mode 100644 lib/serve/rest-api/src/utils/route_utils.py diff --git a/lambda/api_tokens/lambda_functions.py b/lambda/api_tokens/lambda_functions.py index 94c90343b..2be579e65 100644 --- a/lambda/api_tokens/lambda_functions.py +++ b/lambda/api_tokens/lambda_functions.py @@ -21,6 +21,7 @@ from fastapi import HTTPException, Path, Request from fastapi.responses import JSONResponse from mangum import Mangum +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND from utilities.auth import get_user_context, is_api_user from utilities.common_functions import retry_config from utilities.fastapi_factory import create_fastapi_app @@ -54,14 +55,14 @@ @app.exception_handler(TokenNotFoundError) async def token_not_found_handler(request: Request, exc: TokenNotFoundError) -> JSONResponse: """Handle exception when token cannot be found and translate to a 404 error.""" - return JSONResponse(status_code=404, content={"message": str(exc)}) + return JSONResponse(status_code=HTTP_404_NOT_FOUND, content={"message": str(exc)}) @app.exception_handler(TokenAlreadyExistsError) @app.exception_handler(ValueError) async def user_error_handler(request: Request, exc: TokenAlreadyExistsError | ValueError) -> JSONResponse: """Handle errors when customer requests options that cannot be processed.""" - return JSONResponse(status_code=400, content={"message": str(exc)}) + return JSONResponse(status_code=HTTP_400_BAD_REQUEST, content={"message": str(exc)}) @app.post(path="/{username}") @@ -73,7 +74,7 @@ async def create_token_for_user( """Admin-only endpoint to create token for a specific user.""" # Get current user from AWS API Gateway context if "aws.event" not in request.scope: - raise HTTPException(status_code=401, detail="Unauthorized") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized") event = request.scope["aws.event"] current_user, is_admin_user, _ = get_user_context(event) @@ -88,7 +89,7 @@ async def create_own_token(request: Request, create_request: CreateTokenUserRequ """User endpoint to create their own token - requires API group membership.""" # Get current user from AWS API Gateway context if "aws.event" not in request.scope: - raise HTTPException(status_code=401, detail="Unauthorized") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized") event = request.scope["aws.event"] current_user, is_admin_user, user_groups = get_user_context(event) @@ -104,7 +105,7 @@ async def list_tokens(request: Request) -> ListTokensResponse: """List tokens - admins see all, users see only their own.""" # Get current user from AWS API Gateway context if "aws.event" not in request.scope: - raise HTTPException(status_code=401, detail="Unauthorized") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized") event = request.scope["aws.event"] current_user, is_admin_user, _ = get_user_context(event) @@ -120,7 +121,7 @@ async def get_token( """Get specific token details.""" # Get current user from AWS API Gateway context if "aws.event" not in request.scope: - raise HTTPException(status_code=401, detail="Unauthorized") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized") event = request.scope["aws.event"] current_user, is_admin_user, _ = get_user_context(event) @@ -136,7 +137,7 @@ async def delete_token( """Delete a token.""" # Get current user from AWS API Gateway context if "aws.event" not in request.scope: - raise HTTPException(status_code=401, detail="Unauthorized") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Unauthorized") event = request.scope["aws.event"] current_user, is_admin_user, _ = get_user_context(event) diff --git a/lambda/configuration/lambda_functions.py b/lambda/configuration/lambda_functions.py index 951392507..a70c0d7dd 100644 --- a/lambda/configuration/lambda_functions.py +++ b/lambda/configuration/lambda_functions.py @@ -26,7 +26,7 @@ from mcp_workbench.lambda_functions import MCPWORKBENCH_UUID from utilities.auth import is_admin from utilities.common_functions import api_wrapper, get_property_path, retry_config -from utilities.exceptions import HTTPException +from utilities.exceptions import ForbiddenException logger = logging.getLogger(__name__) @@ -73,7 +73,7 @@ def update_configuration(event: dict, context: dict) -> dict[str, str]: # Only admins can update global configuration if config_scope == "global" and not is_admin(event): - raise HTTPException(status_code=403, message="Only admins can update global configuration") + raise ForbiddenException("Only admins can update global configuration") body["created_at"] = str(Decimal(time.time())) diff --git a/lambda/mcp_server/lambda_functions.py b/lambda/mcp_server/lambda_functions.py index 36b614b83..909f58bb5 100644 --- a/lambda/mcp_server/lambda_functions.py +++ b/lambda/mcp_server/lambda_functions.py @@ -28,6 +28,13 @@ from boto3.dynamodb.conditions import Attr, Key from utilities.auth import admin_only, get_user_context from utilities.common_functions import api_wrapper, get_bearer_token, get_item, retry_config +from utilities.exceptions import ( + BadRequestException, + ConflictException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, +) from .models import ( HostedMcpServerModel, @@ -169,7 +176,7 @@ def get(event: dict, context: dict) -> Any: item = get_item(response) if item is None: - raise ValueError(f"MCP Server {mcp_server_id} not found.") + raise NotFoundException(f"MCP Server {mcp_server_id} not found.") # Check if the user is authorized to get the mcp server is_owner = item["owner"] == user_id or item["owner"] == "lisa:public" @@ -187,7 +194,7 @@ def get(event: dict, context: dict) -> Any: return item - raise ValueError(f"Not authorized to get {mcp_server_id}.") + raise ForbiddenException(f"Not authorized to get {mcp_server_id}.") def _is_member(user_groups: list[str], prompt_groups: list[str]) -> bool: @@ -254,14 +261,14 @@ def update(event: dict, context: dict) -> Any: mcp_server_model = McpServerModel(**body) if mcp_server_id != mcp_server_model.id: - raise ValueError(f"URL id {mcp_server_id} doesn't match body id {mcp_server_model.id}") + raise BadRequestException(f"URL id {mcp_server_id} doesn't match body id {mcp_server_model.id}") # Query for the latest mcp server revision response = table.query(KeyConditionExpression=Key("id").eq(mcp_server_id), Limit=1, ScanIndexForward=False) item = get_item(response) if item is None: - raise ValueError(f"MCP Server {mcp_server_model} not found.") + raise NotFoundException(f"MCP Server {mcp_server_id} not found.") # Check if the user is authorized to update the mcp server if is_admin_user or item["owner"] == user_id: @@ -273,7 +280,7 @@ def update(event: dict, context: dict) -> Any: table.put_item(Item=mcp_server_model.model_dump(exclude_none=True)) return mcp_server_model.model_dump() - raise ValueError(f"Not authorized to update {mcp_server_id}.") + raise ForbiddenException(f"Not authorized to update {mcp_server_id}.") @api_wrapper @@ -287,7 +294,7 @@ def delete(event: dict, context: dict) -> dict[str, str]: item = get_item(response) if item is None: - raise ValueError(f"MCP Server {mcp_server_id} not found.") + raise NotFoundException(f"MCP Server {mcp_server_id} not found.") # Check if the user is authorized to delete the mcp server if is_admin_user or item["owner"] == user_id: @@ -295,7 +302,7 @@ def delete(event: dict, context: dict) -> dict[str, str]: table.delete_item(Key={"id": mcp_server_id, "owner": item.get("owner")}) return {"status": "ok"} - raise ValueError(f"Not authorized to delete {mcp_server_id}.") + raise ForbiddenException(f"Not authorized to delete {mcp_server_id}.") def get_mcp_server_id(event: dict) -> str: @@ -320,7 +327,7 @@ def create_hosted_mcp_server(event: dict, context: dict) -> Any: # Check if normalized name is unique normalized_name = _normalize_server_name(hosted_server_model.name) if not normalized_name: - raise ValueError("Server name must contain at least one alphanumeric character.") + raise BadRequestException("Server name must contain at least one alphanumeric character.") # Scan all items to check for duplicate normalized names items = [] @@ -339,7 +346,7 @@ def create_hosted_mcp_server(event: dict, context: dict) -> Any: existing_name = item.get("name", "") existing_normalized = _normalize_server_name(existing_name) if existing_normalized == normalized_name and item.get("id") != body["id"]: - raise ValueError( + raise ConflictException( f"Server name '{hosted_server_model.name}' conflicts with existing server '{existing_name}'. " f"Normalized names must be unique (alphanumeric characters only)." ) @@ -350,7 +357,7 @@ def create_hosted_mcp_server(event: dict, context: dict) -> Any: # kick off state machine sfn_arn = os.environ.get("CREATE_MCP_SERVER_SFN_ARN") if not sfn_arn: - raise ValueError("CREATE_MCP_SERVER_SFN_ARN not configured") + raise InternalServerErrorException("CREATE_MCP_SERVER_SFN_ARN not configured") stepfunctions.start_execution( stateMachineArn=sfn_arn, input=json.dumps(hosted_server_model.model_dump(exclude_none=True)), @@ -359,7 +366,7 @@ def create_hosted_mcp_server(event: dict, context: dict) -> Any: result = hosted_server_model.model_dump(exclude_none=True) result["status"] = HostedMcpServerStatus.CREATING return result - raise ValueError(f"Not authorized to create hosted MCP server. User {user_id} is not an admin.") + raise ForbiddenException(f"Not authorized to create hosted MCP server. User {user_id} is not an admin.") @api_wrapper @@ -385,7 +392,7 @@ def list_hosted_mcp_servers(event: dict, context: dict) -> dict[str, Any]: return {"Items": items} - raise ValueError(f"Not authorized to list hosted MCP servers. User {user_id} is not an admin.") + raise ForbiddenException(f"Not authorized to list hosted MCP servers. User {user_id} is not an admin.") @api_wrapper @@ -400,13 +407,15 @@ def get_hosted_mcp_server(event: dict, context: dict) -> Any: item = get_item(response) if item is None: - raise ValueError(f"Hosted MCP Server {mcp_server_id} not found.") + raise NotFoundException(f"Hosted MCP Server {mcp_server_id} not found.") # Check if the user is authorized to get the hosted mcp server if is_admin_user: return item - raise ValueError(f"Not authorized to get hosted MCP server {mcp_server_id}. User {user_id} is not an admin.") + raise ForbiddenException( + f"Not authorized to get hosted MCP server {mcp_server_id}. User {user_id} is not an admin." + ) @api_wrapper @@ -421,7 +430,7 @@ def delete_hosted_mcp_server(event: dict, context: dict) -> Any: item = get_item(response) if item is None: - raise ValueError(f"Hosted MCP Server {mcp_server_id} not found.") + raise NotFoundException(f"Hosted MCP Server {mcp_server_id} not found.") # Validate server status - only allow deletion if in specific states server_status = item.get("status", "") @@ -431,7 +440,7 @@ def delete_hosted_mcp_server(event: dict, context: dict) -> Any: HostedMcpServerStatus.FAILED, ] if server_status not in allowed_statuses: - raise ValueError( + raise ConflictException( f"Cannot delete server {mcp_server_id} with status '{server_status}'. " f"Only servers with status '{HostedMcpServerStatus.IN_SERVICE}', " f"'{HostedMcpServerStatus.STOPPED}', or '{HostedMcpServerStatus.FAILED}' can be deleted." @@ -440,7 +449,7 @@ def delete_hosted_mcp_server(event: dict, context: dict) -> Any: # Kick off state machine sfn_arn = os.environ.get("DELETE_MCP_SERVER_SFN_ARN") if not sfn_arn: - raise ValueError("DELETE_MCP_SERVER_SFN_ARN not configured") + raise InternalServerErrorException("DELETE_MCP_SERVER_SFN_ARN not configured") stepfunctions.start_execution( stateMachineArn=sfn_arn, @@ -462,13 +471,13 @@ def update_hosted_mcp_server(event: dict, context: dict) -> Any: item = get_item(response) if item is None: - raise ValueError(f"Hosted MCP Server {mcp_server_id} not found.") + raise NotFoundException(f"Hosted MCP Server {mcp_server_id} not found.") server_status = item.get("status", "") # Validate server is not actively mutating or failed before starting if server_status not in (HostedMcpServerStatus.IN_SERVICE, HostedMcpServerStatus.STOPPED): - raise ValueError( + raise ConflictException( f"Server cannot be updated when it is not in the '{HostedMcpServerStatus.IN_SERVICE}' or " f"'{HostedMcpServerStatus.STOPPED}' states" ) @@ -481,13 +490,17 @@ def update_hosted_mcp_server(event: dict, context: dict) -> Any: if update_request.enabled is not None: # Force capacity changes and enable/disable operations to happen in separate requests if update_request.autoScalingConfig is not None: - raise ValueError("Start or Stop operations and AutoScaling changes must happen in separate requests.") + raise BadRequestException( + "Start or Stop operations and AutoScaling changes must happen in separate requests." + ) # Server cannot be enabled if it isn't already stopped if update_request.enabled and server_status != HostedMcpServerStatus.STOPPED: - raise ValueError(f"Server cannot be enabled when it is not in the '{HostedMcpServerStatus.STOPPED}' state.") + raise ConflictException( + f"Server cannot be enabled when it is not in the '{HostedMcpServerStatus.STOPPED}' state." + ) # Server cannot be stopped if it isn't already in service elif not update_request.enabled and server_status != HostedMcpServerStatus.IN_SERVICE: - raise ValueError( + raise ConflictException( f"Server cannot be stopped when it is not in the '{HostedMcpServerStatus.IN_SERVICE}' state." ) @@ -495,7 +508,9 @@ def update_hosted_mcp_server(event: dict, context: dict) -> Any: if update_request.autoScalingConfig is not None: stack_name = item.get("stack_name") if not stack_name: - raise ValueError("Cannot update AutoScaling Config for server that does not have a CloudFormation stack.") + raise BadRequestException( + "Cannot update AutoScaling Config for server that does not have a CloudFormation stack." + ) asg_config = update_request.autoScalingConfig.model_dump(exclude_none=True) current_asg_config = item.get("autoScalingConfig", {}) @@ -505,13 +520,15 @@ def update_hosted_mcp_server(event: dict, context: dict) -> Any: max_capacity = asg_config.get("maxCapacity", current_asg_config.get("maxCapacity", 1)) if min_capacity > max_capacity: - raise ValueError(f"Min capacity ({min_capacity}) cannot be greater than max capacity ({max_capacity}).") + raise BadRequestException( + f"Min capacity ({min_capacity}) cannot be greater than max capacity ({max_capacity})." + ) # Validate min and max are positive if min_capacity < 1: - raise ValueError("Min capacity must be at least 1.") + raise BadRequestException("Min capacity must be at least 1.") if max_capacity < 1: - raise ValueError("Max capacity must be at least 1.") + raise BadRequestException("Max capacity must be at least 1.") # Validate container config updates if ( @@ -522,12 +539,14 @@ def update_hosted_mcp_server(event: dict, context: dict) -> Any: ): stack_name = item.get("stack_name") if not stack_name: - raise ValueError("Cannot update container config for server that does not have a CloudFormation stack.") + raise BadRequestException( + "Cannot update container config for server that does not have a CloudFormation stack." + ) # Kick off state machine sfn_arn = os.environ.get("UPDATE_MCP_SERVER_SFN_ARN") if not sfn_arn: - raise ValueError("UPDATE_MCP_SERVER_SFN_ARN not configured") + raise InternalServerErrorException("UPDATE_MCP_SERVER_SFN_ARN not configured") # Package server ID and request payload into single payload for step functions state_machine_payload = {"server_id": mcp_server_id, "update_payload": update_request.model_dump()} diff --git a/lambda/mcp_workbench/lambda_functions.py b/lambda/mcp_workbench/lambda_functions.py index 4d6ebf3ad..36fe2a383 100644 --- a/lambda/mcp_workbench/lambda_functions.py +++ b/lambda/mcp_workbench/lambda_functions.py @@ -25,7 +25,12 @@ from pydantic import BaseModel, Field from utilities.auth import is_admin from utilities.common_functions import api_wrapper, retry_config -from utilities.exceptions import HTTPException +from utilities.exceptions import ( + BadRequestException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, +) from utilities.time import iso_string from .syntax_validator import PythonSyntaxValidator @@ -77,46 +82,41 @@ def _get_tool_from_s3(tool_id: str) -> MCPToolModel: except botocore.exceptions.ClientError as e: code = e.response.get("Error", {}).get("Code") if code in ("NoSuchKey", "404"): - raise HTTPException(status_code=404, message=f"Tool {tool_id} not found in S3 bucket.") from e - # Log and re-raise as ValueError to keep the function's contract + raise NotFoundException(f"Tool {tool_id} not found in S3 bucket.") from e logger.error("Error retrieving tool from S3: %s", e, exc_info=True) - raise ValueError(f"Failed to retrieve tool: {e}") from e + raise InternalServerErrorException(f"Failed to retrieve tool: {e}") from e except Exception as e: logger.error("Unexpected error retrieving tool from S3: %s", e, exc_info=True) - raise ValueError(f"Failed to retrieve tool: {e}") from e + raise InternalServerErrorException(f"Failed to retrieve tool: {e}") from e @api_wrapper def read(event: dict, context: dict) -> Any: """Retrieve a specific tool from S3.""" if not is_admin(event): - raise ValueError("Only admin users can access tools.") + raise ForbiddenException("Only admin users can access tools.") tool_id = event.get("pathParameters", {}).get("toolId") if not tool_id: - raise ValueError("Missing toolId parameter.") + raise BadRequestException("Missing toolId parameter.") logger.info(f"Reading tool with ID: {tool_id}") try: tool = _get_tool_from_s3(tool_id) return tool.model_dump() - except HTTPException: - # Let HTTPException pass through for proper status codes + except (NotFoundException, ForbiddenException, BadRequestException, InternalServerErrorException): raise - except ValueError as e: - # This is likely from _get_tool_from_s3 already properly formatted - raise e except Exception as e: logger.error("Unexpected error reading tool: %s", e, exc_info=True) - raise ValueError(f"Failed to read tool: {e}") from e + raise InternalServerErrorException(f"Failed to read tool: {e}") from e @api_wrapper def list(event: dict, context: dict) -> dict[str, Any]: """List all tools from S3.""" if not is_admin(event): - raise ValueError("Only admin users can access tools.") + raise ForbiddenException("Only admin users can access tools.") try: response = s3_client.list_objects_v2(Bucket=WORKBENCH_BUCKET, Prefix="") @@ -138,24 +138,24 @@ def list(event: dict, context: dict) -> dict[str, Any]: return {"tools": tools} except botocore.exceptions.ClientError as e: logger.error("Error listing tools from S3: %s", e, exc_info=True) - raise ValueError(f"Failed to list tools: {e}") from e + raise InternalServerErrorException(f"Failed to list tools: {e}") from e except Exception as e: logger.error("Unexpected error listing tools: %s", e, exc_info=True) - raise ValueError(f"Failed to list tools: {e}") from e + raise InternalServerErrorException(f"Failed to list tools: {e}") from e @api_wrapper def create(event: dict, context: dict) -> Any: """Create a new tool in S3.""" if not is_admin(event): - raise ValueError("Only admin users can access tools.") + raise ForbiddenException("Only admin users can access tools.") try: body = json.loads(event["body"], parse_float=Decimal) # Ensure the required fields are present if "id" not in body or "contents" not in body: - raise ValueError("Missing required fields: 'id' and 'contents' are required.") + raise BadRequestException("Missing required fields: 'id' and 'contents' are required.") # Create the tool model tool_model = MCPToolModel(id=body["id"], contents=body["contents"]) @@ -171,37 +171,36 @@ def create(event: dict, context: dict) -> Any: return tool_model.model_dump() except botocore.exceptions.ClientError as e: logger.error("Error creating tool in S3: %s", e, exc_info=True) - raise ValueError(f"Failed to create tool: {e}") from e + raise InternalServerErrorException(f"Failed to create tool: {e}") from e except json.JSONDecodeError as e: logger.error("Invalid JSON in request body: %s", e, exc_info=True) - raise ValueError(f"Invalid request body: {e}") from e + raise BadRequestException(f"Invalid request body: {e}") from e + except (NotFoundException, ForbiddenException, BadRequestException, InternalServerErrorException): + raise except Exception as e: logger.error("Unexpected error creating tool: %s", e, exc_info=True) - raise ValueError(f"Failed to create tool: {e}") from e + raise InternalServerErrorException(f"Failed to create tool: {e}") from e @api_wrapper def update(event: dict, context: dict) -> Any: """Update an existing tool in S3.""" if not is_admin(event): - raise ValueError("Only admin users can access tools.") + raise ForbiddenException("Only admin users can access tools.") try: tool_id = event.get("pathParameters", {}).get("toolId") if not tool_id: - raise ValueError("Missing toolId parameter.") + raise BadRequestException("Missing toolId parameter.") body = json.loads(event["body"], parse_float=Decimal) # Ensure the contents field is present if "contents" not in body: - raise ValueError("Missing required field: 'contents' is required.") + raise BadRequestException("Missing required field: 'contents' is required.") - # Check if the tool exists - try: - _get_tool_from_s3(tool_id) - except HTTPException: - raise HTTPException(status_code=404, message=f"Tool {tool_id} does not exist.") + # Check if the tool exists (will raise NotFoundException if not found) + _get_tool_from_s3(tool_id) # Create updated tool model tool_model = MCPToolModel(id=tool_id, contents=body["contents"]) @@ -215,66 +214,67 @@ def update(event: dict, context: dict) -> Any: ) return tool_model.model_dump() + except (NotFoundException, ForbiddenException, BadRequestException, InternalServerErrorException): + raise except botocore.exceptions.ClientError as e: logger.error("Error updating tool in S3: %s", e, exc_info=True) - raise ValueError(f"Failed to update tool: {e}") from e + raise InternalServerErrorException(f"Failed to update tool: {e}") from e except json.JSONDecodeError as e: logger.error("Invalid JSON in request body: %s", e, exc_info=True) - raise ValueError(f"Invalid request body: {e}") from e + raise BadRequestException(f"Invalid request body: {e}") from e except Exception as e: logger.error("Unexpected error updating tool: %s", e, exc_info=True) - raise ValueError(f"Failed to update tool: {e}") from e + raise InternalServerErrorException(f"Failed to update tool: {e}") from e @api_wrapper def delete(event: dict, context: dict) -> dict[str, str]: """Delete a tool from S3.""" if not is_admin(event): - raise ValueError("Only admin users can access tools.") + raise ForbiddenException("Only admin users can access tools.") try: tool_id = event.get("pathParameters", {}).get("toolId") if not tool_id: - raise ValueError("Missing toolId parameter.") + raise BadRequestException("Missing toolId parameter.") # Ensure the tool_id ends with .py if not tool_id.endswith(".py"): tool_id = f"{tool_id}.py" - # Check if the tool exists before deletion - try: - _get_tool_from_s3(tool_id) - except HTTPException: - raise HTTPException(status_code=404, message=f"Tool {tool_id} does not exist.") + # Check if the tool exists before deletion (will raise NotFoundException if not found) + _get_tool_from_s3(tool_id) # Delete from S3 s3_client.delete_object(Bucket=WORKBENCH_BUCKET, Key=tool_id) return {"status": "ok", "message": f"Tool {tool_id} deleted successfully."} + except (NotFoundException, ForbiddenException, BadRequestException, InternalServerErrorException): + raise except botocore.exceptions.ClientError as e: logger.error("Error deleting tool from S3: %s", e, exc_info=True) - raise ValueError(f"Failed to delete tool: {e}") from e + raise InternalServerErrorException(f"Failed to delete tool: {e}") from e except Exception as e: logger.error("Unexpected error deleting tool: %s", e, exc_info=True) - raise ValueError(f"Failed to delete tool: {e}") from e + raise InternalServerErrorException(f"Failed to delete tool: {e}") from e @api_wrapper def validate_syntax(event: dict, context: dict) -> dict[str, Any]: """Validate Python code syntax without execution.""" if not is_admin(event): - raise ValueError("Only admin users can validate code syntax.") + raise ForbiddenException("Only admin users can validate code syntax.") try: body = json.loads(event["body"], parse_float=Decimal) # Ensure the required field is present if "code" not in body: - raise ValueError("Missing required field: 'code' is required.") + raise BadRequestException("Missing required field: 'code' is required.") code = body["code"] if not isinstance(code, str): - raise ValueError("Code must be a string.") + raise BadRequestException("Code must be a string.") logger.info("Validating Python code syntax") @@ -294,9 +294,11 @@ def validate_syntax(event: dict, context: dict) -> dict[str, Any]: return response + except (NotFoundException, ForbiddenException, BadRequestException, InternalServerErrorException): + raise except json.JSONDecodeError as e: logger.error("Invalid JSON in request body: %s", e, exc_info=True) - raise ValueError(f"Invalid request body: {e}") from e + raise BadRequestException(f"Invalid request body: {e}") from e except Exception as e: logger.error("Unexpected error validating syntax: %s", e, exc_info=True) - raise ValueError(f"Failed to validate syntax: {e}") from e + raise InternalServerErrorException(f"Failed to validate syntax: {e}") from e diff --git a/lambda/models/lambda_functions.py b/lambda/models/lambda_functions.py index 8721ab9ef..864084370 100644 --- a/lambda/models/lambda_functions.py +++ b/lambda/models/lambda_functions.py @@ -23,6 +23,7 @@ from fastapi import HTTPException, Path, Request from fastapi.responses import JSONResponse from mangum import Mangum +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT from utilities.auth import get_groups, get_username, is_admin, require_admin from utilities.common_functions import retry_config from utilities.fastapi_factory import create_fastapi_app @@ -85,7 +86,7 @@ def get_admin_status_and_groups(request: Request) -> tuple[bool, list[str]]: @app.exception_handler(ModelNotFoundError) async def model_not_found_handler(request: Request, exc: ModelNotFoundError) -> JSONResponse: """Handle exception when model cannot be found and translate to a 404 error.""" - return JSONResponse(status_code=404, content={"detail": str(exc)}) + return JSONResponse(status_code=HTTP_404_NOT_FOUND, content={"detail": str(exc)}) @app.exception_handler(InvalidStateTransitionError) @@ -95,13 +96,13 @@ async def user_error_handler( request: Request, exc: InvalidStateTransitionError | ModelAlreadyExistsError | ValueError ) -> JSONResponse: """Handle errors when customer requests options that cannot be processed.""" - return JSONResponse(status_code=400, content={"detail": str(exc)}) + return JSONResponse(status_code=HTTP_400_BAD_REQUEST, content={"detail": str(exc)}) @app.exception_handler(ModelInUseError) async def model_in_use_handler(request: Request, exc: ModelInUseError) -> JSONResponse: """Handle exception when attempting to delete a model that is in use.""" - return JSONResponse(status_code=409, content={"detail": str(exc)}) + return JSONResponse(status_code=HTTP_409_CONFLICT, content={"detail": str(exc)}) @app.post(path="", include_in_schema=False) @@ -206,7 +207,7 @@ async def create_model(create_request: CreateModelRequest, request: Request) -> "error": str(e), }, ) - raise HTTPException(status_code=409, detail=str(e)) + raise HTTPException(status_code=HTTP_409_CONFLICT, detail=str(e)) except Exception as e: # Log unexpected failure logger.error( @@ -253,7 +254,7 @@ async def get_model( try: return get_handler(model_id=model_id, user_groups=user_groups, is_admin=admin_status) except ModelNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=str(e)) @app.put(path="/{model_id}") @@ -273,9 +274,9 @@ async def update_model( try: return update_handler(model_id=model_id, update_request=update_request) except ModelNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=str(e)) except InvalidStateTransitionError as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(e)) @app.delete(path="/{model_id}") @@ -293,9 +294,9 @@ async def delete_model( try: return delete_handler(model_id=model_id) except ModelInUseError as e: - raise HTTPException(status_code=409, detail=str(e)) + raise HTTPException(status_code=HTTP_409_CONFLICT, detail=str(e)) except ModelNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=str(e)) @app.get(path="/metadata/instances") diff --git a/lambda/prompt_templates/lambda_functions.py b/lambda/prompt_templates/lambda_functions.py index 62afe3057..801fb7f66 100644 --- a/lambda/prompt_templates/lambda_functions.py +++ b/lambda/prompt_templates/lambda_functions.py @@ -26,6 +26,7 @@ from boto3.dynamodb.conditions import Attr, Key from utilities.auth import get_user_context, get_username from utilities.common_functions import api_wrapper, get_item, retry_config +from utilities.exceptions import BadRequestException, ForbiddenException, NotFoundException from .models import PromptTemplateModel @@ -95,7 +96,7 @@ def get(event: dict, context: dict) -> Any: item = get_item(response) if item is None: - raise ValueError(f"Prompt template {prompt_template_id} not found.") + raise NotFoundException(f"Prompt template {prompt_template_id} not found.") # Check if the user is authorized to get the prompt template is_owner = item["owner"] == user_id @@ -105,7 +106,7 @@ def get(event: dict, context: dict) -> Any: item["isOwner"] = True return item - raise ValueError(f"Not authorized to get {prompt_template_id}.") + raise ForbiddenException(f"Not authorized to get {prompt_template_id}.") def is_member(user_groups: list[str], prompt_groups: list[str]) -> bool: @@ -156,14 +157,14 @@ def update(event: dict, context: dict) -> Any: prompt_template_model = PromptTemplateModel(**body) if prompt_template_id != prompt_template_model.id: - raise ValueError(f"URL id {prompt_template_id} doesn't match body id {prompt_template_model.id}") + raise BadRequestException(f"URL id {prompt_template_id} doesn't match body id {prompt_template_model.id}") # Query for the latest prompt template revision response = table.query(KeyConditionExpression=Key("id").eq(prompt_template_id), Limit=1, ScanIndexForward=False) item = get_item(response) if item is None: - raise ValueError(f"Prompt template {prompt_template_model} not found.") + raise NotFoundException(f"Prompt template {prompt_template_id} not found.") # Check if the user is authorized to update the prompt template if is_admin or item["owner"] == user_id: @@ -184,7 +185,7 @@ def update(event: dict, context: dict) -> Any: response = table.put_item(Item=prompt_template_model.model_dump(exclude_none=True)) return prompt_template_model.model_dump() - raise ValueError(f"Not authorized to update {prompt_template_id}.") + raise ForbiddenException(f"Not authorized to update {prompt_template_id}.") @api_wrapper @@ -198,7 +199,7 @@ def delete(event: dict, context: dict) -> dict[str, str]: item = get_item(response) if item is None: - raise ValueError(f"Prompt template {prompt_template_id} not found.") + raise NotFoundException(f"Prompt template {prompt_template_id} not found.") # Check if the user is authorized to delete the prompt template if is_admin or item["owner"] == user_id: @@ -212,7 +213,7 @@ def delete(event: dict, context: dict) -> dict[str, str]: return {"status": "ok"} - raise ValueError(f"Not authorized to delete {prompt_template_id}.") + raise ForbiddenException(f"Not authorized to delete {prompt_template_id}.") def get_prompt_template_id(event: dict) -> str: diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index c9133d05f..d9b04c2fb 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -58,7 +58,7 @@ ) from utilities.bedrock_kb_validation import validate_bedrock_kb_exists from utilities.common_functions import api_wrapper, retry_config -from utilities.exceptions import HTTPException +from utilities.exceptions import ForbiddenException, NotFoundException from utilities.repository_types import RepositoryType from utilities.validation import ValidationError @@ -224,7 +224,7 @@ def get_repository(event: dict[str, Any], repository_id: str) -> dict[str, Any]: # Non-admins must have matching group access user_groups = get_groups(event) if not user_has_group_access(user_groups, repo.get("allowedGroups", [])): - raise HTTPException(status_code=403, message="User does not have permission to access this repository") + raise ForbiddenException("User does not have permission to access this repository") return repo @@ -301,7 +301,7 @@ def create_bedrock_collection(event: dict, context: dict) -> dict[str, Any]: logger.info(f"Collection {collection_id} already exists, skipping creation") skipped_collections.append(existing_collection.model_dump(mode="json")) continue - except (HTTPException, ValidationError): + except (NotFoundException, ValidationError): # Collection doesn't exist, proceed with creation pass @@ -482,9 +482,7 @@ def get_collection(event: dict, context: dict) -> dict[str, Any]: ) if collection is None: - raise HTTPException( - status_code=404, message=f"Collection '{collection_id}' not found in repository '{repository_id}'" - ) + raise NotFoundException(f"Collection '{collection_id}' not found in repository '{repository_id}'") # Return collection configuration result: dict[str, Any] = collection.model_dump(mode="json") diff --git a/lambda/repository/services/bedrock_kb_repository_service.py b/lambda/repository/services/bedrock_kb_repository_service.py index e4fe516b4..cea08b2b8 100644 --- a/lambda/repository/services/bedrock_kb_repository_service.py +++ b/lambda/repository/services/bedrock_kb_repository_service.py @@ -32,7 +32,7 @@ ) from repository.rag_document_repo import RagDocumentRepository from utilities.bedrock_kb import bulk_delete_documents_from_kb, delete_document_from_kb -from utilities.exceptions import HTTPException +from utilities.exceptions import ServiceUnavailableException from utilities.time import now, utc_now from .repository_service import RepositoryService @@ -304,12 +304,9 @@ def retrieve_documents( # Check for Aurora DB auto-pause error if error_code == "ValidationException" and "auto-paused" in error_message.lower(): logger.warning(f"Aurora DB is resuming from auto-pause for KB {kb_id}") - raise HTTPException( - status_code=503, - message=( - "The knowledge base database is currently starting up. " - "Please retry your request in a few moments." - ), + raise ServiceUnavailableException( + "The knowledge base database is currently starting up. " + "Please retry your request in a few moments." ) logger.error(f"Bedrock retrieve failed for KB {kb_id}: {error_message}") diff --git a/lambda/user_preferences/lambda_functions.py b/lambda/user_preferences/lambda_functions.py index 78102b8fc..2d13466ae 100644 --- a/lambda/user_preferences/lambda_functions.py +++ b/lambda/user_preferences/lambda_functions.py @@ -23,6 +23,7 @@ from boto3.dynamodb.conditions import Key from utilities.auth import get_username from utilities.common_functions import api_wrapper, get_item, retry_config +from utilities.exceptions import ForbiddenException from .models import UserPreferencesModel @@ -48,7 +49,7 @@ def get(event: dict, context: dict) -> Any: if item["user"] == user_id: return item - raise ValueError(f"Not authorized to get {user_id}'s preferences.") + raise ForbiddenException(f"Not authorized to get {user_id}'s preferences.") @api_wrapper @@ -77,4 +78,4 @@ def update(event: dict, context: dict) -> Any: table.put_item(Item=user_preferences_model.model_dump(exclude_none=True)) return user_preferences_model.model_dump() - raise ValueError(f"Not authorized to update {user_id}'s preferences.") + raise ForbiddenException(f"Not authorized to update {user_id}'s preferences.") diff --git a/lambda/utilities/auth.py b/lambda/utilities/auth.py index 3cd26dde1..d06744b7f 100644 --- a/lambda/utilities/auth.py +++ b/lambda/utilities/auth.py @@ -25,7 +25,8 @@ from botocore.config import Config from fastapi import HTTPException as FastAPIHTTPException from fastapi import Request -from utilities.exceptions import HTTPException +from starlette.status import HTTP_403_FORBIDDEN, HTTP_500_INTERNAL_SERVER_ERROR +from utilities.exceptions import ForbiddenException from .auth_provider import get_authorization_provider @@ -93,7 +94,7 @@ def admin_only(func: Callable) -> Callable: @wraps(func) def wrapper(event: dict[str, Any], context: dict[str, Any], *args: Any, **kwargs: Any) -> Any: if not is_admin(event): - raise HTTPException(status_code=403, message="User does not have permission to access this repository") + raise ForbiddenException("User does not have permission to access this repository") return func(event, context, *args, **kwargs) return wrapper @@ -136,7 +137,8 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: if request is None: raise FastAPIHTTPException( - status_code=500, detail="Internal error: Request object not found in handler" + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal error: Request object not found in handler", ) # Extract event from request scope @@ -145,7 +147,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: auth_module = sys.modules.get("utilities.auth") is_admin_func = getattr(auth_module, "is_admin", is_admin) if auth_module else is_admin if not is_admin_func(event): - raise FastAPIHTTPException(status_code=403, detail=message) + raise FastAPIHTTPException(status_code=HTTP_403_FORBIDDEN, detail=message) return await func(*args, **kwargs) diff --git a/lambda/utilities/exceptions.py b/lambda/utilities/exceptions.py index c24fae60d..5f96af16d 100644 --- a/lambda/utilities/exceptions.py +++ b/lambda/utilities/exceptions.py @@ -29,14 +29,34 @@ def __init__(self, status_code: int = 400, message: str = "Bad Request") -> None class NotFoundException(HTTPException): def __init__(self, detail: str = "Not Found"): - super().__init__(404, detail) # flake8: noqa + super().__init__(404, detail) class UnauthorizedException(HTTPException): def __init__(self, detail: str = "Unauthorized"): - super().__init__(401, detail) # flake8: noqa + super().__init__(401, detail) class ForbiddenException(HTTPException): def __init__(self, detail: str = "Forbidden"): - super().__init__(403, detail) # flake8: noqa + super().__init__(403, detail) + + +class BadRequestException(HTTPException): + def __init__(self, detail: str = "Bad Request"): + super().__init__(400, detail) + + +class ConflictException(HTTPException): + def __init__(self, detail: str = "Conflict"): + super().__init__(409, detail) + + +class InternalServerErrorException(HTTPException): + def __init__(self, detail: str = "Internal Server Error"): + super().__init__(500, detail) + + +class ServiceUnavailableException(HTTPException): + def __init__(self, detail: str = "Service Unavailable"): + super().__init__(503, detail) diff --git a/lambda/utilities/fastapi_factory.py b/lambda/utilities/fastapi_factory.py index 0bd05c0ba..12358536f 100644 --- a/lambda/utilities/fastapi_factory.py +++ b/lambda/utilities/fastapi_factory.py @@ -14,7 +14,7 @@ """Factory for creating FastAPI applications with standard LISA configuration.""" -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, status from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware @@ -103,24 +103,24 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe @app.exception_handler(UnauthorizedException) async def unauthorized_handler(request: Request, exc: UnauthorizedException) -> JSONResponse: """Handle unauthorized exceptions and translate to a 401 error.""" - return JSONResponse(status_code=401, content={"message": exc.message}) + return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={"message": exc.message}) @app.exception_handler(ForbiddenException) async def forbidden_handler(request: Request, exc: ForbiddenException) -> JSONResponse: """Handle forbidden exceptions and translate to a 403 error.""" - return JSONResponse(status_code=403, content={"message": exc.message}) + return JSONResponse(status_code=status.HTTP_403_FORBIDDEN, content={"message": exc.message}) @app.exception_handler(NotFoundException) async def not_found_handler(request: Request, exc: NotFoundException) -> JSONResponse: """Handle not found exceptions and translate to a 404 error.""" - return JSONResponse(status_code=404, content={"message": exc.message}) + return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": exc.message}) # Request validation errors (422) @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """Handle exception when request fails validation and translate to a 422 error.""" return JSONResponse( - status_code=422, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": jsonable_encoder(exc.errors()), "type": "RequestValidationError"}, ) diff --git a/lambda/utilities/fastapi_middleware/exception_handlers.py b/lambda/utilities/fastapi_middleware/exception_handlers.py index b416446b3..6acbdab0a 100644 --- a/lambda/utilities/fastapi_middleware/exception_handlers.py +++ b/lambda/utilities/fastapi_middleware/exception_handlers.py @@ -18,6 +18,7 @@ from fastapi import Request from fastapi.responses import JSONResponse +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR logger = logging.getLogger(__name__) @@ -54,7 +55,7 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes # Return generic error message to client return JSONResponse( - status_code=500, + status_code=HTTP_500_INTERNAL_SERVER_ERROR, content={ "error": "Internal Server Error", "message": "An unexpected error occurred while processing your request", diff --git a/lambda/utilities/fastapi_middleware/input_validation_middleware.py b/lambda/utilities/fastapi_middleware/input_validation_middleware.py index 43a4b92ad..9a5008d8f 100644 --- a/lambda/utilities/fastapi_middleware/input_validation_middleware.py +++ b/lambda/utilities/fastapi_middleware/input_validation_middleware.py @@ -121,7 +121,7 @@ async def check_request_size(self, request: Request) -> JSONResponse | None: }, ) return JSONResponse( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + status_code=status.HTTP_413_CONTENT_TOO_LARGE, content={ "error": "Payload Too Large", "message": ( diff --git a/lib/core/layers/fastapi/requirements.txt b/lib/core/layers/fastapi/requirements.txt index 0a768869d..c5b5b85e2 100644 --- a/lib/core/layers/fastapi/requirements.txt +++ b/lib/core/layers/fastapi/requirements.txt @@ -4,3 +4,4 @@ fastapi==0.124.2 mangum==0.19.0 pydantic==2.12.5 cryptography==46.0.3 +starlette==0.46.2 diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py b/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py index e78fd5ecc..5a980bd18 100644 --- a/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py +++ b/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py @@ -28,6 +28,7 @@ from loguru import logger from starlette.middleware.base import BaseHTTPMiddleware, DispatchFunction from starlette.responses import JSONResponse, Response +from starlette.status import HTTP_401_UNAUTHORIZED from starlette.types import ASGIApp # The following are field names, not passwords or tokens @@ -177,7 +178,7 @@ async def dispatch(self, request: Request, call_next: Any) -> Response: if not valid: return JSONResponse( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, content={"detail": "Unauthorized"}, headers={ "Access-Control-Allow-Origin": "*", diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/server/mcp_server.py b/lib/serve/mcp-workbench/src/mcpworkbench/server/mcp_server.py index 7ecb4eda3..bfb22ad2a 100644 --- a/lib/serve/mcp-workbench/src/mcpworkbench/server/mcp_server.py +++ b/lib/serve/mcp-workbench/src/mcpworkbench/server/mcp_server.py @@ -27,6 +27,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Mount, Route +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR from ..config.models import ServerConfig from ..core.base_tool import BaseTool @@ -131,7 +132,7 @@ async def rescan_endpoint(request: Request) -> JSONResponse: "error": str(e), "timestamp": datetime.utcnow().isoformat() + "Z", } - return JSONResponse(error_result, status_code=500) + return JSONResponse(error_result, status_code=HTTP_500_INTERNAL_SERVER_ERROR) app.add_route(self.config.rescan_route_path, rescan_endpoint, methods=["GET"]) diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py b/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py index 603d026bf..798e9c119 100644 --- a/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py +++ b/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py @@ -24,6 +24,7 @@ from starlette.middleware.cors import CORSMiddleware as StarletteCORSMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR from ..config.models import CORSConfig from ..core.tool_discovery import ToolDiscovery @@ -134,7 +135,7 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: logger.error(f"Error during rescan: {e}") return JSONResponse( {"status": "error", "message": f"Rescan failed: {str(e)}", "timestamp": datetime.now().isoformat()}, - status_code=500, + status_code=HTTP_500_INTERNAL_SERVER_ERROR, ) # Continue with normal request processing diff --git a/lib/serve/rest-api/src/api/endpoints/v1/embeddings.py b/lib/serve/rest-api/src/api/endpoints/v1/embeddings.py index 454fd1ced..1d7932b76 100644 --- a/lib/serve/rest-api/src/api/endpoints/v1/embeddings.py +++ b/lib/serve/rest-api/src/api/endpoints/v1/embeddings.py @@ -18,6 +18,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse +from starlette.status import HTTP_200_OK from ....handlers.embeddings import handle_embeddings from ....utils.resources import EmbeddingsRequest, RestApiResource @@ -32,4 +33,4 @@ async def embeddings(request: EmbeddingsRequest) -> JSONResponse: """Text embeddings.""" response = await handle_embeddings(request.dict()) - return JSONResponse(content=response, status_code=200) + return JSONResponse(content=response, status_code=HTTP_200_OK) diff --git a/lib/serve/rest-api/src/api/endpoints/v1/generation.py b/lib/serve/rest-api/src/api/endpoints/v1/generation.py index d80ff6f14..85ed986cd 100644 --- a/lib/serve/rest-api/src/api/endpoints/v1/generation.py +++ b/lib/serve/rest-api/src/api/endpoints/v1/generation.py @@ -18,6 +18,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse, StreamingResponse +from starlette.status import HTTP_200_OK from ....handlers.generation import handle_generate, handle_generate_stream, handle_openai_generate_stream from ....utils.resources import ( @@ -38,7 +39,7 @@ async def generate(request: GenerateRequest) -> JSONResponse: """Text generation.""" response = await handle_generate(request.dict()) - return JSONResponse(content=response, status_code=200) + return JSONResponse(content=response, status_code=HTTP_200_OK) @router.post(f"/{RestApiResource.GENERATE_STREAM}") diff --git a/lib/serve/rest-api/src/api/endpoints/v1/models.py b/lib/serve/rest-api/src/api/endpoints/v1/models.py index 68f7ad1e4..3fdef4ed7 100644 --- a/lib/serve/rest-api/src/api/endpoints/v1/models.py +++ b/lib/serve/rest-api/src/api/endpoints/v1/models.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Query from fastapi.responses import JSONResponse +from starlette.status import HTTP_200_OK from ....handlers.models import ( handle_describe_model, @@ -48,7 +49,7 @@ async def describe_model( """Describe model by provider and model name.""" response = await handle_describe_model(provider, model_name) - return JSONResponse(content=response, status_code=200) + return JSONResponse(content=response, status_code=HTTP_200_OK) @router.get(f"/{RestApiResource.DESCRIBE_MODELS}") @@ -65,7 +66,7 @@ async def describe_models( response = await handle_describe_models(model_types) - return JSONResponse(content=response, status_code=200) + return JSONResponse(content=response, status_code=HTTP_200_OK) @router.get(f"/{RestApiResource.LIST_MODELS}") @@ -82,11 +83,11 @@ async def list_models( response = await handle_list_models(model_types) - return JSONResponse(content=response, status_code=200) + return JSONResponse(content=response, status_code=HTTP_200_OK) @router.get(f"/{RestApiResource.OPENAI_LIST_MODELS}") async def openai_list_models() -> JSONResponse: """List models for OpenAI Compatibility. Only returns TEXTGEN models.""" response = await handle_openai_list_models() - return JSONResponse(content=response, status_code=200) + return JSONResponse(content=response, status_code=HTTP_200_OK) diff --git a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py index 8617dd559..ba3b581bb 100644 --- a/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py +++ b/lib/serve/rest-api/src/api/endpoints/v2/litellm_passthrough.py @@ -14,7 +14,6 @@ """Model invocation routes.""" -import fnmatch import json import logging import os @@ -22,11 +21,11 @@ from collections.abc import Iterator import boto3 -from auth import Authorizer, extract_user_groups_from_jwt +from auth import extract_user_groups_from_jwt from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, Response, StreamingResponse from requests import request as requests_request -from starlette.status import HTTP_401_UNAUTHORIZED +from starlette.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from utils.guardrails import ( create_guardrail_json_response, create_guardrail_streaming_response, @@ -36,71 +35,12 @@ is_guardrail_violation, ) from utils.metrics import publish_metrics_event +from utils.route_utils import is_anthropic_route, is_chat_route, is_lisa_public_route, is_openai_route # Local LiteLLM installation URL. By default, LiteLLM runs on port 4000. Change the port here if the # port was changed as part of the LiteLLM startup in entrypoint.sh LITELLM_URL = "http://localhost:4000" -# The following is an allowlist of OpenAI routes that users would not need elevated permissions to invoke. This is so -# that we may assume anything *not* in this allowlist is an admin operation that requires greater LiteLLM permissions. -# Assume that anything not within these routes requires admin permissions, which would only come from the LISA model -# management API. -OPENAI_ROUTES = ( - # List models - "models", - "v1/models", - # Model Info - "model/info", - "v1/model/info", - # Text completions - "chat/completions", - "v1/chat/completions", - "completions", - "v1/completions", - # Embeddings - "embeddings", - "v1/embeddings", - # Create images - "images/generations", - "v1/images/generations", - "images/edits", - "v1/images/edits", - # Audio routes - "audio/speech", - "v1/audio/speech", - "audio/transcriptions", - "v1/audio/transcriptions", - # Video routes (using wildcards for IDs) - "videos", - "v1/videos", - "videos/*", - "v1/videos/*", - "videos/*/content", - "v1/videos/*/content", - "videos/*/remix", - "v1/videos/*/remix", - # Health check routes - "health", - "health/readiness", - "health/liveliness", - # MCP - "mcp/enabled", - "mcp/tools/list", - "mcp/tools/call", - "v1/mcp/server", -) - -# Specific routes for anthropic (Claude Code compatibility) -# LiteLLM's request/response format: https://litellm-api.up.railway.app/#/ -ANTHROPIC_ROUTES = ( - # Anthropic Messages API - "v1/messages", - "v1/messages/count_tokens", - # Anthropic Messages API with prefix - "anthropic/v1/messages", - "anthropic/v1/messages/count_tokens", -) - # With the introduction of the LiteLLM database for model configurations, it forces a requirement to have a # LiteLLM-vended API key. Since we are not requiring LiteLLM keys for customers, we are using the LiteLLM key # required for the db and injecting that into all requests instead to overcome that requirement. @@ -131,33 +71,6 @@ def _generate_presigned_video_url(key: str, content_type: str = "video/mp4") -> return url -def is_openai_route(api_path: str) -> bool: - # First check for exact matches (most common case) - if api_path in OPENAI_ROUTES: - return True - - # Only check wildcard patterns if the path contains "video" (since only video routes have wildcards) - # This avoids expensive pattern matching for non-video routes - if "video" not in api_path: - return False - - wildcard_patterns = [pattern for pattern in OPENAI_ROUTES if "*" in pattern] - wildcard_patterns.sort(key=len, reverse=True) - - for route_pattern in wildcard_patterns: - if fnmatch.fnmatch(api_path, route_pattern): - # For patterns like "videos/*" (not "videos/*/something"), ensure we don't match - # paths with additional segments (e.g., "videos/123/content" should not match "videos/*") - if route_pattern.endswith("/*") and not route_pattern.endswith("/*/"): - pattern_segments = route_pattern.count("/") - path_segments = api_path.count("/") - if path_segments != pattern_segments: - continue - return True - - return False - - async def apply_guardrails_to_request(params: dict, model_id: str, jwt_data: dict) -> None: """ Apply guardrails to a chat completion request. @@ -214,7 +127,7 @@ def handle_guardrail_violation_response( Returns: Response object if a guardrail violation was handled, None otherwise """ - if response.status_code != 400: + if response.status_code != HTTP_400_BAD_REQUEST: return None try: @@ -235,7 +148,7 @@ def handle_guardrail_violation_response( if is_streaming: # Return as streaming response return StreamingResponse( - create_guardrail_streaming_response(guardrail_response, model_id, created), status_code=200 + create_guardrail_streaming_response(guardrail_response, model_id, created), status_code=HTTP_200_OK ) else: # Return as a normal completion response @@ -314,36 +227,42 @@ def generate_response_with_guardrail_handling(iterator: Iterator[str | bytes], m yield f"{line}\n\n" -@router.api_route("/{api_path:path}", methods=["GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE"]) +@router.api_route("/{api_path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) async def litellm_passthrough(request: Request, api_path: str) -> Response: """ Pass requests directly to LiteLLM. LiteLLM and deployed models will respond here directly. - This accepts all HTTP methods as to not put any restriction on how deployed models would act given different HTTP - payload requirements. Results are only streamed if the OpenAI-compatible request specifies streaming as part of the + Authentication is handled by auth_middleware. This function checks authorization + based on whether the route requires admin access. + + Results are only streamed if the OpenAI-compatible request specifies streaming as part of the input payload. """ litellm_path = f"{LITELLM_URL}/{api_path}" headers = dict(request.headers.items()) - authorizer = Authorizer() - require_admin = not is_openai_route(api_path) and api_path not in ANTHROPIC_ROUTES - jwt_data = await authorizer.authenticate_request(request) - if not await authorizer.can_access(request, require_admin): + # Auth is handled by middleware - check authorization for admin routes + require_admin = ( + not is_openai_route(api_path) and not is_anthropic_route(api_path) and not is_lisa_public_route(api_path) + ) + is_admin = getattr(request.state, "is_admin", False) + + if require_admin and not is_admin: raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="Not authenticated in litellm_passthrough", + status_code=HTTP_403_FORBIDDEN, + detail="Admin access required for this endpoint", ) - # At this point in the request, we have already validated auth with IdP or persistent token. By using LiteLLM for - # model management, LiteLLM requires an admin key, and that forces all requests to require a key as well. To avoid - # soliciting yet another form of auth from the user, we add the existing LiteLLM key to the headers that go directly - # to the LiteLLM instance. + # Get JWT data from request state (set by auth middleware) + jwt_data = getattr(request.state, "jwt_data", None) + + # Inject LiteLLM key for all requests to the local LiteLLM instance headers["Authorization"] = f"Bearer {LITELLM_KEY}" http_method = request.method - if http_method == "GET" or http_method == "DELETE": + # Handle GET and DELETE requests (no body expected) + if http_method in ("GET", "DELETE", "OPTIONS"): response = requests_request(method=http_method, url=litellm_path, headers=headers) # Check content type to handle binary responses (e.g., video content) @@ -358,7 +277,7 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: pass # For video content, store in S3 and return presigned URL - if "video/" in content_type and "/content" in api_path and response.status_code == 200: + if "video/" in content_type and "/content" in api_path and response.status_code == HTTP_200_OK: try: # Extract video ID from path (e.g., videos/video_abc123/content -> video_abc123) path_parts = api_path.split("/") @@ -391,7 +310,7 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: "s3_key": s3_key, "content_type": content_type, }, - status_code=200, + status_code=HTTP_200_OK, ) except Exception as e: logger.error(f"Error storing video to S3: {e}") @@ -405,7 +324,7 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: media_type=content_type if content_type else None, ) - # Check if request is multipart/form-data (used for video generation and image edits with reference images) + # POST requests below - check content type content_type = request.headers.get("content-type", "").lower() is_multipart = "multipart/form-data" in content_type is_video_endpoint = "video" in api_path.lower() @@ -427,14 +346,13 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: # It's a file - read the content and prepare for upload file_content = await field_value.read() filename = getattr(field_value, "filename", "file") - content_type = getattr(field_value, "content_type", "application/octet-stream") - files[field_name] = (filename, file_content, content_type) + file_content_type = getattr(field_value, "content_type", "application/octet-stream") + files[field_name] = (filename, file_content, file_content_type) else: # It's a regular form field data[field_name] = field_value # Create new headers without Content-Type (requests library will set it with correct boundary) - # Use LITELLM_KEY instead of the user's token (consistent with rest of the code) forward_headers = {"Authorization": f"Bearer {LITELLM_KEY}"} # Forward multipart request to LiteLLM @@ -442,26 +360,36 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: method=http_method, url=litellm_path, data=data, files=files, headers=forward_headers ) - if response.status_code != 200: + if response.status_code != HTTP_200_OK: logger.error(f"LiteLLM error response: {response.text}") return JSONResponse(response.json(), status_code=response.status_code) except Exception as e: logger.error(f"Error processing multipart request: {e}") - raise HTTPException(status_code=400, detail="Error processing multipart request") - - # Handle JSON requests (default behavior) - params = await request.json() + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Error processing multipart request") - # Apply guardrails for chat/completions requests - if api_path in ["chat/completions", "v1/chat/completions"]: + # Handle JSON POST requests + # Parse request body first + try: + body = await request.body() + if not body: + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Request body is required") + params = json.loads(body) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in request body: {e}") + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Invalid JSON in request body") + + # Apply guardrails BEFORE sending to LiteLLM for chat/completions requests + # This adds guardrail configuration to the request so LiteLLM enforces them + is_chat_completion = is_chat_route(api_path) + if is_chat_completion: model_id = params.get("model") if model_id and jwt_data: await apply_guardrails_to_request(params, model_id, jwt_data) # Validate and cap max_tokens if needed for Claude Code requests - if api_path in ["v1/messages", "anthropic/v1/messages"]: + if is_anthropic_route(api_path): model_id = params.get("model") # Check for anthropic specific headers @@ -472,26 +400,22 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: if "max_completion_tokens" in params: params["max_completion_tokens"] = None - if params.get("stream", False): # if a streaming request - + is_streaming = params.get("stream", False) + if is_streaming: response = requests_request(method=http_method, url=litellm_path, json=params, headers=headers, stream=True) - # Check for guardrail violations + # Check for guardrail violations in the initial response (before streaming starts) model_id = params.get("model", "") guardrail_response = handle_guardrail_violation_response(response, model_id, params, is_streaming=True) if guardrail_response: return guardrail_response - # Publish metrics for streaming chat completions and messages (API users) - if ( - api_path in ["chat/completions", "v1/chat/completions", "v1/messages", "anthropic/v1/messages"] - and response.status_code == 200 - ): + # Publish metrics for streaming chat completions (API users) + if is_chat_completion and response.status_code == HTTP_200_OK: publish_metrics_event(request, params, response.status_code) - # Normal streaming (no error or non-guardrail error) - # Use guardrail-aware generator for chat/completions and messages endpoints - if api_path in ["chat/completions", "v1/chat/completions", "v1/messages", "anthropic/v1/messages"]: + # Use guardrail-aware generator for chat/completions to catch violations in the stream + if is_chat_completion: model_id = params.get("model", "") return StreamingResponse( generate_response_with_guardrail_handling(response.iter_lines(), model_id), @@ -502,21 +426,21 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response: generate_response(response.iter_lines()), status_code=response.status_code, ) - else: # not a streaming request - response = requests_request(method=http_method, url=litellm_path, json=params, headers=headers) + # Non-streaming request + response = requests_request(method=http_method, url=litellm_path, json=params, headers=headers) - # Check for guardrail violations - model_id = params.get("model", "") - guardrail_response = handle_guardrail_violation_response(response, model_id, params, is_streaming=False) - if guardrail_response: - return guardrail_response + # Check for guardrail violations in the response + model_id = params.get("model", "") + guardrail_response = handle_guardrail_violation_response(response, model_id, params, is_streaming=False) + if guardrail_response: + return guardrail_response - if response.status_code != 200: - logger.error(f"LiteLLM error response: {response.text}") + if response.status_code != HTTP_200_OK: + logger.error(f"LiteLLM error response: {response.text}") - # Publish metrics for chat completions and messages (API users) - if api_path in ["chat/completions", "v1/chat/completions", "v1/messages", "anthropic/v1/messages"]: - publish_metrics_event(request, params, response.status_code) + # Publish metrics for chat completions (API users) + if is_chat_completion: + publish_metrics_event(request, params, response.status_code) - return JSONResponse(response.json(), status_code=response.status_code) + return JSONResponse(response.json(), status_code=response.status_code) diff --git a/lib/serve/rest-api/src/api/routes.py b/lib/serve/rest-api/src/api/routes.py index a2ff9289f..242658e19 100644 --- a/lib/serve/rest-api/src/api/routes.py +++ b/lib/serve/rest-api/src/api/routes.py @@ -18,25 +18,18 @@ import os from api.endpoints.v2 import litellm_passthrough -from auth import Authorizer -from fastapi import APIRouter, Depends +from fastapi import APIRouter from fastapi.responses import JSONResponse +from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE logger = logging.getLogger(__name__) router = APIRouter() -dependencies = [] -if os.getenv("USE_AUTH", "true").lower() == "true": - logger.info("Auth enabled") - security = Authorizer() - dependencies = [Depends(security)] -else: - logger.info("Auth disabled") +# Auth is now handled by auth_middleware in main.py +# Routes can use @require_auth or @require_admin decorators for additional checks -router.include_router( - litellm_passthrough.router, prefix="/v2/serve", tags=["litellm_passthrough"], dependencies=dependencies -) +router.include_router(litellm_passthrough.router, prefix="/v2/serve", tags=["litellm_passthrough"]) @router.get("/health") @@ -52,10 +45,10 @@ async def health_check() -> JSONResponse: if missing_vars: content = {"status": "UNHEALTHY", "missing_env_vars": missing_vars} - return JSONResponse(content=content, status_code=503) + return JSONResponse(content=content, status_code=HTTP_503_SERVICE_UNAVAILABLE) content = {"status": "OK"} - return JSONResponse(content=content, status_code=200) + return JSONResponse(content=content, status_code=HTTP_200_OK) except Exception as e: content = {"status": "UNHEALTHY", "error": str(e)} - return JSONResponse(content=content, status_code=503) + return JSONResponse(content=content, status_code=HTTP_503_SERVICE_UNAVAILABLE) diff --git a/lib/serve/rest-api/src/auth.py b/lib/serve/rest-api/src/auth.py index 80091fd5c..b781d1a81 100644 --- a/lib/serve/rest-api/src/auth.py +++ b/lib/serve/rest-api/src/auth.py @@ -372,7 +372,7 @@ async def authenticate_request(self, request: Request) -> dict[str, Any] | None: logger.trace("Valid OIDC token") return jwt_data - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, message="Not authenticated") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Not authenticated") def _log_access_attempt( self, request: Request, auth_method: str, user_id: str, endpoint: str, success: bool, reason: str = "" diff --git a/lib/serve/rest-api/src/handlers/models.py b/lib/serve/rest-api/src/handlers/models.py index cc159e17e..bc8fcafb5 100644 --- a/lib/serve/rest-api/src/handlers/models.py +++ b/lib/serve/rest-api/src/handlers/models.py @@ -19,6 +19,7 @@ from fastapi import HTTPException from services.model_service import ModelService +from starlette.status import HTTP_404_NOT_FOUND from utils.cache_manager import get_registered_models_cache from utils.resources import ModelType @@ -101,7 +102,7 @@ async def handle_describe_model( if not metadata: error_message = f"Metadata for provider {provider} and model {model_name} not found." logger.error(error_message, extra={"event": "handle_describe_model", "status": "ERROR"}) - raise HTTPException(status_code=404, detail=error_message) + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=error_message) return metadata diff --git a/lib/serve/rest-api/src/main.py b/lib/serve/rest-api/src/main.py index dfa6d0031..34a9cb745 100644 --- a/lib/serve/rest-api/src/main.py +++ b/lib/serve/rest-api/src/main.py @@ -25,6 +25,7 @@ from lisa_serve.registry import registry from loguru import logger from middleware import ( + auth_middleware, process_request_middleware, register_exception_handlers, security_middleware, @@ -110,6 +111,16 @@ async def lifespan(app: FastAPI): # type: ignore ############## +@app.middleware("http") +async def authenticate(request, call_next): # type: ignore + """Authentication middleware. + + Validates tokens and sets user context on request.state. + Runs after security checks but before request processing. + """ + return await auth_middleware(request, call_next) + + @app.middleware("http") async def validate_input(request, call_next): # type: ignore """Middleware for validating all HTTP request inputs.""" diff --git a/lib/serve/rest-api/src/middleware/__init__.py b/lib/serve/rest-api/src/middleware/__init__.py index f43d4bda7..464f0e329 100644 --- a/lib/serve/rest-api/src/middleware/__init__.py +++ b/lib/serve/rest-api/src/middleware/__init__.py @@ -13,14 +13,18 @@ # limitations under the License. """Middleware modules.""" +from .auth_middleware import auth_middleware, require_admin, require_auth from .exception_handlers import register_exception_handlers from .input_validation import validate_input_middleware from .request_middleware import process_request_middleware from .security_middleware import security_middleware __all__ = [ + "auth_middleware", "process_request_middleware", "register_exception_handlers", + "require_admin", + "require_auth", "security_middleware", "validate_input_middleware", ] diff --git a/lib/serve/rest-api/src/middleware/auth_middleware.py b/lib/serve/rest-api/src/middleware/auth_middleware.py new file mode 100644 index 000000000..a30ab8c8f --- /dev/null +++ b/lib/serve/rest-api/src/middleware/auth_middleware.py @@ -0,0 +1,226 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Authentication middleware for the serve API. + +This middleware handles authentication at the request level, validating tokens +and setting user context on request.state for downstream handlers. +""" + +import os +from collections.abc import Callable +from functools import wraps +from typing import Any + +from auth import Authorizer +from fastapi import HTTPException, Request, Response +from fastapi.responses import JSONResponse +from loguru import logger +from starlette.status import ( + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_500_INTERNAL_SERVER_ERROR, +) +from utils.route_utils import is_openai_or_anthropic_route + +# Paths that don't require authentication +PUBLIC_PATHS = {"/health", "/health/readiness", "/health/liveliness"} + + +def is_public_path(path: str) -> bool: + """Check if path is public (no auth required).""" + return path in PUBLIC_PATHS + + +async def auth_middleware(request: Request, call_next: Callable[[Request], Response]) -> Response: + """Authentication middleware. + + Validates authentication tokens and sets user context on request.state. + Public paths (health checks) and OPTIONS requests (CORS preflight) bypass authentication. + OpenAI/Anthropic routes require authentication but authorization is handled by the endpoint. + + Sets on request.state: + - authenticated: bool - Whether user is authenticated + - jwt_data: dict | None - JWT claims if OIDC auth, None for API tokens + - is_admin: bool - Whether user has admin privileges + - username: str - Username from token + - groups: list[str] - User groups from token + + Args: + request: The incoming FastAPI request + call_next: The next middleware or route handler + + Returns: + Response from the next handler or 401 error + """ + path = request.url.path + + # Skip auth for OPTIONS requests (CORS preflight) + if request.method == "OPTIONS": + return await call_next(request) + + # Skip auth for public paths + if is_public_path(path): + return await call_next(request) + + # Skip auth if disabled + if os.getenv("USE_AUTH", "true").lower() != "true": + request.state.authenticated = True + request.state.jwt_data = None + request.state.is_admin = True + request.state.username = "anonymous" + request.state.groups = [] + return await call_next(request) + + # For OpenAI/Anthropic routes, authentication is required but we let the endpoint handle authorization + is_openai_route = is_openai_or_anthropic_route(path) + + try: + authorizer = Authorizer() + jwt_data = await authorizer.authenticate_request(request) + + # Set authentication context on request state + request.state.authenticated = True + request.state.jwt_data = jwt_data + + # Determine admin status based on auth type + if jwt_data: + # OIDC auth - check JWT for admin group + request.state.is_admin = authorizer.auth_provider.check_admin_access_jwt( + jwt_data, authorizer.jwt_groups_property + ) + request.state.username = jwt_data.get("sub", jwt_data.get("username", "unknown")) + request.state.groups = _extract_groups_from_jwt(jwt_data, authorizer.jwt_groups_property) + elif hasattr(request.state, "api_token_info"): + # API token auth + token_info = request.state.api_token_info + request.state.is_admin = authorizer.auth_provider.check_admin_access( + token_info.get("username", ""), token_info.get("groups", []) + ) + request.state.username = token_info.get("username", "api-token") + request.state.groups = token_info.get("groups", []) + else: + # Management token - full admin access + request.state.is_admin = True + request.state.username = "management-token" + request.state.groups = [] + + return await call_next(request) + + except HTTPException as e: + # For OpenAI/Anthropic routes, provide more specific error messages + if is_openai_route: + logger.warning(f"Authentication failed for OpenAI/Anthropic route {path}: {e.detail}") + return JSONResponse( + status_code=HTTP_401_UNAUTHORIZED, + content={ + "error": { + "message": "Invalid authentication credentials", + "type": "invalid_request_error", + "code": "invalid_api_key", + } + }, + ) + raise + except Exception as e: + logger.error(f"Authentication error: {e}") + if is_openai_route: + return JSONResponse( + status_code=HTTP_401_UNAUTHORIZED, + content={ + "error": { + "message": "Authentication failed", + "type": "invalid_request_error", + "code": "authentication_error", + } + }, + ) + return JSONResponse( + status_code=HTTP_401_UNAUTHORIZED, + content={"error": "Unauthorized", "message": "Authentication failed"}, + ) + + +def _extract_groups_from_jwt(jwt_data: dict[str, Any], jwt_groups_property: str) -> list[str]: + """Extract user groups from JWT data.""" + if not jwt_groups_property: + return [] + + props = jwt_groups_property.split(".") + current_node: Any = jwt_data + + for prop in props: + if isinstance(current_node, dict) and prop in current_node: + current_node = current_node[prop] + else: + return [] + + return current_node if isinstance(current_node, list) else [] + + +def require_auth(func: Callable) -> Callable: + """Decorator to require authentication on a route. + + Use this for routes that need authentication but not admin access. + The auth middleware must run before this decorator. + + Example: + @router.get("/protected") + @require_auth + async def protected_route(request: Request): + return {"user": request.state.username} + """ + + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + request = kwargs.get("request") or (args[0] if args else None) + if not request or not isinstance(request, Request): + raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="Request object not found") + + if not getattr(request.state, "authenticated", False): + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Authentication required") + + return await func(*args, **kwargs) + + return wrapper + + +def require_admin(func: Callable) -> Callable: + """Decorator to require admin access on a route. + + Use this for routes that need admin privileges. + The auth middleware must run before this decorator. + + Example: + @router.post("/admin-only") + @require_admin + async def admin_route(request: Request): + return {"admin": request.state.username} + """ + + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + request = kwargs.get("request") or (args[0] if args else None) + if not request or not isinstance(request, Request): + raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="Request object not found") + + if not getattr(request.state, "authenticated", False): + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Authentication required") + + if not getattr(request.state, "is_admin", False): + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Admin access required") + + return await func(*args, **kwargs) + + return wrapper diff --git a/lib/serve/rest-api/src/middleware/exception_handlers.py b/lib/serve/rest-api/src/middleware/exception_handlers.py index b43259aa7..9f0f7ec3a 100644 --- a/lib/serve/rest-api/src/middleware/exception_handlers.py +++ b/lib/serve/rest-api/src/middleware/exception_handlers.py @@ -20,7 +20,7 @@ import traceback -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from loguru import logger @@ -47,7 +47,7 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes ) return JSONResponse( - status_code=500, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ "error": "Internal Server Error", "message": "An unexpected error occurred", @@ -137,7 +137,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE logger.warning(f"Validation error for {request.method} {request.url.path}: {exc.errors()}") return JSONResponse( - status_code=422, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={ "error": "Unprocessable Entity", "message": "Request validation failed", diff --git a/lib/serve/rest-api/src/middleware/input_validation.py b/lib/serve/rest-api/src/middleware/input_validation.py index 68fbe13cb..0cd6f1c8f 100644 --- a/lib/serve/rest-api/src/middleware/input_validation.py +++ b/lib/serve/rest-api/src/middleware/input_validation.py @@ -19,6 +19,11 @@ from fastapi import Request, Response from fastapi.responses import JSONResponse from loguru import logger +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_413_CONTENT_TOO_LARGE, +) # Maximum request size: 10MB # This allows for large prompts, image uploads, and other content @@ -72,7 +77,7 @@ async def validate_input_middleware( status="ERROR", ) return JSONResponse( - status_code=405, + status_code=HTTP_405_METHOD_NOT_ALLOWED, content={ "error": "Method Not Allowed", "message": f"HTTP method {request.method} is not allowed", @@ -86,7 +91,7 @@ async def validate_input_middleware( status="ERROR", ) return JSONResponse( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, content={ "error": "Bad Request", "message": "Invalid characters detected in request path", @@ -101,7 +106,7 @@ async def validate_input_middleware( status="ERROR", ) return JSONResponse( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, content={ "error": "Bad Request", "message": "Invalid characters detected in path parameters", @@ -116,7 +121,7 @@ async def validate_input_middleware( status="ERROR", ) return JSONResponse( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, content={ "error": "Bad Request", "message": "Invalid characters detected in query parameters", @@ -137,7 +142,7 @@ async def validate_input_middleware( status="ERROR", ) return JSONResponse( - status_code=413, + status_code=HTTP_413_CONTENT_TOO_LARGE, content={ "error": "Payload Too Large", "message": f"Request body size exceeds maximum allowed size of {max_request_size} bytes", @@ -154,7 +159,7 @@ async def validate_input_middleware( status="ERROR", ) return JSONResponse( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, content={ "error": "Bad Request", "message": "Invalid characters detected in request body", diff --git a/lib/serve/rest-api/src/middleware/request_middleware.py b/lib/serve/rest-api/src/middleware/request_middleware.py index 9227542a0..dca9ecd25 100644 --- a/lib/serve/rest-api/src/middleware/request_middleware.py +++ b/lib/serve/rest-api/src/middleware/request_middleware.py @@ -21,6 +21,7 @@ from fastapi import Request, Response from fastapi.responses import JSONResponse from loguru import logger +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR from utils.header_sanitizer import get_real_client_ip, get_sanitized_headers_from_request @@ -70,7 +71,7 @@ async def process_request_middleware(request: Request, call_next: Callable[[Requ status="ERROR", ) response = JSONResponse( - status_code=500, + status_code=HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Internal server error"}, ) diff --git a/lib/serve/rest-api/src/middleware/security_middleware.py b/lib/serve/rest-api/src/middleware/security_middleware.py index f940e38e2..109b0429b 100644 --- a/lib/serve/rest-api/src/middleware/security_middleware.py +++ b/lib/serve/rest-api/src/middleware/security_middleware.py @@ -28,6 +28,11 @@ from fastapi import Request, Response from fastapi.responses import JSONResponse from loguru import logger +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_413_CONTENT_TOO_LARGE, +) # HTTP methods that require a request body METHODS_REQUIRING_BODY = {"POST", "PUT", "PATCH"} @@ -118,7 +123,7 @@ async def security_middleware( if method not in ALLOWED_METHODS: logger.warning(f"Unsupported HTTP method: {method} for path: {path}") response = create_error_response( - status_code=405, + status_code=HTTP_405_METHOD_NOT_ALLOWED, error="Method Not Allowed", message=f"HTTP method {method} is not allowed", ) @@ -129,7 +134,7 @@ async def security_middleware( if contains_null_bytes(path.encode("utf-8", errors="surrogateescape")): logger.warning(f"Null bytes detected in request path: {path}") return create_error_response( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, error="Bad Request", message="Invalid characters detected in request", ) @@ -139,7 +144,7 @@ async def security_middleware( if contains_null_bytes(query_string.encode("utf-8", errors="surrogateescape")): logger.warning(f"Null bytes detected in query string for path: {path}") return create_error_response( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, error="Bad Request", message="Invalid characters detected in request", ) @@ -166,7 +171,7 @@ async def security_middleware( if not is_binary_content and contains_null_bytes(body): logger.warning(f"Null bytes detected in request body for path: {path}") return create_error_response( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, error="Bad Request", message="Invalid characters detected in request", ) @@ -177,7 +182,7 @@ async def security_middleware( if not body: logger.warning(f"Missing request body for {method} request to: {path}") return create_error_response( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, error="Bad Request", message="Request body is required", ) @@ -189,7 +194,7 @@ async def security_middleware( except json.JSONDecodeError: logger.warning(f"Invalid JSON in request body for path: {path}") return create_error_response( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, error="Bad Request", message="Request body must be valid JSON", ) @@ -202,7 +207,7 @@ async def security_middleware( f"max allowed: {default_max_size} bytes" ) return create_error_response( - status_code=413, + status_code=HTTP_413_CONTENT_TOO_LARGE, error="Payload Too Large", message="Request body exceeds maximum size", ) diff --git a/lib/serve/rest-api/src/utils/guardrails.py b/lib/serve/rest-api/src/utils/guardrails.py index d44b33475..e6587e127 100644 --- a/lib/serve/rest-api/src/utils/guardrails.py +++ b/lib/serve/rest-api/src/utils/guardrails.py @@ -23,6 +23,7 @@ import boto3 from fastapi.responses import JSONResponse from loguru import logger +from starlette.status import HTTP_200_OK async def get_model_guardrails(model_id: str) -> list[dict[str, Any]]: @@ -236,4 +237,4 @@ def create_guardrail_json_response(guardrail_response: str, model_id: str, creat "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "lisa_guardrail_triggered": True, } - return JSONResponse(response_data, status_code=200) + return JSONResponse(response_data, status_code=HTTP_200_OK) diff --git a/lib/serve/rest-api/src/utils/route_utils.py b/lib/serve/rest-api/src/utils/route_utils.py new file mode 100644 index 000000000..a1efb2ea8 --- /dev/null +++ b/lib/serve/rest-api/src/utils/route_utils.py @@ -0,0 +1,194 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared route utilities for API path matching and validation.""" + +import fnmatch + +# The following is an allowlist of OpenAI routes that users would not need elevated permissions to invoke. This is so +# that we may assume anything *not* in this allowlist is an admin operation that requires greater LiteLLM permissions. +# Assume that anything not within these routes requires admin permissions, which would only come from the LISA model +# management API. +OPENAI_ROUTES = ( + # List models + "models", + "v1/models", + # Model Info + "model/info", + "v1/model/info", + # Text completions + "chat/completions", + "v1/chat/completions", + "completions", + "v1/completions", + # Embeddings + "embeddings", + "v1/embeddings", + # Create images + "images/generations", + "v1/images/generations", + "images/edits", + "v1/images/edits", + # Audio routes + "audio/speech", + "v1/audio/speech", + "audio/transcriptions", + "v1/audio/transcriptions", + # Video routes (using wildcards for IDs) + "videos", + "v1/videos", + "videos/*", + "v1/videos/*", + "videos/*/content", + "v1/videos/*/content", + "videos/*/remix", + "v1/videos/*/remix", + # Health check routes + "health", + "health/readiness", + "health/liveliness", + # MCP + "mcp/enabled", + "mcp/tools/list", + "mcp/tools/call", + "v1/mcp/server", +) + +# LISA-specific routes that don't require admin access +# These are metadata and informational endpoints +LISA_PUBLIC_ROUTES = ( + "models/metadata/instances", + "models/metadata/*", +) + +# Specific routes for anthropic (Claude Code compatibility) +# LiteLLM's request/response format: https://litellm-api.up.railway.app/#/ +ANTHROPIC_ROUTES = ( + # Anthropic Messages API + "v1/messages", + "v1/messages/count_tokens", + # Anthropic Messages API with prefix + "anthropic/v1/messages", + "anthropic/v1/messages/count_tokens", +) + +CHAT_ROUTES = ( + "chat/completions", + "v1/chat/completions", + "v1/messages", + "anthropic/v1/messages", +) + + +def is_openai_route(api_path: str) -> bool: + """Check if the given API path is an OpenAI-compatible route. + + This function checks both exact matches and wildcard patterns (for video routes). + + Args: + api_path: The API path to check (without leading slash) + + Returns: + True if the path matches an OpenAI route, False otherwise + """ + # First check for exact matches (most common case) + if api_path in OPENAI_ROUTES: + return True + + # Only check wildcard patterns if the path contains "video" (since only video routes have wildcards) + # This avoids expensive pattern matching for non-video routes + if "video" not in api_path: + return False + + wildcard_patterns = [pattern for pattern in OPENAI_ROUTES if "*" in pattern] + wildcard_patterns.sort(key=len, reverse=True) + + for route_pattern in wildcard_patterns: + if fnmatch.fnmatch(api_path, route_pattern): + # For patterns like "videos/*" (not "videos/*/something"), ensure we don't match + # paths with additional segments (e.g., "videos/123/content" should not match "videos/*") + if route_pattern.endswith("/*") and not route_pattern.endswith("/*/"): + pattern_segments = route_pattern.count("/") + path_segments = api_path.count("/") + if path_segments != pattern_segments: + continue + return True + + return False + + +def is_lisa_public_route(api_path: str) -> bool: + """Check if the given API path is a LISA public route. + + LISA public routes are metadata and informational endpoints that don't require admin access. + + Args: + api_path: The API path to check (without leading slash) + + Returns: + True if the path matches a LISA public route, False otherwise + """ + # Check exact matches first + if api_path in LISA_PUBLIC_ROUTES: + return True + + # Check wildcard patterns + wildcard_patterns = [pattern for pattern in LISA_PUBLIC_ROUTES if "*" in pattern] + for route_pattern in wildcard_patterns: + if fnmatch.fnmatch(api_path, route_pattern): + return True + + return False + + +def is_anthropic_route(api_path: str) -> bool: + """Check if the given API path is an Anthropic-compatible route. + + Args: + api_path: The API path to check (without leading slash) + + Returns: + True if the path matches an Anthropic route, False otherwise + """ + return api_path in ANTHROPIC_ROUTES + + +def is_openai_or_anthropic_route(api_path: str) -> bool: + """Check if the given API path is an OpenAI, Anthropic, or LISA public route. + + These routes require authentication but authorization (admin vs non-admin) is handled + by the endpoint itself. + + Args: + api_path: The API path to check (with or without leading slash) + + Returns: + True if the path matches an OpenAI, Anthropic, or LISA public route, False otherwise + """ + # Remove leading slash for comparison + api_path = api_path.lstrip("/") + + return is_openai_route(api_path) or is_anthropic_route(api_path) or is_lisa_public_route(api_path) + + +def is_chat_route(api_path: str) -> bool: + """Check if the given API path is a chat completion route. + + Args: + api_path: The API path to check (without leading slash) + + Returns: + True if the path is a chat completion route, False otherwise + """ + return api_path in CHAT_ROUTES diff --git a/test/lambda/test_configuration_lambda.py b/test/lambda/test_configuration_lambda.py index 84085810e..13aa233ea 100644 --- a/test/lambda/test_configuration_lambda.py +++ b/test/lambda/test_configuration_lambda.py @@ -57,24 +57,29 @@ def wrapper(*args, **kwargs): "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, "body": json.dumps(result, default=str), } - except ValueError as e: - error_msg = str(e) - status_code = 400 - if "not found" in error_msg.lower(): - status_code = 404 + except Exception as e: + # Check for http_status_code attribute (custom HTTPException subclasses) + if hasattr(e, "http_status_code"): + status_code = e.http_status_code + error_msg = getattr(e, "message", str(e)) + elif hasattr(e, "status_code"): + status_code = e.status_code + error_msg = getattr(e, "message", str(e)) + elif isinstance(e, ValueError): + error_msg = str(e) + status_code = 400 + if "not found" in error_msg.lower(): + status_code = 404 + else: + logging.error(f"Error in {func.__name__}: {str(e)}") + status_code = 500 + error_msg = str(e) return { "statusCode": status_code, "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, "body": json.dumps({"error": error_msg}), } - except Exception as e: - logging.error(f"Error in {func.__name__}: {str(e)}") - return { - "statusCode": 500, - "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, - "body": json.dumps({"error": str(e)}), - } return wrapper diff --git a/test/lambda/test_mcp_server_lambda.py b/test/lambda/test_mcp_server_lambda.py index aae9756d2..93dfaa57d 100644 --- a/test/lambda/test_mcp_server_lambda.py +++ b/test/lambda/test_mcp_server_lambda.py @@ -522,7 +522,7 @@ def test_delete_hosted_mcp_server_missing_sfn_arn(mcp_servers_table, lambda_cont set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.delete_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 500 body = json.loads(response["body"]) assert "DELETE_MCP_SERVER_SFN_ARN not configured" in get_error_message(body) @@ -1135,7 +1135,7 @@ def test_create_hosted_mcp_server_missing_sfn_arn(mcp_servers_table, lambda_cont set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.create_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 500 body = json.loads(response["body"]) assert "CREATE_MCP_SERVER_SFN_ARN not configured" in get_error_message(body) @@ -1183,7 +1183,7 @@ def test_create_hosted_mcp_server_duplicate_normalized_name(mcp_servers_table, l set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.create_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 409 body = json.loads(response["body"]) assert "conflicts with existing server" in get_error_message(body).lower() assert "normalized names must be unique" in get_error_message(body).lower() @@ -1246,7 +1246,7 @@ def test_delete_hosted_mcp_server_invalid_status_creating(mcp_servers_table, lam set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.delete_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 409 body = json.loads(response["body"]) assert "cannot delete server" in get_error_message(body).lower() assert "creating" in get_error_message(body).lower() @@ -1274,7 +1274,7 @@ def test_delete_hosted_mcp_server_invalid_status_starting(mcp_servers_table, lam set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.delete_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 409 body = json.loads(response["body"]) assert "cannot delete server" in get_error_message(body).lower() @@ -1301,7 +1301,7 @@ def test_delete_hosted_mcp_server_invalid_status_stopping(mcp_servers_table, lam set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.delete_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 409 body = json.loads(response["body"]) assert "cannot delete server" in get_error_message(body).lower() @@ -1328,7 +1328,7 @@ def test_delete_hosted_mcp_server_invalid_status_updating(mcp_servers_table, lam set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.delete_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 409 body = json.loads(response["body"]) assert "cannot delete server" in get_error_message(body).lower() @@ -1355,7 +1355,7 @@ def test_delete_hosted_mcp_server_invalid_status_deleting(mcp_servers_table, lam set_auth_user(mock_auth, "admin-user", [], True) response = mcp_module.delete_hosted_mcp_server(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 409 body = json.loads(response["body"]) assert "cannot delete server" in get_error_message(body).lower() diff --git a/test/lambda/test_mcp_workbench_lambda.py b/test/lambda/test_mcp_workbench_lambda.py index 374404ff1..bf3d602bb 100644 --- a/test/lambda/test_mcp_workbench_lambda.py +++ b/test/lambda/test_mcp_workbench_lambda.py @@ -307,7 +307,7 @@ def test_read_not_admin(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.api_wrapper", mock_api_wrapper): response = read(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 403 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") @@ -331,7 +331,7 @@ def test_read_not_found(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.WORKBENCH_BUCKET", WORKBENCH_BUCKET): response = read(event, lambda_context) - assert response["statusCode"] == 500 + assert response["statusCode"] == 404 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") @@ -413,7 +413,7 @@ def test_list_not_admin(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.api_wrapper", mock_api_wrapper): response = list_tools(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 403 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") @@ -512,7 +512,7 @@ def test_create_not_admin(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.api_wrapper", mock_api_wrapper): response = create(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 403 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") @@ -604,7 +604,7 @@ def test_update_not_admin(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.api_wrapper", mock_api_wrapper): response = update(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 403 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") @@ -629,11 +629,11 @@ def test_update_not_found(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.WORKBENCH_BUCKET", WORKBENCH_BUCKET): response = update(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 404 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") - assert "does not exist" in error_text + assert "not found" in error_text.lower() def test_update_missing_tool_id(s3_setup, lambda_context): @@ -751,7 +751,7 @@ def test_delete_not_admin(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.api_wrapper", mock_api_wrapper): response = delete(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 403 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") @@ -775,11 +775,11 @@ def test_delete_not_found(s3_setup, lambda_context): ), patch("mcp_workbench.lambda_functions.WORKBENCH_BUCKET", WORKBENCH_BUCKET): response = delete(event, lambda_context) - assert response["statusCode"] == 400 + assert response["statusCode"] == 404 body = json.loads(response["body"]) # Handle both string and dict response formats error_text = body if isinstance(body, str) else body.get("error", "") - assert "does not exist" in error_text + assert "not found" in error_text.lower() def test_delete_missing_tool_id(s3_setup, lambda_context): diff --git a/test/lambda/test_prompt_templates_lambda.py b/test/lambda/test_prompt_templates_lambda.py index fcfcc3c77..6ced981df 100644 --- a/test/lambda/test_prompt_templates_lambda.py +++ b/test/lambda/test_prompt_templates_lambda.py @@ -63,29 +63,28 @@ def wrapper(event, context): "body": json.dumps(result, default=str), "headers": {"Access-Control-Allow-Origin": "*", "Content-Type": "application/json"}, } - except ValueError as e: - error_msg = str(e) - # For tests that need to assert specific errors with pytest.raises, re-raise - if "test" in event.get("raise_errors", ""): - raise - - # Handle specific error patterns with appropriate status codes - status_code = 400 - if "not found" in error_msg.lower(): - status_code = 404 - elif "Not authorized" in error_msg: - status_code = 403 + except Exception as e: + # Handle HTTPException and its subclasses (NotFoundException, ForbiddenException, etc.) + if hasattr(e, "http_status_code"): + status_code = e.http_status_code + error_message = getattr(e, "message", str(e)) + elif isinstance(e, ValueError): + error_msg = str(e) + # Handle specific error patterns with appropriate status codes + status_code = 400 + if "not found" in error_msg.lower(): + status_code = 404 + elif "Not authorized" in error_msg: + status_code = 403 + error_message = error_msg + else: + # For other errors, return a general 400 response + status_code = 400 + error_message = f"Bad Request: {str(e)}" return { "statusCode": status_code, - "body": json.dumps({"error": error_msg}, default=str), - "headers": {"Access-Control-Allow-Origin": "*", "Content-Type": "application/json"}, - } - except Exception as e: - # For other errors, return a general 400 response - return { - "statusCode": 400, - "body": json.dumps({"error": f"Bad Request: {str(e)}"}, default=str), + "body": json.dumps({"error": error_message}, default=str), "headers": {"Access-Control-Allow-Origin": "*", "Content-Type": "application/json"}, } diff --git a/test/lambda/test_user_preferences_lambda.py b/test/lambda/test_user_preferences_lambda.py index 71946ab72..ad9db5534 100644 --- a/test/lambda/test_user_preferences_lambda.py +++ b/test/lambda/test_user_preferences_lambda.py @@ -54,25 +54,28 @@ def wrapper(*args, **kwargs): "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, "body": json.dumps(result, default=str), } - except ValueError as e: - error_msg = str(e) - # Determine status code based on error message - status_code = 400 - if "not found" in error_msg.lower(): - status_code = 404 - elif "Not authorized" in error_msg: - status_code = 403 + except Exception as e: + # Handle HTTPException and its subclasses + if hasattr(e, "http_status_code"): + status_code = e.http_status_code + error_message = getattr(e, "message", str(e)) + elif isinstance(e, ValueError): + error_msg = str(e) + # Determine status code based on error message + status_code = 400 + if "not found" in error_msg.lower(): + status_code = 404 + elif "Not authorized" in error_msg: + status_code = 403 + error_message = error_msg + else: + status_code = 500 + error_message = str(e) return { "statusCode": status_code, "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, - "body": json.dumps({"error": error_msg}), - } - except Exception as e: - return { - "statusCode": 500, - "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"}, - "body": json.dumps({"error": str(e)}), + "body": json.dumps({"error": error_message}), } return wrapper From eae02f8f7668b43fd15fcc2bc50fac702459ba07 Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:02:41 -0500 Subject: [PATCH 03/21] Fix/remove borders from Tables to sync with design (#731) --- .../api-token-management/ApiTokenManagementComponent.tsx | 1 + .../components/document-library/CollectionLibraryComponent.tsx | 1 + .../src/components/document-library/DocumentLibraryComponent.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/lib/user-interface/react/src/components/api-token-management/ApiTokenManagementComponent.tsx b/lib/user-interface/react/src/components/api-token-management/ApiTokenManagementComponent.tsx index 1dd86316a..d854e971b 100644 --- a/lib/user-interface/react/src/components/api-token-management/ApiTokenManagementComponent.tsx +++ b/lib/user-interface/react/src/components/api-token-management/ApiTokenManagementComponent.tsx @@ -236,6 +236,7 @@ export function ApiTokenManagementComponent ({ currentUserOnly = false }: ApiTok resizableColumns selectionType='single' trackBy='name' + variant='full-page' empty={ diff --git a/lib/user-interface/react/src/components/document-library/CollectionLibraryComponent.tsx b/lib/user-interface/react/src/components/document-library/CollectionLibraryComponent.tsx index b0887c006..6beb479ed 100644 --- a/lib/user-interface/react/src/components/document-library/CollectionLibraryComponent.tsx +++ b/lib/user-interface/react/src/components/document-library/CollectionLibraryComponent.tsx @@ -188,6 +188,7 @@ export function CollectionLibraryComponent ({ admin = false }: CollectionLibrary stickyColumns={{ first: 1, last: 0 }} resizableColumns enableKeyboardNavigation + variant='full-page' items={items} loading={fetchingCollections && !allCollections} loadingText='Loading collections' diff --git a/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx b/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx index 3b08567f9..0cc577af4 100644 --- a/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx +++ b/lib/user-interface/react/src/components/document-library/DocumentLibraryComponent.tsx @@ -185,6 +185,7 @@ export function DocumentLibraryComponent ({ repositoryId, collectionId }: Docume resizableColumns enableKeyboardNavigation items={items} + variant='full-page' loading={isLoading && !paginatedDocs} loadingText='Loading documents' selectionType='multi' From 61fbcf01e485e395f3c0f743694bfa25e7903205 Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:57:41 -0500 Subject: [PATCH 04/21] Added a MCP Server identifier to the tool approval modal. (#733) --- lib/user-interface/react/src/components/chatbot/Chat.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 576480c73..25f4fd6da 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -823,6 +823,7 @@ export default function Chat ({ sessionId }) {

The AI is about to execute the following tool:

Tool Name: {toolApprovalModal.tool.name}

+

MCP Server: {toolToServerMap.get(toolApprovalModal.tool.name)}

Arguments:

{JSON.stringify(toolApprovalModal.tool.args).replace('{', '').replace('}', '')}

Do you want to allow this tool execution?

From 61f27a3d4a094b7b762609fb0ebb0a3b14fde44f Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:24:36 -0500 Subject: [PATCH 05/21] Added documentation about the vLLM variables LISA supports. (#737) --- lib/docs/.vitepress/config.mts | 6 +++- lib/docs/config/vllm_variables.md | 48 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 lib/docs/config/vllm_variables.md diff --git a/lib/docs/.vitepress/config.mts b/lib/docs/.vitepress/config.mts index bb47a11c3..5c58bcc99 100644 --- a/lib/docs/.vitepress/config.mts +++ b/lib/docs/.vitepress/config.mts @@ -54,7 +54,11 @@ const navLinks = [ text: 'Advanced Configuration', items: [ { text: 'API Token Management', link: '/config/api-tokens' }, - { text: 'Model Compatibility', link: '/config/model-compatibility' }, + { text: 'Model Compatibility', link: '/config/model-compatibility', + items: [ + {text: "vLLM Variables", link: '/config/vllm_variables'} + ] + }, { text: 'Model Management API', link: '/config/model-management-api' }, { text: 'Model Management UI', link: '/config/model-management-ui' }, { text: 'Bedrock Guardrails', link: '/config/guardrails' }, diff --git a/lib/docs/config/vllm_variables.md b/lib/docs/config/vllm_variables.md new file mode 100644 index 000000000..1e409386d --- /dev/null +++ b/lib/docs/config/vllm_variables.md @@ -0,0 +1,48 @@ +# vLLM Environment Variables + +LISA Serve supports configuring vLLM model serving through environment variables. These variables allow you to control performance, memory usage, parallelization, and advanced features when deploying models with vLLM. +- **NOTE:** Standard vLLM environment variables are supported and passed directly into the VLLM container. [See vLLM's documentation](https://docs.vllm.ai/en/latest/configuration/env_vars/) + +## Core Performance & Memory + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VLLM_GPU_MEMORY_UTILIZATION` | Fraction of GPU memory to use (0.0-1.0) | `0.9` | `0.85` | +| `VLLM_MAX_MODEL_LEN` | Maximum context length override | Auto | `4096` | +| `MAX_TOTAL_TOKENS` | *Legacy alias for `VLLM_MAX_MODEL_LEN`* | Auto | `4096` | + +## Model Format & Loading + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VLLM_DTYPE` | Model precision | `auto` | `half`, `float16`, `bfloat16`, `float32` | +| `VLLM_QUANTIZATION` | Quantization method | - | `awq`, `gptq`, `squeezellm`, `fp8` | +| `VLLM_TRUST_REMOTE_CODE` | Allow custom model code execution | `false` | `true` | + +## Performance Tuning + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VLLM_MAX_NUM_BATCHED_TOKENS` | Maximum tokens per batch | Auto | `8192` | +| `VLLM_MAX_NUM_SEQS` | Maximum concurrent sequences | `256` | `128`, `512` | +| `VLLM_ENABLE_PREFIX_CACHING` | Enable prefix caching for repeated prompts | `false` | `true` | +| `VLLM_ENABLE_CHUNKED_PREFILL` | Enable chunked prefill | `false` | `true` | + +## Parallel Processing + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VLLM_TENSOR_PARALLEL_SIZE` | Split model across N GPUs | `1` | `2`, `4`, `8` | + +## Tool Calling / Function Calling + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `VLLM_ENABLE_AUTO_TOOL_CHOICE` | Enable automatic tool choice routing | `false` | `true` | +| `VLLM_TOOL_CALL_PARSER` | Tool call parser implementation | - | `hermes`, `mistral`, `llama3_json`, `qwen` | + +> **Note**: Tool calling requires both `VLLM_ENABLE_AUTO_TOOL_CHOICE=true` and specifying an appropriate `VLLM_TOOL_CALL_PARSER` for your model. See [vLLM Tool Calling Documentation](https://docs.vllm.ai/en/stable/features/tool_calling/) for details. + +## Reference + +For more details on vLLM configuration, see the [official vLLM documentation](https://docs.vllm.ai/en/latest/configuration/env_vars/). From 7ef14f511256f8231281cc2e928971978bff5b70 Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:54:23 -0500 Subject: [PATCH 06/21] Updated overall apperance of the MCP tool execution modal (#738) --- .../react/src/components/chatbot/Chat.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 25f4fd6da..1a2c3b018 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -815,18 +815,16 @@ export default function Chat ({ sessionId }) { {toolApprovalModal && ( - -

The AI is about to execute the following tool:

-

Tool Name: {toolApprovalModal.tool.name}

-

MCP Server: {toolToServerMap.get(toolApprovalModal.tool.name)}

-

Arguments:

+ + +
MCP Server: {toolToServerMap.get(toolApprovalModal.tool.name)}
+
MCP Tool: {toolApprovalModal.tool.name}
+
Details:
{JSON.stringify(toolApprovalModal.tool.args).replace('{', '').replace('}', '')} -

Do you want to allow this tool execution?


{updatingAutoApprovalForTool === toolApprovalModal.tool.name ? ( From 45458cf41e9d3107bcd760e593ea067dc8b7493a Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Wed, 11 Feb 2026 16:09:57 -0700 Subject: [PATCH 07/21] session config model mapping --- lambda/session/lambda_functions.py | 70 +++++------ lambda/session/models.py | 196 +++++++++++++++++++++++++++-- test/lambda/test_session_lambda.py | 43 ++++--- 3 files changed, 244 insertions(+), 65 deletions(-) diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index b60ca4ca4..ede502f2c 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -33,7 +33,9 @@ AttachImageRequest, PutSessionRequest, RenameSessionRequest, + SelectedModelFeature, Session, + SessionConfigurationModel, SessionSummary, ) from utilities.auth import get_user_context, get_username @@ -130,27 +132,26 @@ def _get_current_model_config(model_id: str) -> Any: return {} -def _update_session_with_current_model_config(session_config: dict[str, Any]) -> dict[str, Any]: +def _update_session_with_current_model_config( + session_config: SessionConfigurationModel, +) -> SessionConfigurationModel: """Update session configuration with the most recent model configuration. Parameters ---------- - session_config : Dict[str, Any] + session_config : SessionConfigurationModel The session configuration containing model information. Returns ------- - Dict[str, Any] + SessionConfigurationModel Updated configuration with current model settings. """ - if not session_config: + if not session_config or not session_config.selectedModel: return session_config - # Extract model ID from selectedModel section - selected_model = session_config.get("selectedModel", {}) - - # Get the modelId directly - model_id = selected_model.get("modelId") + selected_model = session_config.selectedModel + model_id = selected_model.modelId if not model_id: logger.warning("No modelId found in session selectedModel") return session_config @@ -161,30 +162,25 @@ def _update_session_with_current_model_config(session_config: dict[str, Any]) -> logger.warning(f"Could not fetch current config for model {model_id}, using existing session config") return session_config - # Create updated config with current model settings - updated_config = session_config.copy() - - # Update the selectedModel section with current model configuration - if "selectedModel" not in updated_config: - updated_config["selectedModel"] = {} + # Build updated SelectedModel with current model settings + updated_selected = selected_model.model_copy(deep=True) - selected_model_section = updated_config["selectedModel"] - - # Update features from current model config if "features" in current_model_config: - selected_model_section["features"] = current_model_config["features"] - - # Update streaming setting + updated_selected.features = [ + SelectedModelFeature.model_validate(f) if isinstance(f, dict) else f + for f in current_model_config["features"] + ] if "streaming" in current_model_config: - selected_model_section["streaming"] = current_model_config["streaming"] - - # Update other model-specific settings that might have changed - for key in ["modelType", "modelDescription", "allowedGroups"]: - if key in current_model_config: - selected_model_section[key] = current_model_config[key] + updated_selected.streaming = current_model_config["streaming"] + if "modelType" in current_model_config: + updated_selected.modelType = str(current_model_config["modelType"]) + if "modelDescription" in current_model_config: + updated_selected.modelDescription = current_model_config["modelDescription"] + if "allowedGroups" in current_model_config: + updated_selected.allowedGroups = current_model_config["allowedGroups"] logger.info(f"Updated session selectedModel config for model {model_id} with current model settings") - return updated_config + return session_config.model_copy(update={"selectedModel": updated_selected}) def _get_all_user_sessions(user_id: str) -> list[dict[str, Any]]: @@ -435,11 +431,9 @@ def get_session(event: dict, context: dict) -> Session | dict: session = Session.from_dynamodb_item(item) # Update configuration with current model settings before returning - if session.configuration and session.configuration.get("selectedModel"): - temp_config = {"selectedModel": session.configuration["selectedModel"]} - updated_temp_config = _update_session_with_current_model_config(temp_config) - session.configuration["selectedModel"] = updated_temp_config.get( - "selectedModel", session.configuration["selectedModel"] + if session.configuration and session.configuration.selectedModel: + session = session.model_copy( + update={"configuration": _update_session_with_current_model_config(session.configuration)} ) # Create a list of tasks for parallel processing presigned URLs @@ -579,13 +573,11 @@ def put_session(event: dict, context: dict) -> SuccessResponse | dict: return {"statusCode": 400, "body": json.dumps({"error": str(e)})} # Get the configuration from the request body (what the frontend sends) - configuration = request.configuration or {} + configuration = request.configuration or SessionConfigurationModel() # Update the selectedModel within the configuration with current model settings - if configuration and configuration.get("selectedModel"): - temp_config = {"selectedModel": configuration["selectedModel"]} - updated_temp_config = _update_session_with_current_model_config(temp_config) - configuration["selectedModel"] = updated_temp_config.get("selectedModel", configuration["selectedModel"]) + if configuration and configuration.selectedModel: + configuration = _update_session_with_current_model_config(configuration) # Check if encryption is enabled via configuration table encryption_enabled = _is_session_encryption_enabled() @@ -650,7 +642,7 @@ def put_session(event: dict, context: dict) -> SuccessResponse | dict: ExpressionAttributeValues={ ":history": session_data.history, ":name": session_data.name, - ":configuration": session_data.configuration, + ":configuration": session_data.configuration.model_dump_for_storage(), ":startTime": session_data.startTime, ":createTime": session_data.createTime, ":lastUpdated": session_data.lastUpdated, diff --git a/lambda/session/models.py b/lambda/session/models.py index 7b4a6aaa7..46dbfcd68 100644 --- a/lambda/session/models.py +++ b/lambda/session/models.py @@ -14,18 +14,180 @@ """Pydantic models for session API requests and responses.""" -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from utilities.time import iso_string +# --- Session configuration models (aligned with chat.configurations.model.ts) --- + +ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"] + + +class ModelArgs(BaseModel): + """Model generation arguments (ISessionConfiguration.modelArgs).""" + + model_config = ConfigDict(extra="ignore") + + n: int | None = None + top_p: float | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + temperature: float | None = None + seed: int | None = None + stop: list[str] = Field(default_factory=list) + reasoning_effort: ReasoningEffort | None = None + + +class ImageGenerationArgs(BaseModel): + """Image generation arguments (ISessionConfiguration.imageGenerationArgs).""" + + model_config = ConfigDict(extra="ignore") + + size: str = "1024x1024" + numberOfImages: int = 1 + quality: str = "standard" + + +class VideoGenerationArgs(BaseModel): + """Video generation arguments (ISessionConfiguration.videoGenerationArgs).""" + + model_config = ConfigDict(extra="ignore") + + seconds: str = "4" + size: str = "720x1280" + + +class SessionConfiguration(BaseModel): + """Session configuration (ISessionConfiguration from chat.configurations.model.ts).""" + + model_config = ConfigDict(extra="ignore") + + markdownDisplay: bool = True + streaming: bool = False + showMetadata: bool = False + showReasoningContent: bool = True + max_tokens: int | None = None + chatHistoryBufferSize: int = 7 + ragTopK: int = 3 + modelArgs: ModelArgs = Field(default_factory=ModelArgs) + imageGenerationArgs: ImageGenerationArgs = Field(default_factory=ImageGenerationArgs) + videoGenerationArgs: VideoGenerationArgs = Field(default_factory=VideoGenerationArgs) + remixVideoId: str | None = None + + +class PromptConfiguration(BaseModel): + """Prompt configuration (IPromptConfiguration from chat.configurations.model.ts).""" + + model_config = ConfigDict(extra="ignore") + + promptTemplate: str = "" + + +class ChatConfiguration(BaseModel): + """Chat configuration (IChatConfiguration = promptConfiguration + sessionConfiguration).""" + + model_config = ConfigDict(extra="ignore") + + promptConfiguration: PromptConfiguration = Field(default_factory=PromptConfiguration) + sessionConfiguration: SessionConfiguration = Field(default_factory=SessionConfiguration) + + +# --- RAG config (aligned with RagOptions.tsx RagConfig) --- +# Session stores partial snapshots; all fields optional for empty {} + + +class RagCollectionRef(BaseModel): + """Minimal collection reference for session RAG config snapshot.""" + + model_config = ConfigDict(extra="ignore") + + collectionId: str | None = None + name: str | None = None + + +class RagConfig(BaseModel): + """RAG configuration (RagConfig from RagOptions.tsx).""" + + model_config = ConfigDict(extra="ignore") + + collection: RagCollectionRef | dict[str, Any] | None = None + embeddingModel: dict[str, Any] | None = None + repositoryId: str | None = None + repositoryType: str | None = None + + +# --- Selected model (session snapshot of IModel) --- + + +class SelectedModelFeature(BaseModel): + """Model feature (ModelFeature from model-management.model.ts).""" + + model_config = ConfigDict(extra="ignore") + + name: str = "" + overview: str = "" + + +class SelectedModel(BaseModel): + """Selected model snapshot (IModel subset for session storage).""" + + model_config = ConfigDict(extra="ignore") + + modelId: str = "" + modelName: str = "" + modelType: str = "textgen" + modelUrl: str = "" + modelDescription: str | None = None + status: str | None = None + streaming: bool = True + features: list[SelectedModelFeature] = Field(default_factory=list) + allowedGroups: list[str] | None = None + containerConfig: dict[str, Any] | None = None + inferenceContainer: str | None = None + instanceType: str | None = None + autoScalingConfig: dict[str, Any] | None = None + loadBalancerConfig: dict[str, Any] | None = None + guardrailsConfig: dict[str, Any] | None = None + + +# --- Full session configuration (IChatConfiguration + IModelConfiguration) --- + + +class SessionConfigurationModel(BaseModel): + """Full session configuration stored with each session.""" + + model_config = ConfigDict(extra="ignore") + + sessionConfiguration: SessionConfiguration | None = None + promptConfiguration: PromptConfiguration | None = None + ragConfig: RagConfig | None = None + selectedModel: SelectedModel | None = None + + def model_dump_for_storage(self) -> dict[str, Any]: + """Serialize to dict for DynamoDB storage.""" + return self.model_dump(mode="json", exclude_none=False) + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "SessionConfigurationModel": + """Parse configuration from dict (e.g. DynamoDB or API payload).""" + if not data: + return cls() + try: + return cls.model_validate(data) + except Exception: + return cls() + + +# --- Session models --- + class SessionData(BaseModel): """Session data model for DynamoDB storage.""" history: list[dict[str, Any]] name: str | None - configuration: dict[str, Any] + configuration: SessionConfigurationModel startTime: str createTime: str lastUpdated: str @@ -51,7 +213,7 @@ class Session(BaseModel): userId: str history: list[dict[str, Any]] = Field(default_factory=list) name: str | None = None - configuration: dict[str, Any] = Field(default_factory=dict) + configuration: SessionConfigurationModel = Field(default_factory=SessionConfigurationModel) startTime: str | None = None createTime: str | None = None lastUpdated: str | None = None @@ -64,7 +226,7 @@ def from_dynamodb_item(cls, item: dict[str, Any]) -> "Session": userId=item.get("userId", ""), history=item.get("history", []), name=item.get("name"), - configuration=item.get("configuration", {}), + configuration=SessionConfigurationModel.from_dict(item.get("configuration")), startTime=item.get("startTime"), createTime=item.get("createTime"), lastUpdated=item.get("lastUpdated"), @@ -87,18 +249,34 @@ class PutSessionRequest(BaseModel): """Request model for updating a session with messages and configuration.""" messages: list[dict[str, Any]] = Field(description="List of message objects representing the session history") - configuration: dict[str, Any] | None = Field( - default=None, description="Optional session configuration including selected model settings" + configuration: SessionConfigurationModel | None = Field( + default=None, + description="Optional session configuration including selected model settings", ) name: str | None = Field(default=None, description="Optional session name") - def to_session_data(self, configuration: dict[str, Any] | None = None) -> SessionData: + @field_validator("configuration", mode="before") + @classmethod + def _parse_configuration(cls, v: Any) -> SessionConfigurationModel | None: + if v is None: + return None + if isinstance(v, SessionConfigurationModel): + return v + if isinstance(v, dict): + return SessionConfigurationModel.from_dict(v) + return None + + def to_session_data( + self, + configuration: SessionConfigurationModel | None = None, + ) -> SessionData: """Convert request to session data for DynamoDB storage.""" timestamp = iso_string() + config = configuration if configuration is not None else (self.configuration or SessionConfigurationModel()) return SessionData( history=self.messages, name=self.name, - configuration=configuration if configuration is not None else (self.configuration or {}), + configuration=config, startTime=timestamp, createTime=timestamp, lastUpdated=timestamp, diff --git a/test/lambda/test_session_lambda.py b/test/lambda/test_session_lambda.py index 4fcc8766a..44dd9c564 100644 --- a/test/lambda/test_session_lambda.py +++ b/test/lambda/test_session_lambda.py @@ -41,6 +41,7 @@ os.environ["GENERATED_IMAGES_S3_BUCKET_NAME"] = "bucket" os.environ["MODEL_TABLE_NAME"] = "model-table" os.environ["CONFIG_TABLE_NAME"] = "config-table" +os.environ["GUARDRAILS_TABLE_NAME"] = "guardrails-table" os.environ["SESSION_ENCRYPTION_KEY_ARN"] = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" # Create a real retry config @@ -173,7 +174,9 @@ def config_table(dynamodb): from session.models import ( PutSessionRequest, RenameSessionRequest, + SelectedModel, Session, + SessionConfigurationModel, SessionSummary, ) @@ -594,35 +597,35 @@ def test_get_current_model_config_success(model_table): def test_update_session_with_current_model_config_empty_config(): """Test _update_session_with_current_model_config with empty config.""" - result = _update_session_with_current_model_config({}) - assert result == {} + result = _update_session_with_current_model_config(SessionConfigurationModel()) + assert result.selectedModel is None def test_update_session_with_current_model_config_no_model_id(): """Test _update_session_with_current_model_config with no modelId.""" - session_config = {"selectedModel": {"name": "test-model"}} # No modelId + session_config = SessionConfigurationModel(selectedModel=SelectedModel(modelName="test-model")) # No modelId result = _update_session_with_current_model_config(session_config) - assert result == session_config + assert result.selectedModel.modelName == "test-model" def test_update_session_with_current_model_config_model_not_found(): """Test _update_session_with_current_model_config when model not found.""" - session_config = {"selectedModel": {"modelId": "non-existent-model"}} + session_config = SessionConfigurationModel(selectedModel=SelectedModel(modelId="non-existent-model")) with patch("session.lambda_functions._get_current_model_config") as mock_get_config: mock_get_config.return_value = {} result = _update_session_with_current_model_config(session_config) - assert result == session_config + assert result.selectedModel.modelId == "non-existent-model" def test_update_session_with_current_model_config_success(): """Test _update_session_with_current_model_config with successful update.""" - session_config = {"selectedModel": {"modelId": "test-model"}} + session_config = SessionConfigurationModel(selectedModel=SelectedModel(modelId="test-model")) current_model_config = { - "features": ["new-feature"], + "features": [{"name": "new-feature", "overview": ""}], "streaming": False, "modelType": "updated-type", "modelDescription": "Updated description", @@ -635,11 +638,13 @@ def test_update_session_with_current_model_config_success(): result = _update_session_with_current_model_config(session_config) # Verify the selectedModel was updated with current config - assert result["selectedModel"]["features"] == ["new-feature"] - assert result["selectedModel"]["streaming"] is False - assert result["selectedModel"]["modelType"] == "updated-type" - assert result["selectedModel"]["modelDescription"] == "Updated description" - assert result["selectedModel"]["allowedGroups"] == ["group1", "group2"] + assert result.selectedModel is not None + assert len(result.selectedModel.features) == 1 + assert result.selectedModel.features[0].name == "new-feature" + assert result.selectedModel.streaming is False + assert result.selectedModel.modelType == "updated-type" + assert result.selectedModel.modelDescription == "Updated description" + assert result.selectedModel.allowedGroups == ["group1", "group2"] # Session Processing Tests @@ -915,8 +920,10 @@ def test_get_session_model_config_update(mock_update_config, dynamodb_table, sam dynamodb_table.put_item(Item=session_with_config) - # Mock model config update - updated_config = {"selectedModel": {"modelId": "test-model", "features": ["new-feature"]}} + # Mock model config update - return SessionConfigurationModel + updated_config = SessionConfigurationModel( + selectedModel=SelectedModel(modelId="test-model", features=[{"name": "new-feature", "overview": ""}]) + ) mock_update_config.return_value = updated_config event = { @@ -1204,8 +1211,10 @@ def test_put_session_model_config_update( ): """Test put_session with model configuration update.""" - # Mock model config update - updated_config = {"selectedModel": {"modelId": "test-model", "features": ["new-feature"]}} + # Mock model config update - return SessionConfigurationModel + updated_config = SessionConfigurationModel( + selectedModel=SelectedModel(modelId="test-model", features=[{"name": "new-feature", "overview": ""}]) + ) mock_update_config.return_value = updated_config event = { From ca0b28e9d6529d7cebe7c3fa7babe94daf9f6eff Mon Sep 17 00:00:00 2001 From: bedanley Date: Thu, 12 Feb 2026 08:50:18 -0700 Subject: [PATCH 08/21] Remove fastapi import from auth --- lambda/models/lambda_functions.py | 3 +- lambda/session/lambda_functions.py | 3 +- lambda/session/models.py | 6 +- lambda/utilities/auth.py | 60 -------------- .../utilities/fastapi_middleware/__init__.py | 31 ++++++++ .../fastapi_middleware/auth_decorators.py | 78 +++++++++++++++++++ test/lambda/conftest.py | 1 + test/lambda/test_audit_logging.py | 23 +++--- test/lambda/test_models_lambda.py | 15 +++- 9 files changed, 144 insertions(+), 76 deletions(-) create mode 100644 lambda/utilities/fastapi_middleware/__init__.py create mode 100644 lambda/utilities/fastapi_middleware/auth_decorators.py diff --git a/lambda/models/lambda_functions.py b/lambda/models/lambda_functions.py index 864084370..72fa15400 100644 --- a/lambda/models/lambda_functions.py +++ b/lambda/models/lambda_functions.py @@ -24,9 +24,10 @@ from fastapi.responses import JSONResponse from mangum import Mangum from starlette.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT -from utilities.auth import get_groups, get_username, is_admin, require_admin +from utilities.auth import get_groups, get_username, is_admin from utilities.common_functions import retry_config from utilities.fastapi_factory import create_fastapi_app +from utilities.fastapi_middleware import require_admin from .domain_objects import ( CreateModelRequest, diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index ede502f2c..3b5ee53cf 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -180,7 +180,8 @@ def _update_session_with_current_model_config( updated_selected.allowedGroups = current_model_config["allowedGroups"] logger.info(f"Updated session selectedModel config for model {model_id} with current model settings") - return session_config.model_copy(update={"selectedModel": updated_selected}) + updated_config: SessionConfigurationModel = session_config.model_copy(update={"selectedModel": updated_selected}) + return updated_config def _get_all_user_sessions(user_id: str) -> list[dict[str, Any]]: diff --git a/lambda/session/models.py b/lambda/session/models.py index 46dbfcd68..186f33b09 100644 --- a/lambda/session/models.py +++ b/lambda/session/models.py @@ -166,7 +166,8 @@ class SessionConfigurationModel(BaseModel): def model_dump_for_storage(self) -> dict[str, Any]: """Serialize to dict for DynamoDB storage.""" - return self.model_dump(mode="json", exclude_none=False) + result: dict[str, Any] = self.model_dump(mode="json", exclude_none=False) + return result @classmethod def from_dict(cls, data: dict[str, Any] | None) -> "SessionConfigurationModel": @@ -174,7 +175,8 @@ def from_dict(cls, data: dict[str, Any] | None) -> "SessionConfigurationModel": if not data: return cls() try: - return cls.model_validate(data) + instance: SessionConfigurationModel = cls.model_validate(data) + return instance except Exception: return cls() diff --git a/lambda/utilities/auth.py b/lambda/utilities/auth.py index d06744b7f..b5a8e0670 100644 --- a/lambda/utilities/auth.py +++ b/lambda/utilities/auth.py @@ -16,16 +16,12 @@ import logging import os import secrets -import sys from collections.abc import Callable from functools import wraps from typing import Any import boto3 from botocore.config import Config -from fastapi import HTTPException as FastAPIHTTPException -from fastapi import Request -from starlette.status import HTTP_403_FORBIDDEN, HTTP_500_INTERNAL_SERVER_ERROR from utilities.exceptions import ForbiddenException from .auth_provider import get_authorization_provider @@ -100,62 +96,6 @@ def wrapper(event: dict[str, Any], context: dict[str, Any], *args: Any, **kwargs return wrapper -def require_admin(message: str = "User does not have permission to perform this action") -> Callable: - """ - Decorator for FastAPI route handlers that require admin access. - - Works with async FastAPI handlers that have a `request: Request` parameter. - The decorator extracts the AWS event from the request scope and checks admin status. - - Args: - message: Custom error message for non-admin users - - Usage: - @app.post("/admin-endpoint") - @require_admin() - async def admin_endpoint(request: Request) -> Response: - ... - - @app.delete("/models/{model_id}") - @require_admin("User does not have permission to delete models") - async def delete_model(model_id: str, request: Request) -> Response: - ... - """ - - def decorator(func: Callable) -> Callable: - @wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - # Find the Request object in kwargs - request = kwargs.get("request") - if request is None: - # Check positional args for Request type - - for arg in args: - if isinstance(arg, Request): - request = arg - break - - if request is None: - raise FastAPIHTTPException( - status_code=HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal error: Request object not found in handler", - ) - - # Extract event from request scope - event = request.scope.get("aws.event", {}) - # Look up is_admin from the module to allow patching in tests - auth_module = sys.modules.get("utilities.auth") - is_admin_func = getattr(auth_module, "is_admin", is_admin) if auth_module else is_admin - if not is_admin_func(event): - raise FastAPIHTTPException(status_code=HTTP_403_FORBIDDEN, detail=message) - - return await func(*args, **kwargs) - - return wrapper - - return decorator - - def get_management_key() -> str: secret_name_param = ssm_client.get_parameter(Name=os.environ["MANAGEMENT_KEY_SECRET_NAME_PS"]) secret_name = secret_name_param["Parameter"]["Value"] diff --git a/lambda/utilities/fastapi_middleware/__init__.py b/lambda/utilities/fastapi_middleware/__init__.py new file mode 100644 index 000000000..d852b772a --- /dev/null +++ b/lambda/utilities/fastapi_middleware/__init__.py @@ -0,0 +1,31 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastAPI middleware and decorators for LISA.""" + +from .auth_decorators import require_admin +from .aws_api_gateway_middleware import AWSAPIGatewayMiddleware +from .exception_handlers import generic_exception_handler +from .input_validation_middleware import InputValidationMiddleware +from .request_logging_middleware import RequestLoggingMiddleware +from .security_headers_middleware import SecurityHeadersMiddleware + +__all__ = [ + "require_admin", + "AWSAPIGatewayMiddleware", + "generic_exception_handler", + "InputValidationMiddleware", + "RequestLoggingMiddleware", + "SecurityHeadersMiddleware", +] diff --git a/lambda/utilities/fastapi_middleware/auth_decorators.py b/lambda/utilities/fastapi_middleware/auth_decorators.py new file mode 100644 index 000000000..b760abc16 --- /dev/null +++ b/lambda/utilities/fastapi_middleware/auth_decorators.py @@ -0,0 +1,78 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Authentication decorators for FastAPI route handlers.""" + +from collections.abc import Callable +from functools import wraps +from typing import Any + +from fastapi import HTTPException, Request, status +from utilities.auth import is_admin + + +def require_admin(message: str = "User does not have permission to perform this action") -> Callable: + """ + Decorator for FastAPI route handlers that require admin access. + + Works with async FastAPI handlers that have a `request: Request` parameter. + The decorator extracts the AWS event from the request scope and checks admin status. + + Args: + message: Custom error message for non-admin users + + Usage: + from utilities.fastapi_middleware.auth_decorators import require_admin + + @app.post("/admin-endpoint") + @require_admin() + async def admin_endpoint(request: Request) -> Response: + ... + + @app.delete("/models/{model_id}") + @require_admin("User does not have permission to delete models") + async def delete_model(model_id: str, request: Request) -> Response: + ... + """ + + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + # Find the Request object in kwargs + request = kwargs.get("request") + if request is None: + # Check positional args for Request type + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if request is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal error: Request object not found in handler", + ) + + # Extract event from request scope (set by AWSAPIGatewayMiddleware) + event = request.scope.get("aws.event", {}) + + # Check admin status using utilities.auth.is_admin + if not is_admin(event): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=message) + + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/test/lambda/conftest.py b/test/lambda/conftest.py index c749cebac..27c63497d 100644 --- a/test/lambda/conftest.py +++ b/test/lambda/conftest.py @@ -124,6 +124,7 @@ def setup_auth_patches(request, mock_auth, aws_credentials): # Also patch where these functions are imported patch("models.lambda_functions.is_admin", mock_auth.is_admin), patch("models.lambda_functions.get_groups", mock_auth.get_groups), + patch("utilities.fastapi_middleware.auth_decorators.is_admin", mock_auth.is_admin), ] for p in patches: diff --git a/test/lambda/test_audit_logging.py b/test/lambda/test_audit_logging.py index a0e0fd8df..fa5c4985b 100644 --- a/test/lambda/test_audit_logging.py +++ b/test/lambda/test_audit_logging.py @@ -83,7 +83,7 @@ async def test_create_model_logs_all_required_fields(self, caplog): # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( @@ -200,7 +200,7 @@ async def test_create_model_logs_container_details_for_lisa_hosted(self, caplog) # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( @@ -270,7 +270,7 @@ async def test_create_model_logs_without_container_config(self, caplog): # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( @@ -337,7 +337,7 @@ async def test_create_model_does_not_log_sensitive_data(self, caplog): # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( @@ -407,7 +407,7 @@ async def test_create_model_logs_for_successful_request(self, caplog): # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( @@ -462,7 +462,7 @@ async def test_create_model_logs_for_failed_request(self, caplog): # Mock the handler to raise ModelAlreadyExistsError with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( @@ -535,7 +535,7 @@ async def test_create_model_extracts_real_ip_from_api_gateway_context(self, capl # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( @@ -588,10 +588,15 @@ async def test_create_model_handles_missing_event_context(self, caplog): # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( "utilities.auth.is_admin" - ) as mock_is_admin, caplog.at_level(logging.INFO): + ) as mock_is_admin, patch( + "utilities.fastapi_middleware.auth_decorators.is_admin" + ) as mock_decorator_is_admin, caplog.at_level( + logging.INFO + ): # Setup mocks - simulate admin with no event context mock_is_admin.return_value = True + mock_decorator_is_admin.return_value = True mock_handler = MagicMock() mock_handler_class.return_value = mock_handler @@ -702,7 +707,7 @@ async def test_create_model_extracts_registry_domain_from_various_formats(self, # Mock the handler and auth functions with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch( - "utilities.auth.is_admin" + "utilities.fastapi_middleware.auth_decorators.is_admin" ) as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups, patch( "utilities.auth.get_username" ) as mock_get_username, caplog.at_level( diff --git a/test/lambda/test_models_lambda.py b/test/lambda/test_models_lambda.py index 48b37fa5e..625cd2b7d 100644 --- a/test/lambda/test_models_lambda.py +++ b/test/lambda/test_models_lambda.py @@ -987,8 +987,11 @@ async def test_create_model_admin_required( modelId="test-model", modelName="test-model", modelType=ModelType.TEXTGEN, streaming=True ) - with patch("utilities.auth.is_admin") as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups: + with patch("utilities.auth.is_admin") as mock_is_admin, patch( + "utilities.auth.get_groups" + ) as mock_get_groups, patch("utilities.fastapi_middleware.auth_decorators.is_admin") as mock_decorator_is_admin: mock_is_admin.return_value = False + mock_decorator_is_admin.return_value = False mock_get_groups.return_value = [] with pytest.raises(HTTPException) as exc_info: @@ -1009,8 +1012,11 @@ async def test_update_model_admin_required( update_request = UpdateModelRequest(streaming=False) - with patch("utilities.auth.is_admin") as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups: + with patch("utilities.auth.is_admin") as mock_is_admin, patch( + "utilities.auth.get_groups" + ) as mock_get_groups, patch("utilities.fastapi_middleware.auth_decorators.is_admin") as mock_decorator_is_admin: mock_is_admin.return_value = False + mock_decorator_is_admin.return_value = False mock_get_groups.return_value = [] with pytest.raises(HTTPException) as exc_info: @@ -1029,8 +1035,11 @@ async def test_delete_model_admin_required( mock_request = MagicMock(spec=Request) mock_request.scope = {"aws.event": non_admin_event} - with patch("utilities.auth.is_admin") as mock_is_admin, patch("utilities.auth.get_groups") as mock_get_groups: + with patch("utilities.auth.is_admin") as mock_is_admin, patch( + "utilities.auth.get_groups" + ) as mock_get_groups, patch("utilities.fastapi_middleware.auth_decorators.is_admin") as mock_decorator_is_admin: mock_is_admin.return_value = False + mock_decorator_is_admin.return_value = False mock_get_groups.return_value = [] with pytest.raises(HTTPException) as exc_info: From df7906e0ad222d99da6a336628beda8ba30b1309 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Thu, 12 Feb 2026 08:51:01 -0700 Subject: [PATCH 09/21] updating get user sessions to get all pages from ddb --- lambda/session/lambda_functions.py | 31 +++++++++++++++++++------- test/lambda/test_session_lambda.py | 35 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index ede502f2c..54171df82 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -186,6 +186,8 @@ def _update_session_with_current_model_config( def _get_all_user_sessions(user_id: str) -> list[dict[str, Any]]: """Get all sessions for a user from DynamoDB. + Paginates through all results when DynamoDB returns more than one page. + Parameters ---------- user_id : str @@ -196,20 +198,33 @@ def _get_all_user_sessions(user_id: str) -> list[dict[str, Any]]: List[Dict[str, Any]] A list of user sessions. """ - response = {} + all_items: list[dict[str, Any]] = [] + exclusive_start_key: dict[str, Any] | None = None + try: - response = table.query( - KeyConditionExpression="userId = :user_id", - ExpressionAttributeValues={":user_id": user_id}, - IndexName=os.environ["SESSIONS_BY_USER_ID_INDEX_NAME"], - ScanIndexForward=False, - ) + while True: + query_params: dict[str, Any] = { + "KeyConditionExpression": "userId = :user_id", + "ExpressionAttributeValues": {":user_id": user_id}, + "IndexName": os.environ["SESSIONS_BY_USER_ID_INDEX_NAME"], + "ScanIndexForward": False, + } + if exclusive_start_key is not None: + query_params["ExclusiveStartKey"] = exclusive_start_key + + response = table.query(**query_params) + items = response.get("Items", []) + all_items.extend(items) + + exclusive_start_key = response.get("LastEvaluatedKey") + if exclusive_start_key is None: + break except ClientError as error: if error.response["Error"]["Code"] == "ResourceNotFoundException": logger.warning(f"No sessions found for user {user_id}") else: logger.exception("Error listing sessions") - return response.get("Items", []) # type: ignore [no-any-return] + return all_items def _extract_video_s3_keys(session: dict) -> list[str]: diff --git a/test/lambda/test_session_lambda.py b/test/lambda/test_session_lambda.py index 44dd9c564..eb42f3fcf 100644 --- a/test/lambda/test_session_lambda.py +++ b/test/lambda/test_session_lambda.py @@ -674,6 +674,41 @@ def test_get_all_user_sessions_general_client_error(mock_table): assert result == [] +@patch("session.lambda_functions.table") +def test_get_all_user_sessions_pagination(mock_table): + """Test _get_all_user_sessions fetches all pages when DynamoDB paginates.""" + # First page returns 2 items and LastEvaluatedKey + # Second page returns 1 item and no LastEvaluatedKey + mock_table.query.side_effect = [ + { + "Items": [ + {"sessionId": "session-1", "userId": "test-user"}, + {"sessionId": "session-2", "userId": "test-user"}, + ], + "LastEvaluatedKey": {"userId": "test-user", "sessionId": "session-2"}, + }, + { + "Items": [{"sessionId": "session-3", "userId": "test-user"}], + }, + ] + + result = _get_all_user_sessions("test-user") + + assert len(result) == 3 + assert result[0]["sessionId"] == "session-1" + assert result[1]["sessionId"] == "session-2" + assert result[2]["sessionId"] == "session-3" + assert mock_table.query.call_count == 2 + # Second call should include ExclusiveStartKey + mock_table.query.assert_called_with( + KeyConditionExpression="userId = :user_id", + ExpressionAttributeValues={":user_id": "test-user"}, + IndexName="sessions-by-user-id-index", + ScanIndexForward=False, + ExclusiveStartKey={"userId": "test-user", "sessionId": "session-2"}, + ) + + @patch("session.lambda_functions.table") @patch("session.lambda_functions.s3_resource") def test_delete_user_session_resource_not_found(mock_s3_resource, mock_table): From f0dd7ec424348bda589755beb7ca916da0ce8eb5 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Thu, 12 Feb 2026 16:53:14 +0000 Subject: [PATCH 10/21] Bump cdk --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index acd1b377d..3ef1579f1 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "generate-config": "tsx scripts/generate-config.ts" }, "devDependencies": { - "@aws-cdk/aws-lambda-python-alpha": "^2.236.0-alpha.0", + "@aws-cdk/aws-lambda-python-alpha": "^2.238.0-alpha.0", "@aws-sdk/client-iam": "^3.948.0", "@aws-sdk/client-ssm": "^3.948.0", "@cdklabs/cdk-enterprise-iac": "^0.1.0", @@ -84,7 +84,7 @@ "@types/readline-sync": "^1.4.8", "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", - "aws-cdk": "^2.1103.0", + "aws-cdk": "^2.1106.0", "depcheck": "^1.4.7", "esbuild": "^0.27.1", "eslint": "^9.39.1", @@ -106,7 +106,7 @@ }, "dependencies": { "@aws-sdk/util-dynamodb": "^3.948.0", - "aws-cdk-lib": "^2.236.0", + "aws-cdk-lib": "^2.238.0", "aws-sdk": "^2.1693.0", "cdk-ecr-deployment": "^4.0.5", "cdk-nag": "^2.37.55", From b447a97be8463e7eac390037783e389ca70a5856 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Thu, 12 Feb 2026 17:07:12 +0000 Subject: [PATCH 11/21] Update package lock --- package-lock.json | 66 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c26021ff..ac1f536c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ ], "dependencies": { "@aws-sdk/util-dynamodb": "^3.948.0", - "aws-cdk-lib": "^2.236.0", + "aws-cdk-lib": "^2.238.0", "aws-sdk": "^2.1693.0", "cdk-ecr-deployment": "^4.0.5", "cdk-nag": "^2.37.55", @@ -32,7 +32,7 @@ "zod": "^4.1.13" }, "devDependencies": { - "@aws-cdk/aws-lambda-python-alpha": "^2.236.0-alpha.0", + "@aws-cdk/aws-lambda-python-alpha": "^2.238.0-alpha.0", "@aws-sdk/client-iam": "^3.948.0", "@aws-sdk/client-ssm": "^3.948.0", "@cdklabs/cdk-enterprise-iac": "^0.1.0", @@ -46,7 +46,7 @@ "@types/readline-sync": "^1.4.8", "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", - "aws-cdk": "^2.1103.0", + "aws-cdk": "^2.1106.0", "depcheck": "^1.4.7", "esbuild": "^0.27.1", "eslint": "^9.39.1", @@ -686,16 +686,16 @@ "license": "Apache-2.0" }, "node_modules/@aws-cdk/aws-lambda-python-alpha": { - "version": "2.236.0-alpha.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/aws-lambda-python-alpha/-/aws-lambda-python-alpha-2.236.0-alpha.0.tgz", - "integrity": "sha512-+QxeP2N9sMB1weUB4KV+ctz1BaM49f8OslIOOYYXLTJIHd8iiiY5qQbPFU5ixBF9arnbiv77xvIPgsbz5ItRvQ==", + "version": "2.238.0-alpha.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/aws-lambda-python-alpha/-/aws-lambda-python-alpha-2.238.0-alpha.0.tgz", + "integrity": "sha512-JtmnKVHVdg1nk5z8237FTYYKAvWIoYo4VeFSoY5FkvAQbs4lQjWKCcYu4ZJWRRufm3DguMiKFYdXYUg1R3Hprw==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 18.0.0" }, "peerDependencies": { - "aws-cdk-lib": "^2.236.0", + "aws-cdk-lib": "^2.238.0", "constructs": "^10.0.0" } }, @@ -8588,9 +8588,9 @@ } }, "node_modules/aws-cdk": { - "version": "2.1103.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1103.0.tgz", - "integrity": "sha512-bxEcqIeAT983x7525gf4Ya4zgpDt3Ou54El7j1ITCa/KqJ8ZaOP4F0ZHiiGuCbZduMcGJlszIXkaPJuvyNADgg==", + "version": "2.1106.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1106.0.tgz", + "integrity": "sha512-1tyQNnuCnH3nc0QpOL84UNhr+y73fyS75nwSnuy5z7XtRwdsOuqyqcDxd6tvCXkUBA7fdgu8p1FR3hkqrW0GWA==", "license": "Apache-2.0", "bin": { "cdk": "bin/cdk" @@ -8600,11 +8600,12 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.236.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.236.0.tgz", - "integrity": "sha512-LauY4BX8vdYL9DvVKCgtJ2gZBwLEgfszTlFe6R2p2NUfEJ+PPpeRGxUbTaOdwLqJGN6mDqmzdoF4or8l2v69PA==", + "version": "2.238.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.238.0.tgz", + "integrity": "sha512-lmS7DEGcEjNhnl88Z7SynPA1UHdCOkx2pNSGiiBQG5I2jH8H2nnWnr6cZRWmxV2GyNeBmmd9wHdEcSBpkow53Q==", "bundleDependencies": [ "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", "case", "fs-extra", "ignore", @@ -8620,6 +8621,7 @@ "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.263", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", + "@aws-cdk/cloud-assembly-api": "^2.0.0", "@aws-cdk/cloud-assembly-schema": "^48.20.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", @@ -8640,6 +8642,44 @@ "constructs": "^10.0.0" } }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.0.0", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.3" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=50.1.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { + "version": "7.7.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { "version": "1.0.2", "inBundle": true, From a3f042e5811500bd789ee8ec9a8df61f6d80fd9e Mon Sep 17 00:00:00 2001 From: Joseph Harold <121983012+jmharold@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:14:29 -0700 Subject: [PATCH 12/21] preview panel for rendering prompt (#747) * preview panel for rendering prompt * Remove .md render logic from Message.tsx --------- Co-authored-by: jmharold Co-authored-by: bedanley --- lib/user-interface/react/package.json | 3 +- .../react/src/components/chatbot/Chat.tsx | 184 +++++++++--------- .../chatbot/components/ChatPromptInput.tsx | 141 ++++++++++++++ .../components/chatbot/components/Message.tsx | 158 +-------------- .../chatbot/components/PromptPreview.tsx | 74 +++++++ .../chatbot/config/buttonConfig.tsx | 10 +- .../chatbot/hooks/useDynamicMaxRows.ts | 56 ++++++ .../chatbot/utils/markdownRenderer.tsx | 179 +++++++++++++++++ .../reducers/user-preferences.reducer.ts | 1 + package-lock.json | 30 +++ 10 files changed, 597 insertions(+), 239 deletions(-) create mode 100644 lib/user-interface/react/src/components/chatbot/components/ChatPromptInput.tsx create mode 100644 lib/user-interface/react/src/components/chatbot/components/PromptPreview.tsx create mode 100644 lib/user-interface/react/src/components/chatbot/hooks/useDynamicMaxRows.ts create mode 100644 lib/user-interface/react/src/components/chatbot/utils/markdownRenderer.tsx diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index 8b373a19c..23500d72f 100644 --- a/lib/user-interface/react/package.json +++ b/lib/user-interface/react/package.json @@ -42,13 +42,13 @@ "lodash": "^4.17.21", "luxon": "^3.7.2", "mermaid": "^11.12.2", + "oidc-client-ts": "^3.1.0", "react": "^19.2.1", "react-ace": "^14.0.1", "react-dom": "^19.2.1", "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", "react-oidc-context": "^3.3.0", - "oidc-client-ts": "^3.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.10.1", "react-syntax-highlighter": "^16.1.0", @@ -56,6 +56,7 @@ "redux-persist": "^6.0.0", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwindcss": "^4.1.18", diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 1a2c3b018..b593dc5df 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -22,13 +22,10 @@ import SpaceBetween from '@cloudscape-design/components/space-between'; import Spinner from '@cloudscape-design/components/spinner'; import { Autosuggest, - ButtonGroup, Checkbox, Grid, - PromptInput, Icon, Flashbar, - FileTokenGroup, } from '@cloudscape-design/components'; import StatusIndicator from '@cloudscape-design/components/status-indicator'; @@ -68,9 +65,14 @@ import { useModels } from './hooks/useModels.hooks'; import { useMemory } from './hooks/useMemory.hooks'; import { useModals } from './hooks/useModals.hooks'; import { useToolChain } from './hooks/useToolChain.hooks'; +import { useDynamicMaxRows } from './hooks/useDynamicMaxRows'; import { WelcomeScreen } from './components/WelcomeScreen'; import { buildMessageContent, buildMessageMetadata } from './utils/messageBuilder.utils'; import { getButtonItems, useButtonActions } from './config/buttonConfig'; +import PromptPreview from './components/PromptPreview'; +import ChatPromptInput from './components/ChatPromptInput'; +import { Mode } from '@cloudscape-design/global-styles'; +import ColorSchemeContext from '@/shared/color-scheme.provider'; import { McpServerStatus, useListMcpServersQuery } from '@/shared/reducers/mcp-server.reducer'; import { DefaultUserPreferences, @@ -133,9 +135,13 @@ export default function Chat ({ sessionId }) { const [mermaidRenderComplete, setMermaidRenderComplete] = useState(0); const [videoLoadComplete, setVideoLoadComplete] = useState(0); const [imageLoadComplete, setImageLoadComplete] = useState(0); - const [dynamicMaxRows, setDynamicMaxRows] = useState(8); const [shouldAutoScroll, setShouldAutoScroll] = useState(true); const [updatingAutoApprovalForTool, setUpdatingAutoApprovalForTool] = useState(null); + const [showMarkdownPreview, setShowMarkdownPreview] = useState(false); + + // Get color scheme context for markdown preview + const { colorScheme } = useContext(ColorSchemeContext); + const isDarkMode = colorScheme === Mode.Dark; // Callback to handle Mermaid diagram rendering completion const handleMermaidRenderComplete = useCallback(() => { @@ -181,6 +187,28 @@ export default function Chat ({ sessionId }) { const { tools: mcpTools, callTool, McpConnections, toolToServerMap } = useMultipleMcp(enabledServers, userPreferences?.preferences?.mcp); const [updatePreferences, {isSuccess: isUpdatingPreferencesSuccess, isError: isUpdatingPreferencesError, isLoading: isUpdatingPreferences}] = useUpdateUserPreferencesMutation(); + // Load markdown preview preference from user preferences + useEffect(() => { + if (userPreferences?.preferences?.showMarkdownPreview !== undefined) { + setShowMarkdownPreview(userPreferences.preferences.showMarkdownPreview); + } + }, [userPreferences]); + + // Handle markdown preview toggle + const handleToggleMarkdownPreview = useCallback((enabled: boolean) => { + setShowMarkdownPreview(enabled); + + const updated = { + ...preferences, + preferences: { + ...preferences.preferences, + showMarkdownPreview: enabled + } + }; + setPreferences(updated); + updatePreferences(updated); + }, [preferences, updatePreferences, setPreferences]); + useEffect(() => { if (userPreferences) { setPreferences(userPreferences); @@ -205,28 +233,8 @@ export default function Chat ({ sessionId }) { } }, [isUpdatingPreferencesError, notificationService]); - useEffect(() => { - const calculateMaxRows = () => { - const LINE_HEIGHT = 24; // pixels per row - const RESERVED_UI_HEIGHT = 280; // model selector, buttons, status - const MAX_INPUT_PERCENTAGE = 0.5; // 50% of viewport max - - const availableHeight = window.innerHeight - RESERVED_UI_HEIGHT; - const maxInputHeight = availableHeight * MAX_INPUT_PERCENTAGE; - const calculatedMaxRows = Math.floor(maxInputHeight / LINE_HEIGHT); - - // Clamp between 3 and 12 rows - const clampedMaxRows = Math.max(3, Math.min(12, calculatedMaxRows)); - setDynamicMaxRows(clampedMaxRows); - }; - - calculateMaxRows(); - window.addEventListener('resize', calculateMaxRows); - return () => window.removeEventListener('resize', calculateMaxRows); - }, []); - - // Custom hooks + const { dynamicMaxRows } = useDynamicMaxRows(); const { session, setSession, @@ -291,12 +299,21 @@ export default function Chat ({ sessionId }) { refreshPromptTemplate } = useModals(); - const { handleButtonClick } = useButtonActions({ + const { handleButtonClick: baseHandleButtonClick } = useButtonActions({ openModal, refreshPromptTemplate, setFilterPromptTemplateType, }); + // Extended button click handler that includes markdown preview toggle + const handleButtonClick = useCallback(({ detail }: { detail: { id: string } }) => { + if (detail.id === 'toggle-markdown-preview') { + handleToggleMarkdownPreview(!showMarkdownPreview); + } else { + baseHandleButtonClick({ detail }); + } + }, [baseHandleButtonClick, handleToggleMarkdownPreview, showMarkdownPreview]); + // Derived states const isImageGenerationMode = selectedModel?.modelType === ModelType.imagegen; const isVideoGenerationMode = selectedModel?.modelType === ModelType.videogen; @@ -423,7 +440,7 @@ export default function Chat ({ sessionId }) { }, [stopToolChain, stopGeneration, setIsRunning, notificationService]); // Determine if we should show stop button - const shouldShowStopButton = isRunning || callingToolName; + const shouldShowStopButton = Boolean(isRunning || callingToolName); useEffect(() => { if (sessionHealth) { @@ -746,6 +763,48 @@ export default function Chat ({ sessionId }) { } }, [shouldShowStopButton, userPrompt.length, isRunning, callingToolName, loadingSession, handleSendGenerateRequest]); + const promptInputProps = useMemo(() => ({ + userPrompt, + shouldShowStopButton, + dynamicMaxRows, + isModelDeleted, + isConnected, + selectedModel, + loadingSession, + isImageGenerationMode, + isVideoGenerationMode, + fileContext, + fileContextName, + config, + useRag, + showMarkdownPreview, + setUserPrompt, + setFileContext, + setFileContextName, + handleAction, + handleKeyPress, + handleButtonClick, + getButtonItems, + }), [ + userPrompt, + shouldShowStopButton, + dynamicMaxRows, + isModelDeleted, + isConnected, + selectedModel, + loadingSession, + isImageGenerationMode, + isVideoGenerationMode, + fileContext, + fileContextName, + config, + useRag, + showMarkdownPreview, + handleAction, + handleKeyPress, + handleButtonClick, + ]); + return (
{/* MCP Connections - invisible components that manage the connections */} @@ -971,67 +1030,16 @@ export default function Chat ({ sessionId }) { /> )} - setUserPrompt(detail.value)} - onAction={handleAction} - onKeyDown={handleKeyPress} - controlId='chat-prompt-input' - secondaryActions={ - - - - } - secondaryContent={ - fileContext && ( - { - setFileContext(''); - setFileContextName(''); - }} - alignment='horizontal' - showFileSize={false} - showFileLastModified={false} - showFileThumbnail={false} - i18nStrings={{ - removeFileAriaLabel: () => 'Remove file', - limitShowFewer: 'Show fewer files', - limitShowMore: 'Show more files', - errorIconAriaLabel: 'Error', - warningIconAriaLabel: 'Warning' - }} - /> - ) - } - /> + {showMarkdownPreview ? ( +
+ + + + +
+ ) : ( + + )} {enabledServers && enabledServers.length > 0 && selectedModel?.features?.filter((feature) => feature.name === ModelFeatures.TOOL_CALLS)?.length && true ? ( diff --git a/lib/user-interface/react/src/components/chatbot/components/ChatPromptInput.tsx b/lib/user-interface/react/src/components/chatbot/components/ChatPromptInput.tsx new file mode 100644 index 000000000..07c81799a --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/components/ChatPromptInput.tsx @@ -0,0 +1,141 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import React from 'react'; +import { PromptInput, ButtonGroup, FileTokenGroup, Box } from '@cloudscape-design/components'; +import { IConfiguration } from '@/shared/model/configuration.model'; + +type ChatPromptInputProps = { + userPrompt: string; + shouldShowStopButton: boolean; + dynamicMaxRows: number; + isModelDeleted: boolean; + isConnected: boolean; + selectedModel: any; + loadingSession: boolean; + isImageGenerationMode: boolean; + isVideoGenerationMode: boolean; + fileContext: string; + fileContextName: string; + config: IConfiguration; + useRag: boolean; + showMarkdownPreview: boolean; + setUserPrompt: (value: string) => void; + setFileContext: (value: string) => void; + setFileContextName: (value: string) => void; + handleAction: () => void; + handleKeyPress: (event: any) => void; + handleButtonClick: (event: { detail: { id: string } }) => void; + getButtonItems: ( + config: IConfiguration, + useRag: boolean, + isImageGenerationMode: boolean, + isVideoGenerationMode: boolean, + isConnected: boolean, + isModelDeleted: boolean, + showMarkdownPreview: boolean + ) => any[]; +}; + +export const ChatPromptInput: React.FC = ({ + userPrompt, + shouldShowStopButton, + dynamicMaxRows, + isModelDeleted, + isConnected, + selectedModel, + loadingSession, + isImageGenerationMode, + isVideoGenerationMode, + fileContext, + fileContextName, + config, + useRag, + showMarkdownPreview, + setUserPrompt, + setFileContext, + setFileContextName, + handleAction, + handleKeyPress, + handleButtonClick, + getButtonItems, +}) => { + return ( + setUserPrompt(detail.value)} + onAction={handleAction} + onKeyDown={handleKeyPress} + controlId='chat-prompt-input' + secondaryActions={ + + + + } + secondaryContent={ + fileContext && ( + { + setFileContext(''); + setFileContextName(''); + }} + alignment='horizontal' + showFileSize={false} + showFileLastModified={false} + showFileThumbnail={false} + i18nStrings={{ + removeFileAriaLabel: () => 'Remove file', + limitShowFewer: 'Show fewer files', + limitShowMore: 'Show more files', + errorIconAriaLabel: 'Error', + warningIconAriaLabel: 'Warning' + }} + /> + ) + } + /> + ); +}; + +export default ChatPromptInput; diff --git a/lib/user-interface/react/src/components/chatbot/components/Message.tsx b/lib/user-interface/react/src/components/chatbot/components/Message.tsx index 07b620e2f..391620d2f 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx @@ -20,19 +20,15 @@ import ExpandableSection from '@cloudscape-design/components/expandable-section' import { ButtonDropdown, ButtonGroup, Grid, SpaceBetween, StatusIndicator } from '@cloudscape-design/components'; import { JsonView, darkStyles, defaultStyles } from 'react-json-view-lite'; import 'react-json-view-lite/dist/index.css'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { LisaChatMessage, LisaChatMessageMetadata, MessageTypes } from '../../types'; import { useAppSelector } from '@/config/store'; import { selectCurrentUsername } from '@/shared/reducers/user.reducer'; import ChatBubble from '@cloudscape-design/chat-components/chat-bubble'; import Avatar from '@cloudscape-design/chat-components/avatar'; -import remarkMath from 'remark-math'; -import remarkGfm from 'remark-gfm'; -import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; import styles from './Message.module.css'; +import { getMarkdownComponents, markdownPlugins } from '../utils/markdownRenderer'; import { MessageContent } from '@langchain/core/messages'; import { base64ToBlob, fetchImage, getDisplayableMessage } from '@/components/utils'; @@ -41,7 +37,6 @@ import { IChatConfiguration } from '@/shared/model/chat.configurations.model'; import { downloadFile } from '@/shared/util/downloader'; import Link from '@cloudscape-design/components/link'; import ImageViewer from '@/components/chatbot/components/ImageViewer'; -import MermaidDiagram from '@/components/chatbot/components/MermaidDiagram'; import UsageInfo from '@/components/chatbot/components/UsageInfo'; import { merge } from 'lodash'; import { useContext } from 'react'; @@ -97,145 +92,10 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami }, [resend]); // Memoize the ReactMarkdown components to prevent re-creation on every render - const markdownComponents = useMemo(() => ({ - code ({ className, children, ...props }: any) { - const match = /language-(\w+)/.exec(className || ''); - const codeString = String(children).replace(/\n$/, ''); - - const CodeBlockWithCopyButton = ({ language, code }: { language: string, code: string }) => { - return ( -
-
- - navigator.clipboard.writeText(code) - } - ariaLabel='Chat actions' - dropdownExpandToViewport - items={[ - { - type: 'icon-button', - id: 'copy code', - iconName: 'copy', - text: 'Copy Code', - popoverFeedback: ( - - Code copied - - ) - } - ]} - variant='icon' - /> -
- - {code} - -
- ); - }; - const CodeBlockWithoutLanguage = ({ code }: { code: string }) => { - return ( -
-
- - navigator.clipboard.writeText(code) - } - ariaLabel='Chat actions' - dropdownExpandToViewport - items={[ - { - type: 'icon-button', - id: 'copy code', - iconName: 'copy', - text: 'Copy Code', - popoverFeedback: ( - - Code copied - - ) - } - ]} - variant='icon' - /> -
-
-                            
-                                {code}
-                            
-                        
-
- ); - }; - // Check if this is inline code by examining the props - const isInlineCode = !props.node || props.node.position?.start?.line === props.node.position?.end?.line; - - if (isInlineCode) { - return ( - - {children} - - ); - } - return match ? ( - match[1] === 'mermaid' ? ( - - ) : ( - - ) - ) : ( - - ); - }, - }), [isStreaming, onMermaidRenderComplete, isDarkMode]); + const markdownComponents = useMemo( + () => getMarkdownComponents(isDarkMode, isStreaming, onMermaidRenderComplete), + [isDarkMode, isStreaming, onMermaidRenderComplete] + ); const renderContent = (content: MessageContent, metadata?: LisaChatMessageMetadata) => { if (Array.isArray(content)) { @@ -249,8 +109,8 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
{markdownDisplay ? ( @@ -364,8 +224,8 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
{markdownDisplay ? ( diff --git a/lib/user-interface/react/src/components/chatbot/components/PromptPreview.tsx b/lib/user-interface/react/src/components/chatbot/components/PromptPreview.tsx new file mode 100644 index 000000000..3781e6b94 --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/components/PromptPreview.tsx @@ -0,0 +1,74 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import React, { useMemo } from 'react'; +import ReactMarkdown from 'react-markdown'; +import Box from '@cloudscape-design/components/box'; +import { getMarkdownComponents, markdownPlugins } from '../utils/markdownRenderer'; +import { useDynamicMaxRows } from '../hooks/useDynamicMaxRows'; +import remarkBreaks from 'remark-breaks'; +import 'katex/dist/katex.min.css'; +import styles from './Message.module.css'; + +type PromptPreviewProps = { + content: string; + isDarkMode: boolean; +}; + +export const PromptPreview: React.FC = ({ content, isDarkMode }) => { + // Use the same hook as Chat.tsx to ensure identical sizing + const { dynamicMaxRows, LINE_HEIGHT, PADDING } = useDynamicMaxRows(); + + // Memoize markdown components to prevent re-creation on every render + const markdownComponents = useMemo( + () => getMarkdownComponents(isDarkMode, false), + [isDarkMode] + ); + + // Calculate max height based on dynamicMaxRows (matching PromptInput behavior) + const maxHeight = (dynamicMaxRows * LINE_HEIGHT) + PADDING; + + return ( +
+ {content.trim() ? ( +
+ + {content} + +
+ ) : ( + + Preview will appear here as you type... + + )} +
+ ); +}; + +export default PromptPreview; diff --git a/lib/user-interface/react/src/components/chatbot/config/buttonConfig.tsx b/lib/user-interface/react/src/components/chatbot/config/buttonConfig.tsx index dee335873..e09741ea8 100644 --- a/lib/user-interface/react/src/components/chatbot/config/buttonConfig.tsx +++ b/lib/user-interface/react/src/components/chatbot/config/buttonConfig.tsx @@ -25,7 +25,8 @@ export const getButtonItems = ( isImageGenerationMode: boolean, isVideoGenerationMode: boolean, isConnected: boolean, - isModelDeleted: boolean = false + isModelDeleted: boolean = false, + showMarkdownPreview: boolean = false ): ButtonGroupProps.Item[] => { const baseItems: ButtonGroupProps.Item[] = [ { @@ -34,6 +35,13 @@ export const getButtonItems = ( iconName: 'settings', text: 'Session configuration', disabled: !isConnected || isModelDeleted + }, + { + type: 'icon-button', + id: 'toggle-markdown-preview', + iconName: 'view-vertical', + text: showMarkdownPreview ? 'Hide Preview' : 'Show Preview', + disabled: !isConnected || isModelDeleted } ]; diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useDynamicMaxRows.ts b/lib/user-interface/react/src/components/chatbot/hooks/useDynamicMaxRows.ts new file mode 100644 index 000000000..3bb213983 --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/hooks/useDynamicMaxRows.ts @@ -0,0 +1,56 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { useEffect, useState } from 'react'; + +/** + * Hook to calculate dynamic maximum rows for prompt input and preview + * based on available window height. + * + * @returns An object containing: + * - dynamicMaxRows: The calculated maximum number of rows + * - LINE_HEIGHT: Pixels per row (24px) + * - PADDING: Total padding for the container + */ +export const useDynamicMaxRows = () => { + const [dynamicMaxRows, setDynamicMaxRows] = useState(8); + + useEffect(() => { + const calculateMaxRows = () => { + const LINE_HEIGHT = 24; // pixels per row + const RESERVED_UI_HEIGHT = 280; // model selector, buttons, status + const MAX_INPUT_PERCENTAGE = 0.5; // 50% of viewport max + + const availableHeight = window.innerHeight - RESERVED_UI_HEIGHT; + const maxInputHeight = availableHeight * MAX_INPUT_PERCENTAGE; + const calculatedMaxRows = Math.floor(maxInputHeight / LINE_HEIGHT); + + // Clamp between 3 and 12 rows + const clampedMaxRows = Math.max(3, Math.min(12, calculatedMaxRows)); + setDynamicMaxRows(clampedMaxRows); + }; + + calculateMaxRows(); + window.addEventListener('resize', calculateMaxRows); + return () => window.removeEventListener('resize', calculateMaxRows); + }, []); + + return { + dynamicMaxRows, + LINE_HEIGHT: 24, + PADDING: 24, + }; +}; diff --git a/lib/user-interface/react/src/components/chatbot/utils/markdownRenderer.tsx b/lib/user-interface/react/src/components/chatbot/utils/markdownRenderer.tsx new file mode 100644 index 000000000..37b51e07c --- /dev/null +++ b/lib/user-interface/react/src/components/chatbot/utils/markdownRenderer.tsx @@ -0,0 +1,179 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import React from 'react'; +import { ButtonGroup, StatusIndicator } from '@cloudscape-design/components'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import remarkMath from 'remark-math'; +import remarkGfm from 'remark-gfm'; +import rehypeKatex from 'rehype-katex'; +import MermaidDiagram from '../components/MermaidDiagram'; + +/** + * Shared markdown plugins configuration + */ +export const markdownPlugins = { + remarkPlugins: [remarkMath, remarkGfm], + rehypePlugins: [rehypeKatex], +}; + +/** + * Get markdown components configuration for ReactMarkdown + * @param isDarkMode - Whether dark mode is enabled + * @param isStreaming - Whether content is being streamed + * @param onMermaidRenderComplete - Callback for when mermaid rendering completes + */ +export const getMarkdownComponents = ( + isDarkMode: boolean, + isStreaming?: boolean, + onMermaidRenderComplete?: () => void +) => ({ + code ({ className, children, ...props }: any) { + const match = /language-(\w+)/.exec(className || ''); + const codeString = String(children).replace(/\n$/, ''); + + const CodeBlockWithCopyButton = ({ language, code }: { language: string; code: string }) => { + return ( +
+
+ navigator.clipboard.writeText(code)} + ariaLabel='Chat actions' + dropdownExpandToViewport + items={[ + { + type: 'icon-button', + id: 'copy code', + iconName: 'copy', + text: 'Copy Code', + popoverFeedback: ( + Code copied + ), + }, + ]} + variant='icon' + /> +
+ + {code} + +
+ ); + }; + + const CodeBlockWithoutLanguage = ({ code }: { code: string }) => { + return ( +
+
+ navigator.clipboard.writeText(code)} + ariaLabel='Chat actions' + dropdownExpandToViewport + items={[ + { + type: 'icon-button', + id: 'copy code', + iconName: 'copy', + text: 'Copy Code', + popoverFeedback: ( + Code copied + ), + }, + ]} + variant='icon' + /> +
+
+                        
+                            {code}
+                        
+                    
+
+ ); + }; + + // Check if this is inline code by examining the props + const isInlineCode = !props.node || props.node.position?.start?.line === props.node.position?.end?.line; + + if (isInlineCode) { + return ( + + {children} + + ); + } + + return match ? ( + match[1] === 'mermaid' ? ( + + ) : ( + + ) + ) : ( + + ); + }, +}); diff --git a/lib/user-interface/react/src/shared/reducers/user-preferences.reducer.ts b/lib/user-interface/react/src/shared/reducers/user-preferences.reducer.ts index 246f02004..960fab2e9 100644 --- a/lib/user-interface/react/src/shared/reducers/user-preferences.reducer.ts +++ b/lib/user-interface/react/src/shared/reducers/user-preferences.reducer.ts @@ -33,6 +33,7 @@ export type McpPreferences = { export type Preferences = { mcp?: McpPreferences; + showMarkdownPreview?: boolean; }; export type UserPreferences = { diff --git a/package-lock.json b/package-lock.json index ac1f536c5..2259f527d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -158,6 +158,7 @@ "redux-persist": "^6.0.0", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwindcss": "^4.1.18", @@ -17198,6 +17199,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -19882,6 +19897,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", From 795b85724239e51b4855cdcf79ea5c2bd372643c Mon Sep 17 00:00:00 2001 From: bedanley Date: Thu, 12 Feb 2026 11:16:18 -0700 Subject: [PATCH 13/21] Publish artifacts --- .github/workflows/code.publish.yml | 79 ++++++++-- .gitignore | 1 + .npmignore | 1 + CHANGELOG.md | 2 +- bin/build-assets | 26 +++- bin/build-images | 2 +- bin/build-lambda-layers | 65 +++++++++ bin/{build-lambdas => package-lambda} | 0 bin/package-lambda-layer | 189 ------------------------ bin/pull-images | 200 ++++++++++++++++++++++++++ lib/docs/admin/deploy.md | 14 +- lib/docs/config/config-generator.md | 15 +- package-lock.json | 79 +++++++--- package.json | 4 +- scripts/generate-config.ts | 105 ++++++++++++-- 15 files changed, 529 insertions(+), 253 deletions(-) create mode 100755 bin/build-lambda-layers rename bin/{build-lambdas => package-lambda} (100%) delete mode 100755 bin/package-lambda-layer create mode 100755 bin/pull-images diff --git a/.github/workflows/code.publish.yml b/.github/workflows/code.publish.yml index c75d5c27e..0c45b6afe 100644 --- a/.github/workflows/code.publish.yml +++ b/.github/workflows/code.publish.yml @@ -1,7 +1,19 @@ -name: Publish LISA NPM Package +name: Publish LISA Packages on: release: types: [released] + workflow_dispatch: + inputs: + test_mode: + description: 'Test mode (skips npm publish and release upload)' + required: false + type: boolean + default: true + version: + description: 'Version tag for testing' + required: false + type: string + default: 'test-v0.0.0' permissions: contents: read # Default read-only @@ -10,19 +22,64 @@ jobs: PublishLISA: runs-on: ubuntu-latest permissions: - contents: read - packages: write # Required for npm package publishing + contents: write # Required for uploading release assets + id-token: write # Required for npm trusted publishing (OIDC) steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - # Setup .npmrc file to publish to GitHub Packages + # Setup .npmrc file to publish to NpmJs Packages - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4 with: node-version: '24.x' - registry-url: 'https://npm.pkg.github.com' + registry-url: 'https://registry.npmjs.org' + + # Setup Python for build scripts + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + # Install npm dependencies and publish package. Auth is established with NpmJs Trusted publishing. + # To update, modify package at https://www.npmjs.com/package/awslabs-lisa/access + # More info: https://docs.npmjs.com/trusted-publishers - run: npm ci - - run: npm publish + - name: Publish NPM Package + if: github.event_name == 'release' || !inputs.test_mode + run: npm publish + - name: Publish NPM Package (Dry Run) + if: github.event_name == 'workflow_dispatch' && inputs.test_mode + run: npm publish --dry-run + + # Build binary assets (lambda layers and container images) + - name: Build Lambda Layers and Container Images + run: | + # Create build directory for lambda layers + mkdir -p build + + # Build assets (runs build-lambdas and build-images --export) + ./bin/build-assets env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYPI_URL: https://pypi.org/simple + LISA_VERSION: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.version }} + + # Upload binary assets to GitHub Release + - name: Upload Release Assets + if: github.event_name == 'release' || !inputs.test_mode + uses: softprops/action-gh-release@v2 + with: + files: | + dist/layers/*.zip + dist/images/*.tar + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # In test mode, just list what would be uploaded + - name: List Build Artifacts (Test Mode) + if: github.event_name == 'workflow_dispatch' && inputs.test_mode + run: | + echo "=== Lambda Layers (dist/layers/*.zip) ===" + ls -lh dist/layers/*.zip 2>/dev/null || echo "No zip files found" + echo "" + echo "=== Container Images (dist/images/*.tar) ===" + ls -lh dist/images/*.tar 2>/dev/null || echo "No tar files found" SendSlackNotification: name: Send Slack Notification @@ -37,9 +94,9 @@ jobs: env: SLACK_WEBHOOK: ${{ secrets.INTERNAL_DEV_SLACK_WEBHOOK_URL }} SLACK_COLOR: ${{ contains(join(needs.*.result, ' '), 'failure') && 'failure' || 'success' }} - SLACK_TITLE: 'NPM Package Published' + SLACK_TITLE: 'LISA Package Published' SLACK_FOOTER: '' MSG_MINIMAL: 'actions url,commit' - SLACK_MESSAGE_ON_FAILURE: ' NPM Package publish FAILED for version ${{ github.event.pull_request.head.ref }}|commit>' - SLACK_MESSAGE_ON_SUCCESS: 'NPM Package published SUCCESS for ${{ github.event.pull_request.head.ref }}|commit>.' - SLACK_MESSAGE: 'NPM Publish Finished with status ${{ job.status }} for <${{ github.event.pull_request.head.ref }}|commit>' + SLACK_MESSAGE_ON_FAILURE: ' LISA Package publish FAILED for version ${{ github.event.release.tag_name }}' + SLACK_MESSAGE_ON_SUCCESS: 'LISA Package published SUCCESS for version ${{ github.event.release.tag_name }} with NPM package and binary assets.' + SLACK_MESSAGE: 'LISA Publish Finished with status ${{ job.status }} for version ${{ github.event.release.tag_name }}' diff --git a/.gitignore b/.gitignore index 64ab7b487..343778277 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ config-generated.yaml # Cypress local environment /cypress/.env.local +.npmrc diff --git a/.npmignore b/.npmignore index 857095671..55b64cf46 100644 --- a/.npmignore +++ b/.npmignore @@ -14,6 +14,7 @@ lib/**/dist/ lib/**/build/ !/dist/ !/dist/**/node_modules +/dist/images # Non-source files .eslintrc.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c18503bd2..4eee21056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ LISA supports video generation. ### Interactive Configuration Generator CLI LISA offers an interactive CLI tool that guides customers through creating a valid `config-custom.yaml` file for deployment. Instead of manually editing YAML and referencing `example_config.yaml`, customers can now run: -> @awslabs/lisa@6.2.0 generate-config +> awslabs-lisa@6.2.0 generate-config > tsx scripts/generate-config.ts ╔════════════════════════════════════════════════════════════════╗ diff --git a/bin/build-assets b/bin/build-assets index 4a214ae4a..3dcc739fb 100755 --- a/bin/build-assets +++ b/bin/build-assets @@ -2,6 +2,30 @@ set -e ROOT=$(pwd) +LAYER_DIR=$ROOT/dist/layers +IMAGE_DIR=$ROOT/dist/images -./bin/build-lambdas +# Default PYPI_URL if not set +export PYPI_URL=${PYPI_URL:-"https://pypi.org/simple"} +export OUTPUT_DIR=$LAYER_DIR +export IMAGE_DIR + +echo "Building all assets..." + +# Build Lambda layers (Python and Node.js) +echo "Building Lambda Layers..." +./bin/build-lambda-layers + +# Build Lambda function (no dependencies, uses layers) +echo "Building Lambda function..." +cd lambda +$ROOT/bin/package-lambda --src . --output "Lambda.zip" --pypi "$PYPI_URL" +mv ./build/Lambda.zip "$LAYER_DIR/" +rm -rf ./build +cd "$ROOT" + +# Build and export container images +echo "Building Image exports..." ./bin/build-images --export + +echo "All assets built successfully!" diff --git a/bin/build-images b/bin/build-images index e4f249cb2..3b7f68707 100755 --- a/bin/build-images +++ b/bin/build-images @@ -3,7 +3,7 @@ set -e ROOT=$(pwd) -OUTPUT_DIR=$ROOT/dist/images +OUTPUT_DIR=${IMAGE_DIR:-$ROOT/dist/images} # Container runtime: Use CDK_DOCKER env var (same as CDK), default to docker DOCKER_CMD="${CDK_DOCKER:-docker}" diff --git a/bin/build-lambda-layers b/bin/build-lambda-layers new file mode 100755 index 000000000..0e4d4ef22 --- /dev/null +++ b/bin/build-lambda-layers @@ -0,0 +1,65 @@ +#!/bin/bash +set -e + +ROOT=$(pwd) +OUTPUT_DIR=${OUTPUT_DIR:-$ROOT/dist/layers} +mkdir -p $OUTPUT_DIR + +# Default PYPI_URL if not set +PYPI_URL=${PYPI_URL:-"https://pypi.org/simple"} + +# Define associative array for package name and source mapping +declare -A LAMBDA_LAYERS +LAMBDA_LAYERS["CommonLayer"]="./lib/core/layers/common" +LAMBDA_LAYERS["AuthLayer"]="./lib/core/layers/authorizer" +LAMBDA_LAYERS["FastApiLayer"]="./lib/core/layers/fastapi" +LAMBDA_LAYERS["Rag"]="./lib/rag/layer" +LAMBDA_LAYERS["Sdk"]="./lisa-sdk" + +echo "Building Python Lambda Layers..." +for package_name in "${!LAMBDA_LAYERS[@]}"; do + source_path=${LAMBDA_LAYERS[$package_name]} + echo "Building Lambda Layer $package_name from $source_path..." + + # Use package-lambda utility with --layer flag (output is relative to source) + cd "$source_path" + $ROOT/bin/package-lambda --src . --output "$package_name.zip" --pypi "$PYPI_URL" --layer + # Move the built zip to the output directory + mv "./build/$package_name.zip" "$OUTPUT_DIR/" + rm -rf ./build + cd "$ROOT" +done + +# Build Node.js CDK Layer +build_node_layer() { + local package_name=$1 + local source_path=$2 + echo "Building Node.js Lambda Layer $package_name from $source_path..." + + cd "$source_path" + + # Clean previous build + rm -rf build node_modules + + # Create layer structure: nodejs/node_modules + mkdir -p build/nodejs + + # Copy package.json and install production dependencies + cp package.json build/nodejs/ + cd build/nodejs + npm install --omit=dev --production + cd ../.. + + # Create zip + cd build + zip -r "$package_name.zip" nodejs + mv "$package_name.zip" $OUTPUT_DIR/ + cd .. + rm -rf build + cd $ROOT +} + +echo "Building Node.js Lambda Layers..." +build_node_layer "CdkLayer" "./lib/core/layers/cdk" + +echo "All Lambda layers built successfully in $OUTPUT_DIR!" diff --git a/bin/build-lambdas b/bin/package-lambda similarity index 100% rename from bin/build-lambdas rename to bin/package-lambda diff --git a/bin/package-lambda-layer b/bin/package-lambda-layer deleted file mode 100755 index 66792f973..000000000 --- a/bin/package-lambda-layer +++ /dev/null @@ -1,189 +0,0 @@ -#!/bin/bash -set -e - -SRC=src -OUTPUT=Lambda.zip -EXCLUDE_PACKAGES="" -BUILD_DIR=$PWD/build -IS_LAYER=0 -TMP_DIR=$BUILD_DIR/tmp/ -PYPI_URL= -PYTHON_VERSION=3.13 -PLATFORM="linux/amd64" - -# Container runtime: Use CDK_DOCKER env var (same as CDK), default to docker -DOCKER_CMD="${CDK_DOCKER:-docker}" - -# Parse named parameters -while [ $# -gt 0 ]; do - if [[ $1 == *"="* ]]; then - # Handle --param=value style - param="${1%%=*}" - value="${1#*=}" - - case "$param" in - --src) - SRC="$value" - ;; - --output) - OUTPUT="$value" - ;; - --build) - BUILD_DIR="$value" - ;; - --exclude) - EXCLUDE_PACKAGES="$value" - ;; - --pypi) - PYPI_URL="$value" - ;; - --layer) - IS_LAYER=1 - ;; - --python-version) - PYTHON_VERSION="$value" - ;; - *) - echo "Unknown parameter: $param" - echo "Usage: $0 --src --output --exclude --layer --python-version " - exit 1 - ;; - esac - else - # Handle --param value style - case "$1" in - --src) - shift - SRC="$1" - ;; - --output) - shift - OUTPUT="$1" - ;; - --build) - shift - BUILD_DIR="$1" - TMP_DIR=$BUILD_DIR/tmp/python/ - ;; - --exclude) - shift - EXCLUDE_PACKAGES="$1" - ;; - --pypi) - shift - PYPI_URL="$1" - ;; - --layer) - IS_LAYER=1 - ;; - --python-version) - shift - PYTHON_VERSION="$1" - ;; - *) - echo "Unknown parameter: $1" - echo "Usage: $0 --src --output --exclude --layer --python-version " - exit 1 - ;; - esac - fi - shift -done - -echo "Starting" -if [ $IS_LAYER -eq 1 ]; then - TMP_DIR=$BUILD_DIR/tmp/python/ -fi - -if [ -z "$PYPI_URL" ]; then - echo "Must supply PYPI_URL via --pypi" - exit 1 -fi - -# Extract IP from PYPI_URL for trusted host -TRUSTED_HOST=$(echo $PYPI_URL | sed 's|http://||' | sed 's|/.*||') - -# Print parameters for debugging -echo "Source directory: $SRC" -echo "Output file: $OUTPUT" -echo "Build directory: $BUILD_DIR" -echo "Temp directory: $TMP_DIR" -echo "Python version: $PYTHON_VERSION" -echo "Platform: $PLATFORM" -echo "Container runtime: $DOCKER_CMD" - -# Use AWS SAM build image for Lambda-compatible builds -# This ensures native extensions are compiled for the correct platform -PYTHON_IMAGE="public.ecr.aws/sam/build-python${PYTHON_VERSION}:latest" - -install_requirements() { - echo "Installing requirements using container..." - rm -rf "$TMP_DIR" 2>/dev/null || sudo rm -rf "$TMP_DIR" 2>/dev/null || true - mkdir -p "$TMP_DIR" - - if [ -f "$SRC/requirements.txt" ]; then - echo "Installing requirements from $SRC/requirements.txt" - echo "Using container image: $PYTHON_IMAGE" - - # Get absolute paths for container volume mounts - ABS_SRC=$(cd "$SRC" && pwd) - ABS_TMP=$(cd "$TMP_DIR" && pwd) - - # Get current user's UID/GID for ownership fix - CURRENT_UID=$(id -u) - CURRENT_GID=$(id -g) - - # Run pip install inside container with correct platform, then fix ownership - $DOCKER_CMD run --rm \ - --platform "$PLATFORM" \ - -v "$ABS_SRC:/var/task/src:ro" \ - -v "$ABS_TMP:/var/task/output" \ - -e PYPI_URL="$PYPI_URL" \ - -e TRUSTED_HOST="$TRUSTED_HOST" \ - -e TARGET_UID="$CURRENT_UID" \ - -e TARGET_GID="$CURRENT_GID" \ - "$PYTHON_IMAGE" \ - /bin/bash -c " - pip install -r /var/task/src/requirements.txt \ - --force-reinstall \ - --no-cache-dir \ - --target /var/task/output \ - --index-url \$PYPI_URL \ - --trusted-host \$TRUSTED_HOST && \ - chown -R \$TARGET_UID:\$TARGET_GID /var/task/output - " - else - echo "No requirements.txt found in $SRC" - fi -} - -build_package() { - echo "Building package" - if [ -d "$SRC" ]; then - rsync -av --exclude='build' --exclude='.hatch' --exclude='.venv' --exclude='requirements.txt' "$SRC/" "$TMP_DIR/" - fi -} - -package_artifacts() { - echo "Packaging" - if [ -n "$EXCLUDE_PACKAGES" ]; then - echo "Removing excluded packages: $EXCLUDE_PACKAGES" - for pkg in ${EXCLUDE_PACKAGES//,/ }; do - echo "Removing $pkg" - rm -rf ${TMP_DIR}/${pkg} - rm -rf ${TMP_DIR}/${pkg}-* - # Also remove egg-info directories - find "$TMP_DIR" -type d -name "${pkg}*egg-info" -exec rm -rf {} + - done - fi - - # AWS Lambda recommends to exclude __pycache__: https://docs.aws.amazon.com/lambda/latest/dg/python-package.html#python-package-pycache - find "${TMP_DIR}" -depth -name __pycache__ -exec rm -rf {} \; - cd "${BUILD_DIR}/tmp/" - zip -r "${BUILD_DIR}/${OUTPUT}" . - rm -rf "${BUILD_DIR}/tmp" -} - -install_requirements -build_package -package_artifacts diff --git a/bin/pull-images b/bin/pull-images new file mode 100755 index 000000000..ce744313f --- /dev/null +++ b/bin/pull-images @@ -0,0 +1,200 @@ +#!/bin/bash +set -e + +# Pull container image tarballs from a GitHub Release into dist/images/ +# These can then be used with imageConfig type: tarball in config-custom.yaml + +REPO="awslabs/LISA" +VERSION="" +OUTPUT_DIR="./dist/images" +TOKEN="" +IMAGES=("lisa-rest-api" "lisa-batch-ingestion" "lisa-mcp-workbench" "lisa-tei" "lisa-tgi" "lisa-vllm") + +usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Download container image tarballs from a GitHub Release." + echo "" + echo "Options:" + echo " --version VERSION Release version tag (default: reads from VERSION file)" + echo " --repo OWNER/REPO GitHub repository (default: awslabs/LISA)" + echo " --output DIR Output directory (default: ./dist/images)" + echo " --token TOKEN GitHub token for private repos or rate limiting" + echo " --images LIST Comma-separated image names to download (default: all)" + echo " --help Show this help message" + echo "" + echo "Example:" + echo " $0 --version v6.2.0" + echo " $0 --version v6.2.0 --images lisa-rest-api,lisa-batch-ingestion" + echo "" + echo "After downloading, add to config-custom.yaml:" + echo " restApiConfig:" + echo " imageConfig:" + echo " type: tarball" + echo " path: ./dist/images/lisa-rest-api_.tar" + exit 0 +} + +# Parse arguments +while [ $# -gt 0 ]; do + case "$1" in + --version) + shift + VERSION="$1" + ;; + --repo) + shift + REPO="$1" + ;; + --output) + shift + OUTPUT_DIR="$1" + ;; + --token) + shift + TOKEN="$1" + ;; + --images) + shift + IFS=',' read -ra IMAGES <<< "$1" + ;; + --help) + usage + ;; + *) + echo "Unknown option: $1" + usage + ;; + esac + shift +done + +# Resolve version from VERSION file if not provided +if [ -z "$VERSION" ]; then + if [ -f "./VERSION" ]; then + VERSION=$(cat ./VERSION) + echo "Using version from VERSION file: $VERSION" + else + echo "ERROR: No --version specified and no VERSION file found" + exit 1 + fi +fi + +# Strip leading 'v' if present for matching asset filenames +CLEAN_VERSION="${VERSION#v}" + +mkdir -p "$OUTPUT_DIR" + +# Build curl auth header +AUTH_HEADER="" +if [ -n "$TOKEN" ]; then + AUTH_HEADER="Authorization: token $TOKEN" +fi + +# Resolve the release API URL +API_URL="https://api.github.com/repos/$REPO/releases/tags/$VERSION" + +# If version doesn't start with 'v', also try with 'v' prefix +echo "Fetching release info from: $API_URL" +RELEASE_JSON=$(curl -sfL ${AUTH_HEADER:+-H "$AUTH_HEADER"} "$API_URL" 2>/dev/null) || { + # Try with 'v' prefix + API_URL="https://api.github.com/repos/$REPO/releases/tags/v$VERSION" + echo "Retrying with v-prefix: $API_URL" + RELEASE_JSON=$(curl -sfL ${AUTH_HEADER:+-H "$AUTH_HEADER"} "$API_URL") || { + echo "ERROR: Could not find release for version '$VERSION' or 'v$VERSION'" + echo "Check available releases at: https://github.com/$REPO/releases" + exit 1 + } +} + +echo "Found release. Downloading image tarballs..." +echo "" + +DOWNLOADED=0 +FAILED=0 + +for IMAGE_NAME in "${IMAGES[@]}"; do + # The build-images script names exports as: {repository_name}_{image_tag}.tar + ASSET_NAME="${IMAGE_NAME}_${CLEAN_VERSION}.tar" + + # Extract the download URL for this asset + DOWNLOAD_URL=$(echo "$RELEASE_JSON" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for asset in data.get('assets', []): + if asset['name'] == '$ASSET_NAME': + print(asset.get('browser_download_url', asset['url'])) + sys.exit(0) +sys.exit(1) +" 2>/dev/null) || { + # Some images use 'latest' as tag instead of version (tei, tgi, vllm) + ASSET_NAME="${IMAGE_NAME}_latest.tar" + DOWNLOAD_URL=$(echo "$RELEASE_JSON" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for asset in data.get('assets', []): + if asset['name'] == '$ASSET_NAME': + print(asset.get('browser_download_url', asset['url'])) + sys.exit(0) +sys.exit(1) +" 2>/dev/null) || { + echo "SKIP: $IMAGE_NAME (no matching asset found in release)" + continue + } + } + + DEST="$OUTPUT_DIR/$ASSET_NAME" + echo "Downloading $ASSET_NAME..." + + if curl -sfL ${AUTH_HEADER:+-H "$AUTH_HEADER"} -H "Accept: application/octet-stream" -o "$DEST" "$DOWNLOAD_URL"; then + SIZE=$(du -h "$DEST" | cut -f1) + echo " -> $DEST ($SIZE)" + DOWNLOADED=$((DOWNLOADED + 1)) + else + echo " FAILED to download $ASSET_NAME" + FAILED=$((FAILED + 1)) + fi +done + +echo "" +echo "Download complete: $DOWNLOADED succeeded, $FAILED failed" +echo "" + +if [ $DOWNLOADED -gt 0 ]; then + echo "Add the following to your config-custom.yaml to use these images:" + echo "" + echo "---" + for IMAGE_NAME in "${IMAGES[@]}"; do + # Check which file actually exists for this image + TAR_FILE=$(ls "$OUTPUT_DIR/${IMAGE_NAME}_"*.tar 2>/dev/null | head -1) + if [ -n "$TAR_FILE" ]; then + case "$IMAGE_NAME" in + lisa-rest-api) + echo "restApiConfig:" + echo " imageConfig:" + echo " type: tarball" + echo " path: $TAR_FILE" + ;; + lisa-mcp-workbench) + echo "mcpWorkbenchConfig:" + echo " imageConfig:" + echo " type: tarball" + echo " path: $TAR_FILE" + ;; + lisa-batch-ingestion) + echo "batchIngestionConfig:" + echo " imageConfig:" + echo " type: tarball" + echo " path: $TAR_FILE" + ;; + *) + echo "# $IMAGE_NAME:" + echo "# imageConfig:" + echo "# type: tarball" + echo "# path: $TAR_FILE" + ;; + esac + fi + done + echo "---" +fi diff --git a/lib/docs/admin/deploy.md b/lib/docs/admin/deploy.md index 3a593cd01..0721d5e3a 100644 --- a/lib/docs/admin/deploy.md +++ b/lib/docs/admin/deploy.md @@ -290,19 +290,21 @@ Update your `config-custom.yaml` in the ADC region: ```yaml # Lambda layers from pre-built archives lambdaLayerAssets: - authorizerLayerPath: './dist/layers/AimlAdcLisaAuthLayer.zip' - commonLayerPath: './dist/layers/AimlAdcLisaCommonLayer.zip' - fastapiLayerPath: './dist/layers/AimlAdcLisaFastApiLayer.zip' - ragLayerPath: './dist/layers/AimlAdcLisaRag.zip' - sdkLayerPath: './dist/layers/AimlAdcLisaSdk.zip' + authorizerLayerPath: './dist/layers/LisaAuthLayer.zip' + commonLayerPath: './dist/layers/LisaCommonLayer.zip' + cdkLayerPath: './dist/layers/LisaCdkLayer.zip' + fastapiLayerPath: './dist/layers/LisaFastApiLayer.zip' + ragLayerPath: './dist/layers/LisaRag.zip' + sdkLayerPath: './dist/layers/LisaSdk.zip' # Lambda functions -lambdaPath: './dist/layers/AimlAdcLisaLambda.zip' +lambdaPath: './dist/layers/LisaLambda.zip' # Pre-built web assets webAppAssetsPath: './dist/lisa-web' documentsPath: './dist/docs' ecsModelDeployerPath: './dist/ecs_model_deployer' +mcpServerDeployerPath: './dist/mcp_server_model_deployer' vectorStoreDeployerPath: './dist/vector_store_deployer' # Container images from ECR diff --git a/lib/docs/config/config-generator.md b/lib/docs/config/config-generator.md index 7a1405ecc..3ab15d5db 100644 --- a/lib/docs/config/config-generator.md +++ b/lib/docs/config/config-generator.md @@ -31,7 +31,7 @@ The generator walks you through the following configuration sections: ### Prebuilt Assets -If you're using prebuilt assets from `@amzn/lisa-adc`, the generator automatically configures: +If you're using prebuilt assets from `awslabs-lisa`, the generator automatically configures: - Lambda layer paths - Web app assets path @@ -107,7 +107,7 @@ S3 Bucket for Models: my-models-bucket 📦 Prebuilt Assets -Use prebuilt assets from @amzn/lisa-adc? [Y/n]: y +Use prebuilt assets from awslabs-lisa? [Y/n]: y 🔐 Authentication Configuration @@ -179,11 +179,12 @@ batchIngestionConfig: repositoryArn: arn:aws:ecr:us-east-1:123456789012:repository/lisa-batch-ingestion tag: latest lambdaLayerAssets: - authorizerLayerPath: ./node_modules/@amzn/lisa-adc/dist/layers/AimlAdcLisaAuthLayer.zip - commonLayerPath: ./node_modules/@amzn/lisa-adc/dist/layers/AimlAdcLisaCommonLayer.zip - fastapiLayerPath: ./node_modules/@amzn/lisa-adc/dist/layers/AimlAdcLisaFastApiLayer.zip - ragLayerPath: ./node_modules/@amzn/lisa-adc/dist/layers/AimlAdcLisaRag.zip - sdkLayerPath: ./node_modules/@amzn/lisa-adc/dist/layers/AimlAdcLisaSdk.zip + authorizerLayerPath: ./node_modules/awslabs-lisa/dist/layersAuthLayer.zip + commonLayerPath: ./node_modules/awslabs-lisa/dist/layersCommonLayer.zip + cdkLayerPath: ./node_modules/awslabs-lisa/dist/layers/CdkLayer.zip + fastapiLayerPath: ./node_modules/awslabs-lisa/dist/layersFastApiLayer.zip + ragLayerPath: ./node_modules/awslabs-lisa/dist/layersRag.zip + sdkLayerPath: ./node_modules/awslabs-lisa/dist/layersSdk.zip deployChat: true deployMetrics: true deployMcpWorkbench: true diff --git a/package-lock.json b/package-lock.json index 2259f527d..bac9d590d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@awslabs/lisa", + "name": "awslabs-lisa", "version": "6.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@awslabs/lisa", + "name": "awslabs-lisa", "version": "6.2.0", "hasInstallScript": true, "license": "Apache-2.0", @@ -270,6 +270,7 @@ "version": "7.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -515,6 +516,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.47.0.tgz", "integrity": "sha512-b5hlU69CuhnS2Rqgsz7uSW0t4VqrLMLTPbUpEl0QVz56rsSwr1Sugyogrjb493sWDA+XU1FU5m9eB8uH7MoI0g==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.47.0", "@algolia/requester-browser-xhr": "5.47.0", @@ -1255,7 +1257,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.1.tgz", "integrity": "sha512-epXDCJWnaPraPQ8ZXE1AA6T/wMPw+jQqtQThuOTHFyvjAFezGAYqF+DHBUsJE7DqZXRLRR3v4ammtTaYC6uhvQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "^3.973.0", "@smithy/core": "^3.21.0", @@ -1276,7 +1277,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.1.tgz", "integrity": "sha512-w9TVoCUNwPG4njcbnZpSQaOZ1BF2z1Guox8NltoXm7oS1+q/8iHeG8eqY9TlGQsKLNA4KfnKUEAx4rlEc6Qv6w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" @@ -1290,7 +1290,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.1.tgz", "integrity": "sha512-3d6QaHQAjevuCioG0lZmZM/Nb8mT4JiF2mRmlh/aTM32Fc/YNGxp2Qbri8B8nfeYlfoi8GM12gH7SaIwkihuBQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.1", "@aws-sdk/types": "^3.973.0", @@ -1616,6 +1615,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2127,7 +2127,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", @@ -2219,6 +2220,7 @@ "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1181.tgz", "integrity": "sha512-znT/MKJCb0ANsa0Q/7KCFodaA9tTOJqipTIhvqvKLa9SL8kx9SY2va5tX/3eJVMefKoVzyKuWtzyRw0WZoS/pg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", "@cloudscape-design/component-toolkit": "^1.0.0-beta", @@ -2386,6 +2388,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2429,6 +2432,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2511,6 +2515,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3374,6 +3379,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz", "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" }, @@ -4093,6 +4099,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.16.tgz", "integrity": "sha512-2XKQKxvQdeQiuIo0tacAmDVojhSVAci8D2WDdmmyN+6CqDusLHEHyIDaOt4o+UBvpkyHXbCdrljzDTQY/AKeqg==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -6266,7 +6273,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6277,7 +6283,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6291,7 +6296,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6306,8 +6310,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -6429,8 +6432,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/aws-lambda": { "version": "8.10.159", @@ -6916,6 +6918,7 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6938,6 +6941,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6948,6 +6952,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7117,6 +7122,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -7745,6 +7751,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -8066,6 +8073,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8181,6 +8189,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.47.0.tgz", "integrity": "sha512-AGtz2U7zOV4DlsuYV84tLp2tBbA7RPtLA44jbVH4TTpDcc1dIWmULjHSsunlhscbzDydnjuFlNhflR3nV4VJaQ==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.13.0", "@algolia/client-abtesting": "5.47.0", @@ -8619,6 +8628,7 @@ "mime-types" ], "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.263", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", @@ -9318,6 +9328,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10031,7 +10042,8 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.5.tgz", "integrity": "sha512-fOoP70YLevMZr5avJHx2DU3LNYmC6wM8OwdrNewMZou1kZnPGOeVzBrRjZNgFDHUlulYUjkpFRSpTE3D+n+ZSg==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/content-disposition": { "version": "1.0.1", @@ -10633,6 +10645,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -11024,6 +11037,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -11569,8 +11583,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -11942,6 +11955,7 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12012,6 +12026,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12072,6 +12087,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -13018,6 +13034,7 @@ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -14986,6 +15003,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -16191,6 +16209,7 @@ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -16836,7 +16855,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -17380,6 +17398,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -17415,6 +17434,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", @@ -18093,7 +18113,6 @@ "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", "license": "MIT", - "peer": true, "dependencies": { "obliterator": "^1.6.1" } @@ -18395,8 +18414,7 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/obug": { "version": "2.1.1", @@ -18414,6 +18432,7 @@ "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "jwt-decode": "^4.0.0" }, @@ -19223,6 +19242,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19475,6 +19495,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -19501,6 +19522,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19571,6 +19593,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -19755,7 +19778,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-mock-store": { "version": "1.5.5", @@ -21638,6 +21662,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -21724,6 +21749,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -21931,6 +21957,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22429,6 +22456,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -22502,6 +22530,7 @@ "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", "license": "MIT", + "peer": true, "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", @@ -22555,6 +22584,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -22685,6 +22715,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -22808,6 +22839,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -23192,6 +23224,7 @@ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -23270,6 +23303,7 @@ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">= 6" } @@ -23407,6 +23441,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -23416,6 +23451,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } @@ -23468,6 +23504,7 @@ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } diff --git a/package.json b/package.json index 3ef1579f1..6656ea3b2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@awslabs/lisa", + "name": "awslabs-lisa", "version": "6.2.0", "description": "A scalable infrastructure-as-code solution for self-hosting and orchestrating LLM inference with RAG capabilities, providing low-latency access to generative AI and embedding models across multiple providers.", "keywords": [ @@ -64,7 +64,7 @@ "prepare": "husky || true", "postinstall": "patch-package", "dev": "cd lib/user-interface/react/ && npm run dev", - "prepublishOnly": "npm run build && npm run copy-dist -ws", + "prepublishOnly": "BUILD_ASSETS=true npm run build && npm run copy-dist -ws", "migrate-properties": "node ./scripts/migrate-properties.mjs", "generateSchemaDocs": "npx zod2md -c ./lib/zod2md.config.ts && npx zod2md -c ./lib/zod2md.rag.ts", "generate-config": "tsx scripts/generate-config.ts" diff --git a/scripts/generate-config.ts b/scripts/generate-config.ts index fb9fcb528..6ccf02ee7 100644 --- a/scripts/generate-config.ts +++ b/scripts/generate-config.ts @@ -62,12 +62,19 @@ type RestApiConfig = { imageConfig?: ImageConfig; }; -type ImageConfig = { +type ImageConfigEcr = { type: 'ecr'; repositoryArn: string; tag: string; }; +type ImageConfigTarball = { + type: 'tarball'; + path: string; +}; + +type ImageConfig = ImageConfigEcr | ImageConfigTarball; + type McpWorkbenchConfig = { imageConfig: ImageConfig; }; @@ -79,6 +86,7 @@ type BatchIngestionConfig = { type LambdaLayerAssets = { authorizerLayerPath: string; commonLayerPath: string; + cdkLayerPath: string; fastapiLayerPath: string; ragLayerPath: string; sdkLayerPath: string; @@ -90,6 +98,7 @@ type PrebuiltAssetsConfig = { webAppAssetsPath: string; documentsPath: string; ecsModelDeployerPath: string; + mcpServerDeployerPath: string; vectorStoreDeployerPath: string; certificateAuthorityBundle: string; restApiImageConfig: ImageConfig; @@ -135,6 +144,7 @@ type LisaConfig = { webAppAssetsPath?: string; documentsPath?: string; ecsModelDeployerPath?: string; + mcpServerDeployerPath?: string; vectorStoreDeployerPath?: string; certificateAuthorityBundle?: string; deployChat: boolean; @@ -287,9 +297,13 @@ class ConfigBuilder { return this; } - setPrebuiltAssets (usePrebuilt: boolean, partition?: AwsPartition, region?: string, accountNumber?: string): this { - if (usePrebuilt && partition && region && accountNumber) { - this.prebuiltAssets = this.createPrebuiltAssetsConfig(partition, region, accountNumber); + setPrebuiltAssets (usePrebuilt: boolean, imageSource?: 'ecr' | 'tarball', partition?: AwsPartition, region?: string, accountNumber?: string): this { + if (usePrebuilt) { + if (imageSource === 'tarball') { + this.prebuiltAssets = this.createPrebuiltAssetsConfigWithTarball(); + } else if (partition && region && accountNumber) { + this.prebuiltAssets = this.createPrebuiltAssetsConfig(partition, region, accountNumber); + } } else { this.prebuiltAssets = undefined; } @@ -318,17 +332,19 @@ class ConfigBuilder { const base = PREBUILT_ASSETS_BASE; return { lambdaLayerAssets: { - authorizerLayerPath: `${base}/layers/AimlAdcLisaAuthLayer.zip`, - commonLayerPath: `${base}/layers/AimlAdcLisaCommonLayer.zip`, - fastapiLayerPath: `${base}/layers/AimlAdcLisaFastApiLayer.zip`, - ragLayerPath: `${base}/layers/AimlAdcLisaRag.zip`, - sdkLayerPath: `${base}/layers/AimlAdcLisaSdk.zip`, + authorizerLayerPath: `${base}/layers/AuthLayer.zip`, + commonLayerPath: `${base}/layers/CommonLayer.zip`, + cdkLayerPath: `${base}/layers/CdkLayer.zip`, + fastapiLayerPath: `${base}/layers/FastApiLayer.zip`, + ragLayerPath: `${base}/layers/Rag.zip`, + sdkLayerPath: `${base}/layers/Sdk.zip`, }, - lambdaPath: `${base}/layers/AimlAdcLisaLambda.zip`, + lambdaPath: `${base}/layers/Lambda.zip`, webAppAssetsPath: `${base}/lisa-web`, documentsPath: `${base}/docs`, ecsModelDeployerPath: `${base}/ecs_model_deployer`, vectorStoreDeployerPath: `${base}/vector_store_deployer`, + mcpServerDeployerPath: `${base}/mcp_server_deployer`, certificateAuthorityBundle: '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', restApiImageConfig: this.createImageConfig(partition, region, accountNumber, 'lisa-rest-api'), mcpWorkbenchImageConfig: this.createImageConfig(partition, region, accountNumber, 'lisa-mcp-workbench'), @@ -336,6 +352,38 @@ class ConfigBuilder { }; } + private createTarballImageConfig (repositoryName: string, version: string): ImageConfig { + return { + type: 'tarball', + path: `./dist/images/${repositoryName}_${version}.tar`, + }; + } + + private createPrebuiltAssetsConfigWithTarball (): PrebuiltAssetsConfig { + const base = PREBUILT_ASSETS_BASE; + const version = fs.readFileSync(path.join(process.cwd(), 'VERSION'), 'utf-8').trim(); + return { + lambdaLayerAssets: { + authorizerLayerPath: `${base}/layers/AuthLayer.zip`, + commonLayerPath: `${base}/layers/CommonLayer.zip`, + cdkLayerPath: `${base}/layers/CdkLayer.zip`, + fastapiLayerPath: `${base}/layers/FastApiLayer.zip`, + ragLayerPath: `${base}/layers/Rag.zip`, + sdkLayerPath: `${base}/layers/Sdk.zip`, + }, + lambdaPath: `${base}/layers/Lambda.zip`, + webAppAssetsPath: `${base}/lisa-web`, + documentsPath: `${base}/docs`, + ecsModelDeployerPath: `${base}/ecs_model_deployer`, + vectorStoreDeployerPath: `${base}/vector_store_deployer`, + mcpServerDeployerPath: `${base}/mcp_server_deployer`, + certificateAuthorityBundle: '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', + restApiImageConfig: this.createTarballImageConfig('lisa-rest-api', version), + mcpWorkbenchImageConfig: this.createTarballImageConfig('lisa-mcp-workbench', version), + batchIngestionImageConfig: this.createTarballImageConfig('lisa-batch-ingestion', version), + }; + } + build (): LisaConfig { if (!this.core) { throw new Error('Core configuration is required'); @@ -371,6 +419,7 @@ class ConfigBuilder { config.webAppAssetsPath = this.prebuiltAssets.webAppAssetsPath; config.documentsPath = this.prebuiltAssets.documentsPath; config.ecsModelDeployerPath = this.prebuiltAssets.ecsModelDeployerPath; + config.mcpServerDeployerPath = this.prebuiltAssets.mcpServerDeployerPath; config.vectorStoreDeployerPath = this.prebuiltAssets.vectorStoreDeployerPath; config.certificateAuthorityBundle = this.prebuiltAssets.certificateAuthorityBundle; @@ -555,9 +604,37 @@ class ConfigPrompter { }; } - async promptPrebuiltAssets (): Promise { + async promptPrebuiltAssets (): Promise<{ usePrebuilt: boolean; imageSource?: 'ecr' | 'tarball' }> { console.log('\n📦 Prebuilt Assets\n'); - return await this.promptYesNo('Use prebuilt assets from @awslabs/lisa?', true); + const usePrebuilt = await this.promptYesNo('Use prebuilt assets from @awslabs/lisa?', true); + if (!usePrebuilt) { + return { usePrebuilt: false }; + } + + console.log('\n🐳 Container Image Source\n'); + console.log(' Container images can be sourced from:'); + console.log(''); + console.log(' 1) ecr - Pull from an ECR repository you have already pushed images to.'); + console.log(' Requires account number, region, and partition from the core config.'); + console.log(''); + console.log(' 2) tarball - Use local .tar image exports downloaded from a GitHub Release.'); + console.log(' Run "./bin/pull-images --version " to download them first.'); + console.log(' Images are stored in ./dist/images/ and loaded by CDK at deploy time.'); + console.log(''); + + const sourceInput = await this.promptWithValidation( + 'Image source (ecr/tarball)', + (v) => { + const lower = v.toLowerCase(); + if (lower === 'ecr' || lower === 'tarball') { + return { isValid: true }; + } + return { isValid: false, error: 'Must be "ecr" or "tarball"' }; + }, + 'tarball' + ); + + return { usePrebuilt: true, imageSource: sourceInput.toLowerCase() as 'ecr' | 'tarball' }; } async promptAuthConfig (): Promise { @@ -729,7 +806,7 @@ async function main (): Promise { // Collect configuration through prompts const coreConfig = await prompter.promptCoreConfig(); - const usePrebuiltAssets = await prompter.promptPrebuiltAssets(); + const { usePrebuilt: usePrebuiltAssets, imageSource } = await prompter.promptPrebuiltAssets(); const authConfig = await prompter.promptAuthConfig(); const apiGatewayConfig = await prompter.promptApiGatewayConfig(); const restApiConfig = await prompter.promptRestApiConfig(); @@ -739,7 +816,7 @@ async function main (): Promise { // Build the configuration builder .setCoreConfig(coreConfig) - .setPrebuiltAssets(usePrebuiltAssets, coreConfig.partition, coreConfig.region, coreConfig.accountNumber) + .setPrebuiltAssets(usePrebuiltAssets, imageSource, coreConfig.partition, coreConfig.region, coreConfig.accountNumber) .setAuthConfig(authConfig) .setApiGatewayConfig(apiGatewayConfig) .setRestApiConfig(restApiConfig) From 24e2f4937dba57f25f764d2e57771d6b4cb8c97a Mon Sep 17 00:00:00 2001 From: Ernest-Gray <99225408+Ernest-Gray@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:25:25 -0500 Subject: [PATCH 14/21] Updated deployment guide --- example_config.yaml | 6 +- lib/docs/admin/deploy.md | 186 +++++++++++++---------- lib/docs/admin/idp-config.md | 124 ++++++++++++++- lib/docs/assets/LISA_Cognito_Example.png | Bin 0 -> 152489 bytes 4 files changed, 228 insertions(+), 88 deletions(-) create mode 100644 lib/docs/assets/LISA_Cognito_Example.png diff --git a/example_config.yaml b/example_config.yaml index 70484107d..27fadde20 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -80,9 +80,9 @@ ragRepositories: [] # iamRdsAuth: true # Set to false to use password-based authentication instead of IAM auth (default: false) # You can optionally provide a list of models and the deployment process will ensure they exist in your model bucket and try to download them if they don't exist # ecsModels: -# - modelName: mistralai/Mistral-7B-Instruct-v0.2 -# inferenceContainer: tgi -# baseImage: ghcr.io/huggingface/text-generation-inference:2.0.1 +# - modelName: mistralai/Mistral-7B-Instruct-v0.3 +# inferenceContainer: vllm +# baseImage: vllm/vllm-openai:latest # - modelName: intfloat/e5-large-v2 # inferenceContainer: tei # baseImage: ghcr.io/huggingface/text-embeddings-inference:1.2.3 diff --git a/lib/docs/admin/deploy.md b/lib/docs/admin/deploy.md index 0721d5e3a..dd74939c6 100644 --- a/lib/docs/admin/deploy.md +++ b/lib/docs/admin/deploy.md @@ -3,7 +3,7 @@ * Set up or have access to an AWS account. * Ensure that your AWS account has the appropriate permissions. Resource creation during the AWS CDK deployment expects Administrator or Administrator-like permissions, to include resource creation and mutation permissions. Installation will not succeed if this profile does not have permissions to create and edit arbitrary resources for the system. This level of permissions is not required for the runtime of LISA. This is only necessary for deployment and subsequent updates. -* If using the chat UI, have your Identity Provider (IdP) information available, and access. +* If using the chat UI, have your Identity Provider (IdP) information available. * If using an existing VPC, have its information available. * Familiarity with AWS Cloud Development Kit (CDK) and infrastructure-as-code principles is a plus. * AWS CDK and Model Management both leverage AWS Systems Manager Agent (SSM) parameter store. Confirm that SSM is approved for use by your organization before beginning. If you're new to CDK, review the [AWS CDK Documentation](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) and consult with your AWS support team. @@ -30,13 +30,14 @@ git clone -b main --single-branch cd lisa ``` -### Step 2: Set Up Environment Variables - -Create and configure your `config-custom.yaml` file: - +### Step 2a: Create/Configure `config-custom.yaml`: +Run the command below to copy the example configuration into `config-custom.yaml`. This will create the file if it doesn't exist already. ```bash cp example_config.yaml config-custom.yaml ``` +Review the `config-custom.yaml` settings. Some settings will be configured later in this guide. + +### Step 2b: Set Up Environment Variables Set the following environment variables: @@ -50,6 +51,7 @@ export CDK_DOCKER=finch # Optional, only required if not using docker as contain ### Step 3: Set Up Python and TypeScript Environments Install system dependencies and set up both Python and TypeScript environments: +- ***NOTE** The code block below has two tabs for Debian & EL/AL2* :::tabs == Debian @@ -108,48 +110,7 @@ Edit the `config-custom.yaml` file to customize your LISA deployment. Key config - Authentication settings - Model bucket name -### Step 5: Stage Model Weights - -LISA requires model weights to be staged in the S3 bucket specified in your `config-custom.yaml` file, assuming the S3 bucket follows this structure: - -``` -s3:/// -s3://// -s3://// -... -s3:/// -``` - -**Example:** - -``` -s3:///mistralai/Mistral-7B-Instruct-v0.2 -s3:///mistralai/Mistral-7B-Instruct-v0.2/ -s3:///mistralai/Mistral-7B-Instruct-v0.2/ -... -``` - -To automatically download and stage the model weights defined by the `ecsModels` parameter in your `config-custom.yaml`, use the following command: - -```bash -make modelCheck -``` - -This command verifies if the model's weights are already present in your S3 bucket. If not, it downloads the weights, converts them to the required format, and uploads them to your S3 bucket. Ensure adequate disk space is available for this process. - -> **WARNING** -> As of LISA 3.0, the `ecsModels` parameter in `config-custom.yaml` is solely for staging model weights in your S3 bucket. -> Previously, before models could be managed through the [API](/config/model-management-api) or via the Model Management -> section of the [Chatbot](/user/chat), this parameter also -> dictated which models were deployed. - -> **NOTE** -> For air-gapped systems, before running `make modelCheck` you should manually download model artifacts and place them in a `models` directory at the project root, using the structure: `models/`. - -> **NOTE** -> This process is primarily designed and tested for HuggingFace models. For other model formats, you will need to manually create and upload safetensors. - -### Step 6: Configure Identity Provider +### Step 5: Configure Identity Provider In the `config-custom.yaml` file, configure the `authConfig` block for authentication. LISA supports OpenID Connect (OIDC) providers such as AWS Cognito or Keycloak. Required fields include: @@ -163,7 +124,7 @@ In the `config-custom.yaml` file, configure the `authConfig` block for authentic IDP Configuration examples using AWS Cognito and Keycloak can be found: [IDP Configuration Examples](/admin/idp-config) -### Step 7: Configure LiteLLM +### Step 6: Configure LiteLLM We utilize LiteLLM under the hood to allow LISA to respond to the [OpenAI specification](https://platform.openai.com/docs/api-reference). For LiteLLM configuration, a key must be set up so that the system may communicate with a database for tracking all the models that are added or removed using the [Model Management API](/config/model-management-api). The key must start with `sk-` and then can be any @@ -177,50 +138,68 @@ litellmConfig: db_key: sk-00000000-0000-0000-0000-000000000000 # needed for db operations, create your own key # pragma: allowlist-secret ``` -### Step 8: Set Up SSL Certificates (Development Only) +### Step 7: Set Up SSL Certificates (Development Only) -**WARNING: THIS IS FOR DEV ONLY** -When deploying for dev and testing you can use a self-signed certificate for the REST API ALB. You can create this by using the script: `gen-cert.sh` and uploading it to `IAM`. +LISA requires SSL certificates for secure communication. Choose the appropriate method based on your deployment environment. -```bash -export REGION= -export DOMAIN= #Optional if not running in 'aws' partition -./scripts/gen-certs.sh -aws iam upload-server-certificate --server-certificate-name --certificate-body file://scripts/server.pem --private-key file://scripts/server.key -``` +#### AWS Certificate Manager + +Use AWS Certificate Manager to create and manage certificates: -Update your `config-custom.yaml` with the certificate ARN: +1. **Create a Certificate in AWS Certificate Manager**: + - Navigate to the [AWS Certificate Manager Console](https://console.aws.amazon.com/acm) + - Request a public certificate + - For internal AWS deployments, use the domain pattern: `.people.aws.dev` + - Follow the DNS validation process to verify domain ownership + - Note: You may need access to specific AWS bindles or Route 53 hosted zones + +2. **Configure Custom Domains** in your `config-custom.yaml`: ```yaml restApiConfig: - sslCertIamArn: arn::iam:::server-certificate/ + sslCertIamArn: arn:aws:acm:::certificate/ + domainName: serve..people.aws.dev + +apiGatewayConfig: + domainName: chat..people.aws.dev ``` -#### Accepting Self-Signed Certificate in Browser +- For `sslCertIamArn` copy the arn from your ssl certificate from the AWS Certificate Manager. Otherwise you can manually fill it in. +- For `domainName` replace `` with your chosen subdomain. + +1. **Set Up Route 53 and Custom Domains**: -When using a self-signed certificate, the LISA UI will load normally, but API calls from the UI to the SERVE ALB (REST API ALB) will be blocked by the browser due to the self-signed certificate. To allow the UI to communicate with the SERVE ALB, you need to accept the certificate for the SERVE ALB domain: +After configuring your certificate and custom domains in `config-custom.yaml`, you need to set up DNS routing: -1. **Navigate to the SERVE ALB Domain**: Open a new browser tab and navigate directly to the REST API ALB domain (the serve domain). You can test this by navigating to `https:///health` or any other endpoint. -2. **Accept the Certificate**: When the browser displays a security warning about the self-signed certificate: - - Look for an "Advanced" or "Show Details" option - - Click "Proceed to [domain]" or "Accept the Risk and Continue" - - Some browsers may show this as a "rejected host" warning - you can accept it to proceed +**Create Route 53 Hosted Zone**: + - Navigate to Route 53 in the AWS Console + - Create a hosted zone for your domain (if it does not already exists) + - Note the hosted zone ID and name servers -**Alternative Method - Using Developer Tools**: -1. **Open Developer Tools**: Press `F12` or right-click and select "Inspect" to open your browser's developer tools -2. **Navigate to the LISA UI** -3. **Go to the Network Tab**: This will show you the API requests that are being blocked when the LISA UI tries to connect to the SERVE ALB -4. **Click on a Failed Request**: Click on a failed request (typically showing a certificate error) to see the SERVE ALB domain -5. **Navigate to the Domain**: Copy the SERVE ALB domain from the failed request and navigate to it directly in a new tab to accept the certificate +**Configure API Gateway Custom Domain** (after LISA deployment): + - Navigate to API Gateway → Custom domain names + - Create a custom domain for your chat endpoint: `chat..people.aws.dev` + - Associate it with your API Gateway stage -**Note**: The exact steps vary by browser: -- **Chrome/Edge**: Click "Advanced" → "Proceed to [domain] (unsafe)" -- **Firefox**: Click "Advanced" → "Accept the Risk and Continue" -- **Safari**: Click "Show Details" → "visit this website" → "Visit Website" +**Create DNS Records**: + - In Route 53, create an A record for `chat..people.aws.dev`: + - Type: A record (Alias) + - Alias target: Your API Gateway custom domain + - Create a CNAME record for `serve..people.aws.dev`: + - Type: CNAME + - Value: Your LisaServe REST API Application Load Balancer DNS name (found in EC2 → Load Balancers) -After accepting the certificate for the SERVE ALB domain, the UI will be able to make API calls to the SERVE ALB successfully. The browser will remember your choice for this domain. +**For Internal AWS Deployments**: + - Register your DNS name using Supernova at https://supernova.amazon.dev/ + - Follow the guide at https://w.amazon.com/bin/view/SuperNova/PreOnboardingSteps/ + - Use the pattern: `{username}.people.aws.dev` + - Associate with the appropriate AWS bindle for access control -### Step 9: Customize Model Deployment +**Redeploy LISA** + - Redeploy LISA for the changes to take effect + - After completing these steps and redeploying LISA, your application will be accessible via custom domains with valid SSL certificates, eliminating the need to accept self-signed certificates in your browser. + +### Step 8a: Customize Model Deployment (If Using LISA Serve) In the `ecsModels` section of `config-custom.yaml`, allow our deployment process to pull the model weights for you. @@ -232,11 +211,55 @@ Here we define the model name, inference container, and baseImage: ```yaml ecsModels: - modelName: your-model-name - inferenceContainer: tgi - baseImage: ghcr.io/huggingface/text-generation-inference:2.0.1 + inferenceContainer: vllm + baseImage: vllm/vllm-openai:latest +``` + +### Step 8b: Stage Model Weights + +LISA requires model weights to be staged in the S3 bucket specified in your `config-custom.yaml` file, assuming the S3 bucket follows this structure: + +``` +s3:/// +s3://// +s3://// +... +s3:/// +``` + +**Example:** + +``` +s3:///mistralai/Mistral-7B-Instruct-v0.2 +s3:///mistralai/Mistral-7B-Instruct-v0.2/ +s3:///mistralai/Mistral-7B-Instruct-v0.2/ +... +``` + +To automatically download and stage the model weights defined by the `ecsModels` parameter in your `config-custom.yaml`, use the following command: + +```bash +make modelCheck ``` -### Step 10: Bootstrap CDK (If Not Already Done) +This command verifies if the model's weights are already present in your S3 bucket. If not, it downloads the weights, converts them to the required format, and uploads them to your S3 bucket. Ensure adequate disk space is available for this process. + +> **WARNING** +> As of LISA 3.0, the `ecsModels` parameter in `config-custom.yaml` is solely for staging model weights in your S3 bucket. +> Previously, before models could be managed through the [API](/config/model-management-api) or via the Model Management +> section of the [Chatbot](/user/chat), this parameter also +> dictated which models were deployed. + +> **NOTE** +> For air-gapped systems, before running `make modelCheck` you should manually download model artifacts and place them in a `models` directory at the project root, using the structure: `models/`. + +> **NOTE** +> This process is primarily designed and tested for HuggingFace models. For other model formats, you will need to manually create and upload safetensors. + +> **NOTE** +> Please valdiate that all files successfully downloaded locally AND were uploaded to the S3 Bucket. Ensure all large files such as .safetensor files exist. + +### Step 9: Bootstrap CDK (If Not Already Done) If you haven't bootstrapped your AWS account for CDK: @@ -445,3 +468,4 @@ pytest lisa-sdk/tests --url --verify /dev` + - Add the same URL with trailing slash: `https:///dev/` + - **Allowed sign-out URLs**: + - Use the same URLs as callback URLs + - **Note**: The callback URLs should point to your LISA API Gateway endpoint, not the REST API ALB + +### Image Example +- **Note** you should replace +![LISA Cognito Setup Example](../assets/LISA_Cognito_Example.png) + +### LISA Configuration + +If using Amazon Cognito, the `authority` will be the URL to your User Pool. As an example, if your User Pool ID (not +the name) is `us-east-1_example`, and is running in `us-east-1`, then the URL for the `authority` field would be `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example`. The `clientId` can be found in your User Pool's "App integration" tab from within the AWS Management Console. At the bottom of the page you will see the list of clients and their associated Client IDs. The ID here is what we will need for the `clientId` field. - -``` +```yaml authConfig: authority: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example clientId: your-client-id @@ -18,6 +86,54 @@ authConfig: jwtGroupsProperty: cognito:groups ``` +## Troubleshooting Cognito Authentication + +#### Sign-in Loop (Continuous Redirect to Login Page) + +**Symptom**: Clicking the sign-in button continuously redirects you to the same login page without showing a sign-in form. + +**Cause**: Incorrect OpenID Connect scopes configuration. + +**Solution**: +- Verify that your App Client has the correct OpenID Connect scopes enabled: + - `email` + - `openid` + - `phone` + - `profile` +- Ensure OAuth grant type is set to "Authorization code grant" + +#### Authentication Token Error (400 Bad Request / Invalid client_secret) + +**Symptom**: After signing in, Cognito redirects you back to LISA, but the token exchange fails with a 400 error mentioning `invalid_client` or `client_secret`. + +**Cause**: Using "Traditional Web App" instead of "Single Page Application" (SPA) when creating the App Client. + +**Solution**: +- Recreate your App Client and select **"Single Page Application" (SPA)** as the app type +- SPA clients do not require a client secret for token exchange, which is correct for browser-based applications + +**Testing Tip**: Use Chrome or Firefox Developer Tools: +- Open Developer Tools (F12) +- Navigate to the "Application" tab (Chrome) or "Storage" tab (Firefox) +- Find and clear Cookies related to your Cognito domain +- This allows you to retry the login process with a fresh authentication flow + +#### "Contact Your Administrator" Error on Login Page + +**Symptom**: The Cognito hosted UI displays an error message asking you to contact your administrator. + +**Possible Causes**: +- Incorrect callback URLs in the App Client configuration +- Mismatch between the URL that Cognito is redirecting to and the allowed callback URLs +- The callback URL must exactly match (including trailing slashes) + +**Solution**: +- Verify that your App Client's "Allowed callback URLs" include: + - Your API Gateway dev stage URL: `https:///dev` + - The same URL with trailing slash: `https:///dev/` +- Ensure the URLs match exactly (check for http vs https, trailing slashes, etc.) +- If the issue persists after correcting the URLs, you may need to redeploy LISA to update the configuration + ## Keycloak If using Keycloak, the `authority` will be the URL to your Keycloak server. The `clientId` is likely not a random string diff --git a/lib/docs/assets/LISA_Cognito_Example.png b/lib/docs/assets/LISA_Cognito_Example.png new file mode 100644 index 0000000000000000000000000000000000000000..67f32aac86cf2dbfce951ed368e22bef29a2b9e2 GIT binary patch literal 152489 zcmb@tWmH^E(>4krf#699ZV3bkI>6vEB!S@W?l8E!21p>dTNogO;1-<0-I>8*aCdj- zOy1`{-}~I(TIb*CwPwxUy{oILudeFqUEMoWQCJ7)n8VVeJ8 zEP%ZJTMeKA2|1aX3n+_;|3iVi6Q;3nad8j;0NmZ(+1!C__D)~`2R}bQfSnV-$;pbO zV0HGib20K@wR5KZhk=-xvx$?XgNvoT-J8D*jg0MGU4&^|T`f%o%#F;sjZICtS&fZ= zrmP$srbetrra%r>Qy|ckmz#r+)11SY<{$AcmgfH}-_H3T7?6Mf{yqV4u(AJD`R}!W zf|I2gQj5PLK^#K=^ZZ|RLV&-D{Fh>&|I{G+ioE(i$oz%r|DbDThlJD}i5*Kq3N{ML z!z(GVkE$N}d-LudDidhk$M$IlN%AulyuDQYs$$PbX`X+7&iVP-Qw+8_oO{MV>{^!3 zUEh$;#4nUh59I<5nnrW4?_;avMn8rthBaqy_GLF+Zfq<#%iDcg@P;||4|1ix{8x#! z?ngKVeE(O`^~C~z7W?la{iR*&-_2hAbWs0q3bEf$-$MxhMZ<{t{}UVe)KvP))%GGf z;dx1AWzN1n#^1TQu}Mkt;^OpLT3S(QVgFJ;;XN1(*44%F@Tgne-VR7kCI*4@|K6;v zt<_zNxJ`5*1_xz-Wt)UXMbWadrvLnj^5lun@ySW&#Kbc&*md$>-H081%gh`;C`-V| zSkT!;_lkfZJUkp571i(Qvu8^i8(tHEGetzO+&pqj{OGmO9LfGHvy%}SM}hh;Q< zaB^ZIWcs?atFsa2HKOmxM@~sO5J~{9+s-9>ihd(lO7yRG*BYVG3QJeVzyxMuSqBF# zoZywn_dcJOZ7$|2&UdG}J38hatN#UhZ;P^5eXL23{PM0&u+w@V2T2%c(6PJ&adad} zLu+C&{Ie#Q<6lBzA1UJdI!Chej*gFMxT>~LZOd;E;u1Yh+seNK|7~s8a844O@dB0f zuV3H&`en?XT0z0vrxDwMNJw}s58)HNzriy9m%*;J3>mU37O8Wy>4m-H;6SasK0zZQ z=7$LT3PhyY6=w1On_LXV-JREJviOsj7!E0EYE;qbdu2ZFg~1Q1j(JP_O)7Y{iNLgU~`F zBN>F}H4P6DpE?j*Tbf7%>PA3^|1ZmX7=xszV`5?=p$IK5X6c!5xOU))8Xh+6>7w~p zS$XsReN1QPYuQ}m;L7c7@zK%I>0Omp$`+m+CWyW%k`w2!3N_7~|M@JiYbTuk5!wvs>x+JI7{I8*H8$%O#>DlW%n6Wpe}p!R`5~ z1wwXqrEa@eIN-}Ye#x=ib4R!4k1*x`HI_92KI-JYO>%;37hjsbZXbWA_``yI~v z1B!t064h^PMvkdI<&2>UlMFElr2>SExahTK8_2B4mu3jqNnF)PxuRI^zcU0H3rC`naVyV*v=2g0YsNb=1a9jXZDXs0TTfJr<=BS#zl`(I(;HyQZf?Y~82ERGyB<+!m*%?gpkL$`O82byG-q|n0wdcygX@s7*}EAOQP zWr4+n26yV8^66y%ifIp_2JU(6dwCWsHeTvA`8(aG`}bRj>B@2RXE{SZvMn21=-(=k z+$bG>2%g^wO4jL1T)k^iA-Qno;^>3A&NPOR!>ccIz$O48J(&Ydv3F(XJ)>8Kwoi(p zr$efc+8cJ-f)W^?H62hAzJ`%?N0N)Q*@dcUPSY`IupQQE{+H#5SkG*TW&9wQ@zJ*X ze%amhAiGN}Rw->)$H&~sTuAnj4aa6#&Qk=9JUVX^$Nla$OH~(=<*8Qt3bP(;BfnB$ zVDeqAuVn044>^1?X1uy(q5%DuX9BVP1EUJa!*IOvYZ{_Id_Dj@S?y>;>KvK8qxyT?QoAqGo7}^R zg){ySA3-k5v)Khh_u-uQH z8bm_sNzJ_8>p1=outE=WJ1LqGbJd_x>E+`8oi8NcUg-B4t^uIVg9}On>DSTU6zPVa zF_?L1L`F9NML-mT_i{~6PiDXyU4I@t$i&Aca4jzNdY3{CjK7!Tna!2v3bufVWUJpy z6!b>6&d#UWVvq)I_FlKqgT>QRleIz{mFVId+5CdjC2larm7{!N?}`b^3=-f#;iNw_k}$33JBg=&~>*%k~;PU*j$%vYVveXifC_-hUZewLYbqQSl# z#!`J=)vRDUNz}MboqnW;zL@Z>6sA9d8-XK zV~0fcjmYrBTO7lQ{-hBH84BdXMg}j&wq0zOr07J@V2^8o$M|~nucZ_fQP|kn+y>G) zfv42@zW+PXJx)U&2uaKP%|A{k+02}i;o1@r+EDohWtx8*ih~(^<;aU33LIT)4XN5b zLk)>Y62T3)Pr8-ASArvVAL9% zixaCjuZ_t&IO^)rb5~2QVmNp&N@@*-&m?ssGzqjcuF#}qrHKq|wbh%+YO!G zegE1Xgz+-=@mF1DZsnV{w~R|CnSi zuGN7--*0ox2eZXrh2(m>-YW-A*};{TEZVQzT1k_)gjoZ%HY+8o(TPZ7j~j1n`S4+3 z#zr1QlXDg0TZ%nFv({c~3HN0U@lrhcS~HyX?pTbIf0{fAc@GK>9no-=();?-m4t52 z>CgcYn-*3?^gVK=MW(s;B=V1zf4PS(zN(aTpIiIE>FA{KI@;Ga^dt5vKSX(^e{*p+ z{v7jZlM7y69_;nik>>-8NpjcAWZz||_(Ac08*)|VyfB6RDF2q@ex`XN@%_ZIP4g?F z(~-URJvp9`n-k#;q}vlRYT0Ib1=KV{{mHE&jaS}?%*u&Le{&9O-U=ENaroH_da^lk!vJpO$QowOyIB4E5;_ zkj+qL=zY(aR+UKl^=;&yG?xi~HCOI^T-C>uKZ_8Ba`3GI9H z+deJy8Oe|RG`XsYaDSbOkwFaWSKrHF=RU0Si=Bdb5_cy-1@Ch85a%n}Ov}vh2}9%E zgv)8w>9qd3#Axu|l-^6Hdo}XGQPB{-vnjWW`|9LV9fR!Dv{iAMso9Sg6zm9GIo%o+ z>VvbvO=^k>^zyZ(k7+#j0=(9InG~D&lniu>T$8cPwha4Ads+K?x7LCoQHSfv7@}N@ z|AatEMn~j~m=2CK)!cknUlzORiV0rr;MDYDN5YBiJsn)4F$EK$A>}-M)#M*xw&=Q7 zfM|+k(4Rc{8$$ZF295F6ypSC9z@r@I+E|6&qHsuSZKtXW{dGd@`t4Y1i%25A`|r3I z1&wuV|2%eQ12X5!Jm8}wky&7?!+q}1YbcX4a8N_&ou}j6Y%4AJ{h6?lxV*SoOsZgu zZh-?@aOH<91=*YOB~QUszq=U3a+J*HQUYFsevMLO0Q%@5P)fj<<8yb1bn3g^Glg|n04)^t) zj(nC73CD0IW{L3DY7yRhO6<9A7s?W&WhZun;>sUu-8z|J`3oC7_!cVS>p#c?4nXit|(woH; zN+Yhv3i+ck_3n=cph*cV_U>T2vh57#(=J$LH$>@$Gh>ev;k<%o zn3R5_p8|QdluwJ;A7Jp8B;?n?ur0}47Mqxc>l}gF=ldPB?p)b6Sv>~qCU=ZAC3>AJT^PO1IG!5z zQqkUrn=qXde4q;4ZX?;=-{_cn7D1kbHEFFdD|o}cHPT?QV$kXdL3Hj#f=ej%n+m4Z zLD~tf6&Z_9%?(ad9lH2<-pWZkfEjw ze46_1Csr-Kg^VkM69|8@_$Ekg7SG{5e6x)b$MfvtjOw8GGucQ``)1JL#y+JMblqdB zGGgCOB}&cCYeAYB$ub(A4Bo7Lnw9KVg;v2LuE0n65sA|_%nGJNG-(<&Qf=> zZ`%+_X`zcpGQkC1ajuAF$bkVp-Cd`mHbj=F8O1(XEbcb~Dy;yusW}G!cbsmP6quG; zf3pZ7ml#V15l7&;3clQ8nKz8{gV>Zlq_bTB@YYydLb*ts_+p+D7a{_INQ~|dKGD=d zTR@PftdQQcJlzlj$A$res0&&pB5|J9O&{i+g>J?kW8P83e`YF`^`uQO3gFqKV_R1Xz3kZGo$xn&N2}o;MqPEsp(5ufjg7i_J z-oEx~YoK>K1bs*s;66I~^LTy{j0MI;N26D{XvNfbc`zn+zL|zEl3vPDTSXz_fQ^Co z&L@~)_r^X}{J}M`opp56qxGaI|Bc!axjoT7XwWfrHT@=>C;s9{t=bZYk~?yr;b2{D@hX|*ef)8{_v!K5T~=;4-GZ4z zQS@Npu37TRWHqz18`j2=RzI0vJnS0}ZG*k;B1Fx+ErY-46xM-a_Dq5chaG6~?(v5S zEp}@8WDZHdn&sOiRkxk3p-6g*BrA$#>!eIb{Js}T1 zxR|oQ%6CZVUEt5zJb>oo!`_l!r(C^=frCRngzP6kPK~t54oE;Y1Meb*KDc_}K)TwV zfDcIm)kmRIs_XPld__!17}}hj&AR zJ3x=KP)nO|n7=qit(U^_ZDBHt=u7JB1UE|hO5V&Ym6LpdxJW^R?2*P0Op59m^Q+K8 zA*8zYeB%l`uuzw+^Jf^Rv$Z;eH@@S^*b0sC;Ju(SfjC@2{p@mA-9-a*RrAim;<;<> z!-td_~C+AxLU#c^^ny1%3{es1sjc`(stOX)9T0yI0^d~9{KVND+`J&PA z^I1+S`1sM`R>Ll1?-IwK?NlJ=+dQq2=n=o;X(czyK~2l83`8XB!yh|;#8~{9oSI^*PRFln@xRs z)L#8H_GNBNhUG?DfZ4WpB{9{MQNESr+V*4Dv8yRjpSi_iHigLIf&8Cy3E76D$)2EV zKw?f&3#$7o4?8EC)71*eu2x#nPo6n7a;&e%SjmLShw2X$FK}8J#^6t0C>*>fO zs{@Ud^;1fXTxSm4{H>vxw-4jYs}aDX*)n2JJ9~N#QrOvnPE;+qHrnzShs({|Z}(E> zvI_@3_o^=_?{Tj0GW?Kf?sH>`GPsQKg%p7jsbM!}W(8Zs)*f7j*IRjVXt}oJ_dELg z*KiCBU?}V2;MK8<`YXWR;5(Z6#K$I*!7nIi3-6BZ8bSxds}tbEZCQ=FJzrW- z{c1CGs)_{1GLEwL0!$a~Xd~rflb}EJn)E!}K4|F|YzF%q9iAR(Qe6q8+G3iM&6D;P zQ8CKS#dO!_vy4`@0$U@?@t>WT+Vb|B9`+=_N=rf;o46z8mg$WnH2rj3-Ds+QVH{qZ zZU#t~dLSO?QxZB&cReK#kV_RUsm3bicp1ymMt=H~0G{=M%U0Yj3gAnZtgs#762BGIch`*X!9GXk=wwE zJXMnqJGNnc@0>&kP`KZ*>*IpeV$l~vqBlW6ky-T0XWxHFEUM!Zk_|q2cgG#t;0>o4 zHM)KXO^4?FG$d->dNHWataM7^F#7?-_Qq$>NNC{K2xXngN=DGU-J5;;n7ubM*EGjXy! zGXW=n?8+W~CCz6We_;+e8O=Sj)`>FxbDLf|2lBFfi&Q2wMbF@5X+QeJ`K#P?TBe@; zX#VvZ&_S4WXh9A8K zYHi>omC75cI0=$%5cD*CvR+AMi*yBEKe5@+WrQJ-+_9-}=<5w#8*}+| zb$rexrDb+IlpVnXGouU zilqwYp$}2lpCNHPdYwS6FA**T14&&Dn~h?y+!RKgiD$_7+mzodG*LN~pfOo86e$~_ z*uLvJ7MeItEl0j@s@Y}?#PX#4B=#%D%0#K_^A>w62@34qo+JLBhwoOOf&`5FEz=C? z8JU9c{l5P`pA&=qUW?k3uzErZ3SIZVWkJnBc!36zdmIj>X>YSx*JB`KL_?j3s&&42 z{=8KQZ7U)rDJ?Ov-@ua~$%7m2Z)(d)NCT(kk9ThgH`3QlGV81lG))bahS3NyhO4!F zcAjc{?HBzAndd4f7uQcG>q|isH?Hva376dXM##t_Y)!*7Pd2E1N$AIx_|~Ai;ywL< zHh$8n-<`^Ur-T5+y*5$&LYzN?*Ax=HoT0FKeWgerD?VGneX=wDzMF#mmEKZxfCikr zrRz9Dj(zsh901y({NRup7fn-yUcY43qz?%ndYAors801?=em$`(%Wp@`RHtS;WCCdEVF2J~d8h4o=wEcuhi!{ynthY2*ik>% z*H3@Io_)|BSiz^>AX=Pr&9x=)9Lc@ozh}$_@f%TJkqQ+ca+&8FTywl@C&yV=HHoG^ zySy0SjaxqQq79 z2KDuXCP=`XI*hO(U3|#6cg*@b98u1!))2j1bZw2OJthYEotZn`j-^fCw3M?{qw6?U zwwT%l4!tDpALG2G$cBpPdL$w~T6ow)PYC#o**Ef|$v=?PQ+@CpC69IhYmqv%u z6Ffnl>mAnD3yfj+KGzpyD*yt48DA;z9CDVrka0t?@QD3->JY_K8pI_ zX!@_VG`FjeUf4cu%G2xjy(1ZD9%tFLuShM{|It~Y3A8k4_kf<5KBC|Wt-_Nj=L>e1 zRXZ0=ak%ogVZPNkPobKd6|UUFHKl^WW@Ci*f&!8*Y(lHbGLMJtlJP_ z(lh+1xiA~}dLkByyVSJUaiQnJkl!8BX$dxgnZk#Lqxe>oY0)3wz-qps2pCRDRsP^StAF$#D6{_87<9|AH(V{Q`WnTE^je zjJ|P(Ux6UIuv;`go%#s6b!yOiZmb^FX4Ztwl}!NQ1}`xpLf8AzRg zDZvSyRPrJ%8tEirP_x@E^{6*pX46X_U=*7@JnJ5M;IH-RoS~pS88i z66^)5)NWo-RM(6x@^0GZsbO6=m){2|Agl=P)VzJ(3O0KMFVrrIt=HFbn2!2@4!6_2 zDHyFa8yB2NZt*E=8*<8K^6j;b3(hIfK^?c1=6`&~s9@w9OLjr@Q%kZB2b zjKKq_A1dn2mvqnP`Qv*e+&Mm;p!0sPjuh+^viA%p(!g-HJd!1oI|$+Ix7wSbu(bmA z#$hb>IMu5mCSy_0c~lVO-*mVq)rJ}I#q8}W1Zf~B9W&~B)r3@r{L-uxO5gF&MAS*{ znoR9-F-fOVq`;DAZGx=Osxx(aqdp%VjlM~{g!T4MS@!CpWuUs&RqJTn2y+K0=u@@( z&d(@LU_!JT+xta(_C)Z}wPw5FMbkXZ{58CNxp&a?dvh872SPj6o*jfJla8vl(=(2m z^{X*7ybdDbcNx?RcRy6zB7eUuv*Bwkr313MHuy7)3Es{on)L6~rq%&3OIz<$y|;8O zGKD%9CPX;BCvi4P7GQU8*Y{w!Fo(O8{l%_Wn1nL{>4OYS$7HU7V@kb^8rCf*tLUF` z`>iev-$u{ku(yYb+yA7IrapvmTrN(5s9;mu?l>5ssVpK1u)BMn=slwlRLSK$QLT46 zuil~yzwXyz^(inIi|#pO?r3$Ae{CD4j_-B$eED`EWBj-(_#?Bx*M zc@Kk{pDHQN@54=LU+ncU%->nOToYIP4zQm;kpJ00V46DZ*e|Oc%qzTGzqF4Y)0I(K z@EiOiR3x?S7^l|(IeB72+Bv6FEa?-u-1Mu^lSwz>TvE<(~?wZWE9O$9HTC6JOHc%po@?_n;l)7;|yrenubsaBa3-R<;uY-P8a_gdj3g;|?sN0vy>?&$VEyRgImMSTCF#tcysCxEVqZ9 zr0y0gDZT?)}#jU>7MPa1iH{G^8INUU8 zc)RI$n@Cx3t(!Qndphp=@dBPP@UfvR(3SLb^__tFU;_rPg2EiiYT<-Ub=ra8*Q`S! zQQKJp#@Vz26ZUNb;I>u^ty=f5z z`{pW%W}Zit8g=tsJetvl=Z6=pqe|(sGb(MRpLC%m8WR0U<4{5=&RIYT>n8P5+f+xO zXN{hgzUB7z3z~nnF7HC>_>?ARQ(sWehL}-lGJL@%rRZnZ5s;{>cL2LG%$41bM>&0N z+}2|7wpeB1p_HL#?HJ4w7LOulb)Ehhz)-E1&L&b;R=p63QXo$6NBz;j8ouMV8!48P z=8X0MI9{|VPNyNW<#(kk(3fh?1L=Rq-60#KBUFc&$E+=ut%0z|6B(H*#F1u`Hd=W* z)D{sud-PKOv%e|JFRZ*fdG@pM{JiZO%vJZe0(PI~#27@IYR=iaq$;E-?#(mYm^;9o zMtxPd&(oVOIAFer65BN*V_3CckYMy~&*q7UO(b}=>or?W+G(mAdyopI`R%FOozX;F zRye(KNqVYrG#$svtb;ZDMiL}sMAP=vG)0Pi7AMG;ute}jca@wJf7@Ow(ZZB)O4G|Z zk7X7!&#Uzi8!tFL`{P)s?3JNshJK}Ib}X}^g`%oURF5>V{o7lJEEt zRr})#;<}d3ygFZOekT+n=)SQ0QnYYL{oOlFue9#_t^?;9=AULE4Q29_uVsXE+-B$| zhJP_%-p&G^SWGk#nAsAiME&U@JEG=u8Fj-|wf_(wCz7iB&^#US-goF~(N_?6uk?JA z-)ON8YklK~FiZ-BM>SNNPE_UDOfjpqE-)^MGcYTky?=-*E%_Y-I1Yt!=<@(91VHI9 zZYyV*mfCAG`%lfHk;H64Eti?)3Nqy9h99Z}!%+=;E?V|QJc0y9vOh0uUR`cU%QWPG zr&}1npMq&_^QJOa_&l}}?w8fd6bLRGH{F^@bi=)Es~i(S_+h3~cn@3%b=Mc^4ct9h z_*_@+dEViB{sCH$g%*4hBR%lx>gE8qJsmx*kx~I9RJztjpL`5a&JbM2)6IT*_#5)p znq~0!wCUK$$lu3wqZe{koKAZVko9YF6HT9P&8xeie?n8&()vci*e(+zYtgV$p|mVF z^+iO9Uq}XUBv1(q*vB#oiW}l#Yh*q!%AQuZJc4IOy5FXv#sSjDHev=n(E{ypJb$8(&W|z zX>(zW7m*7s7R>ytoePxP@fa(p0A>UueMIijT6*c> zmkm~(bhHEb8XCLYuJ&C0 zJ^aX)+F#`x?OiXBSdT|dr-G%CI%{jP=MA?@gAw04W#g4aWs@i+=vN{lh!Gd-z&TGD zy-$J6EB3x$$1OgsN)Q9*sPd-f*T_&*G^Wj;`N1h-|1W>Ar-dnx&WF;kE7$lkFciDJBBJgn zJHDSn{@|^WCNHt?yPs>1R~wuhsulzeMdA}l;PIceGgqY_uwKc?f%dD^9cL}4pRvtQ z4M7oS(5~`Gq=0ETx1QzQT@3jpx5LM)KiQf07(W;j39Oq9Jwm)xt&UJk{Df7nejmC; zInKT9$G|n8UIR2~6WV*aP@o>ZqJHXX*4SH90C&9FSP#k+x%MGH?I?@r_@#2uv5`zhyaL@FdS2M{vWuWa|9AU9 zvIRFVw#QG(bXsHNsY1kB84LSiu*YzEOnu+7k3azkZF0bC9WHpE6W+?@5sYBLjgKp=evA!CRaVEXy4Z+O*07CMM84cHJ|V8wwf zeIGrru732KKCdyGLqDLi3a7$m0Mss)()X%=11|eX+ilo0+u>IP1D{|U;@lBu#&k`! zkd)Y8L)_(LIsCTKBfkQu|1CGCOXM)^hsWu|z2~l9H=2IcfW72e(HgYDM~jo@t0n$3 zrAdZE@>Aq?Uh42fCi3V2=2?HkgZ6jHewr~aRGbsHZbeRjj%m)GLs)8d8pytFl+afd{_|pe;74cFhm7@ZP zd+Rf#c;V5f{qnsk>d)nrdBd?Mixl4 z?(K=iL<9L3kw@a6SK&>(UPJ%gc|1IBT9fky(bc+-I|ps4M|E%YVEo25W&%}>KyDKL zr(74jGHf-J11pH9u@f@9h3Y?HXJ;yV z0r$Mv1obOVjU(s&?NG?dvUFzB1D-2&uL*0vV&6Q}Vt*TJUcG}7Uc@7QCgM&{ACI$p z{>$kLc4Y;=qvxzEL{yw*(^HBF-?CzS9MMw-kTvAv)SH(r6HD?lcwJ#5P|zys^;Mc(P1FwQF}R^!{Q# zY$~WoW+5v=IyDMkb?K;bJw@b~5c#2F^+G7*vnGp!^Lt`?^dpxG)js3~zE=Zh@kbnNqQQ_dCbg)StP%ST2A-6T~C>bXOiPKn@&e+0|4#U+Y$W%;VO4M_w#hMZUf;;4)@; zHlJ0rup&<2&M^0-BbieQhzetKvEKCdhjwk@miO z3;HJK-&GD03a{FeF52$i*O&L;YdgSBMZW$OD0QV*u*vgjIOk*)nJpmDVhy=I?xr;| zuI@nG6>FcQYE}w*purL)n&TweEO9bpUbIhQTtL*qi1+|VD9wcEj_cs z=F67cgSj0`Oc;D$cBx$C6@QB1zL{PBn>%G1>{`AulYI^R94&)YybOUhc4eG4lvkKVczA&5KhYxabo`s%vPQ35-23#_ zaZJrf_l&Nx!@g}dNM{C8h z;CV9GU0)L4?dL~n|HI9g`M@`Ys^x>&)=1%tvu+aDl)dduQTtVnutr?Z@v`{Zbh~K4 z!cjKvj#K=;#+N#rPY1s56&kAi=VZq2*swCxEO?!;cJU}y2WR@u%D^Hxm)oCPDsC$z zG%~%eYiipwr+(V0-7Z_gsn@@7WM*mNQ!IatdbYudR8ao+&cbZ$7? z)PY#Ay9yciU$j+mnM`UPF#q;Ep~1Vj#kkGqX?XpD!xH zvMfK>LXPZj3tS>5sjth5zPog@$Qq}(hGou4ys8AGNDZAVjU4E69oGz+ScS>2#zwav zlRJmjkHu+`gXy#R@|0ldv-glcYhv^pcNC(lwKVw`pP%^Cr`d{6Fq6(IY)*>ChuPI3 zbI{eku&A+Z{-{s7{g5D$3vzQu7DYi{i<9lq7Llll!Pu&UDS(1s!ckNOeL=hnUWd<~;b1=tJ8lmt_e zxoz@wTfg(#3}IlW1D9nmsf!{jat!XYq#hi0|y62CvS;_7(dc^Qw& zNA9twZ?NMjxEx4q%S3wQfXdEsUxYpGc~9|R!Zu>9gZ{Ec^ioR=I+k2($Ei43{e5#^ zD>zvP`eM%#;WuIL$mOw5!KRSeww;Qd;4K)eFEhrMqN((Q-Yvg^x_?0dOlmVY(Oj~+kwk2dU_f4xIhkRzWzu8MH*JG$NAJ%nv)}0-Z z#~7ZDxwZSG~fN{+yL&_jZ88f+J$WW$SsKEq?jv zP!*%~*y;^dDkmoeF`-duP;Fw5zAW`N4oHzm@Z`YH2~FO@ehbRdD+&NchBIod;1P%mMB$nkyC z_NyHmTnkp+d5D866XF3)YFq2hktLGL`sYmH5AuU9937j?%4IQu5h<;lpYnR!h+)D- zNWarchK|pXGe91o=1}sw_k_X6^LN;gsuGV78ZOrv`m7RIkjss3c=#e?j+bSf%Uxy*^$Cx1gX> zNe2EvF#7PHh)OdLa}*)cD+?|4^NU;wLNvkw5{t39v@XE)=+xqMScqn`)!m%O?`CYX zd8b8ebO5pV8NR8pFz=HR+Y&vnfw(I7k~NB~`LFm&Nk?a+>3J%ylZrV>BX~S&bb{IX z{lZxl|KVrA%_yLiKhukSIA4an$jf`A8y+fTj}2xhOmu(StJrB&5&mwBdF_maCqt5<%fd6LHh)FEFj#j(93Na(-|Gyi!6r~alzX7jfx^wFwQd#1 zJz}3RUm(~V6*CzmYAT=>sF>CyBfm-AFDSczW}($pIX0@p@;0Y=i=2h z)I5Sl6JuN!4){$WcqWRA-uW8q^fz~}Y-@*bSMQ!ITsr|7Dt5b5S{DGfz@-Ll|&<-W?rfR)!G8c z^*Q5>pX1B{_OIod452N93Im_;k~RnN8n<%6?ynQsmg9el=Djxq_?5bDPZ|!v^d(RW zN-u_>t9a(rqZI{6llp0sSjK9Ep8bZZ8w+CV1*f1>+r%<8-=?2e+9eOw#vb$(nTN{g z0An{`&F-{j-+$WUoJ^Q7A?0$VE}y)PQGv4=UbR|6Dp=7($#3QkFUw%&l_AtmfQ~%L z$9qPR|9EX_u=E34Res#8x7%^^I7DBQuHd0*J=CLxX+r_qTXp5sYw`%=y|>B>nQpbE z_OCBs5%_QK?)xERq@CfS%r9?^7WNPQ>t1oFu-poaEha0bDV6FnW1Q?-Cx)hz^dm6e zer+-MRy7p}t&DI6WsO!>ni?=MPM+AS_eCNG%zLRdO;;3OrT>p!%m}{t+Iy1-im_qRS-!0Z?F8_#6TRzNWOc{4upt@$zvqxgR3gUA>1Em|3!?o3B!%z!7( zhY={$)bv7X8O~+TDjdF0GaIl-jL#BYoir<6$d~><%)Mt+Q{DRL3!)&>1e9JBDbjmy zDpf%c5T*CtdoL;oA_yWK0*Ex}y+dfydyyJyAR)BSLr6$Y-0!>J|K8)?5BH97#uU9_)n6)K3 zt5ni{=SR<@&2_b6p$)rejWpp8o$~_rL(}KzP~r2FUd`9RfEWT7B2Nj*I*j~bTOkeR z{+@1|8MV`VnTEQD%J95v-%14M(Z{i)q2IY-9~CaZnn~42vZddpQQ;mc=rS?L;Xr3i zmT}YNuFia3nnzD*nnS2JFN1sosJ1KoZc@0!Z8^H>`~@1_Dn0+g8DzL*g~_*)stH&DyJ{9{OHtK``z%vIdp{{&YG zR0SY;`ab71_ulhQZPeq~qqU(N1(w(3tum-qDsTVdt(zq)3_+WYjA8ec5QI4ahI?me zD?{wi?6cD-DD2>AVJVQ*#Uv{0nLP2a#}6BCZrYG*xu5F?nbj_KHRi4)e4~9 zCXoToz9v^LNOGxV78gkBd{bO2+$YoId|z^@#Q~5^RIwEr%#ynpT!<{G)`OOz?^b9879iN!8O+Dm*)~%1({uA z)Y>TWa649s<<4*6J6PB5D)jyN!@$Z~PH9$GrAvG00D;=icC}90gJ6aZqpB>C>Pw zea7ySLctlC$5<-QvZa*76`uP>K;LM2%{@75j|I`25>T4xEb&T%!l!Y-0pLK%@u-Xf zJ%Ww8b9MMQ`9~;=PY{fo!5q>YJK8C0J{FpJrE+y*@xABTy!I@GaDJOrorC*(h2X?T z17Men5!KV3GU#n)>h2^iK7aC5cUZ3wTz9qCPDOI?gS%6{0imCkErD8ZP(`|zC zYFPT2dHa(QeN}-XcLH2`g2FA_vFwOtHMMQ$JA4nsqTbzDh0SC z{G7|l%T_~sk4-@#T&4 zYkqw5w;IlqX8g9*Klt4gE2bw0Y>aF)7mvP9>z&!f)3yR<&bPFvmz1O!2NH(WMSNjZP_LV8x4rYun10u649$s5v%ub<2(E43TlaazeVnh@_8KQ38OzU3r3FZoEaa~`IzO1|!8y)$ z@2SGibPjdqF3Mkt|Pura^?l8F%CUyj0()f zBtH4(`%{x!Iv1OmE~*ThoKtvBEM#&FfBN2RTyCkKh;@&GKi5@#5N63$7?*?#LaYO9 zGm6D=iM^j*i*kxu2np@vJy~rY`YP&k+!<9zZA-fTdOeWnsQ)G54Xi6PG(~eISzYt< zw!Hk$Ldo9*WPL0*I@qOb?J`fU#TIM8?o0J8i=M}wZ=BBD=CFRB)bWOOu{q0*EXhrJ zmklXT(L>DTt6otyc<@rt9FRl&`p)g(&(0h9H_1>db_)ySnz873pdaJn@B_?dSY!E+ z04<(-skpmJ62tZp?_=#+k$&^dt<{75PdpkU5Z@9b>BqTl7ua{g?_)QJZE!#VUY*9S zY^Xd1ytK(f?KJC7pw`qVtL{g&)Me7CrDN*NuzlCI=OxwHF=Iy*ECX~)U#-S*-dT)2 z;xcxFX}Ruw#4Nay#tP({0hzi3kB#N3%J%S8z7reseatZkeOnSh($Smu;b{_vOUczFf8q^K=2< z`Et__&s&}0jcbesFRXYH=cEHVLga}tRWy-*=YNahkO(c?D;mn|MqvaYV6*TXcX$@{ z!f=DQw=!3$Ton+iAIv}!NZ8t`wJbXAE7fJ|Blym$6q*mBxxhkXAh=DXz1f? zvW*s~e08Ga{i=b$wFkh(oKYMCLz%;Hb$L%M+RNA%u_v>(f4D;QQ?Hnih2UE zF=(-}W4v>2?Q{4;Rj@AnKo6}!1nIblV+{Cltc14EEBIo*)*Ur<(GjB&@u7Ednlr+8 zLJW#w{oyq*<9;TBnv+x6-t)gsE*oop&y{rnjUEIAQZsZmW}oNtnjV`b{2c*en;*u3 z4&uydw5?h>9+(L8B)9E0gM*mEkFbIZ{tqMKb3anMk@f$B$3dSu?EVXnL!;K-Fu#A2 z_|T_$h(Hnt`5=RJ^^qPTMLu}fq>hW^!_RC%@?kfFu21*+6m?!n1&1cQsLVNyy`n{=9iZMCk}(;MessvVzW@CM*0zxSjkTRDRW3wu#hp`IpDfU z>PX@x9Q|QtPH|;8xo?8uS22fj;!gV4*UaOs{9wnThYv_>)|O{eL~O6CN1d{?LS9?t zOqMKxM;4DeISai4rgUe#FS?e++V)7a469^R9E+VL6QFjetQQ5z%h=B4=Ifc6Il(%W z0C=21{{>c@-$P6PpMJT*Ka$FX1jYPh*Dwgc5n>>UG|#^K!>*69suJtVk0IFXXPLf- zb+(U`no&Fm7vKGi<-h4H)d}66dX(IKQDLlof^B;0!bX*}BvH4#wL`T*6_VyA+Pl&y zR%^{T91G%PkU9XlI^3q%S!&T}AfjHMYHy>k@eS_kF6{3DBem108)=vsQtbF)aS`R; zf*8kM+PYBR`ZqvFw-o2(#DTmBH{g!Q=hq2f7)Vj{L*1@xpgu>T{S-D!z0KkxfAu}c zaBr8e4(|(3^9eHIy>!Q(1VA17J|htj|0er0=r#^%L?^PWkRDoPkH@?lko?)AQRt36 zmfs0s-J^F<0yA7Nm9LCkmHB*Y87k7AS_XdM`KlOO7I*k6`&sNPf8=t`Rg5And12$X z#gVd=gkx=*itO<{?cwE1h}1FF|c70KDzz8m73r zG8_9Gace8`kWs6^8$Np*SAN#}6UVt~AU7HPK0o>xf=*!^1x`;T47i==jp{RC&_>hM z{a6|LU*@2RRPpGACU<5OTUT3=lWju7#XYOQtpeXh)izz}WfhPcrOht6W%ZL8f!cv#RvwugE*Fp(H;eXl2PoJbR z8>JIzY5Nnu+R<;)t$zu=4g_`xuFgMJMmJ@(h{p(%SjA|J@y~dgq60SE%!ixJQQvL* zA?#)*HrOk0o6&B4oD9vUWS%t_ZM)GPgCF1cl)hP$*_rR}WO?VSIOhs3@ z3O6Z=dr4r_8OLIQE~BREJYng3N2=8j2L^c6bBlv1vADU_T7zg27QeCy@jc zun%>x`sTe?kK=yiIKwq2`pp7bQ$aPK)Xnx13Xi*&ZoXlC{)qn(BIwv`0#6n6MkaWa z=Rf(4EiW!`NF$txbIc8_bImIHl7te7yfuEanQK& zSQOQtS$d4?%f3|7I3|`J5I6ilu3P!rRGjph`8GDR-6Z?Afc1B$?<6>u)MWc-*`|4) zVmrfmMjneHnm13fIWf_HKyJrAzHAEvPkuikz+EpvEeX;=e|L7s^B^vyq_MZ`t$X9Y^n$x48SlR|kGqQF$^W^p z0pJ*qb^{;rWc|=Dvm_*C1!&%#&G2C^^}?3!f22IBn0-I6$~5Y#5YLE_+(#jqAuLg~ z2P|ONMR$Bz;jHXn0yT|i`h!C{aRB+l_℞m-Qq;32GJQE6tskj|R@YdY>zv%jzi@ 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 z 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 literal 0 HcmV?d00001 From 8b4da4a66ca1da94be81baafb4fa8c1bb7b34f11 Mon Sep 17 00:00:00 2001 From: github_actions_lisa Date: Thu, 12 Feb 2026 19:38:21 +0000 Subject: [PATCH 15/21] Updating version for release v6.2.1 --- CHANGELOG.md | 65 +++++++++++++++ VERSION | 2 +- lib/user-interface/react/package.json | 2 +- lisa-sdk/pyproject.toml | 4 +- package-lock.json | 81 +++++-------------- package.json | 2 +- .../cdk/stacks/__baselines__/LisaApiBase.json | 6 +- .../__baselines__/LisaApiDeployment.json | 2 +- test/cdk/stacks/__baselines__/LisaChat.json | 46 +++++------ test/cdk/stacks/__baselines__/LisaCore.json | 2 +- test/cdk/stacks/__baselines__/LisaMcpApi.json | 38 ++++----- .../__baselines__/LisaMcpWorkbench.json | 14 ++-- .../cdk/stacks/__baselines__/LisaMetrics.json | 8 +- test/cdk/stacks/__baselines__/LisaModels.json | 64 +++++++-------- test/cdk/stacks/__baselines__/LisaRAG.json | 80 +++++++++--------- test/cdk/stacks/__baselines__/LisaServe.json | 6 +- 16 files changed, 225 insertions(+), 197 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eee21056..34d4a383e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,68 @@ +# v6.2.1 + +## Key Features + +### Update Deployment Guide +The deployment guide has been extensively updated to include new walk-throughs and updated instructions for various components of the LISA deployment. + +**Deployment Guide Updates:** +- Added a walk-through for setting up AWS Certificate Manager +- Updated the Route 53 walk-through +- Enhanced the API Gateway walk-through +- Added a new section for the SuperNova walk-through (AWS internal only) +- Updated the example configurations, including a working config for Mistral 7B v0.3 with VLLM +- Revised the Cognito setup guide to reflect the latest changes + +### Publish Artifacts +This release publishes a set of pre-built assets, including lambdas and container images, to GitHub and NPM. This will make it easier for users to consume these resources without having to build them from scratch. + +### Preview Panel for Rendering Prompts +This feature introduces a new preview panel that allows users to view a rendered version of the contents in the PromptInput area. The preview panel supports the following capabilities: +- Markdown rendering +- Mermaid diagram rendering (within code blocks) +- Inline and block LaTeX math equation rendering + +### Session Config Model Mapping +This change updates the session configuration model to improve the mapping between the data stored in DynamoDB and the session configuration object. + +### MCP Execution Modal Clarity Update +The MCP Tool Execution Modal has been updated to be more concise and remove duplicate information, including: +- Removal of the duplicate tool name +- Reduction of excess spacing +- Removal of the do you want to allow this tool execution message (already mentioned above) +- Changing arguments to details + +### vLLM Variables Documentation +New documentation has been added to describe the vLLM environment variables that LISA Serve supports. This will help users understand the available configuration options for running vLLM models. + +### MCP Server Identifier in Approval Modal +A new MCP server identification label has been added to the tool approval modal. This will help users distinguish which MCP server initiated the tool execution request. + +### Table Border Removal +The borders have been removed from several table components to align the UI with the overall design of the application. + +### Consistent Exception Handling +A comprehensive refactor has been performed to ensure consistent exception handling across the various APIs. + +## Key Changes +- **Deployment Guide**: Extensive updates to the deployment guide, including new walk-throughs and configuration examples +- **Artifact Publishing**: Pre-built assets, including lambdas and container images, are now available on GitHub and NPM +- **Prompt Rendering**: Introduction of a preview panel to render Markdown, Mermaid diagrams, and LaTeX math equations +- **Session Config Mapping**: Improved mapping between DynamoDB data and session configuration objects +- **MCP Execution Modal**: Streamlined the MCP tool execution modal to be more concise and remove duplicate information +- **vLLM Variables Documentation**: New documentation added to describe the available vLLM environment variables +- **MCP Server Identifier**: Added a server identification label to the tool approval modal +- **Table Borders**: Removed borders from several table components to align with the overall design +- **Exception Handling**: Comprehensive refactor to ensure consistent exception handling across APIs + +## Acknowledgements +* @121983012+jmharold +* @99225408+Ernest-Gray +* @bedanley +* @evmann + +**Full Changelog**: https://github.com/awslabs/LISA/compare/v6.2.0..v6.2.1 + # v6.2.0 ## Key Features diff --git a/VERSION b/VERSION index 6abaeb2f9..024b066c0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.2.0 +6.2.1 diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index 23500d72f..b70fa8a42 100644 --- a/lib/user-interface/react/package.json +++ b/lib/user-interface/react/package.json @@ -1,7 +1,7 @@ { "name": "lisa-web", "private": true, - "version": "6.2.0", + "version": "6.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/lisa-sdk/pyproject.toml b/lisa-sdk/pyproject.toml index 989951696..a09c1ca93 100644 --- a/lisa-sdk/pyproject.toml +++ b/lisa-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lisapy" -version = "6.2.0" +version = "6.2.1" description = "A simple SDK to help you interact with LISA. LISA is an LLM hosting solution for AWS dedicated clouds or ADCs." readme = "README.md" requires-python = ">=3.13" @@ -15,7 +15,7 @@ dependencies = [ [tool.poetry] name = "lisapy" -version = "6.2.0" +version = "6.2.1" description = "A simple SDK to help you interact with LISA. LISA is an LLM hosting solution for AWS dedicated clouds or ADCs." readme = "README.md" diff --git a/package-lock.json b/package-lock.json index bac9d590d..501be44a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "awslabs-lisa", - "version": "6.2.0", + "version": "6.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "awslabs-lisa", - "version": "6.2.0", + "version": "6.2.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -117,7 +117,7 @@ }, "lib/user-interface/react": { "name": "lisa-web", - "version": "6.2.0", + "version": "6.2.1", "dependencies": { "@cloudscape-design/chat-components": "^1.0.77", "@cloudscape-design/collection-hooks": "^1.0.78", @@ -270,7 +270,6 @@ "version": "7.3.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -516,7 +515,6 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.47.0.tgz", "integrity": "sha512-b5hlU69CuhnS2Rqgsz7uSW0t4VqrLMLTPbUpEl0QVz56rsSwr1Sugyogrjb493sWDA+XU1FU5m9eB8uH7MoI0g==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.47.0", "@algolia/requester-browser-xhr": "5.47.0", @@ -1257,6 +1255,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.1.tgz", "integrity": "sha512-epXDCJWnaPraPQ8ZXE1AA6T/wMPw+jQqtQThuOTHFyvjAFezGAYqF+DHBUsJE7DqZXRLRR3v4ammtTaYC6uhvQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/core": "^3.973.0", "@smithy/core": "^3.21.0", @@ -1277,6 +1276,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.1.tgz", "integrity": "sha512-w9TVoCUNwPG4njcbnZpSQaOZ1BF2z1Guox8NltoXm7oS1+q/8iHeG8eqY9TlGQsKLNA4KfnKUEAx4rlEc6Qv6w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" @@ -1290,6 +1290,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.1.tgz", "integrity": "sha512-3d6QaHQAjevuCioG0lZmZM/Nb8mT4JiF2mRmlh/aTM32Fc/YNGxp2Qbri8B8nfeYlfoi8GM12gH7SaIwkihuBQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.1", "@aws-sdk/types": "^3.973.0", @@ -1615,7 +1616,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2127,8 +2127,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", @@ -2220,7 +2219,6 @@ "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1181.tgz", "integrity": "sha512-znT/MKJCb0ANsa0Q/7KCFodaA9tTOJqipTIhvqvKLa9SL8kx9SY2va5tX/3eJVMefKoVzyKuWtzyRw0WZoS/pg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", "@cloudscape-design/component-toolkit": "^1.0.0-beta", @@ -2388,7 +2386,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2432,7 +2429,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2515,7 +2511,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3379,7 +3374,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz", "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" }, @@ -4099,7 +4093,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.16.tgz", "integrity": "sha512-2XKQKxvQdeQiuIo0tacAmDVojhSVAci8D2WDdmmyN+6CqDusLHEHyIDaOt4o+UBvpkyHXbCdrljzDTQY/AKeqg==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -6273,6 +6266,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6283,6 +6277,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6296,6 +6291,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6310,7 +6306,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -6432,7 +6429,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/aws-lambda": { "version": "8.10.159", @@ -6918,7 +6916,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6941,7 +6938,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6952,7 +6948,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7122,7 +7117,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -7751,7 +7745,6 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -8073,7 +8066,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8189,7 +8181,6 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.47.0.tgz", "integrity": "sha512-AGtz2U7zOV4DlsuYV84tLp2tBbA7RPtLA44jbVH4TTpDcc1dIWmULjHSsunlhscbzDydnjuFlNhflR3nV4VJaQ==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.13.0", "@algolia/client-abtesting": "5.47.0", @@ -8628,7 +8619,6 @@ "mime-types" ], "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.263", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", @@ -9328,7 +9318,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10042,8 +10031,7 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.5.tgz", "integrity": "sha512-fOoP70YLevMZr5avJHx2DU3LNYmC6wM8OwdrNewMZou1kZnPGOeVzBrRjZNgFDHUlulYUjkpFRSpTE3D+n+ZSg==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/content-disposition": { "version": "1.0.1", @@ -10645,7 +10633,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -11037,7 +11024,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -11583,7 +11569,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -11955,7 +11942,6 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12026,7 +12012,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12087,7 +12072,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -13034,7 +13018,6 @@ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -15003,7 +14986,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -16209,7 +16191,6 @@ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "devOptional": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -16855,6 +16836,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -17398,7 +17380,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -17434,7 +17415,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", @@ -18113,6 +18093,7 @@ "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", "license": "MIT", + "peer": true, "dependencies": { "obliterator": "^1.6.1" } @@ -18414,7 +18395,8 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/obug": { "version": "2.1.1", @@ -18432,7 +18414,6 @@ "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "jwt-decode": "^4.0.0" }, @@ -19242,7 +19223,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19495,7 +19475,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -19522,7 +19501,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19593,7 +19571,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -19778,8 +19755,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-mock-store": { "version": "1.5.5", @@ -21662,7 +21638,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -21749,7 +21724,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -21957,7 +21931,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22456,7 +22429,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -22530,7 +22502,6 @@ "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", "license": "MIT", - "peer": true, "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", @@ -22584,7 +22555,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -22715,7 +22685,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -22839,7 +22808,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -23224,7 +23192,6 @@ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -23303,7 +23270,6 @@ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">= 6" } @@ -23441,7 +23407,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -23451,7 +23416,6 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } @@ -23504,7 +23468,6 @@ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } diff --git a/package.json b/package.json index 6656ea3b2..f022d0da8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "awslabs-lisa", - "version": "6.2.0", + "version": "6.2.1", "description": "A scalable infrastructure-as-code solution for self-hosting and orchestrating LLM inference with RAG capabilities, providing low-latency access to generative AI and embedding models across multiple providers.", "keywords": [ "aws", diff --git a/test/cdk/stacks/__baselines__/LisaApiBase.json b/test/cdk/stacks/__baselines__/LisaApiBase.json index 863a334f4..23bd87882 100644 --- a/test/cdk/stacks/__baselines__/LisaApiBase.json +++ b/test/cdk/stacks/__baselines__/LisaApiBase.json @@ -489,7 +489,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -707,7 +707,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "FunctionName": "test-lisa-dev-iam_auth_setup", "Handler": "utilities.db_setup_iam_auth.handler", @@ -964,7 +964,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "REST API and UI Authorization Lambda", "Environment": { diff --git a/test/cdk/stacks/__baselines__/LisaApiDeployment.json b/test/cdk/stacks/__baselines__/LisaApiDeployment.json index 80d8cc343..e3a1d36e2 100644 --- a/test/cdk/stacks/__baselines__/LisaApiDeployment.json +++ b/test/cdk/stacks/__baselines__/LisaApiDeployment.json @@ -1,6 +1,6 @@ { "Resources": { - "Deployment177033172649606CD2074": { + "Deployment1770925099307EC932D39": { "Type": "AWS::ApiGateway::Deployment", "Properties": { "RestApiId": { diff --git a/test/cdk/stacks/__baselines__/LisaChat.json b/test/cdk/stacks/__baselines__/LisaChat.json index 688ee4434..aacc39aac 100644 --- a/test/cdk/stacks/__baselines__/LisaChat.json +++ b/test/cdk/stacks/__baselines__/LisaChat.json @@ -900,7 +900,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Lists available mcp servers for user", "Environment": { @@ -980,7 +980,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Returns the selected mcp server", "Environment": { @@ -1060,7 +1060,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Creates the mcp server", "Environment": { @@ -1140,7 +1140,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Deletes selected mcp server", "Environment": { @@ -1220,7 +1220,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Creates or updates selected mcp server", "Environment": { @@ -1623,7 +1623,7 @@ { "Ref": "ConfigurationApiConfigurationTable4B2B7EE1" }, - "\",\"Item\":{\"versionId\":{\"N\":\"0\"},\"changedBy\":{\"S\":\"System\"},\"configScope\":{\"S\":\"global\"},\"changeReason\":{\"S\":\"Initial deployment default config\"},\"createdAt\":{\"S\":\"1770331727\"},\"configuration\":{\"M\":{\"enabledComponents\":{\"M\":{\"deleteSessionHistory\":{\"BOOL\":\"True\"},\"viewMetaData\":{\"BOOL\":\"True\"},\"editKwargs\":{\"BOOL\":\"True\"},\"editPromptTemplate\":{\"BOOL\":\"True\"},\"editChatHistoryBuffer\":{\"BOOL\":\"True\"},\"editNumOfRagDocument\":{\"BOOL\":\"True\"},\"uploadRagDocs\":{\"BOOL\":\"True\"},\"uploadContextDocs\":{\"BOOL\":\"True\"},\"documentSummarization\":{\"BOOL\":\"True\"},\"showRagLibrary\":{\"BOOL\":\"True\"},\"showMcpWorkbench\":{\"BOOL\":\"True\"},\"showPromptTemplateLibrary\":{\"BOOL\":\"True\"},\"mcpConnections\":{\"BOOL\":\"True\"},\"modelLibrary\":{\"BOOL\":\"True\"},\"encryptSession\":{\"BOOL\":\"False\"}}},\"systemBanner\":{\"M\":{\"isEnabled\":{\"BOOL\":\"False\"},\"text\":{\"S\":\"\"},\"textColor\":{\"S\":\"\"},\"backgroundColor\":{\"S\":\"\"}}}}}}}}" + "\",\"Item\":{\"versionId\":{\"N\":\"0\"},\"changedBy\":{\"S\":\"System\"},\"configScope\":{\"S\":\"global\"},\"changeReason\":{\"S\":\"Initial deployment default config\"},\"createdAt\":{\"S\":\"1770925100\"},\"configuration\":{\"M\":{\"enabledComponents\":{\"M\":{\"deleteSessionHistory\":{\"BOOL\":\"True\"},\"viewMetaData\":{\"BOOL\":\"True\"},\"editKwargs\":{\"BOOL\":\"True\"},\"editPromptTemplate\":{\"BOOL\":\"True\"},\"editChatHistoryBuffer\":{\"BOOL\":\"True\"},\"editNumOfRagDocument\":{\"BOOL\":\"True\"},\"uploadRagDocs\":{\"BOOL\":\"True\"},\"uploadContextDocs\":{\"BOOL\":\"True\"},\"documentSummarization\":{\"BOOL\":\"True\"},\"showRagLibrary\":{\"BOOL\":\"True\"},\"showMcpWorkbench\":{\"BOOL\":\"True\"},\"showPromptTemplateLibrary\":{\"BOOL\":\"True\"},\"mcpConnections\":{\"BOOL\":\"True\"},\"modelLibrary\":{\"BOOL\":\"True\"},\"encryptSession\":{\"BOOL\":\"False\"}}},\"systemBanner\":{\"M\":{\"isEnabled\":{\"BOOL\":\"False\"},\"text\":{\"S\":\"\"},\"textColor\":{\"S\":\"\"},\"backgroundColor\":{\"S\":\"\"}}}}}}}}" ] ] }, @@ -1935,7 +1935,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Get configuration", "Environment": { @@ -2023,7 +2023,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Updates config data", "Environment": { @@ -2111,7 +2111,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "a38f8a5114ae969de815d5c66b3ddbd646445697a789f7b16424b4ea541d4ff9.zip" + "S3Key": "f1ef0b7df9fbd6678b581ed85579e856c8f7832ce4e06e642f2c2421ef37c427.zip" }, "Handler": "index.handler", "Role": { @@ -3484,7 +3484,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Lists available sessions for user", "Environment": { @@ -3581,7 +3581,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Returns the selected session", "Environment": { @@ -3678,7 +3678,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Deletes selected session", "Environment": { @@ -3775,7 +3775,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Deletes all sessions for selected user", "Environment": { @@ -3872,7 +3872,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Creates or updates selected session", "Environment": { @@ -3969,7 +3969,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Updates session name", "Environment": { @@ -4066,7 +4066,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Attaches image to session", "Environment": { @@ -5048,7 +5048,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Creates prompt template", "Environment": { @@ -5128,7 +5128,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Retrieves specific prompt template by ID", "Environment": { @@ -5208,7 +5208,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Lists all available prompt templates", "Environment": { @@ -5288,7 +5288,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Updates an existing prompt template", "Environment": { @@ -5368,7 +5368,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Deletes a specific prompt template by ID", "Environment": { @@ -5862,7 +5862,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Returns the preferences for the calling user", "Environment": { @@ -5940,7 +5940,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Creates or updates user preferences for user", "Environment": { diff --git a/test/cdk/stacks/__baselines__/LisaCore.json b/test/cdk/stacks/__baselines__/LisaCore.json index 4412d0853..976a45fdf 100644 --- a/test/cdk/stacks/__baselines__/LisaCore.json +++ b/test/cdk/stacks/__baselines__/LisaCore.json @@ -234,7 +234,7 @@ ], "Content": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "faa87eaaea7a6adfcffa5bcf40857522d11b66521e7098a91230cd679235eb4c.zip" + "S3Key": "6d33eab3e8e206597598f33d7a30f5c5d4b7a646c969bd3fbb466413fad41296.zip" }, "Description": "FastAPI requirements for REST API Lambdas" }, diff --git a/test/cdk/stacks/__baselines__/LisaMcpApi.json b/test/cdk/stacks/__baselines__/LisaMcpApi.json index 84998d335..541bc7dfb 100644 --- a/test/cdk/stacks/__baselines__/LisaMcpApi.json +++ b/test/cdk/stacks/__baselines__/LisaMcpApi.json @@ -912,7 +912,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -975,7 +975,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1038,7 +1038,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1101,7 +1101,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1164,7 +1164,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1470,7 +1470,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1523,7 +1523,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1576,7 +1576,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1629,7 +1629,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1888,7 +1888,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1941,7 +1941,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1994,7 +1994,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2047,7 +2047,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2100,7 +2100,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2478,7 +2478,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Create LISA MCP hosted server", "Environment": { @@ -2565,7 +2565,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List LISA MCP hosted servers", "Environment": { @@ -2652,7 +2652,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Get LISA MCP hosted server by ID", "Environment": { @@ -2739,7 +2739,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Delete LISA MCP hosted server by ID", "Environment": { @@ -2826,7 +2826,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Update LISA MCP hosted server by ID", "Environment": { diff --git a/test/cdk/stacks/__baselines__/LisaMcpWorkbench.json b/test/cdk/stacks/__baselines__/LisaMcpWorkbench.json index 81132cd75..1781ea50f 100644 --- a/test/cdk/stacks/__baselines__/LisaMcpWorkbench.json +++ b/test/cdk/stacks/__baselines__/LisaMcpWorkbench.json @@ -892,7 +892,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Lists available MCP Workbench tools", "Environment": { @@ -971,7 +971,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Create MCP Workbench tools", "Environment": { @@ -1050,7 +1050,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Get MCP Workbench tool", "Environment": { @@ -1129,7 +1129,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Update MCP Workbench tool", "Environment": { @@ -1208,7 +1208,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Delete MCP Workbench tool", "Environment": { @@ -1287,7 +1287,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Validate Python code syntax", "Environment": { @@ -1475,7 +1475,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { diff --git a/test/cdk/stacks/__baselines__/LisaMetrics.json b/test/cdk/stacks/__baselines__/LisaMetrics.json index 124060ad9..3346a3646 100644 --- a/test/cdk/stacks/__baselines__/LisaMetrics.json +++ b/test/cdk/stacks/__baselines__/LisaMetrics.json @@ -655,7 +655,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Gets metrics for a specific user", "Environment": { @@ -730,7 +730,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Gets aggregated metrics across all users", "Environment": { @@ -805,7 +805,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -891,7 +891,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { diff --git a/test/cdk/stacks/__baselines__/LisaModels.json b/test/cdk/stacks/__baselines__/LisaModels.json index d6b9dc491..043f2bfd5 100644 --- a/test/cdk/stacks/__baselines__/LisaModels.json +++ b/test/cdk/stacks/__baselines__/LisaModels.json @@ -1062,7 +1062,7 @@ "cdk-hnb659fds-assets-012345678901-us-iso-east-1" ], "SourceObjectKeys": [ - "9f3ea1eafc68ae8e89106b5a5558bdeb3a38a08821681b1f69441cbbbec07237.zip" + "ffe1c48bcf2d745bcfa70b2ca14a28a1e7c75f23b98e9038b681788acd4dd6d4.zip" ], "DestinationBucketName": { "Ref": "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketA3074A95" @@ -1261,7 +1261,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1613,7 +1613,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Manages Auto Scaling scheduled actions for LISA model scheduling", "Environment": { @@ -1665,7 +1665,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Processes Auto Scaling Group CloudWatch events to update model status", "Environment": { @@ -1795,7 +1795,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1880,7 +1880,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1965,7 +1965,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2050,7 +2050,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2135,7 +2135,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2220,7 +2220,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2305,7 +2305,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2390,7 +2390,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2478,7 +2478,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2563,7 +2563,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3076,7 +3076,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3137,7 +3137,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3198,7 +3198,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3259,7 +3259,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3320,7 +3320,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3381,7 +3381,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3722,7 +3722,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3784,7 +3784,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3846,7 +3846,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3908,7 +3908,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -3970,7 +3970,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -4032,7 +4032,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -4493,7 +4493,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Manage model", "Environment": { @@ -4611,7 +4611,7 @@ ] } }, - "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler292673D9D37A": { + "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler3ff83C1FEF1A": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -4650,7 +4650,7 @@ } } }, - "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler843cDF6CA924": { + "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler77dc5CFE1E46": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -4694,7 +4694,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Manage model", "Environment": { @@ -4788,7 +4788,7 @@ "RetentionInDays": 30 } }, - "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler851b7E6C1542": { + "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandlera4f62D9A3786": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -4979,7 +4979,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Remove api_key from existing Bedrock models to fix Invalid API Key format errors", "Environment": { diff --git a/test/cdk/stacks/__baselines__/LisaRAG.json b/test/cdk/stacks/__baselines__/LisaRAG.json index 984d51cbb..8964ecd3b 100644 --- a/test/cdk/stacks/__baselines__/LisaRAG.json +++ b/test/cdk/stacks/__baselines__/LisaRAG.json @@ -1450,7 +1450,7 @@ "IngestionStackConstructIngestionJobFargateEnvD92342F8": { "Type": "AWS::Batch::ComputeEnvironment", "Properties": { - "ComputeEnvironmentName": "test-lisa-dev-ingestion-job-94117c65ef0f", + "ComputeEnvironmentName": "test-lisa-dev-ingestion-job-6518f5dfcb2f", "ComputeResources": { "MaxvCpus": 128, "SecurityGroupIds": [ @@ -1491,7 +1491,7 @@ "Order": 1 } ], - "JobQueueName": "test-lisa-dev-ingestion-job-94117c65ef0f", + "JobQueueName": "test-lisa-dev-ingestion-job-6518f5dfcb2f", "Priority": 1, "State": "ENABLED" } @@ -1745,7 +1745,7 @@ ], "RuntimePlatform": {} }, - "JobDefinitionName": "test-lisa-dev-ingestion-job-94117c65ef0f", + "JobDefinitionName": "test-lisa-dev-ingestion-job-6518f5dfcb2f", "PlatformCapabilities": [ "FARGATE" ], @@ -1779,7 +1779,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -1869,7 +1869,7 @@ "TIKTOKEN_CACHE_DIR": "/opt/python/TIKTOKEN_CACHE" } }, - "FunctionName": "test-lisa-dev-ingestion-ingest-schedule-94117c65ef0f", + "FunctionName": "test-lisa-dev-ingestion-ingest-schedule-6518f5dfcb2f", "Handler": "repository.pipeline_ingest_documents.handle_pipline_ingest_schedule", "Layers": [ { @@ -1908,7 +1908,7 @@ "LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60" ] }, - "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E54d7ec1f630ae7ff839416c4f723eaa43": { + "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E5a306c971a4af8f9360b69f32e5ba064a": { "Type": "AWS::Lambda::Version", "Properties": { "FunctionName": { @@ -1927,7 +1927,7 @@ }, "FunctionVersion": { "Fn::GetAtt": [ - "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E54d7ec1f630ae7ff839416c4f723eaa43", + "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E5a306c971a4af8f9360b69f32e5ba064a", "Version" ] }, @@ -1975,7 +1975,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2065,7 +2065,7 @@ "TIKTOKEN_CACHE_DIR": "/opt/python/TIKTOKEN_CACHE" } }, - "FunctionName": "test-lisa-dev-ingestion-ingest-event-94117c65ef0f", + "FunctionName": "test-lisa-dev-ingestion-ingest-event-6518f5dfcb2f", "Handler": "repository.pipeline_ingest_documents.handle_pipeline_ingest_event", "Layers": [ { @@ -2104,7 +2104,7 @@ "LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60" ] }, - "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBC2dee4c902e71215dce0dc6f1b013820d": { + "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBCa4ba5f8a9524abcd6558da3fcc02ad65": { "Type": "AWS::Lambda::Version", "Properties": { "FunctionName": { @@ -2123,7 +2123,7 @@ }, "FunctionVersion": { "Fn::GetAtt": [ - "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBC2dee4c902e71215dce0dc6f1b013820d", + "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBCa4ba5f8a9524abcd6558da3fcc02ad65", "Version" ] }, @@ -2171,7 +2171,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -2261,7 +2261,7 @@ "TIKTOKEN_CACHE_DIR": "/opt/python/TIKTOKEN_CACHE" } }, - "FunctionName": "test-lisa-dev-ingestion-delete-event-94117c65ef0f", + "FunctionName": "test-lisa-dev-ingestion-delete-event-6518f5dfcb2f", "Handler": "repository.pipeline_ingest_documents.handle_pipeline_delete_event", "Layers": [ { @@ -2300,7 +2300,7 @@ "LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60" ] }, - "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E9561837d7a9baa5a3011c1dda4e72825c4": { + "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E9515e0eb2e79eb2a92e58fb4eb4620c0c0": { "Type": "AWS::Lambda::Version", "Properties": { "FunctionName": { @@ -2319,7 +2319,7 @@ }, "FunctionVersion": { "Fn::GetAtt": [ - "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E9561837d7a9baa5a3011c1dda4e72825c4", + "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E9515e0eb2e79eb2a92e58fb4eb4620c0c0", "Version" ] }, @@ -5317,7 +5317,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List all repositories", "Environment": { @@ -5472,7 +5472,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List status for all repositories", "Environment": { @@ -5627,7 +5627,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Generates a presigned url for uploading files to RAG", "Environment": { @@ -5782,7 +5782,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Create a new repository", "Environment": { @@ -5937,7 +5937,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Get a repository by ID", "Environment": { @@ -6092,7 +6092,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Update a repository", "Environment": { @@ -6247,7 +6247,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Delete a repository", "Environment": { @@ -6402,7 +6402,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Run a similarity search against the specified repository using the specified query", "Environment": { @@ -6557,7 +6557,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Ingest a set of documents based on specified S3 path", "Environment": { @@ -6712,7 +6712,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List all docs for a repository", "Environment": { @@ -6867,7 +6867,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Get a document by ID", "Environment": { @@ -7022,7 +7022,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Creates presigned url to download document within repository", "Environment": { @@ -7177,7 +7177,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Deletes all records associated with documents from the repository", "Environment": { @@ -7332,7 +7332,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List all ingestion jobs for a repository", "Environment": { @@ -7487,7 +7487,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List all collections within a repository", "Environment": { @@ -7642,7 +7642,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List all collections user has access to across all repositories", "Environment": { @@ -7797,7 +7797,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Create a new collection within a repository", "Environment": { @@ -7952,7 +7952,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Get a collection by ID within a repository", "Environment": { @@ -8107,7 +8107,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Update a collection within a repository", "Environment": { @@ -8262,7 +8262,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "Delete a collection within a repository", "Environment": { @@ -8417,7 +8417,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List all ACTIVE Bedrock Knowledge Bases", "Environment": { @@ -8572,7 +8572,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Description": "List data sources for a Bedrock Knowledge Base", "Environment": { @@ -9502,7 +9502,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -9847,7 +9847,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { @@ -9996,7 +9996,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip" + "S3Key": "7b9da1eac7762656aa12e7c319d279a382f78d06b5b8da98d7c4d014c7c8af0a.zip" }, "Environment": { "Variables": { diff --git a/test/cdk/stacks/__baselines__/LisaServe.json b/test/cdk/stacks/__baselines__/LisaServe.json index e9312c297..efe0af101 100644 --- a/test/cdk/stacks/__baselines__/LisaServe.json +++ b/test/cdk/stacks/__baselines__/LisaServe.json @@ -1590,7 +1590,7 @@ "Timeout": 5 }, "Image": { - "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:7010d3fc60c43bea5c2f4b926df34e4ef454fe95b966cc5c642b247a7554b8e6" + "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:a19a3b0ff7139ac8e66b83819be54584e63ecd109c973a534ba36293c1ff0005" }, "LogConfiguration": { "LogDriver": "awslogs", @@ -1944,7 +1944,7 @@ "Timeout": 5 }, "Image": { - "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:fcba8fe82ada4af0a2de9d06ec3d45e26bd0320633c64e3a5d2a1a32953789cd" + "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:8afec7b2364847d0d18f93e1c04ef0da5c9a397271bcca2cf6e263a76e983832" }, "LogConfiguration": { "LogDriver": "awslogs", @@ -2519,7 +2519,7 @@ "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1", - "S3Key": "a38f8a5114ae969de815d5c66b3ddbd646445697a789f7b16424b4ea541d4ff9.zip" + "S3Key": "f1ef0b7df9fbd6678b581ed85579e856c8f7832ce4e06e642f2c2421ef37c427.zip" }, "Handler": "index.handler", "Role": { From 064a3edab63f66267ad9f20f82fa41121dbe4f57 Mon Sep 17 00:00:00 2001 From: jmharold Date: Thu, 12 Feb 2026 20:35:04 +0000 Subject: [PATCH 16/21] add refresh button back --- .../react/src/components/Topbar.tsx | 41 ++++++++++++++++++- .../chatbot/components/Sessions.tsx | 41 ++++--------------- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/lib/user-interface/react/src/components/Topbar.tsx b/lib/user-interface/react/src/components/Topbar.tsx index 2e4dd26f4..f438ff439 100644 --- a/lib/user-interface/react/src/components/Topbar.tsx +++ b/lib/user-interface/react/src/components/Topbar.tsx @@ -14,12 +14,12 @@ limitations under the License. */ -import { ReactElement, useContext } from 'react'; +import { ReactElement, useContext, useEffect } from 'react'; import { useAuth } from '../auth/useAuth'; import { useHref, useNavigate } from 'react-router-dom'; import { applyDensity, Density, Mode } from '@cloudscape-design/global-styles'; import TopNavigation, { TopNavigationProps } from '@cloudscape-design/components/top-navigation'; -import { purgeStore, useAppSelector } from '@/config/store'; +import { purgeStore, useAppDispatch, useAppSelector } from '@/config/store'; import { selectCurrentUserIsAdmin, selectCurrentUserIsApiUser, selectCurrentUsername } from '../shared/reducers/user.reducer'; import { IConfiguration } from '@/shared/model/configuration.model'; import { ButtonDropdownProps } from '@cloudscape-design/components'; @@ -27,6 +27,9 @@ import ColorSchemeContext from '@/shared/color-scheme.provider'; import { OidcConfig } from '@/config/oidc.config'; import { getBrandingAssetPath } from '../shared/util/branding'; import { getDisplayName } from '@/shared/util/branding'; +import { useDeleteAllSessionsForUserMutation } from '@/shared/reducers/session.reducer'; +import { setConfirmationModal } from '@/shared/reducers/modal.reducer'; +import { useNotificationService } from '@/shared/util/hooks'; applyDensity(Density.Comfortable); @@ -37,11 +40,30 @@ export type TopbarProps = { function Topbar ({ configs }: TopbarProps): ReactElement { const navigate = useNavigate(); const auth = useAuth(); + const dispatch = useAppDispatch(); + const notificationService = useNotificationService(dispatch); const isUserAdmin = useAppSelector(selectCurrentUserIsAdmin); const isApiUser = useAppSelector(selectCurrentUserIsApiUser); const userName = useAppSelector(selectCurrentUsername); const { colorScheme, setColorScheme } = useContext(ColorSchemeContext); + const [deleteUserSessions, { + isSuccess: isDeleteUserSessionsSuccess, + isError: isDeleteUserSessionsError, + error: deleteUserSessionsError, + isLoading: isDeleteUserSessionsLoading, + }] = useDeleteAllSessionsForUserMutation(); + + useEffect(() => { + if (!isDeleteUserSessionsLoading && isDeleteUserSessionsSuccess) { + notificationService.generateNotification('Successfully deleted all user sessions', 'success'); + } else if (!isDeleteUserSessionsLoading && isDeleteUserSessionsError) { + const errorMessage = 'message' in deleteUserSessionsError ? deleteUserSessionsError.message : 'Unknown error'; + notificationService.generateNotification(`Error deleting user sessions: ${errorMessage}`, 'error'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDeleteUserSessionsSuccess, isDeleteUserSessionsError, deleteUserSessionsError, isDeleteUserSessionsLoading]); + const libraryItems = [ ...(configs?.configuration.enabledComponents?.modelLibrary ? [{ id: 'model-library', @@ -187,6 +209,16 @@ function Topbar ({ configs }: TopbarProps): ReactElement { case 'api-token': navigate('/user-api-token'); break; + case 'delete-chat-history': + dispatch( + setConfirmationModal({ + action: 'Delete', + resourceName: 'All Sessions', + onConfirm: () => deleteUserSessions(), + description: 'This will delete all of your user sessions.' + }) + ); + break; case 'signin': auth.signinRedirect({ redirect_uri: window.location.toString() }); break; @@ -215,6 +247,11 @@ function Topbar ({ configs }: TopbarProps): ReactElement { id: 'api-token', text: 'API Token', }] : []), + ...(configs?.configuration.enabledComponents?.deleteSessionHistory ? [{ + id: 'delete-chat-history', + text: 'Delete Chat History', + iconName: 'remove' as const, + }] : []), { id: 'color-mode', text: colorScheme === Mode.Light ? 'Dark mode' : 'Light mode', iconSvg: ( { - if (!isDeleteUserSessionsLoading && isDeleteUserSessionsSuccess) { - notificationService.generateNotification('Successfully deleted all user sessions', 'success'); - } else if (!isDeleteUserSessionsLoading && isDeleteUserSessionsError) { - const errorMessage = 'message' in deleteUserSessionsError ? deleteUserSessionsError.message : 'Unknown error'; - notificationService.generateNotification(`Error deleting user sessions: ${errorMessage}`, 'error'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isDeleteUserSessionsSuccess, isDeleteUserSessionsError, deleteUserSessionsError, isDeleteUserSessionsLoading]); - useEffect(() => { if (!isUpdateSessionNameLoading && isUpdateSessionNameSuccess) { notificationService.generateNotification('Successfully renamed session', 'success'); @@ -243,21 +227,14 @@ export function Sessions ({ newSession }) { > New - {config?.configuration.enabledComponents.deleteSessionHistory && - } +
From 31fa45589b5a2134c4a506db22048a40752ec5a6 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Thu, 12 Feb 2026 13:42:27 -0700 Subject: [PATCH 17/21] Fix session float encoding --- lambda/session/models.py | 4 +++- lambda/utilities/encoders.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lambda/session/models.py b/lambda/session/models.py index 186f33b09..c57bccc7a 100644 --- a/lambda/session/models.py +++ b/lambda/session/models.py @@ -17,6 +17,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from utilities.encoders import convert_float_to_decimal from utilities.time import iso_string # --- Session configuration models (aligned with chat.configurations.model.ts) --- @@ -167,7 +168,8 @@ class SessionConfigurationModel(BaseModel): def model_dump_for_storage(self) -> dict[str, Any]: """Serialize to dict for DynamoDB storage.""" result: dict[str, Any] = self.model_dump(mode="json", exclude_none=False) - return result + converted: dict[str, Any] = convert_float_to_decimal(result) + return converted @classmethod def from_dict(cls, data: dict[str, Any] | None) -> "SessionConfigurationModel": diff --git a/lambda/utilities/encoders.py b/lambda/utilities/encoders.py index 1cb35ef15..e6fc9d854 100644 --- a/lambda/utilities/encoders.py +++ b/lambda/utilities/encoders.py @@ -24,3 +24,14 @@ def convert_decimal(obj: Any) -> Any: elif isinstance(obj, Decimal): return float(obj) return obj + + +def convert_float_to_decimal(obj: Any) -> Any: + """Recursively convert float values to Decimal for DynamoDB compatibility.""" + if isinstance(obj, dict): + return {key: convert_float_to_decimal(value) for key, value in obj.items()} + elif isinstance(obj, list): + return [convert_float_to_decimal(element) for element in obj] + elif isinstance(obj, float): + return Decimal(str(obj)) + return obj From 9cd6203ec5cfdf7edde50c003c2c57f6eb844e36 Mon Sep 17 00:00:00 2001 From: Evan Stohlmann Date: Thu, 12 Feb 2026 13:45:41 -0700 Subject: [PATCH 18/21] Changelog updates --- CHANGELOG.md | 70 ++++--------------- .../react/src/components/Topbar.test.tsx | 1 + 2 files changed, 16 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d4a383e..e05c64353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,65 +1,25 @@ # v6.2.1 -## Key Features - -### Update Deployment Guide -The deployment guide has been extensively updated to include new walk-throughs and updated instructions for various components of the LISA deployment. - -**Deployment Guide Updates:** -- Added a walk-through for setting up AWS Certificate Manager -- Updated the Route 53 walk-through -- Enhanced the API Gateway walk-through -- Added a new section for the SuperNova walk-through (AWS internal only) -- Updated the example configurations, including a working config for Mistral 7B v0.3 with VLLM -- Revised the Cognito setup guide to reflect the latest changes - -### Publish Artifacts -This release publishes a set of pre-built assets, including lambdas and container images, to GitHub and NPM. This will make it easier for users to consume these resources without having to build them from scratch. - -### Preview Panel for Rendering Prompts -This feature introduces a new preview panel that allows users to view a rendered version of the contents in the PromptInput area. The preview panel supports the following capabilities: -- Markdown rendering -- Mermaid diagram rendering (within code blocks) -- Inline and block LaTeX math equation rendering - -### Session Config Model Mapping -This change updates the session configuration model to improve the mapping between the data stored in DynamoDB and the session configuration object. - -### MCP Execution Modal Clarity Update -The MCP Tool Execution Modal has been updated to be more concise and remove duplicate information, including: -- Removal of the duplicate tool name -- Reduction of excess spacing -- Removal of the do you want to allow this tool execution message (already mentioned above) -- Changing arguments to details - -### vLLM Variables Documentation -New documentation has been added to describe the vLLM environment variables that LISA Serve supports. This will help users understand the available configuration options for running vLLM models. - -### MCP Server Identifier in Approval Modal -A new MCP server identification label has been added to the tool approval modal. This will help users distinguish which MCP server initiated the tool execution request. - -### Table Border Removal -The borders have been removed from several table components to align the UI with the overall design of the application. +## Bug Fixes +- Removed FastAPI import from auth handler preventing lambdas without FastAPI in the layer dependencies from working +- Updated Session model to account for session configuration data types to allow resuming old stored sessions +- Made exception handling more uniform and consistent across the application -### Consistent Exception Handling -A comprehensive refactor has been performed to ensure consistent exception handling across the various APIs. +## UI Updates +- Cleaned up the MCP approval modal +- Removed borders around all tables for a consistent theme across the UI +- Added markdown preview toggle to prompt input +- Moved delete all sessions button under user profile and added back the refresh button to session panel -## Key Changes -- **Deployment Guide**: Extensive updates to the deployment guide, including new walk-throughs and configuration examples -- **Artifact Publishing**: Pre-built assets, including lambdas and container images, are now available on GitHub and NPM -- **Prompt Rendering**: Introduction of a preview panel to render Markdown, Mermaid diagrams, and LaTeX math equations -- **Session Config Mapping**: Improved mapping between DynamoDB data and session configuration objects -- **MCP Execution Modal**: Streamlined the MCP tool execution modal to be more concise and remove duplicate information -- **vLLM Variables Documentation**: New documentation added to describe the available vLLM environment variables -- **MCP Server Identifier**: Added a server identification label to the tool approval modal -- **Table Borders**: Removed borders from several table components to align with the overall design -- **Exception Handling**: Comprehensive refactor to ensure consistent exception handling across APIs +## Documentation Updates +- Cleared up the vLLM variables LISA supports +- Updated deployment guide to account for new cognito updates ## Acknowledgements -* @121983012+jmharold -* @99225408+Ernest-Gray * @bedanley -* @evmann +* @Ernest-Gray +* @estohlmann +* @jmharold **Full Changelog**: https://github.com/awslabs/LISA/compare/v6.2.0..v6.2.1 diff --git a/lib/user-interface/react/src/components/Topbar.test.tsx b/lib/user-interface/react/src/components/Topbar.test.tsx index 72418e492..c4bbd45ac 100644 --- a/lib/user-interface/react/src/components/Topbar.test.tsx +++ b/lib/user-interface/react/src/components/Topbar.test.tsx @@ -32,6 +32,7 @@ vi.mock('../auth/useAuth'); // Mock store functions vi.mock('@/config/store', () => ({ purgeStore: vi.fn(), + useAppDispatch: vi.fn(() => vi.fn()), useAppSelector: vi.fn((selector) => { const selectorStr = selector.toString(); if (selectorStr.includes('selectCurrentUserIsAdmin')) return false; From ca7d9f1dd5a16f8060660bcc72ddef552e8f6870 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Fri, 13 Feb 2026 21:11:13 +0000 Subject: [PATCH 19/21] Reduce embedding batches --- lambda/repository/embeddings.py | 169 ++++++++++++------ .../repository/pipeline_ingest_documents.py | 4 +- 2 files changed, 114 insertions(+), 59 deletions(-) diff --git a/lambda/repository/embeddings.py b/lambda/repository/embeddings.py index 666d48845..764eb56e0 100644 --- a/lambda/repository/embeddings.py +++ b/lambda/repository/embeddings.py @@ -14,6 +14,7 @@ import logging import os +import time from typing import Any import boto3 @@ -32,6 +33,12 @@ lisa_api_endpoint = "" +# Max texts per embedding API call — TEI containers enforce a 256 limit +MAX_EMBEDDING_BATCH_SIZE = 256 +# Retry configuration for transient embedding failures +MAX_RETRIES = 3 +INITIAL_BACKOFF_SECONDS = 1.0 + # Module-level session with connection pooling for better performance # This reuses TCP connections across multiple embedding requests _http_session: requests.Session | None = None @@ -100,7 +107,11 @@ def __init__(self, model_name: str, id_token: str | None = None, **data: Any) -> def embed_documents(self, texts: list[str]) -> list[list[float]]: """ - Generate embeddings for a list of documents. + Generate embeddings for a list of documents, automatically batching + to stay within the embedding server's max batch size. + + Uses input_type="passage" so litellm applies the correct model-specific + prefix for document indexing (e.g. "passage: " for E5 models). Args: texts: List of text strings to embed @@ -110,79 +121,123 @@ def embed_documents(self, texts: list[str]) -> list[list[float]]: Raises: ValidationError: If input texts are invalid - Exception: If embedding request fails + Exception: If embedding request fails after retries """ if not texts: raise ValidationError("No texts provided for embedding") logger.info(f"Embedding {len(texts)} documents using {self.model_name}") - try: - url = f"{self.base_url}/embeddings" - # Use encoding_format="float" to ensure embeddings are returned as float arrays - request_data = {"input": texts, "model": self.model_name, "encoding_format": "float"} - # Use shared session with connection pooling for better performance - session = _get_http_session() + all_embeddings: list[list[float]] = [] + batch_size = MAX_EMBEDDING_BATCH_SIZE + + for batch_start in range(0, len(texts), batch_size): + batch = texts[batch_start : batch_start + batch_size] + batch_num = batch_start // batch_size + 1 + total_batches = (len(texts) + batch_size - 1) // batch_size + logger.info(f"Embedding batch {batch_num}/{total_batches} ({len(batch)} texts)") + + batch_embeddings = self._embed_batch_with_retry(batch) + all_embeddings.extend(batch_embeddings) + + if len(all_embeddings) != len(texts): + raise Exception( + f"Embedding count mismatch: expected {len(texts)}, got {len(all_embeddings)}" + ) + + logger.info(f"Successfully embedded {len(texts)} documents") + return all_embeddings + + def _embed_batch_with_retry(self, texts: list[str], input_type: str | None = None) -> list[list[float]]: + """Send a single batch to the embedding API with exponential backoff on failure.""" + last_exception: Exception | None = None + + for attempt in range(1, MAX_RETRIES + 1): + try: + return self._call_embedding_api(texts, input_type=input_type) + except Exception as e: + last_exception = e + if attempt < MAX_RETRIES: + backoff = INITIAL_BACKOFF_SECONDS * (2 ** (attempt - 1)) + logger.warning( + f"Embedding attempt {attempt}/{MAX_RETRIES} failed: {e}. " + f"Retrying in {backoff:.1f}s..." + ) + time.sleep(backoff) + else: + logger.error(f"Embedding failed after {MAX_RETRIES} attempts: {e}") + + raise last_exception # type: ignore[misc] + + def _call_embedding_api(self, texts: list[str], input_type: str | None = None) -> list[list[float]]: + """Make a single embedding HTTP request and parse the response.""" + url = f"{self.base_url}/embeddings" + request_data: dict[str, Any] = { + "input": texts, + "model": self.model_name, + "encoding_format": "float", + } + if input_type is not None: + request_data["input_type"] = input_type + + session = _get_http_session() + try: response = session.post( url, json=request_data, headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, - verify=self.cert_path, # Use proper SSL verification - timeout=300, # 5 minute timeout + verify=self.cert_path, + timeout=300, ) - - if response.status_code != 200: - logger.error(f"Embedding request failed with status {response.status_code}") - logger.error(f"Embedding error response body: {response.text}") - raise Exception(f"Embedding request failed with status {response.status_code}") - - result = response.json() - logger.debug(f"API Response: {result}") # Log the full response for debugging - - # Handle different response formats - embeddings = [] - if isinstance(result, dict): - if "data" in result: - # OpenAI-style format - for item in result["data"]: - if isinstance(item, dict) and "embedding" in item: - embeddings.append(item["embedding"]) - else: - embeddings.append(item) # Assume the item itself is the embedding - else: - # Try to find embeddings in the response - for key in ["embeddings", "embedding", "vectors", "vector"]: - if key in result: - embeddings = result[key] - break - elif isinstance(result, list): - # Direct list format - embeddings = result - - if not embeddings: - logger.error(f"Could not find embeddings in response: {result}") - raise Exception("No embeddings found in API response") - - if len(embeddings) != len(texts): - logger.error(f"Mismatch between number of texts ({len(texts)}) and embeddings ({len(embeddings)})") - raise Exception("Number of embeddings does not match number of input texts") - - logger.info(f"Successfully embedded {len(texts)} documents") - return embeddings - except requests.Timeout: - logger.error("Embedding request timed out") raise Exception("Embedding request timed out after 5 minutes") except requests.RequestException as e: - logger.error(f"Request failed: {str(e)}", exc_info=True) - raise - except Exception as e: - logger.error(f"Failed to get embeddings: {str(e)}", exc_info=True) - raise + raise Exception(f"Embedding HTTP request failed: {e}") from e + + if response.status_code != 200: + logger.error(f"Embedding request failed with status {response.status_code}: {response.text}") + raise Exception(f"Embedding request failed with status {response.status_code}") + + result = response.json() + return self._parse_embeddings(result, expected_count=len(texts)) + + @staticmethod + def _parse_embeddings(result: Any, expected_count: int) -> list[list[float]]: + """Extract embedding vectors from the API response.""" + embeddings: list[list[float]] = [] + + if isinstance(result, dict): + if "data" in result: + # OpenAI-style format + for item in result["data"]: + if isinstance(item, dict) and "embedding" in item: + embeddings.append(item["embedding"]) + else: + embeddings.append(item) + else: + for key in ["embeddings", "embedding", "vectors", "vector"]: + if key in result: + embeddings = result[key] + break + elif isinstance(result, list): + embeddings = result + + if not embeddings: + logger.error(f"Could not find embeddings in response: {result}") + raise Exception("No embeddings found in API response") + + if len(embeddings) != expected_count: + raise Exception( + f"Embedding count mismatch: expected {expected_count}, got {len(embeddings)}" + ) + + return embeddings def embed_query(self, text: str) -> list[float]: + """Embed a single query text using input_type="query" for retrieval.""" if not text or not isinstance(text, str): raise ValidationError("Invalid query text") logger.info("Embedding single query text") - return self.embed_documents([text])[0] + result = self._embed_batch_with_retry([text]) + return result[0] diff --git a/lambda/repository/pipeline_ingest_documents.py b/lambda/repository/pipeline_ingest_documents.py index d3ba11755..7b6aa883b 100644 --- a/lambda/repository/pipeline_ingest_documents.py +++ b/lambda/repository/pipeline_ingest_documents.py @@ -635,14 +635,14 @@ def handle_pipline_ingest_schedule(event: dict[str, Any], context: Any) -> None: raise e -def batch_texts(texts: list[str], metadatas: list[dict], batch_size: int = 500) -> list[tuple[list[str], list[dict]]]: +def batch_texts(texts: list[str], metadatas: list[dict], batch_size: int = 256) -> list[tuple[list[str], list[dict]]]: """ Split texts and metadata into batches of specified size. Args: texts: List of text strings to batch metadatas: List of metadata dictionaries - batch_size: Maximum size of each batch + batch_size: Maximum size of each batch (default 256 to match embedding server limit) Returns: List of tuples containing (texts_batch, metadatas_batch) """ From a6dcf3f0bb09541a7c3d62b2a497ecc9c889d2bd Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Fri, 13 Feb 2026 21:12:23 +0000 Subject: [PATCH 20/21] pre --- lambda/repository/embeddings.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/lambda/repository/embeddings.py b/lambda/repository/embeddings.py index 764eb56e0..cdc440304 100644 --- a/lambda/repository/embeddings.py +++ b/lambda/repository/embeddings.py @@ -141,9 +141,7 @@ def embed_documents(self, texts: list[str]) -> list[list[float]]: all_embeddings.extend(batch_embeddings) if len(all_embeddings) != len(texts): - raise Exception( - f"Embedding count mismatch: expected {len(texts)}, got {len(all_embeddings)}" - ) + raise Exception(f"Embedding count mismatch: expected {len(texts)}, got {len(all_embeddings)}") logger.info(f"Successfully embedded {len(texts)} documents") return all_embeddings @@ -160,8 +158,7 @@ def _embed_batch_with_retry(self, texts: list[str], input_type: str | None = Non if attempt < MAX_RETRIES: backoff = INITIAL_BACKOFF_SECONDS * (2 ** (attempt - 1)) logger.warning( - f"Embedding attempt {attempt}/{MAX_RETRIES} failed: {e}. " - f"Retrying in {backoff:.1f}s..." + f"Embedding attempt {attempt}/{MAX_RETRIES} failed: {e}. " f"Retrying in {backoff:.1f}s..." ) time.sleep(backoff) else: @@ -227,9 +224,7 @@ def _parse_embeddings(result: Any, expected_count: int) -> list[list[float]]: raise Exception("No embeddings found in API response") if len(embeddings) != expected_count: - raise Exception( - f"Embedding count mismatch: expected {expected_count}, got {len(embeddings)}" - ) + raise Exception(f"Embedding count mismatch: expected {expected_count}, got {len(embeddings)}") return embeddings From bb63a2779ab018fd6e3cff13d18f24e64f9ac672 Mon Sep 17 00:00:00 2001 From: Bear Danley Date: Fri, 13 Feb 2026 21:52:38 +0000 Subject: [PATCH 21/21] fix tests --- test/lambda/test_pipeline_ingest_documents.py | 2 +- test/lambda/test_repository_lambda.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/lambda/test_pipeline_ingest_documents.py b/test/lambda/test_pipeline_ingest_documents.py index 41ae8abab..2a981ba18 100644 --- a/test/lambda/test_pipeline_ingest_documents.py +++ b/test/lambda/test_pipeline_ingest_documents.py @@ -137,7 +137,7 @@ def test_store_chunks_in_vectorstore(setup_env): ids = store_chunks_in_vectorstore(texts, metadatas, "repo1", "col1", "model1") assert len(ids) > 0 - assert mock_vs.add_texts.call_count == 3 # 1200 texts / 500 batch size + assert mock_vs.add_texts.call_count == 5 # 1200 texts / 256 batch size def test_store_chunks_in_vectorstore_failure(setup_env): diff --git a/test/lambda/test_repository_lambda.py b/test/lambda/test_repository_lambda.py index 2652bb42c..7fac6cc4c 100644 --- a/test/lambda/test_repository_lambda.py +++ b/test/lambda/test_repository_lambda.py @@ -1271,7 +1271,7 @@ def test_pipeline_embeddings_embed_documents_mismatch(): embeddings = RagEmbeddings("test-model", "test-token") - with pytest.raises(Exception, match="Number of embeddings does not match number of input texts"): + with pytest.raises(Exception, match="Embedding count mismatch: expected 2, got 1"): embeddings.embed_documents(["text1", "text2"])